xtrm-tools 0.5.20 → 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.20",
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": [
package/hooks/hooks.json CHANGED
@@ -16,10 +16,6 @@
16
16
  "type": "command",
17
17
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/quality-check-env.mjs",
18
18
  "timeout": 5000
19
- },
20
- {
21
- "type": "command",
22
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/serena-workflow-reminder.py"
23
19
  }
24
20
  ]
25
21
  }
@@ -95,11 +95,12 @@ if (!data) {
95
95
  const venv = process.env.VIRTUAL_ENV ? `(${basename(process.env.VIRTUAL_ENV)})` : null;
96
96
 
97
97
  // Beads
98
+ let claimId = null;
98
99
  let claimTitle = null;
99
100
  let openCount = 0;
100
101
  if (existsSync(join(cwd, '.beads'))) {
101
102
  const claimFile = join(cwd, '.xtrm', 'statusline-claim');
102
- const claimId = existsSync(claimFile) ? (readFileSync(claimFile, 'utf8').trim() || null) : null;
103
+ claimId = existsSync(claimFile) ? (readFileSync(claimFile, 'utf8').trim() || null) : null;
103
104
  if (claimId) {
104
105
  try {
105
106
  const raw = run(`bd show ${claimId} --json`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.5.20",
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
 
@@ -2,13 +2,17 @@
2
2
  """
3
3
  Detect documentation drift between docs/ files and git-modified files.
4
4
 
5
- A docs file is considered stale when it declares source globs in frontmatter
6
- (`source_of_truth_for` or `tracks`) and recent commits modified matching files.
5
+ A docs file is considered stale when:
6
+ 1. It declares source globs in frontmatter (`source_of_truth_for` or `tracks`)
7
+ 2. AND there are commits affecting those source files AFTER the doc's `synced_at` hash
8
+
9
+ If `synced_at` is not set, the doc is considered stale (never synced).
7
10
 
8
11
  Subcommands:
9
12
  scan [--since N] [--json] — scan all docs files (default N=30 commits)
10
13
  check <docs-file> [--since N] [--json] — check one docs file
11
14
  hook [--json] — check current uncommitted changes
15
+ update-sync <docs-file> -- update synced_at to current HEAD hash
12
16
  """
13
17
 
14
18
  import sys
@@ -98,6 +102,70 @@ def extract_updated(content: str) -> str:
98
102
  return str(fm.get("updated", ""))
99
103
 
100
104
 
105
+ def extract_synced_at(content: str) -> str | None:
106
+ """Extract the synced_at git hash from frontmatter."""
107
+ fm = extract_frontmatter(content)
108
+ synced = fm.get("synced_at")
109
+ if synced and isinstance(synced, str) and synced.strip():
110
+ return synced.strip()
111
+ return None
112
+
113
+
114
+ def get_current_head_hash(project_root: Path) -> str | None:
115
+ """Get the current HEAD commit hash (short form)."""
116
+ try:
117
+ result = subprocess.run(
118
+ ["git", "rev-parse", "--short", "HEAD"],
119
+ cwd=project_root,
120
+ capture_output=True,
121
+ text=True,
122
+ timeout=5,
123
+ )
124
+ if result.returncode == 0:
125
+ return result.stdout.strip()
126
+ except Exception:
127
+ pass
128
+ return None
129
+
130
+
131
+ def get_commits_after_hash(project_root: Path, synced_hash: str, source_files: list[str]) -> list[str]:
132
+ """
133
+ Check if there are commits affecting source_files after synced_hash.
134
+ Returns list of affected files (empty if no changes after sync point).
135
+ """
136
+ affected: list[str] = []
137
+ try:
138
+ # Check each source file for commits after the sync point
139
+ for source in source_files:
140
+ result = subprocess.run(
141
+ ["git", "log", f"{synced_hash}..HEAD", "--oneline", "--", source],
142
+ cwd=project_root,
143
+ capture_output=True,
144
+ text=True,
145
+ timeout=10,
146
+ )
147
+ if result.returncode == 0 and result.stdout.strip():
148
+ affected.append(source)
149
+ except Exception:
150
+ pass
151
+ return affected
152
+
153
+
154
+ def is_valid_git_hash(project_root: Path, hash_ref: str) -> bool:
155
+ """Check if a hash/ref is valid in the git repository."""
156
+ try:
157
+ result = subprocess.run(
158
+ ["git", "rev-parse", "--verify", hash_ref],
159
+ cwd=project_root,
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=5,
163
+ )
164
+ return result.returncode == 0
165
+ except Exception:
166
+ return False
167
+
168
+
101
169
  def _match_glob(path: str, pattern: str) -> bool:
102
170
  path_parts = Path(path).as_posix().split("/")
103
171
  pattern_parts = Path(pattern).as_posix().split("/")
@@ -143,6 +211,28 @@ def get_recent_modified_files(project_root: Path, since_n_commits: int = 30) ->
143
211
  return []
144
212
 
145
213
 
214
+ def get_files_matching_globs(project_root: Path, globs: list[str]) -> list[str]:
215
+ """Get all tracked files matching the given glob patterns."""
216
+ matched: list[str] = []
217
+ try:
218
+ # Get all tracked files
219
+ result = subprocess.run(
220
+ ["git", "ls-files"],
221
+ cwd=project_root,
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=30,
225
+ )
226
+ if result.returncode != 0:
227
+ return []
228
+
229
+ all_files = [l.strip() for l in result.stdout.splitlines() if l.strip()]
230
+ matched = match_files_to_globs(all_files, globs)
231
+ except Exception:
232
+ pass
233
+ return matched
234
+
235
+
146
236
  def get_session_written_files(project_root: Path) -> list[str]:
147
237
  try:
148
238
  unstaged = subprocess.run(
@@ -165,7 +255,13 @@ def get_session_written_files(project_root: Path) -> list[str]:
165
255
  return []
166
256
 
167
257
 
168
- def scan_docs(project_root: Path, changed_files: list[str]) -> list[dict[str, Any]]:
258
+ def scan_docs(project_root: Path, changed_files: list[str], use_hash_check: bool = True) -> list[dict[str, Any]]:
259
+ """
260
+ Scan docs for drift.
261
+
262
+ If use_hash_check=True (default): use synced_at hash comparison
263
+ If use_hash_check=False: use legacy recent-commits matching (for --since flag)
264
+ """
169
265
  stale: list[dict[str, Any]] = []
170
266
  for doc_path in get_docs_files(project_root):
171
267
  content = doc_path.read_text(encoding="utf-8", errors="replace")
@@ -173,16 +269,66 @@ def scan_docs(project_root: Path, changed_files: list[str]) -> list[dict[str, An
173
269
  if not globs:
174
270
  continue
175
271
 
176
- matched = match_files_to_globs(changed_files, globs)
177
- if matched:
178
- stale.append(
179
- {
272
+ synced_at = extract_synced_at(content)
273
+ updated = extract_updated(content)
274
+
275
+ if use_hash_check and synced_at:
276
+ # Hash-based check: are there commits affecting source files after synced_at?
277
+ if not is_valid_git_hash(project_root, synced_at):
278
+ # Invalid hash (maybe rebased/force-pushed) - treat as stale
279
+ stale.append({
280
+ "doc": str(doc_path.relative_to(project_root)),
281
+ "updated": updated,
282
+ "synced_at": synced_at,
283
+ "synced_at_valid": False,
284
+ "matched_files": [],
285
+ "globs": globs,
286
+ "reason": "synced_at hash not found in git history",
287
+ })
288
+ continue
289
+
290
+ # Get all tracked files matching the globs
291
+ source_files = get_files_matching_globs(project_root, globs)
292
+
293
+ # Check which files have commits after synced_at
294
+ affected = get_commits_after_hash(project_root, synced_at, source_files)
295
+
296
+ if affected:
297
+ stale.append({
298
+ "doc": str(doc_path.relative_to(project_root)),
299
+ "updated": updated,
300
+ "synced_at": synced_at,
301
+ "synced_at_valid": True,
302
+ "matched_files": affected[:10],
303
+ "globs": globs,
304
+ "reason": "source files changed after sync point",
305
+ })
306
+ elif use_hash_check:
307
+ # No synced_at hash - doc needs initial sync
308
+ source_files = get_files_matching_globs(project_root, globs)
309
+ if source_files:
310
+ stale.append({
311
+ "doc": str(doc_path.relative_to(project_root)),
312
+ "updated": updated,
313
+ "synced_at": None,
314
+ "synced_at_valid": False,
315
+ "matched_files": source_files[:10],
316
+ "globs": globs,
317
+ "reason": "no synced_at hash - needs initial sync",
318
+ })
319
+ else:
320
+ # Legacy check: match against recent commits
321
+ matched = match_files_to_globs(changed_files, globs)
322
+ if matched:
323
+ stale.append({
180
324
  "doc": str(doc_path.relative_to(project_root)),
181
- "updated": extract_updated(content),
325
+ "updated": updated,
326
+ "synced_at": synced_at,
327
+ "synced_at_valid": synced_at is not None,
182
328
  "matched_files": matched[:10],
183
329
  "globs": globs,
184
- }
185
- )
330
+ "reason": "recent commits match",
331
+ })
186
332
  return stale
187
333
 
188
334
 
@@ -191,34 +337,56 @@ def print_human_report(stale: list[dict[str, Any]], source: str) -> None:
191
337
  print(f"[Docs Drift] All docs up to date ({source}).")
192
338
  return
193
339
 
194
- print(f"[Drift Report] {len(stale)} stale doc(s) detected from {source}:\n")
340
+ print(f"[Drift Report] {len(stale)} stale doc(s) detected:\n")
195
341
  for item in stale:
196
342
  print(f" {item['doc']}")
343
+ synced = item.get("synced_at")
344
+ if synced:
345
+ valid = item.get("synced_at_valid", True)
346
+ status = "valid" if valid else "INVALID (not in git history)"
347
+ print(f" Synced at: {synced} ({status})")
348
+ else:
349
+ print(f" Synced at: (not set)")
197
350
  print(f" Last updated: {item['updated'] or 'unknown'}")
198
- for file_path in item["matched_files"][:3]:
199
- print(f" Modified: {file_path}")
351
+ reason = item.get("reason", "source files changed")
352
+ print(f" Reason: {reason}")
353
+ if item.get("matched_files"):
354
+ for file_path in item["matched_files"][:3]:
355
+ print(f" Modified: {file_path}")
200
356
  print("")
201
357
  print("Run /sync-docs to review and update stale docs.")
358
+ print("After updating, run: drift_detector.py update-sync <docs-file>")
202
359
 
203
360
 
204
361
  def cmd_scan(args: list[str]) -> None:
205
362
  since = 30
206
363
  as_json = "--json" in args
364
+ use_legacy = "--legacy" in args or "--since" in args
365
+
207
366
  if "--since" in args:
208
367
  idx = args.index("--since")
209
368
  if idx + 1 < len(args):
210
369
  since = int(args[idx + 1])
211
370
 
212
371
  project_root = find_project_root()
213
- changed = get_recent_modified_files(project_root, since)
214
- stale = scan_docs(project_root, changed)
372
+
373
+ if use_legacy:
374
+ # Legacy mode: match against recent commits
375
+ changed = get_recent_modified_files(project_root, since)
376
+ stale = scan_docs(project_root, changed, use_hash_check=False)
377
+ source = f"last {since} commits (legacy mode)"
378
+ else:
379
+ # Default: hash-based check
380
+ stale = scan_docs(project_root, [], use_hash_check=True)
381
+ source = "hash-based sync check"
215
382
 
216
383
  if as_json:
217
384
  print(
218
385
  json.dumps(
219
386
  {
220
387
  "mode": "scan",
221
- "since": since,
388
+ "legacy": use_legacy,
389
+ "since": since if use_legacy else None,
222
390
  "count": len(stale),
223
391
  "stale": stale,
224
392
  },
@@ -226,7 +394,7 @@ def cmd_scan(args: list[str]) -> None:
226
394
  )
227
395
  )
228
396
  else:
229
- print_human_report(stale, f"last {since} commits")
397
+ print_human_report(stale, source)
230
398
 
231
399
  sys.exit(1 if stale else 0)
232
400
 
@@ -284,7 +452,8 @@ def cmd_hook(args: list[str]) -> None:
284
452
  if not changed:
285
453
  sys.exit(0)
286
454
 
287
- stale = scan_docs(project_root, changed)
455
+ # Hook mode always uses legacy check (uncommitted changes, not committed history)
456
+ stale = scan_docs(project_root, changed, use_hash_check=False)
288
457
  if not stale:
289
458
  sys.exit(0)
290
459
 
@@ -308,16 +477,83 @@ def cmd_hook(args: list[str]) -> None:
308
477
  sys.exit(1)
309
478
 
310
479
 
311
- SUBCOMMANDS = {"scan": cmd_scan, "check": cmd_check, "hook": cmd_hook}
480
+ def cmd_update_sync(args: list[str]) -> None:
481
+ """Update synced_at field in a doc's frontmatter to current HEAD hash."""
482
+ if not args or args[0].startswith("--"):
483
+ print("Usage: drift_detector.py update-sync <docs-file>")
484
+ print(" Updates the synced_at field to current HEAD hash")
485
+ sys.exit(1)
486
+
487
+ target = args[0]
488
+ project_root = find_project_root()
489
+ doc_path = (project_root / target).resolve()
490
+
491
+ if not doc_path.exists():
492
+ print(f"Doc not found: {target}")
493
+ sys.exit(1)
494
+
495
+ # Get current HEAD hash
496
+ head_hash = get_current_head_hash(project_root)
497
+ if not head_hash:
498
+ print("Failed to get current HEAD hash")
499
+ sys.exit(1)
500
+
501
+ # Read doc content
502
+ content = doc_path.read_text(encoding="utf-8")
503
+
504
+ # Check for frontmatter
505
+ fm_match = re.match(r"^(---\n)(.*?)(\n---\n)", content, re.DOTALL)
506
+ if not fm_match:
507
+ print(f"No frontmatter found in {target}")
508
+ sys.exit(1)
509
+
510
+ frontmatter_block = fm_match.group(2)
511
+ rest_of_content = content[fm_match.end():]
512
+
513
+ # Parse frontmatter
514
+ fm = extract_frontmatter(content)
515
+
516
+ # Update or add synced_at
517
+ if "synced_at" in fm:
518
+ # Replace existing synced_at
519
+ new_frontmatter = re.sub(
520
+ r"^synced_at:\s*.*$",
521
+ f"synced_at: {head_hash}",
522
+ frontmatter_block,
523
+ flags=re.MULTILINE,
524
+ )
525
+ else:
526
+ # Add synced_at after updated field, or at end
527
+ if "updated" in fm:
528
+ new_frontmatter = re.sub(
529
+ r"^(updated:\s*.*)$",
530
+ rf"\1\nsynced_at: {head_hash}",
531
+ frontmatter_block,
532
+ flags=re.MULTILINE,
533
+ )
534
+ else:
535
+ new_frontmatter = frontmatter_block.rstrip() + f"\nsynced_at: {head_hash}"
536
+
537
+ # Reconstruct file
538
+ new_content = f"---\n{new_frontmatter}\n---\n{rest_of_content}"
539
+
540
+ # Write back
541
+ doc_path.write_text(new_content, encoding="utf-8")
542
+ print(f"Updated {target}: synced_at = {head_hash}")
543
+
544
+
545
+ SUBCOMMANDS = {"scan": cmd_scan, "check": cmd_check, "hook": cmd_hook, "update-sync": cmd_update_sync}
312
546
 
313
547
 
314
548
  def main() -> None:
315
549
  args = sys.argv[1:]
316
550
  if not args or args[0] not in SUBCOMMANDS:
317
- print("Usage: drift_detector.py <scan|check|hook> [options]")
318
- print(" scan [--since N] [--json] scan all docs files")
319
- print(" check <docs-file> [--since N] check one docs file")
320
- print(" hook [--json] stop-hook mode")
551
+ print("Usage: drift_detector.py <scan|check|hook|update-sync> [options]")
552
+ print(" scan [--legacy] [--since N] [--json] scan all docs (hash-based by default)")
553
+ print(" scan --legacy --since N legacy mode: match recent commits")
554
+ print(" check <docs-file> [--since N] check one docs file")
555
+ print(" hook [--json] stop-hook mode (uncommitted changes)")
556
+ print(" update-sync <docs-file> set synced_at to current HEAD")
321
557
  sys.exit(1)
322
558
 
323
559
  SUBCOMMANDS[args[0]](args[1:])
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env python3
2
- import sys
3
- import os
4
-
5
- # Add script directory to path to allow importing shared modules
6
- sys.path.append(os.path.dirname(os.path.abspath(__file__)))
7
- from agent_context import AgentContext
8
-
9
- def get_skill_reminder(agent_type):
10
- folder = f".{agent_type}"
11
- return f"""
12
- *** MANDATORY SKILL: Using Serena LSP ***
13
- You are REQUIRED to use semantic tools for all code interactions to ensure safety and token efficiency.
14
-
15
- RULES:
16
- 1. READING: NEVER read full code files >300 lines.
17
- - START with `get_symbols_overview(depth=1)` to map the file.
18
- - READ specific parts with `find_symbol(include_body=True)`.
19
- 2. EDITING: NEVER use the generic `Edit` or `replace` tool on code.
20
- - USE `replace_symbol_body` for atomic updates.
21
- - USE `insert_after_symbol` / `insert_before_symbol` for additions.
22
- - ALWAYS run `find_referencing_symbols` before changing signatures.
23
- 3. SEARCH: USE `search_for_pattern` instead of grep/find.
24
-
25
- Ref: ~/{folder}/skills/using-serena-lsp/SKILL.md
26
- """
27
-
28
- CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.go', '.rs', '.java', '.cpp', '.c', '.h'}
29
-
30
- def count_lines(filepath):
31
- try:
32
- with open(filepath, 'r') as f:
33
- return sum(1 for _ in f)
34
- except:
35
- return 0
36
-
37
- try:
38
- ctx = AgentContext()
39
- event = ctx.event
40
-
41
- if event == 'SessionStart':
42
- ctx.allow(additional_context=get_skill_reminder(ctx.agent_type))
43
-
44
- elif event in ['PreToolUse', 'BeforeTool']:
45
- tool_name = ctx.tool_name
46
-
47
- # Rule 1: Block Reading Large Code Files
48
- if tool_name in ['Read', 'read_file']:
49
- file_path = ctx.get_file_path()
50
- _, ext = os.path.splitext(file_path)
51
- if ext in CODE_EXTENSIONS:
52
- loc = count_lines(file_path)
53
- if loc > 300:
54
- ctx.block(
55
- reason=f"VIOLATION: Reading full file of {loc} lines is forbidden. Use 'get_symbols_overview' and 'find_symbol' to save tokens.",
56
- system_message="⚠️ Blocked inefficient file read. Use Serena semantic tools."
57
- )
58
-
59
- # Rule 2: Block Generic Edits on Code
60
- if tool_name in ['Edit', 'replace']:
61
- file_path = ctx.get_file_path()
62
- _, ext = os.path.splitext(file_path)
63
- if ext in CODE_EXTENSIONS:
64
- ctx.block(
65
- reason=f"VIOLATION: Generic '{tool_name}' is unsafe for code. Use 'replace_symbol_body' or 'insert_after_symbol' for surgical edits.",
66
- system_message="⚠️ Blocked unsafe edit. Use Serena semantic tools."
67
- )
68
-
69
- ctx.fail_open()
70
-
71
- except Exception as e:
72
- # Fail safe: log error but allow operation
73
- print(f"Hook Error: {e}", file=sys.stderr)
74
- sys.exit(0)