claude-jacked 0.2.3__py3-none-any.whl → 0.2.9__py3-none-any.whl

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 (33) hide show
  1. claude_jacked-0.2.9.dist-info/METADATA +523 -0
  2. claude_jacked-0.2.9.dist-info/RECORD +33 -0
  3. jacked/cli.py +752 -47
  4. jacked/client.py +196 -29
  5. jacked/data/agents/code-simplicity-reviewer.md +87 -0
  6. jacked/data/agents/defensive-error-handler.md +93 -0
  7. jacked/data/agents/double-check-reviewer.md +214 -0
  8. jacked/data/agents/git-pr-workflow-manager.md +149 -0
  9. jacked/data/agents/issue-pr-coordinator.md +131 -0
  10. jacked/data/agents/pr-workflow-checker.md +199 -0
  11. jacked/data/agents/readme-maintainer.md +123 -0
  12. jacked/data/agents/test-coverage-engineer.md +155 -0
  13. jacked/data/agents/test-coverage-improver.md +139 -0
  14. jacked/data/agents/wiki-documentation-architect.md +580 -0
  15. jacked/data/commands/audit-rules.md +103 -0
  16. jacked/data/commands/dc.md +155 -0
  17. jacked/data/commands/learn.md +89 -0
  18. jacked/data/commands/pr.md +4 -0
  19. jacked/data/commands/redo.md +85 -0
  20. jacked/data/commands/techdebt.md +115 -0
  21. jacked/data/prompts/security_gatekeeper.txt +58 -0
  22. jacked/data/rules/jacked_behaviors.md +11 -0
  23. jacked/data/skills/jacked/SKILL.md +162 -0
  24. jacked/index_write_tracker.py +227 -0
  25. jacked/indexer.py +255 -129
  26. jacked/retriever.py +389 -137
  27. jacked/searcher.py +65 -13
  28. jacked/transcript.py +339 -0
  29. claude_jacked-0.2.3.dist-info/METADATA +0 -483
  30. claude_jacked-0.2.3.dist-info/RECORD +0 -13
  31. {claude_jacked-0.2.3.dist-info → claude_jacked-0.2.9.dist-info}/WHEEL +0 -0
  32. {claude_jacked-0.2.3.dist-info → claude_jacked-0.2.9.dist-info}/entry_points.txt +0 -0
  33. {claude_jacked-0.2.3.dist-info → claude_jacked-0.2.9.dist-info}/licenses/LICENSE +0 -0
jacked/cli.py CHANGED
@@ -32,11 +32,18 @@ def setup_logging(verbose: bool = False):
32
32
  )
33
33
 
34
34
 
35
- def get_config() -> SmartForkConfig:
36
- """Load configuration from environment."""
35
+ def get_config(quiet: bool = False) -> Optional[SmartForkConfig]:
36
+ """Load configuration from environment.
37
+
38
+ Args:
39
+ quiet: If True, return None instead of printing error and exiting.
40
+ Used by hooks that should fail gracefully.
41
+ """
37
42
  try:
38
43
  return SmartForkConfig.from_env()
39
44
  except ValueError as e:
45
+ if quiet:
46
+ return None
40
47
  console.print(f"[red]Configuration error:[/red] {e}")
41
48
  console.print("\nSet these environment variables:")
42
49
  console.print(" QDRANT_CLAUDE_SESSIONS_ENDPOINT=<your-qdrant-url>")
@@ -63,7 +70,12 @@ def index(session: Optional[str], repo: Optional[str]):
63
70
  import os
64
71
  from jacked.indexer import SessionIndexer
65
72
 
66
- config = get_config()
73
+ # Try to get config quietly - if not configured, nudge and exit cleanly
74
+ config = get_config(quiet=True)
75
+ if config is None:
76
+ print("[jacked] Indexing skipped - run 'jacked configure' to set up Qdrant")
77
+ sys.exit(0)
78
+
67
79
  indexer = SessionIndexer(config)
68
80
 
69
81
  if session:
@@ -119,8 +131,8 @@ def index(session: Optional[str], repo: Optional[str]):
119
131
  if result.get("indexed"):
120
132
  console.print(
121
133
  f"[green][OK][/green] Indexed session {result['session_id']}: "
122
- f"{result['intent_chunks']} intent chunks, "
123
- f"{result['transcript_chunks']} transcript chunks"
134
+ f"{result['plans']}p {result['subagent_summaries']}a "
135
+ f"{result['summary_labels']}l {result['user_messages']}u {result['chunks']}c"
124
136
  )
125
137
  elif result.get("skipped"):
126
138
  console.print(f"[yellow][-][/yellow] Session {result['session_id']} unchanged, skipped")
@@ -167,8 +179,17 @@ def backfill(repo: Optional[str], force: bool):
167
179
  @click.option("--limit", "-n", default=5, help="Maximum results")
168
180
  @click.option("--mine", "-m", is_flag=True, help="Only show my sessions")
169
181
  @click.option("--user", "-u", help="Only show sessions from this user")
170
- def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Optional[str]):
171
- """Search for sessions by semantic similarity with multi-factor ranking."""
182
+ @click.option(
183
+ "--type", "-t", "content_types",
184
+ multiple=True,
185
+ help="Filter by content type (plan, subagent_summary, summary_label, user_message, chunk)"
186
+ )
187
+ def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Optional[str], content_types: tuple):
188
+ """Search for sessions by semantic similarity with multi-factor ranking.
189
+
190
+ By default, searches plan, subagent_summary, summary_label, and user_message content.
191
+ Use --type to filter to specific content types.
192
+ """
172
193
  import os
173
194
  from jacked.searcher import SessionSearcher
174
195
 
@@ -178,6 +199,9 @@ def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Option
178
199
  # Use current repo if not specified
179
200
  current_repo = repo or os.getenv("CLAUDE_PROJECT_DIR")
180
201
 
