xtrm-tools 0.5.19 → 0.5.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
package/config/hooks.json CHANGED
@@ -4,9 +4,6 @@
4
4
  {
5
5
  "script": "beads-compact-restore.mjs",
6
6
  "timeout": 5000
7
- },
8
- {
9
- "script": "serena-workflow-reminder.py"
10
7
  }
11
8
  ],
12
9
  "UserPromptSubmit": [
@@ -16,6 +13,11 @@
16
13
  }
17
14
  ],
18
15
  "PreToolUse": [
16
+ {
17
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
18
+ "script": "worktree-boundary.mjs",
19
+ "timeout": 2000
20
+ },
19
21
  {
20
22
  "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
21
23
  "script": "beads-edit-gate.mjs",
@@ -79,11 +79,21 @@ function getCloseReason(cwd, issueId, command) {
79
79
  return `Close ${issueId}`;
80
80
  }
81
81
 
82
+ function stageUntracked(cwd) {
83
+ const result = runGit(['ls-files', '--others', '--exclude-standard'], cwd);
84
+ if (result.status !== 0) return;
85
+ const untracked = result.stdout.trim().split('\n').filter(Boolean);
86
+ if (untracked.length === 0) return;
87
+ runGit(['add', '--', ...untracked], cwd);
88
+ }
89
+
82
90
  function autoCommit(cwd, issueId, command) {
83
91
  if (!hasGitChanges(cwd)) {
84
92
  return { ok: true, message: 'No changes detected — auto-commit skipped.' };
85
93
  }
86
94
 
95
+ stageUntracked(cwd);
96
+
87
97
  const reason = getCloseReason(cwd, issueId, command);
88
98
  const commitMessage = `${reason} (${issueId})`;
89
99
  const result = runGit(['commit', '-am', commitMessage], cwd, 15000);
@@ -12,13 +12,26 @@ import {
12
12
  resolveSessionContext,
13
13
  resolveClaimAndWorkState,
14
14
  decideEditGate,
15
+ decideWorktreeBoundary,
15
16
  } from './beads-gate-core.mjs';
16
- import { withSafeBdContext } from './beads-gate-utils.mjs';
17
+ import { withSafeBdContext, resolveCwd } from './beads-gate-utils.mjs';
17
18
  import { editBlockMessage, editBlockFallbackMessage } from './beads-gate-messages.mjs';
18
19
 
19
20
  const input = readHookInput();
20
21
  if (!input) process.exit(0);
21
22
 
23
+ // Worktree boundary check — independent of beads, fires first
24
+ const _cwd = resolveCwd(input);
25
+ const _boundary = decideWorktreeBoundary(input, _cwd);
26
+ if (!_boundary.allow) {
27
+ process.stdout.write(JSON.stringify({
28
+ decision: 'block',
29
+ reason: `🚫 Edit outside worktree boundary.\n File: ${_boundary.filePath}\n Allowed: ${_boundary.worktreeRoot}\n\n All edits must stay within the active worktree.`,
30
+ }));
31
+ process.stdout.write('\n');
32
+ process.exit(0);
33
+ }
34
+
22
35
  withSafeBdContext(() => {
23
36
  const ctx = resolveSessionContext(input);
24
37
  if (!ctx || !ctx.isBeadsProject) process.exit(0);
@@ -8,6 +8,7 @@
8
8
  // Dependencies: beads-gate-utils.mjs (adapters)
9
9
 
10
10
  import { readFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
11
12
  import {
12
13
  resolveCwd,
13
14
  resolveSessionId,
@@ -16,6 +17,7 @@ import {
16
17
  getTotalWork,
17
18
  getInProgress,
18
19
  isIssueInProgress,
20
+ resolveWorktreeRoot,
19
21
  } from './beads-gate-utils.mjs';
20
22
 
21
23
  // ── Input parsing ────────────────────────────────────────────────────────────
@@ -88,6 +90,25 @@ export function resolveClaimAndWorkState(ctx) {
88
90
 
89
91
  // ── Decision functions ───────────────────────────────────────────────────────
90
92
 
93
+ /**
94
+ * Decide whether a file edit is within the active worktree boundary.
95
+ * If the session cwd is inside .xtrm/worktrees/<name>, block any edit whose
96
+ * resolved path falls outside that worktree root.
97
+ * Returns { allow: boolean, filePath?: string, worktreeRoot?: string }
98
+ */
99
+ export function decideWorktreeBoundary(input, cwd) {
100
+ const filePath = input?.tool_input?.file_path;
101
+ if (!filePath) return { allow: true };
102
+
103
+ const worktreeRoot = resolveWorktreeRoot(cwd);
104
+ if (!worktreeRoot) return { allow: true }; // not in a worktree — no constraint
105
+
106
+ const abs = resolve(cwd, filePath);
107
+ if (abs === worktreeRoot || abs.startsWith(worktreeRoot + '/')) return { allow: true };
108
+
109
+ return { allow: false, filePath: abs, worktreeRoot };
110
+ }
111
+
91
112
  /**
92
113
  * Decide whether to allow or block an edit operation.
93
114
  * Returns { allow: boolean, reason?: string, sessionId?: string }
@@ -151,6 +151,15 @@ export function isMemoryAckCommand(command) {
151
151
  return /\bbd\s+remember\b/.test(command) || /\btouch\s+\.beads\/\.memory-gate-done\b/.test(command);
152
152
  }
153
153
 
154
+ /**
155
+ * If cwd is inside a .xtrm/worktrees/<name> directory, return the worktree root path.
156
+ * Returns null if not in a worktree.
157
+ */
158
+ export function resolveWorktreeRoot(cwd) {
159
+ const m = cwd.match(/^(.+\/\.xtrm\/worktrees\/[^/]+)/);
160
+ return m ? m[1] : null;
161
+ }
162
+
154
163
  /**
155
164
  * Clear the session claim key from bd kv. Non-fatal — best-effort cleanup.
156
165
  */
package/hooks/hooks.json CHANGED
@@ -16,10 +16,33 @@
16
16
  "type": "command",
17
17
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/quality-check-env.mjs",
18
18
  "timeout": 5000
19
+ }
20
+ ]
21
+ }
22
+ ],
23
+ "PreToolUse": [
24
+ {
25
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/worktree-boundary.mjs",
30
+ "timeout": 2000
19
31
  },
20
32
  {
21
33
  "type": "command",
22
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/serena-workflow-reminder.py"
34
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-edit-gate.mjs",
35
+ "timeout": 5000
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "matcher": "Bash",
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-commit-gate.mjs",
45
+ "timeout": 5000
23
46
  }
24
47
  ]
25
48
  }
@@ -77,28 +100,6 @@
77
100
  ]
78
101
  }
79
102
  ],
