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 +1 -1
- package/config/hooks.json +0 -3
- package/hooks/hooks.json +0 -4
- package/hooks/statusline.mjs +2 -1
- package/package.json +1 -1
- package/skills/sync-docs/SKILL.md +24 -1
- package/skills/sync-docs/scripts/drift_detector.py +259 -23
- package/hooks/serena-workflow-reminder.py +0 -74
package/cli/package.json
CHANGED
package/config/hooks.json
CHANGED
package/hooks/hooks.json
CHANGED
package/hooks/statusline.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|
|
|
@@ -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
|
|
6
|
-
(`source_of_truth_for` or `tracks`)
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
319
|
-
print("
|
|
320
|
-
print("
|
|
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)
|