202
+ # Convert tuple to list or None
203
+ type_filter = list(content_types) if content_types else None
204
+
181
205
  with Progress(
182
206
  SpinnerColumn(),
183
207
  TextColumn("[progress.description]{task.description}"),
@@ -191,6 +215,7 @@ def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Option
191
215
  limit=limit,
192
216
  mine_only=mine,
193
217
  user_filter=user,
218
+ content_types=type_filter,
194
219
  )
195
220
 
196
221
  progress.remove_task(task)
@@ -202,27 +227,61 @@ def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Option
202
227
  table = Table(title="Search Results", show_header=True)
203
228
  table.add_column("#", style="dim", width=3)
204
229
  table.add_column("Score", style="cyan", width=6)
205
- table.add_column("User", style="yellow", width=12)
206
- table.add_column("Date", style="green", width=12)
207
- table.add_column("Repository", style="magenta")
230
+ table.add_column("User", style="yellow", width=10)
231
+ table.add_column("Age", style="green", width=12)
232
+ table.add_column("Repo", style="magenta", width=15)
233
+ table.add_column("Content", style="blue", width=8)
208
234
  table.add_column("Preview")
209
235
 
210
236
  for i, result in enumerate(results, 1):
211
- date_str = result.timestamp.strftime("%Y-%m-%d") if result.timestamp else "?"
212
- preview = result.intent_preview[:50] + "..." if len(result.intent_preview) > 50 else result.intent_preview
237
+ # Format relative time
238
+ if result.timestamp:
239
+ from datetime import datetime, timezone
240
+ now = datetime.now(timezone.utc)
241
+ ts = result.timestamp
242
+ if ts.tzinfo is None:
243
+ ts = ts.replace(tzinfo=timezone.utc)
244
+ days = (now - ts).days
245
+ if days == 0:
246
+ age_str = "today"
247
+ elif days == 1:
248
+ age_str = "yesterday"
249
+ elif days < 7:
250
+ age_str = f"{days}d ago"
251
+ elif days < 30:
252
+ age_str = f"{days // 7}w ago"
253
+ elif days < 365:
254
+ age_str = f"{days // 30}mo ago"
255
+ else:
256
+ age_str = f"{days // 365}y ago"
257
+ else:
258
+ age_str = "?"
259
+
260
+ preview = result.intent_preview[:40] + "..." if len(result.intent_preview) > 40 else result.intent_preview
213
261
  user_display = "YOU" if result.is_own else f"@{result.user_name}"
262
+
263
+ # Content indicators
264
+ indicators = []
265
+ if result.has_plan:
266
+ indicators.append("📋")
267
+ if result.has_agent_summaries:
268
+ indicators.append("🤖")
269
+ content_str = " ".join(indicators) if indicators else "-"
270
+
214
271
  table.add_row(
215
272
  str(i),
216
273
  f"{result.score:.0f}%",
217
274
  user_display,
218
- date_str,
219
- result.repo_name,
275
+ age_str,
276
+ result.repo_name[:15],
277
+ content_str,
220
278
  preview,
221
279
  )
222
280
 
223
281
  console.print(table)
224
- console.print(f"\n[dim]Use 'jacked retrieve <session_id>' to get full transcript[/dim]")
225
- console.print(f"[dim]Use 'jacked retrieve <id1> <id2> ...' to get multiple transcripts[/dim]")
282
+ console.print("\n[dim]📋 = has plan file | 🤖 = has agent summaries[/dim]")
283
+ console.print(f"[dim]Use 'jacked retrieve <id> --mode smart' for optimized context (default)[/dim]")
284
+ console.print(f"[dim]Use 'jacked retrieve <id> --mode full' for complete transcript[/dim]")
226
285
 
227
286
  # Print session IDs for easy copy
228
287
  console.print("\nSession IDs:")
@@ -232,10 +291,26 @@ def search(query: str, repo: Optional[str], limit: int, mine: bool, user: Option
232
291
 
233
292
  @main.command()
234
293
  @click.argument("session_id")
235
- @click.option("--output", "-o", type=click.Path(), help="Save transcript to file")
236
- @click.option("--summary", "-s", is_flag=True, help="Show summary instead of full transcript")
237
- def retrieve(session_id: str, output: Optional[str], summary: bool):
238
- """Retrieve a session's full transcript."""
294
+ @click.option("--output", "-o", type=click.Path(), help="Save output to file")
295
+ @click.option("--summary", "-s", is_flag=True, help="Show summary instead of content")
296
+ @click.option(
297
+ "--mode", "-m",
298
+ type=click.Choice(["smart", "plan", "labels", "agents", "full"]),
299
+ default="smart",
300
+ help="Retrieval mode (default: smart)"
301
+ )
302
+ @click.option("--max-tokens", "-t", default=15000, help="Max token budget for smart mode")
303
+ @click.option("--inject", "-i", is_flag=True, help="Format for context injection")
304
+ def retrieve(session_id: str, output: Optional[str], summary: bool, mode: str, max_tokens: int, inject: bool):
305
+ """Retrieve a session's context with smart mode support.
306
+
307
+ Modes:
308
+ smart - Plan + agent summaries + labels + user messages (default)
309
+ plan - Just the plan file
310
+ labels - Just summary labels (tiny)
311
+ agents - All subagent summaries
312
+ full - Everything including full transcript
313
+ """
239
314
  from jacked.retriever import SessionRetriever
240
315
 
241
316
  config = get_config()
@@ -248,7 +323,7 @@ def retrieve(session_id: str, output: Optional[str], summary: bool):
248
323
  ) as progress:
249
324
  task = progress.add_task(f"Retrieving {session_id}...", total=None)
250
325
 
251
- session = retriever.retrieve(session_id)
326
+ session = retriever.retrieve(session_id, mode=mode)
252
327
 
253
328
  progress.remove_task(task)
254
329
 
@@ -256,12 +331,28 @@ def retrieve(session_id: str, output: Optional[str], summary: bool):
256
331
  console.print(f"[red]Session {session_id} not found[/red]")
257
332
  sys.exit(1)
258
333
 