80
- "PreToolUse": [
81
- {
82
- "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
83
- "hooks": [
84
- {
85
- "type": "command",
86
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-edit-gate.mjs",
87
- "timeout": 5000
88
- }
89
- ]
90
- },
91
- {
92
- "matcher": "Bash",
93
- "hooks": [
94
- {
95
- "type": "command",
96
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-commit-gate.mjs",
97
- "timeout": 5000
98
- }
99
- ]
100
- }
101
- ],
102
103
  "PreCompact": [
103
104
  {
104
105
  "hooks": [
@@ -1,29 +1,30 @@
1
1
  #!/usr/bin/env node
2
- // statusline.mjs — Claude Code statusLine command for xt claude worktree sessions
3
- // Two lines:
4
- // Line 1 (plain): XTRM <branch>
5
- // Line 2 (colored): ◐ <claim title in italics> OR ○ N open
6
- // State file: .xtrm/statusline-claim (written by beads-claim-sync.mjs)
7
- // Results cached 5s in /tmp to avoid hammering bd on every render.
2
+ // statusline.mjs — Claude Code statusLine for xt claude sessions
3
+ // Line 1: XTRM dim(model [xx%]) hostname bold(dir) dim(branch (status)) dim((venv))
4
+ // Line 2: ◐ italic(claim title) OR ○ bold(N) open — no background
5
+ //
6
+ // Colors: bold/dim/italic only no explicit fg/bg, adapts to dark & light themes.
7
+ // State: .xtrm/statusline-claim (written by beads-claim-sync.mjs)
8
+ // Cache: /tmp per cwd, 5s TTL
8
9
 
9
10
  import { execSync } from 'node:child_process';
10
11
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
11
- import { join } from 'node:path';
12
- import { tmpdir } from 'node:os';
12
+ import { join, basename, relative } from 'node:path';
13
+ import { tmpdir, hostname } from 'node:os';
13
14
  import { createHash } from 'node:crypto';
14
15
 
15
- const cwd = process.cwd();
16
+ // Claude Code passes statusline context as JSON on stdin
17
+ let ctx = {};
18
+ try { ctx = JSON.parse(readFileSync(0, 'utf8')); } catch {}
19
+
20
+ const cwd = ctx?.workspace?.current_dir ?? process.cwd();
16
21
  const cacheKey = createHash('md5').update(cwd).digest('hex').slice(0, 8);
17
22
  const CACHE_FILE = join(tmpdir(), `xtrm-sl-${cacheKey}.json`);
18
23
  const CACHE_TTL = 5000;
19
24
 
20
25
  function run(cmd) {
21
26
  try {
22
- return execSync(cmd, {
23
- encoding: 'utf8', cwd,
24
- stdio: ['pipe', 'pipe', 'pipe'],
25
- timeout: 2000,
26
- }).trim();
27
+ return execSync(cmd, { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'], timeout: 2000 }).trim();
27
28
  } catch { return null; }
28
29
  }
29
30
 
@@ -39,76 +40,105 @@ function setCache(data) {
39
40
  try { writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), data })); } catch {}
40
41
  }
41
42
 
42
- // ANSI
43
- const R = '\x1b[0m';
44
- const BOLD = '\x1b[1m';
45
- const BOLD_OFF = '\x1b[22m';
46
- const ITALIC = '\x1b[3m';
47
- const ITALIC_OFF = '\x1b[23m';
48
- const FG_WHITE = '\x1b[38;5;15m';
49
- const FG_ACCENT = '\x1b[38;5;75m';
50
- const FG_MUTED = '\x1b[38;5;245m';
51
- const BG_CLAIMED = '\x1b[48;5;17m';
52
- const BG_IDLE = '\x1b[48;5;238m';
53
-
54
- // Data
43
+ // ANSI — bold/dim/italic only; no explicit fg/bg colors
44
+ const R = '\x1b[0m';
45
+ const B = '\x1b[1m'; // bold on
46
+ const B_ = '\x1b[22m'; // bold off (normal intensity)
47
+ const D = '\x1b[2m'; // dim on
48
+ const I = '\x1b[3m'; // italic on
49
+ const I_ = '\x1b[23m'; // italic off
50
+
55
51
  let data = getCached();
56
52
  if (!data) {
57
- const branch = run('git branch --show-current');
58
- let claimTitle = null;
59
- let openCount = 0;
53
+ // Model + token %
54
+ 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;
57
+
58
+ // Short hostname
59
+ const host = hostname().split('.')[0];
60
+
61
+ // Directory — repo-relative like the global script
62
+ const repoRoot = run('git rev-parse --show-toplevel');
63
+ let displayDir;
64
+ if (repoRoot) {
65
+ const rel = relative(repoRoot, cwd) || '.';
66
+ displayDir = rel === '.' ? basename(repoRoot) : `${basename(repoRoot)}/${rel}`;
67
+ } else {
68
+ displayDir = cwd.replace(process.env.HOME ?? '', '~');
69
+ }
60
70
 
61
- const hasBeads = existsSync(join(cwd, '.beads'));
62
- if (hasBeads) {
63
- const claimFile = join(cwd, '.xtrm', 'statusline-claim');
64
- let claimId = null;
65
- if (existsSync(claimFile)) {
66
- claimId = readFileSync(claimFile, 'utf8').trim() || null;
71
+ // Branch + git status indicators
72
+ let branch = null;
73
+ let gitStatus = '';
74
+ if (repoRoot) {
75
+ branch = run('git -c core.useBuiltinFSMonitor=false branch --show-current') || run('git rev-parse --short HEAD');
76
+ const porcelain = run('git -c core.useBuiltinFSMonitor=false --no-optional-locks status --porcelain') ?? '';
77
+ let modified = false, staged = false, deleted = false;
78
+ for (const l of porcelain.split('\n').filter(Boolean)) {
79
+ if (/^ M|^AM|^MM/.test(l)) modified = true;
80
+ if (/^A |^M /.test(l)) staged = true;
81
+ if (/^ D|^D /.test(l)) deleted = true;
67
82
  }
83
+ let st = (modified ? '*' : '') + (staged ? '+' : '') + (deleted ? '-' : '');
84
+ const ab = run('git -c core.useBuiltinFSMonitor=false --no-optional-locks rev-list --left-right --count @{upstream}...HEAD');
85
+ if (ab) {
86
+ const [behind, ahead] = ab.split(/\s+/).map(Number);
87
+ if (ahead > 0 && behind > 0) st += '↕';
88
+ else if (ahead > 0) st += '↑';
89
+ else if (behind > 0) st += '↓';
90
+ }
91
+ if (st) gitStatus = `(${st})`;
92
+ }
68
93
 
94
+ // Python venv
95
+ const venv = process.env.VIRTUAL_ENV ? `(${basename(process.env.VIRTUAL_ENV)})` : null;
96
+
97
+ // Beads
98
+ let claimId = null;
99
+ let claimTitle = null;
100
+ let openCount = 0;
101
+ if (existsSync(join(cwd, '.beads'))) {
102
+ const claimFile = join(cwd, '.xtrm', 'statusline-claim');
103
+ claimId = existsSync(claimFile) ? (readFileSync(claimFile, 'utf8').trim() || null) : null;
69
104
  if (claimId) {
70
105
  try {
71
106
  const raw = run(`bd show ${claimId} --json`);
72
- if (raw) {
73
- const parsed = JSON.parse(raw);
74
- claimTitle = parsed?.[0]?.title ?? null;
75
- }
107
+ claimTitle = raw ? (JSON.parse(raw)?.[0]?.title ?? null) : null;
76
108
  } catch {}
77
109
  }
78
-
79
110
  if (!claimTitle) {
80
- const listOut = run('bd list');
81
- const m = listOut?.match(/\((\d+)\s+open/);
111
+ const m = run('bd list')?.match(/\((\d+)\s+open/);
82
112
  if (m) openCount = parseInt(m[1], 10);
83
113
  }
84
114
  }
85
115
 
86
- data = { branch, claimTitle, openCount };
116
+ data = { modelStr, host, displayDir, branch, gitStatus, venv, claimId, claimTitle, openCount };
87
117
  setCache(data);
88
118
  }
89
119
 
90
- // Render
91
- const { branch, claimTitle, openCount } = data;
92
- const cols = process.stdout.columns || 80;
93
-
94
- const brand = `${BOLD}${FG_ACCENT}XTRM${BOLD_OFF}${R}`;
95
- const branchStr = branch ? `${FG_MUTED}⎇ ${branch}${R}` : '';
96
- const line1 = [brand, branchStr].filter(Boolean).join(' ');
120
+ const { modelStr, host, displayDir, branch, gitStatus, venv, claimId, claimTitle, openCount } = data;
97
121
 
98
- function padded(text, bg) {
99
- const visible = text.replace(/\x1b\[[0-9;]*m/g, '');
100
- const pad = Math.max(0, cols - visible.length);
101
- return `${bg}${FG_WHITE}${text}${' '.repeat(pad)}${R}`;
102
- }
122
+ // Line 1 — matches global format, XTRM prepended
123
+ const parts = [`${B}XTRM${B_}`];
124
+ if (modelStr) parts.push(`${D}${modelStr}${R}`);
125
+ parts.push(host);
126
+ if (displayDir) parts.push(`${B}${displayDir}${B_}`);
127
+ if (branch) parts.push(`${D}${[branch, gitStatus].filter(Boolean).join(' ')}${R}`);
128
+ if (venv) parts.push(`${D}${venv}${R}`);
129
+ const line1 = parts.join(' ');
103
130
 
131
+ // Line 2 — no background; open count bold
104
132
  let line2;
105
133
  if (claimTitle) {
106
- const maxLen = cols - 4;
107
- const title = claimTitle.length > maxLen ? claimTitle.slice(0, maxLen - 1) + '\u2026' : claimTitle;
108
- line2 = padded(` \u25d0 ${ITALIC}${title}${ITALIC_OFF}`, BG_CLAIMED);
134
+ const cols = process.stdout.columns || 80;
135
+ const prefix = ` ${claimId} `;
136
+ const prefixLen = prefix.replace(/\x1b\[[0-9;]*m/g, '').length;
137
+ const maxLen = cols - prefixLen - 1;
138
+ const t = claimTitle.length > maxLen ? claimTitle.slice(0, maxLen - 1) + '…' : claimTitle;
139
+ line2 = `${prefix}${I}${t}${I_}`;
109
140
  } else {
110
- const idle = openCount > 0 ? `\u25cb ${openCount} open` : '\u25cb no open issues';
111
- line2 = padded(` ${idle}`, BG_IDLE);
141
+ line2 = ` ○ ${openCount > 0 ? `${B}${openCount}${B_} open` : 'no open issues'}`;
112
142
  }
113
143
 
114
144
  process.stdout.write(line1 + '\n' + line2 + '\n');
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // worktree-boundary.mjs — Claude Code PreToolUse hook
3
+ // Blocks Write/Edit when the target file is outside the active worktree root.
4
+ // Only active when session cwd is inside .xtrm/worktrees/<name>.
5
+ // Fail-open: any unexpected error allows the edit through.
6
+ //
7
+ // Installed by: xtrm install
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ let input = {};
13
+ try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { process.exit(0); }
14
+
15
+ const cwd = input.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
16
+ const filePath = input?.tool_input?.file_path;
17
+ if (!filePath) process.exit(0);
18
+
19
+ // Detect worktree root from cwd
20
+ const m = cwd.match(/^(.+\/\.xtrm\/worktrees\/[^/]+)/);
21
+ if (!m) process.exit(0); // not in a worktree — no constraint
22
+
23
+ const worktreeRoot = m[1];
24
+ const abs = resolve(cwd, filePath);
25
+
26
+ if (abs === worktreeRoot || abs.startsWith(worktreeRoot + '/')) process.exit(0);
27
+
28
+ process.stdout.write(JSON.stringify({
29
+ decision: 'block',
30
+ reason: `🚫 Edit outside worktree boundary.\n File: ${abs}\n Allowed: ${worktreeRoot}\n\n All edits must stay within the active worktree.`,
31
+ }));
32
+ process.stdout.write('\n');
33
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.5.19",
3
+ "version": "0.5.21",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,7 +55,30 @@ python3 "skills/sync-docs/scripts/drift_detector.py" scan --since 30
55
55
  python3 "skills/sync-docs/scripts/drift_detector.py" scan --since 30 --json
56
56
  ```
57
57
 
58
- A docs file is stale when frontmatter `source_of_truth_for` (or `tracks`) matches files changed in recent commits.
58
+ A docs file is stale when:
59
+ 1. It declares `source_of_truth_for` globs in frontmatter
60
+ 2. AND there are commits affecting matching files AFTER the `synced_at` hash
61
+
62
+ ### synced_at Checkpoint
63
+
64
+ Add `synced_at: <git-hash>` to doc frontmatter to mark the last sync point:
65
+
66
+ ```yaml
67
+ ---
68
+ title: Hooks Reference
69
+ updated: 2026-03-21
70
+ synced_at: a1b2c3d # git hash when doc was last synced
71
+ source_of_truth_for:
72
+ - "hooks/**/*.mjs"
73
+ ---
74
+ ```
75
+
76
+ After updating a doc, run:
77
+ ```bash
78
+ python3 "skills/sync-docs/scripts/drift_detector.py" update-sync docs/hooks.md
79
+ ```
80
+
81
+ This sets `synced_at` to current HEAD, marking the doc as synced.
59
82
 
60
83
  ---
61
84