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/CHANGELOG.md +17 -0
- package/cli/dist/index.cjs +670 -541
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/hooks.json +5 -3
- package/hooks/beads-claim-sync.mjs +10 -0
- package/hooks/beads-edit-gate.mjs +14 -1
- package/hooks/beads-gate-core.mjs +21 -0
- package/hooks/beads-gate-utils.mjs +9 -0
- package/hooks/hooks.json +24 -23
- package/hooks/statusline.mjs +91 -61
- package/hooks/worktree-boundary.mjs +33 -0
- package/package.json +1 -1
- package/skills/sync-docs/SKILL.md +24 -1
- package/skills/sync-docs/scripts/drift_detector.py +259 -23
- package/skills/using-TDD/SKILL.md +1 -1
- package/hooks/serena-workflow-reminder.py +0 -74
package/cli/package.json
CHANGED
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": "
|
|
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": [
|
package/hooks/statusline.mjs
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// statusline.mjs — Claude Code statusLine
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
107
|
-
const
|
|
108
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
|