259
- # Show metadata
334
+ # Show metadata with content summary
335
+ tokens = session.content.estimate_tokens()
336
+ content_parts = []
337
+ if session.content.plan:
338
+ content_parts.append(f"Plan: {tokens['plan']} tokens")
339
+ if session.content.subagent_summaries:
340
+ content_parts.append(f"Agent summaries: {len(session.content.subagent_summaries)} ({tokens['subagent_summaries']} tokens)")
341
+ if session.content.summary_labels:
342
+ content_parts.append(f"Labels: {len(session.content.summary_labels)} ({tokens['summary_labels']} tokens)")
343
+ if session.content.user_messages:
344
+ content_parts.append(f"User messages: {len(session.content.user_messages)} ({tokens['user_messages']} tokens)")
345
+ if session.content.chunks:
346
+ content_parts.append(f"Transcript chunks: {len(session.content.chunks)} ({tokens['chunks']} tokens)")
347
+
260
348
  console.print(Panel(
261
349
  f"Session: {session.session_id}\n"
262
350
  f"Repository: {session.repo_name}\n"
263
351
  f"Machine: {session.machine}\n"
264
- f"Local: {'Yes' if session.is_local else 'No'}",
352
+ f"Age: {session.format_relative_time()}\n"
353
+ f"Local: {'Yes' if session.is_local else 'No'}\n"
354
+ f"\nContent available:\n " + "\n ".join(content_parts) +
355
+ f"\n\nEstimated tokens (smart): {tokens['total']}",
265
356
  title="Session Info",
266
357
  ))
267
358
 
@@ -272,21 +363,24 @@ def retrieve(session_id: str, output: Optional[str], summary: bool):
272
363
 
273
364
  if summary:
274
365
  text = retriever.get_summary(session)
366
+ elif inject:
367
+ text = retriever.format_for_injection(session, mode=mode, max_tokens=max_tokens)
275
368
  else:
276
- text = session.full_transcript
369
+ # Default: format based on mode
370
+ text = retriever.format_for_injection(session, mode=mode, max_tokens=max_tokens)
277
371
 
278
372
  if output:
279
373
  Path(output).write_text(text, encoding="utf-8")
280
374
  console.print(f"\n[green]Saved to {output}[/green]")
281
375
  else:
282
- console.print(f"\n[bold]Transcript ({len(session.full_transcript)} chars):[/bold]")
376
+ console.print(f"\n[bold]Content (mode={mode}):[/bold]")
283
377
  console.print(text)
284
378
 
285
379
 
286
- @main.command()
380
+ @main.command(name="sessions")
287
381
  @click.option("--repo", "-r", help="Filter by repository path")
288
382
  @click.option("--limit", "-n", default=20, help="Maximum results")
289
- def list(repo: Optional[str], limit: int):
383
+ def list_sessions(repo: Optional[str], limit: int):
290
384
  """List indexed sessions."""
291
385
  from jacked.client import QdrantSessionClient
292
386
 
@@ -340,6 +434,50 @@ def delete(session_id: str, yes: bool):
340
434
  console.print(f"[green][OK][/green] Deleted session {session_id}")
341
435
 
342
436
 
437
+ @main.command()
438
+ def cleardb():
439
+ """
440
+ Delete ALL your indexed data from Qdrant.
441
+
442
+ Only deletes YOUR data (matching your user_name), not teammates' data.
443
+ Use this before re-indexing with a new schema or to start fresh.
444
+ """
445
+ from jacked.client import QdrantSessionClient
446
+
447
+ config = get_config()
448
+ client = QdrantSessionClient(config)
449
+
450
+ # Show what we're about to delete
451
+ user_name = config.user_name
452
+ count = client.count_by_user(user_name)
453
+
454
+ if count == 0:
455
+ console.print(f"[yellow]No data found for user '{user_name}'[/yellow]")
456
+ return
457
+
458
+ console.print(Panel(
459
+ f"[bold red]WARNING: This will permanently delete ALL your indexed data![/bold red]\n\n"
460
+ f"User: [cyan]{user_name}[/cyan]\n"
461
+ f"Points to delete: [red]{count}[/red]\n\n"
462
+ f"This only affects YOUR data. Teammates' data will be untouched.\n"
463
+ f"After clearing, run 'jacked backfill' to re-index.",
464
+ title="Clear Database",
465
+ ))
466
+
467
+ # Require typing confirmation phrase
468
+ console.print("\n[bold]To confirm, type: DELETE MY DATA[/bold]")
469
+ confirmation = click.prompt("Confirmation", default="", show_default=False)
470
+
471
+ if confirmation != "DELETE MY DATA":
472
+ console.print("[yellow]Cancelled - confirmation did not match[/yellow]")
473
+ return
474
+
475
+ # Do the delete
476
+ deleted = client.delete_by_user(user_name)
477
+ console.print(f"\n[green][OK][/green] Deleted {deleted} points for user '{user_name}'")
478
+ console.print("\n[dim]Run 'jacked backfill' to re-index your sessions[/dim]")
479
+
480
+
343
481
  @main.command()
344
482
  def status():
345
483
  """Show indexing health and Qdrant connectivity."""
@@ -448,17 +586,398 @@ def configure(show: bool):
448
586
  from jacked.config import SmartForkConfig
449
587
 
450
588
 
589
+ def _get_data_root() -> Path:
590
+ """Find the data root directory for skills/agents/commands.
591
+
592
+ Data is now inside the package at jacked/data/.
593
+ """
594
+ return Path(__file__).parent / "data"
595
+
596
+
597
+ def _sound_hook_marker() -> str:
598
+ """Marker to identify jacked sound hooks."""
599
+ return "# jacked-sound: "
600
+
601
+
602
+ def _get_sound_command(hook_type: str) -> str:
603
+ """Generate cross-platform sound command (backgrounded, with fallbacks).
604
+
605
+ Args:
606
+ hook_type: 'notification' or 'complete'
607
+ """
608
+ if hook_type == "notification":
609
+ win_sound = "Exclamation"
610
+ mac_sound = "Basso.aiff"
611
+ linux_sound = "dialog-warning.oga"
612
+ else: # complete
613
+ win_sound = "Asterisk"
614
+ mac_sound = "Glass.aiff"
615
+ linux_sound = "complete.oga"
616
+
617
+ # Use uname for detection, background with &, fallback to bell
618
+ return (
619
+ '('
620
+ 'OS=$(uname -s); '
621
+ 'case "$OS" in '
622
+ f'Darwin) afplay /System/Library/Sounds/{mac_sound} 2>/dev/null || printf "\\a";; '
623
+ 'Linux) '
624
+ ' if grep -qi microsoft /proc/version 2>/dev/null; then '
625
+ f' powershell.exe -Command "[System.Media.SystemSounds]::{win_sound}.Play()" 2>/dev/null || printf "\\a"; '
626
+ ' else '
627
+ f' paplay /usr/share/sounds/freedesktop/stereo/{linux_sound} 2>/dev/null || printf "\\a"; '
628
+ ' fi;; '
629
+ f'MINGW*|MSYS*|CYGWIN*) powershell -Command "[System.Media.SystemSounds]::{win_sound}.Play()" 2>/dev/null || printf "\\a";; '
630
+ '*) printf "\\a";; '
631
+ 'esac'
632
+ ') &'
633
+ )
634
+
635
+
636
+ def _install_sound_hooks(existing: dict, settings_path: Path):
637
+ """Install sound notification hooks."""
638
+ import json
639
+
640
+ marker = _sound_hook_marker()
641
+
642
+ # Notification hook
643
+ if "Notification" not in existing["hooks"]:
644
+ existing["hooks"]["Notification"] = []
645
+
646
+ notif_exists = any(marker in str(h) for h in existing["hooks"]["Notification"])
647
+ if not notif_exists:
648
+ existing["hooks"]["Notification"].append({
649
+ "matcher": "",
650
+ "hooks": [{"type": "command", "command": marker + _get_sound_command("notification")}]
651
+ })
652
+ console.print("[green][OK][/green] Added Notification sound hook")
653
+ else:
654
+ console.print("[yellow][-][/yellow] Notification sound hook exists")
655
+
656
+ # Stop sound hook (separate from index)
657
+ stop_exists = any(marker in str(h) for h in existing["hooks"]["Stop"])
658
+ if not stop_exists:
659
+ existing["hooks"]["Stop"].append({
660
+ "matcher": "",
661
+ "hooks": [{"type": "command", "command": marker + _get_sound_command("complete")}]
662
+ })
663
+ console.print("[green][OK][/green] Added Stop sound hook")
664
+ else:
665
+ console.print("[yellow][-][/yellow] Stop sound hook exists")
666
+
667
+ settings_path.write_text(json.dumps(existing, indent=2))
668
+
669
+
670
+ def _remove_sound_hooks(settings_path: Path) -> bool:
671
+ """Remove jacked sound hooks. Returns True if any removed."""
672
+ import json
673
+
674
+ if not settings_path.exists():
675
+ return False
676
+
677
+ settings = json.loads(settings_path.read_text())
678
+ marker = _sound_hook_marker()
679
+ modified = False
680
+
681
+ for hook_type in ["Notification", "Stop"]:
682
+ if hook_type in settings.get("hooks", {}):
683
+ before = len(settings["hooks"][hook_type])
684
+ settings["hooks"][hook_type] = [
685
+ h for h in settings["hooks"][hook_type]
686
+ if marker not in str(h)
687
+ ]
688
+ if len(settings["hooks"][hook_type]) < before:
689
+ console.print(f"[green][OK][/green] Removed {hook_type} sound hook")
690
+ modified = True
691
+
692
+ if modified:
693
+ settings_path.write_text(json.dumps(settings, indent=2))
694
+ return modified
695
+
696
+
697
+ def _get_behavioral_rules() -> str:
698
+ """Load behavioral rules from data file."""
699
+ rules_path = _get_data_root() / "rules" / "jacked_behaviors.md"
700
+ if not rules_path.exists():
701
+ raise FileNotFoundError(f"Behavioral rules not found: {rules_path}")
702
+ return rules_path.read_text(encoding="utf-8").strip()
703
+
704
+
705
+ def _behavioral_rules_marker() -> str:
706
+ """Start marker for jacked behavioral rules block."""
707
+ return "# jacked-behaviors-v2"
708
+
709
+
710
+ def _behavioral_rules_end_marker() -> str:
711
+ """End marker for jacked behavioral rules block."""
712
+ return "# end-jacked-behaviors"
713
+
714
+
715
+ def _install_behavioral_rules(claude_md_path: Path):
716
+ """Install behavioral rules into CLAUDE.md with marker boundaries.
717
+
718
+ - Show rules before writing, require confirmation
719
+ - Backup file before first modification
720
+ - Atomic write (build in memory, write once)
721
+ - Skip if already installed with same version
722
+ """
723
+ import shutil
724
+
725
+ try:
726
+ rules_text = _get_behavioral_rules()
727
+ except FileNotFoundError as e:
728
+ console.print(f"[red][FAIL][/red] {e}")
729
+ console.print("[yellow]Skipping behavioral rules installation[/yellow]")
730
+ return
731
+
732
+ start_marker = _behavioral_rules_marker()
733
+ end_marker = _behavioral_rules_end_marker()
734
+
735
+ # Read existing content
736
+ existing_content = ""
737
+ if claude_md_path.exists():
738
+ existing_content = claude_md_path.read_text(encoding="utf-8")
739
+
740
+ # Check if already installed (any version)
741
+ marker_prefix = "# jacked-behaviors-v"
742
+ has_start = marker_prefix in existing_content
743
+ has_end = end_marker in existing_content
744
+
745
+ # Orphaned marker detection: start without end (or end without start)
746
+ if has_start != has_end:
747
+ which = "start" if has_start else "end"
748
+ missing = "end" if has_start else "start"
749
+ console.print(f"[red][FAIL][/red] Found {which} marker but no {missing} marker in CLAUDE.md")
750
+ console.print("Your CLAUDE.md has a corrupted jacked rules block. Please fix it manually:")
751
+ console.print(f" Start marker: {start_marker}")
752
+ console.print(f" End marker: {end_marker}")
753
+ return
754
+
755
+ has_existing = has_start and has_end
756
+ if has_existing:
757
+ # Extract existing block (find the versioned start marker)
758
+ start_idx = existing_content.index(marker_prefix)
759
+ end_idx = existing_content.index(end_marker) + len(end_marker)
760
+ existing_block = existing_content[start_idx:end_idx].strip()
761
+
762
+ if existing_block == rules_text:
763
+ console.print("[yellow][-][/yellow] Behavioral rules already configured correctly")
764
+ return
765
+ else:
766
+ # Version upgrade needed
767
+ console.print("\n[bold]Behavioral rules update available:[/bold]")
768
+ console.print(f"[dim]{rules_text}[/dim]")
769
+ if not click.confirm("Update behavioral rules in CLAUDE.md?"):
770
+ console.print("[yellow][-][/yellow] Skipped behavioral rules update")
771
+ return
772
+
773
+ # Backup before modifying
774
+ backup_path = claude_md_path.with_suffix(".md.pre-jacked")
775
+ if not backup_path.exists():
776
+ shutil.copy2(claude_md_path, backup_path)
777
+ console.print(f"[dim]Backup: {backup_path}[/dim]")
778
+
779
+ # Replace the block (symmetric with _remove_behavioral_rules)
780
+ before = existing_content[:start_idx].rstrip("\n")
781
+ after = existing_content[end_idx:].lstrip("\n")
782
+ if before and after:
783
+ new_content = before + "\n\n" + rules_text + "\n\n" + after
784
+ elif before:
785
+ new_content = before + "\n\n" + rules_text + "\n"
786
+ else:
787
+ new_content = rules_text + "\n" + after if after else rules_text + "\n"
788
+ try:
789
+ claude_md_path.write_text(new_content, encoding="utf-8")
790
+ except PermissionError:
791
+ console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
792
+ console.print("Check file permissions and try again.")
793
+ return
794
+ console.print("[green][OK][/green] Updated behavioral rules to latest version")
795
+ return
796
+
797
+ # Fresh install - show and confirm
798
+ console.print("\n[bold]Proposed behavioral rules for ~/.claude/CLAUDE.md:[/bold]")
799
+ console.print(f"[dim]{rules_text}[/dim]")
800
+ if not click.confirm("Add these behavioral rules to your global CLAUDE.md?"):
801
+ console.print("[yellow][-][/yellow] Skipped behavioral rules")
802
+ return
803
+
804
+ # Backup before modifying (if file exists and no backup yet)
805
+ if claude_md_path.exists():
806
+ backup_path = claude_md_path.with_suffix(".md.pre-jacked")
807
+ if not backup_path.exists():
808
+ shutil.copy2(claude_md_path, backup_path)
809
+ console.print(f"[dim]Backup: {backup_path}[/dim]")
810
+
811
+ # Ensure parent directory exists
812
+ claude_md_path.parent.mkdir(parents=True, exist_ok=True)
813
+
814
+ # Build new content atomically
815
+ if existing_content and not existing_content.endswith("\n\n"):
816
+ if existing_content.endswith("\n"):
817
+ new_content = existing_content + "\n" + rules_text + "\n"
818
+ else:
819
+ new_content = existing_content + "\n\n" + rules_text + "\n"
820
+ else:
821
+ new_content = existing_content + rules_text + "\n"
822
+
823
+ try:
824
+ claude_md_path.write_text(new_content, encoding="utf-8")
825
+ except PermissionError:
826
+ console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
827
+ console.print("Check file permissions and try again.")
828
+ return
829
+ console.print("[green][OK][/green] Installed behavioral rules in CLAUDE.md")
830
+
831
+
832
+ def _remove_behavioral_rules(claude_md_path: Path) -> bool:
833
+ """Remove jacked behavioral rules block from CLAUDE.md.
834
+
835
+ Returns True if rules were found and removed.
836
+ """
837
+ if not claude_md_path.exists():
838
+ return False
839
+
840
+ content = claude_md_path.read_text(encoding="utf-8")
841
+ marker_prefix = "# jacked-behaviors-v"
842
+ end_marker = _behavioral_rules_end_marker()
843
+
844
+ if marker_prefix not in content or end_marker not in content:
845
+ return False
846
+
847
+ start_idx = content.index(marker_prefix)
848
+ end_idx = content.index(end_marker) + len(end_marker)
849
+
850
+ # Strip the block and any extra blank lines around it
851
+ before = content[:start_idx].rstrip("\n")
852
+ after = content[end_idx:].lstrip("\n")
853
+
854
+ if before and after:
855
+ new_content = before + "\n\n" + after
856
+ elif before:
857
+ new_content = before + "\n"
858
+ else:
859
+ new_content = after
860
+
861
+ try:
862
+ claude_md_path.write_text(new_content, encoding="utf-8")
863
+ except PermissionError:
864
+ console.print(f"[red][FAIL][/red] Permission denied writing to {claude_md_path}")
865
+ return False
866
+ return True
867
+
868
+
869
+ def _security_hook_marker() -> str:
870
+ """Marker to identify jacked security gatekeeper hooks."""
871
+ return "# jacked-security"
872
+
873
+
874
+ def _get_security_prompt() -> str:
875
+ """Load security gatekeeper prompt from data file."""
876
+ prompt_path = _get_data_root() / "prompts" / "security_gatekeeper.txt"
877
+ if not prompt_path.exists():
878
+ raise FileNotFoundError(f"Security prompt not found: {prompt_path}")
879
+ return prompt_path.read_text(encoding="utf-8")
880
+
881
+
882
+ def _install_security_hook(existing: dict, settings_path: Path):
883
+ """Install Opus-powered security gatekeeper hook for Bash commands.
884
+
885
+ Handles fresh install and version upgrades (detects stale prompts).
886
+ """
887
+ import json
888
+
889
+ marker = _security_hook_marker()
890
+
891
+ try:
892
+ prompt_text = _get_security_prompt()
893
+ except FileNotFoundError as e:
894
+ console.print(f"[red][FAIL][/red] {e}")
895
+ console.print("[yellow]Skipping security gatekeeper installation[/yellow]")
896
+ return
897
+
898
+ if "PermissionRequest" not in existing["hooks"]:
899
+ existing["hooks"]["PermissionRequest"] = []
900
+
901
+ # Check if already installed and whether it needs upgrading
902
+ hook_index = None
903
+ needs_upgrade = False
904
+ for i, hook_entry in enumerate(existing["hooks"]["PermissionRequest"]):
905
+ hook_str = str(hook_entry)
906
+ if marker in hook_str:
907
+ hook_index = i
908
+ # Check if installed prompt matches current version
909
+ for h in hook_entry.get("hooks", []):
910
+ installed_prompt = h.get("prompt", "")
911
+ if installed_prompt != prompt_text:
912
+ needs_upgrade = True
913
+ break
914
+
915
+ if hook_index is not None and not needs_upgrade:
916
+ console.print("[yellow][-][/yellow] Security gatekeeper hook already configured")
917
+ return
918
+
919
+ hook_entry = {
920
+ "matcher": "Bash",
921
+ "hooks": [{
922
+ "type": "prompt",
923
+ "prompt": prompt_text,
924
+ "model": "opus",
925
+ "timeout": 60,
926
+ }]
927
+ }
928
+
929
+ if hook_index is not None and needs_upgrade:
930
+ existing["hooks"]["PermissionRequest"][hook_index] = hook_entry
931
+ settings_path.write_text(json.dumps(existing, indent=2))
932
+ console.print("[green][OK][/green] Updated security gatekeeper prompt to latest version")
933
+ else:
934
+ existing["hooks"]["PermissionRequest"].append(hook_entry)
935
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
936
+ settings_path.write_text(json.dumps(existing, indent=2))
937
+ console.print("[green][OK][/green] Installed security gatekeeper (Opus evaluates Bash commands)")
938
+
939
+
940
+ def _remove_security_hook(settings_path: Path) -> bool:
941
+ """Remove jacked security gatekeeper hook. Returns True if removed."""
942
+ import json
943
+
944
+ if not settings_path.exists():
945
+ return False
946
+
947
+ settings = json.loads(settings_path.read_text())
948
+ marker = _security_hook_marker()
949
+
950
+ if "PermissionRequest" not in settings.get("hooks", {}):
951
+ return False
952
+
953
+ before = len(settings["hooks"]["PermissionRequest"])
954
+ settings["hooks"]["PermissionRequest"] = [
955
+ h for h in settings["hooks"]["PermissionRequest"]
956
+ if marker not in str(h)
957
+ ]
958
+ if len(settings["hooks"]["PermissionRequest"]) < before:
959
+ settings_path.write_text(json.dumps(settings, indent=2))
960
+ console.print("[green][OK][/green] Removed security gatekeeper hook")
961
+ return True
962
+
963
+ return False
964
+
965
+
451
966
  @main.command()
452
- def install():
967
+ @click.option("--sounds", is_flag=True, help="Install sound notification hooks")
968
+ @click.option("--no-security", is_flag=True, help="Skip security gatekeeper hook")
969
+ @click.option("--no-rules", is_flag=True, help="Skip behavioral rules in CLAUDE.md")
970
+ def install(sounds: bool, no_security: bool, no_rules: bool):
453
971
  """Auto-install hook config, skill, agents, and commands."""
454
972
  import os
455
973
  import json
456
974
  import shutil
457
975
 
458
976
  home = Path.home()
459
- pkg_root = Path(__file__).parent.parent
977
+ pkg_root = _get_data_root()
460
978
 
461
- # Hook configuration
979
+ # Hook configuration - assumes jacked is on PATH (installed via pipx)
980
+ # async: True runs indexing in background so Claude Code doesn't wait
462
981
  hook_config = {
463
982
  "hooks": {
464
983
  "Stop": [
@@ -467,7 +986,8 @@ def install():
467
986
  "hooks": [
468
987
  {
469
988
  "type": "command",
470
- "command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"'
989
+ "command": 'jacked index --repo "$CLAUDE_PROJECT_DIR"',
990
+ "async": True
471
991
  }
472
992
  ]
473
993
  }
@@ -493,21 +1013,34 @@ def install():
493
1013
  if "Stop" not in existing["hooks"]:
494
1014
  existing["hooks"]["Stop"] = []
495
1015
 
496
- # Check if hook already exists
497
- hook_exists = any(
498
- "jacked" in str(h.get("hooks", []))
499
- for h in existing["hooks"]["Stop"]
500
- )
501
-
502
- if not hook_exists:
1016
+ # Check if hook already exists and if it needs updating
1017
+ hook_index = None
1018
+ needs_async_update = False
1019
+ for i, hook_entry in enumerate(existing["hooks"]["Stop"]):
1020
+ for h in hook_entry.get("hooks", []):
1021
+ if "jacked" in h.get("command", ""):
1022
+ hook_index = i
1023
+ # Check if async is missing or false
1024
+ if not h.get("async"):
1025
+ needs_async_update = True
1026
+ break
1027
+
1028
+ if hook_index is None:
1029
+ # No hook exists - add it
503
1030
  existing["hooks"]["Stop"].append(hook_config["hooks"]["Stop"][0])
504
1031
  settings_path.parent.mkdir(parents=True, exist_ok=True)
505
1032
  settings_path.write_text(json.dumps(existing, indent=2))
506
1033
  console.print(f"[green][OK][/green] Added Stop hook to {settings_path}")
1034
+ elif needs_async_update:
1035
+ # Hook exists but needs async: true
1036
+ existing["hooks"]["Stop"][hook_index] = hook_config["hooks"]["Stop"][0]
1037
+ settings_path.write_text(json.dumps(existing, indent=2))
1038
+ console.print(f"[green][OK][/green] Updated Stop hook with async: true")
507
1039
  else:
508
- console.print(f"[yellow][-][/yellow] Stop hook already exists in {settings_path}")
1040
+ console.print(f"[yellow][-][/yellow] Stop hook already configured correctly")
509
1041
 
510
- # Copy skill file - Claude Code expects skills in subdirectories with SKILL.md
1042
+ # Copy skill file with Python path templating
1043
+ # Claude Code expects skills in subdirectories with SKILL.md
511
1044
  skill_dir = home / ".claude" / "skills" / "jacked"
512
1045
  skill_dir.mkdir(parents=True, exist_ok=True)
513
1046
 
@@ -520,39 +1053,90 @@ def install():
520
1053
  else:
521
1054
  console.print(f"[yellow][-][/yellow] Skill file not found at {skill_src}")
522
1055
 
523
- # Copy agents
1056
+ # Copy agents (with conflict detection)
524
1057
  agents_src = pkg_root / "agents"
525
1058
  agents_dst = home / ".claude" / "agents"
526
1059
  if agents_src.exists():
527
1060
  agents_dst.mkdir(parents=True, exist_ok=True)
528
1061
  agent_count = 0
1062
+ skipped = 0
529
1063
  for agent_file in agents_src.glob("*.md"):
530
- shutil.copy(agent_file, agents_dst / agent_file.name)
1064
+ dst_file = agents_dst / agent_file.name
1065
+ src_content = agent_file.read_text(encoding="utf-8")
1066
+ if dst_file.exists():
1067
+ dst_content = dst_file.read_text(encoding="utf-8")
1068
+ if src_content == dst_content:
1069
+ skipped += 1
1070
+ continue # Same content, skip silently
1071
+ # Different content - ask before overwriting
1072
+ if not click.confirm(f"Agent '{agent_file.name}' exists with different content. Overwrite?"):
1073
+ console.print(f"[yellow][-][/yellow] Skipped {agent_file.name}")
1074
+ continue
1075
+ shutil.copy(agent_file, dst_file)
531
1076
  agent_count += 1
532
- console.print(f"[green][OK][/green] Installed {agent_count} agents")
1077
+ msg = f"[green][OK][/green] Installed {agent_count} agents"
1078
+ if skipped:
1079
+ msg += f" ({skipped} unchanged)"
1080
+ console.print(msg)
533
1081
  else:
534
1082
  console.print(f"[yellow][-][/yellow] Agents directory not found")
535
1083
 
536
- # Copy commands
1084
+ # Copy commands (with conflict detection)
537
1085
  commands_src = pkg_root / "commands"
538
1086
  commands_dst = home / ".claude" / "commands"
539
1087
  if commands_src.exists():
540
1088
  commands_dst.mkdir(parents=True, exist_ok=True)
541
1089
  cmd_count = 0
1090
+ skipped = 0
542
1091
  for cmd_file in commands_src.glob("*.md"):
543
- shutil.copy(cmd_file, commands_dst / cmd_file.name)
1092
+ dst_file = commands_dst / cmd_file.name
1093
+ src_content = cmd_file.read_text(encoding="utf-8")
1094
+ if dst_file.exists():
1095
+ dst_content = dst_file.read_text(encoding="utf-8")
1096
+ if src_content == dst_content:
1097
+ skipped += 1
1098
+ continue # Same content, skip silently
1099
+ # Different content - ask before overwriting
1100
+ if not click.confirm(f"Command '{cmd_file.name}' exists with different content. Overwrite?"):
1101
+ console.print(f"[yellow][-][/yellow] Skipped {cmd_file.name}")
1102
+ continue
1103
+ shutil.copy(cmd_file, dst_file)
544
1104
  cmd_count += 1
545
- console.print(f"[green][OK][/green] Installed {cmd_count} commands")
1105
+ msg = f"[green][OK][/green] Installed {cmd_count} commands"
1106
+ if skipped:
1107
+ msg += f" ({skipped} unchanged)"
1108
+ console.print(msg)
546
1109
  else:
547
1110
  console.print(f"[yellow][-][/yellow] Commands directory not found")
548
1111
 
1112
+ # Install sound hooks if requested
1113
+ if sounds:
1114
+ _install_sound_hooks(existing, settings_path)
1115
+
1116
+ # Install security gatekeeper (default on, --no-security to skip)
1117
+ if not no_security:
1118
+ _install_security_hook(existing, settings_path)
1119
+
1120
+ # Install behavioral rules in CLAUDE.md (default on, --no-rules to skip)
1121
+ if not no_rules:
1122
+ claude_md_path = home / ".claude" / "CLAUDE.md"
1123
+ _install_behavioral_rules(claude_md_path)
1124
+
549
1125
  console.print("\n[bold]Installation complete![/bold]")
550
1126
  console.print("\n[yellow]IMPORTANT: Restart Claude Code for new commands to take effect![/yellow]")
551
1127
  console.print("\nWhat you get:")
552
1128
  console.print(" - /jacked - Search past Claude sessions")
553
- console.print(" - /dc - Double-check reviewer")
1129
+ console.print(" - /dc - Double-check reviewer (with grill mode)")
554
1130
  console.print(" - /pr - PR workflow helper")
1131
+ console.print(" - /learn - Distill lessons into CLAUDE.md rules")
1132
+ console.print(" - /techdebt - Project tech debt audit")
1133
+ console.print(" - /redo - Scrap and re-implement with hindsight")
1134
+ console.print(" - /audit-rules - CLAUDE.md quality audit")
555
1135
  console.print(" - 10 specialized agents (readme, wiki, tests, etc.)")
1136
+ if not no_security:
1137
+ console.print(" - Security gatekeeper (Opus evaluates Bash commands)")
1138
+ if not no_rules:
1139
+ console.print(" - Behavioral rules in CLAUDE.md (auto-triggers for jacked commands)")
556
1140
  console.print("\nNext steps:")
557
1141
  console.print(" 1. Restart Claude Code (exit and run 'claude' again)")
558
1142
  console.print(" 2. Set environment variables (run 'jacked configure' for help)")
@@ -560,5 +1144,126 @@ def install():
560
1144
  console.print(" 4. Use '/jacked <description>' in Claude to search past sessions")
561
1145
 
562
1146
 
1147
+ @main.command()
1148
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
1149
+ @click.option("--sounds", is_flag=True, help="Remove only sound hooks")
1150
+ @click.option("--security", is_flag=True, help="Remove only security gatekeeper hook")
1151
+ @click.option("--rules", is_flag=True, help="Remove only behavioral rules from CLAUDE.md")
1152
+ def uninstall(yes: bool, sounds: bool, security: bool, rules: bool):
1153
+ """Remove jacked hooks, skill, agents, and commands from Claude Code."""
1154
+ import json
1155
+ import shutil
1156
+
1157
+ home = Path.home()
1158
+ pkg_root = _get_data_root()
1159
+ settings_path = home / ".claude" / "settings.json"
1160
+
1161
+ # If --sounds flag, only remove sound hooks
1162
+ if sounds:
1163
+ if _remove_sound_hooks(settings_path):
1164
+ console.print("[bold]Sound hooks removed![/bold]")
1165
+ else:
1166
+ console.print("[yellow]No sound hooks found[/yellow]")
1167
+ return
1168
+
1169
+ # If --security flag, only remove security hook
1170
+ if security:
1171
+ if _remove_security_hook(settings_path):
1172
+ console.print("[bold]Security gatekeeper removed![/bold]")
1173
+ else:
1174
+ console.print("[yellow]No security gatekeeper hook found[/yellow]")
1175
+ return
1176
+
1177
+ # If --rules flag, only remove behavioral rules
1178
+ if rules:
1179
+ claude_md_path = home / ".claude" / "CLAUDE.md"
1180
+ if _remove_behavioral_rules(claude_md_path):
1181
+ console.print("[bold]Behavioral rules removed from CLAUDE.md![/bold]")
1182
+ else:
1183
+ console.print("[yellow]No behavioral rules found in CLAUDE.md[/yellow]")
1184
+ return
1185
+
1186
+ if not yes:
1187
+ if not click.confirm("Remove jacked from Claude Code? (This won't delete your Qdrant index)"):
1188
+ console.print("Cancelled")
1189
+ return
1190
+
1191
+ console.print("[bold]Uninstalling Jacked...[/bold]\n")
1192
+
1193
+ # Also remove sound, security hooks, and behavioral rules during full uninstall
1194
+ _remove_sound_hooks(settings_path)
1195
+ _remove_security_hook(settings_path)
1196
+ claude_md_path = home / ".claude" / "CLAUDE.md"
1197
+ if _remove_behavioral_rules(claude_md_path):
1198
+ console.print("[green][OK][/green] Removed behavioral rules from CLAUDE.md")
1199
+
1200
+ # Remove Stop hook from settings.json
1201
+ if settings_path.exists():
1202
+ try:
1203
+ settings = json.loads(settings_path.read_text())
1204
+ if "hooks" in settings and "Stop" in settings["hooks"]:
1205
+ # Filter out jacked hooks
1206
+ original_count = len(settings["hooks"]["Stop"])
1207
+ settings["hooks"]["Stop"] = [
1208
+ h for h in settings["hooks"]["Stop"]
1209
+ if "jacked" not in str(h.get("hooks", []))
1210
+ ]
1211
+ removed_count = original_count - len(settings["hooks"]["Stop"])
1212
+ if removed_count > 0:
1213
+ settings_path.write_text(json.dumps(settings, indent=2))
1214
+ console.print(f"[green][OK][/green] Removed Stop hook from {settings_path}")
1215
+ else:
1216
+ console.print(f"[yellow][-][/yellow] No jacked hook found in settings")
1217
+ except (json.JSONDecodeError, KeyError) as e:
1218
+ console.print(f"[red][FAIL][/red] Error reading settings: {e}")
1219
+ else:
1220
+ console.print(f"[yellow][-][/yellow] No settings.json found")
1221
+
1222
+ # Remove skill directory
1223
+ skill_dir = home / ".claude" / "skills" / "jacked"
1224
+ if skill_dir.exists():
1225
+ shutil.rmtree(skill_dir)
1226
+ console.print(f"[green][OK][/green] Removed skill: /jacked")
1227
+ else:
1228
+ console.print(f"[yellow][-][/yellow] Skill not found")
1229
+
1230
+ # Remove only jacked-installed agents (not the whole directory!)
1231
+ agents_src = pkg_root / "agents"
1232
+ agents_dst = home / ".claude" / "agents"
1233
+ if agents_src.exists() and agents_dst.exists():
1234
+ agent_count = 0
1235
+ for agent_file in agents_src.glob("*.md"):
1236
+ dst_file = agents_dst / agent_file.name
1237
+ if dst_file.exists():
1238
+ dst_file.unlink()
1239
+ agent_count += 1
1240
+ if agent_count > 0:
1241
+ console.print(f"[green][OK][/green] Removed {agent_count} agents")
1242
+ else:
1243
+ console.print(f"[yellow][-][/yellow] No jacked agents found")
1244
+ else:
1245
+ console.print(f"[yellow][-][/yellow] Agents directory not found")
1246
+
1247
+ # Remove only jacked-installed commands (not the whole directory!)
1248
+ commands_src = pkg_root / "commands"
1249
+ commands_dst = home / ".claude" / "commands"
1250
+ if commands_src.exists() and commands_dst.exists():
1251
+ cmd_count = 0
1252
+ for cmd_file in commands_src.glob("*.md"):
1253
+ dst_file = commands_dst / cmd_file.name
1254
+ if dst_file.exists():
1255
+ dst_file.unlink()
1256
+ cmd_count += 1
1257
+ if cmd_count > 0:
1258
+ console.print(f"[green][OK][/green] Removed {cmd_count} commands")
1259
+ else:
1260
+ console.print(f"[yellow][-][/yellow] No jacked commands found")
1261
+ else:
1262
+ console.print(f"[yellow][-][/yellow] Commands directory not found")
1263
+
1264
+ console.print("\n[bold]Uninstall complete![/bold]")
1265
+ console.print("\n[dim]Note: Your Qdrant index is still intact. Run 'pipx uninstall claude-jacked' to fully remove.[/dim]")
1266
+
1267
+
563
1268
  if __name__ == "__main__":
564
1269
  main()