invar-tools 1.3.0__py3-none-any.whl → 1.3.2__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 (30) hide show
  1. invar/shell/claude_hooks.py +387 -0
  2. invar/shell/commands/guard.py +2 -0
  3. invar/shell/commands/hooks.py +74 -0
  4. invar/shell/commands/init.py +30 -0
  5. invar/shell/commands/template_sync.py +42 -11
  6. invar/shell/commands/test.py +1 -1
  7. invar/shell/templates.py +2 -2
  8. invar/templates/CLAUDE.md.template +25 -5
  9. invar/templates/config/CLAUDE.md.jinja +16 -0
  10. invar/templates/config/context.md.jinja +11 -6
  11. invar/templates/context.md.template +35 -18
  12. invar/templates/hooks/PostToolUse.sh.jinja +102 -0
  13. invar/templates/hooks/PreToolUse.sh.jinja +74 -0
  14. invar/templates/hooks/Stop.sh.jinja +23 -0
  15. invar/templates/hooks/UserPromptSubmit.sh.jinja +77 -0
  16. invar/templates/hooks/__init__.py +1 -0
  17. invar/templates/manifest.toml +2 -2
  18. invar/templates/protocol/INVAR.md +105 -6
  19. invar/templates/skills/develop/SKILL.md.jinja +4 -7
  20. invar/templates/skills/investigate/SKILL.md.jinja +4 -7
  21. invar/templates/skills/propose/SKILL.md.jinja +4 -7
  22. invar/templates/skills/review/SKILL.md.jinja +63 -15
  23. invar_tools-1.3.2.dist-info/METADATA +505 -0
  24. {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/RECORD +29 -22
  25. invar_tools-1.3.0.dist-info/METADATA +0 -377
  26. {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/WHEEL +0 -0
  27. {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/entry_points.txt +0 -0
  28. {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/licenses/LICENSE +0 -0
  29. {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/licenses/LICENSE-GPL +0 -0
  30. {invar_tools-1.3.0.dist-info → invar_tools-1.3.2.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,387 @@
1
+ """
2
+ Claude Code hooks management for Invar.
3
+
4
+ DX-57: Implements hook installation, update, and management.
5
+ Shell module: handles file I/O for hooks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from jinja2 import Environment, FileSystemLoader
16
+ from returns.result import Failure, Result, Success
17
+
18
+ if TYPE_CHECKING:
19
+ from rich.console import Console
20
+
21
+ # Protocol version for hook version tracking
22
+ PROTOCOL_VERSION = "5.0"
23
+
24
+ # Hook types supported
25
+ HOOK_TYPES = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"]
26
+
27
+ # Path constants
28
+ HOOKS_SUBDIR = ".claude/hooks"
29
+ DISABLED_MARKER = ".invar_disabled"
30
+
31
+
32
+ def get_templates_path() -> Path:
33
+ """Get the path to hook templates."""
34
+ return Path(__file__).parent.parent / "templates" / "hooks"
35
+
36
+
37
+ def get_invar_md_content(project_path: Path) -> str:
38
+ """Read INVAR.md content for embedding in UserPromptSubmit hook."""
39
+ invar_md = project_path / "INVAR.md"
40
+ if invar_md.exists():
41
+ return invar_md.read_text()
42
+ # Fallback to template if INVAR.md not yet created
43
+ template_path = Path(__file__).parent.parent / "templates" / "protocol" / "INVAR.md"
44
+ if template_path.exists():
45
+ return template_path.read_text()
46
+ return "# INVAR.md not found"
47
+
48
+
49
+ def detect_syntax(project_path: Path) -> str:
50
+ """Detect syntax mode from .mcp.json presence."""
51
+ mcp_json = project_path / ".mcp.json"
52
+ if mcp_json.exists():
53
+ try:
54
+ content = mcp_json.read_text()
55
+ if '"invar"' in content:
56
+ return "mcp"
57
+ except OSError:
58
+ pass
59
+ return "cli"
60
+
61
+
62
+ # @shell_complexity: Template rendering with syntax detection
63
+ def generate_hook_content(
64
+ hook_type: str,
65
+ project_path: Path,
66
+ ) -> Result[str, str]:
67
+ """Generate hook content from template."""
68
+ templates_path = get_templates_path()
69
+ template_file = f"{hook_type}.sh.jinja"
70
+
71
+ if not (templates_path / template_file).exists():
72
+ return Failure(f"Template not found: {template_file}")
73
+
74
+ try:
75
+ env = Environment(
76
+ loader=FileSystemLoader(str(templates_path)),
77
+ keep_trailing_newline=True,
78
+ )
79
+ template = env.get_template(template_file)
80
+
81
+ # Determine guard command based on syntax
82
+ syntax = detect_syntax(project_path)
83
+ guard_cmd = "invar_guard" if syntax == "mcp" else "invar guard"
84
+
85
+ # Build context for template
86
+ context = {
87
+ "protocol_version": PROTOCOL_VERSION,
88
+ "generated_date": datetime.now().strftime("%Y-%m-%d"),
89
+ "guard_cmd": guard_cmd,
90
+ }
91
+
92
+ # For UserPromptSubmit, add the full INVAR.md content
93
+ if hook_type == "UserPromptSubmit":
94
+ context["invar_protocol"] = get_invar_md_content(project_path)
95
+
96
+ content = template.render(**context)
97
+ return Success(content)
98
+ except Exception as e:
99
+ return Failure(f"Failed to generate {hook_type} hook: {e}")
100
+
101
+
102
+ # @shell_complexity: Hook installation with user hook merging
103
+ def install_claude_hooks(
104
+ project_path: Path,
105
+ console: Console,
106
+ ) -> Result[list[str], str]:
107
+ """
108
+ Install Claude Code hooks for Invar.
109
+
110
+ Creates:
111
+ - .claude/hooks/invar.{HookType}.sh - Invar hook scripts
112
+ - .claude/hooks/{HookType}.sh - Wrapper that sources Invar hook
113
+
114
+ Preserves existing user hooks by creating wrapper that runs both.
115
+ """
116
+ hooks_dir = project_path / HOOKS_SUBDIR
117
+ hooks_dir.mkdir(parents=True, exist_ok=True)
118
+
119
+ installed: list[str] = []
120
+ failed: list[str] = []
121
+
122
+ console.print("\n[bold]Installing Claude Code hooks (DX-57)...[/bold]")
123
+ console.print(" Hooks will:")
124
+ console.print(" ✓ Block pytest/crosshair → redirect to invar_guard")
125
+ console.print(" ✓ Remind to verify after code changes")
126
+ console.print(" ✓ Refresh protocol in long conversations (~1,800 tokens)")
127
+ console.print("")
128
+
129
+ for hook_type in HOOK_TYPES:
130
+ result = generate_hook_content(hook_type, project_path)
131
+ if isinstance(result, Failure):
132
+ console.print(f" [red]Failed:[/red] {result.failure()}")
133
+ failed.append(hook_type)
134
+ continue
135
+
136
+ content = result.unwrap()
137
+ invar_hook = hooks_dir / f"invar.{hook_type}.sh"
138
+ wrapper_hook = hooks_dir / f"{hook_type}.sh"
139
+
140
+ # Write Invar hook
141
+ invar_hook.write_text(content)
142
+ invar_hook.chmod(0o755)
143
+
144
+ # Handle wrapper hook
145
+ if wrapper_hook.exists():
146
+ existing_content = wrapper_hook.read_text()
147
+ if f"invar.{hook_type}.sh" in existing_content:
148
+ # Already has Invar reference, just update invar hook
149
+ console.print(f" [cyan]Updated[/cyan] invar.{hook_type}.sh")
150
+ else:
151
+ # User hook exists, create backup and merge
152
+ backup = hooks_dir / f"{hook_type}.sh.user_backup"
153
+ if not backup.exists():
154
+ backup.write_text(existing_content)
155
+ console.print(f" [dim]Backed up user {hook_type}.sh[/dim]")
156
+
157
+ # Create merged wrapper
158
+ merged = f'''#!/bin/bash
159
+ # Merged hook: User + Invar (DX-57)
160
+ # User hooks run first (higher priority)
161
+
162
+ HOOK_DIR="$(dirname "$0")"
163
+
164
+ # Run user hook first
165
+ if [[ -f "$HOOK_DIR/{hook_type}.sh.user_backup" ]]; then
166
+ source "$HOOK_DIR/{hook_type}.sh.user_backup" "$@"
167
+ USER_EXIT=$?
168
+ [[ $USER_EXIT -ne 0 ]] && exit $USER_EXIT
169
+ fi
170
+
171
+ # Run Invar hook
172
+ if [[ -f "$HOOK_DIR/invar.{hook_type}.sh" ]]; then
173
+ source "$HOOK_DIR/invar.{hook_type}.sh" "$@"
174
+ fi
175
+ '''
176
+ wrapper_hook.write_text(merged)
177
+ wrapper_hook.chmod(0o755)
178
+ console.print(f" [green]Merged[/green] {hook_type}.sh (user hook preserved)")
179
+ else:
180
+ # No user hook, create simple wrapper
181
+ wrapper = f'''#!/bin/bash
182
+ # Invar hook wrapper (DX-57)
183
+ source "$(dirname "$0")/invar.{hook_type}.sh" "$@"
184
+ '''
185
+ wrapper_hook.write_text(wrapper)
186
+ wrapper_hook.chmod(0o755)
187
+ console.print(f" [green]Created[/green] {hook_type}.sh")
188
+
189
+ installed.append(hook_type)
190
+
191
+ if installed:
192
+ console.print("\n [bold green]✓ Claude Code hooks installed[/bold green]")
193
+ console.print(" [dim]Auto-escape: pytest --pdb, pytest --cov, vendor/[/dim]")
194
+ console.print(" [dim]Manual escape: INVAR_ALLOW_PYTEST=1[/dim]")
195
+
196
+ if failed:
197
+ return Failure(f"Failed to install hooks: {', '.join(failed)}")
198
+
199
+ return Success(installed)
200
+
201
+
202
+ # @shell_complexity: Hook sync with version detection
203
+ def sync_claude_hooks(
204
+ project_path: Path,
205
+ console: Console,
206
+ ) -> Result[list[str], str]:
207
+ """
208
+ Update Claude Code hooks with current INVAR.md content.
209
+
210
+ Called during `invar init` to ensure hooks stay in sync with protocol.
211
+ Only updates if Invar hooks are already installed.
212
+ """
213
+ hooks_dir = project_path / HOOKS_SUBDIR
214
+
215
+ # Check if Invar hooks are installed
216
+ check_hook = hooks_dir / "invar.UserPromptSubmit.sh"
217
+ if not check_hook.exists():
218
+ return Success([]) # No hooks installed, nothing to sync
219
+
220
+ # Check version in existing hook
221
+ try:
222
+ existing_content = check_hook.read_text()
223
+ # Extract version from header comment
224
+ version_match = re.search(r"Protocol: v([\d.]+)", existing_content)
225
+ old_version = version_match.group(1) if version_match else "unknown"
226
+
227
+ if old_version != PROTOCOL_VERSION:
228
+ console.print(f"[cyan]Updating Claude hooks: v{old_version} → v{PROTOCOL_VERSION}[/cyan]")
229
+ else:
230
+ # Still update to refresh INVAR.md content
231
+ console.print("[dim]Refreshing Claude hooks...[/dim]")
232
+ except OSError:
233
+ pass
234
+
235
+ updated: list[str] = []
236
+ failed: list[str] = []
237
+
238
+ for hook_type in HOOK_TYPES:
239
+ result = generate_hook_content(hook_type, project_path)
240
+ if isinstance(result, Failure):
241
+ console.print(f" [yellow]Warning:[/yellow] Failed to generate {hook_type}: {result.failure()}")
242
+ failed.append(hook_type)
243
+ continue
244
+
245
+ content = result.unwrap()
246
+ invar_hook = hooks_dir / f"invar.{hook_type}.sh"
247
+
248
+ if invar_hook.exists():
249
+ invar_hook.write_text(content)
250
+ invar_hook.chmod(0o755)
251
+ updated.append(hook_type)
252
+
253
+ if updated:
254
+ console.print(f"[green]✓[/green] Claude hooks synced ({len(updated)} files)")
255
+ if failed:
256
+ console.print(f"[yellow]⚠[/yellow] {len(failed)} hook(s) failed to sync")
257
+
258
+ return Success(updated)
259
+
260
+
261
+ # @shell_complexity: Hook removal with backup restoration
262
+ def remove_claude_hooks(
263
+ project_path: Path,
264
+ console: Console,
265
+ ) -> Result[None, str]:
266
+ """
267
+ Remove Invar Claude Code hooks.
268
+
269
+ Restores user hooks from backup if available.
270
+ """
271
+ hooks_dir = project_path / HOOKS_SUBDIR
272
+
273
+ if not hooks_dir.exists():
274
+ console.print("[yellow]No hooks directory found[/yellow]")
275
+ return Success(None)
276
+
277
+ console.print("[bold]Removing Invar Claude Code hooks...[/bold]")
278
+
279
+ for hook_type in HOOK_TYPES:
280
+ invar_hook = hooks_dir / f"invar.{hook_type}.sh"
281
+ wrapper_hook = hooks_dir / f"{hook_type}.sh"
282
+ backup_hook = hooks_dir / f"{hook_type}.sh.user_backup"
283
+
284
+ # Remove Invar hook
285
+ if invar_hook.exists():
286
+ invar_hook.unlink()
287
+ console.print(f" [red]Removed[/red] invar.{hook_type}.sh")
288
+
289
+ # Restore user backup if exists
290
+ if backup_hook.exists():
291
+ if wrapper_hook.exists():
292
+ wrapper_hook.unlink()
293
+ backup_hook.rename(wrapper_hook)
294
+ console.print(f" [green]Restored[/green] {hook_type}.sh (from backup)")
295
+ elif wrapper_hook.exists():
296
+ # Check if it's just an Invar wrapper
297
+ content = wrapper_hook.read_text()
298
+ if f"invar.{hook_type}.sh" in content and "user_backup" not in content:
299
+ wrapper_hook.unlink()
300
+ console.print(f" [red]Removed[/red] {hook_type}.sh (Invar wrapper)")
301
+
302
+ console.print("[bold green]✓ Invar hooks removed[/bold green]")
303
+ return Success(None)
304
+
305
+
306
+ def disable_claude_hooks(
307
+ project_path: Path,
308
+ console: Console,
309
+ ) -> Result[None, str]:
310
+ """Temporarily disable Invar hooks."""
311
+ hooks_dir = project_path / HOOKS_SUBDIR
312
+
313
+ if not hooks_dir.exists():
314
+ return Failure("No hooks directory found")
315
+
316
+ disabled_marker = hooks_dir / DISABLED_MARKER
317
+ disabled_marker.touch()
318
+
319
+ console.print("[yellow]✓ Invar hooks disabled[/yellow]")
320
+ console.print(f"[dim]Remove {HOOKS_SUBDIR}/{DISABLED_MARKER} to re-enable[/dim]")
321
+ return Success(None)
322
+
323
+
324
+ def enable_claude_hooks(
325
+ project_path: Path,
326
+ console: Console,
327
+ ) -> Result[None, str]:
328
+ """Re-enable Invar hooks."""
329
+ hooks_dir = project_path / HOOKS_SUBDIR
330
+ disabled_marker = hooks_dir / DISABLED_MARKER
331
+
332
+ if disabled_marker.exists():
333
+ disabled_marker.unlink()
334
+ console.print("[green]✓ Invar hooks enabled[/green]")
335
+ else:
336
+ console.print("[dim]Invar hooks were not disabled[/dim]")
337
+
338
+ return Success(None)
339
+
340
+
341
+ # @shell_complexity: Status display with version extraction
342
+ def hooks_status(
343
+ project_path: Path,
344
+ console: Console,
345
+ ) -> Result[dict[str, str], str]:
346
+ """Check status of Claude Code hooks."""
347
+ hooks_dir = project_path / HOOKS_SUBDIR
348
+
349
+ status: dict[str, str] = {}
350
+
351
+ if not hooks_dir.exists():
352
+ console.print("[yellow]No hooks directory[/yellow]")
353
+ return Success({"status": "not_installed"})
354
+
355
+ disabled = (hooks_dir / DISABLED_MARKER).exists()
356
+ if disabled:
357
+ console.print("[yellow]⏸ Invar hooks disabled[/yellow]")
358
+ status["status"] = "disabled"
359
+ else:
360
+ status["status"] = "enabled"
361
+
362
+ for hook_type in HOOK_TYPES:
363
+ invar_hook = hooks_dir / f"invar.{hook_type}.sh"
364
+ if invar_hook.exists():
365
+ status[hook_type] = "installed"
366
+ # Try to get version
367
+ try:
368
+ content = invar_hook.read_text()
369
+ match = re.search(r"Protocol: v([\d.]+)", content)
370
+ if match:
371
+ status[f"{hook_type}_version"] = match.group(1)
372
+ except OSError:
373
+ pass
374
+ else:
375
+ status[hook_type] = "not_installed"
376
+
377
+ # Display status
378
+ installed_count = sum(1 for h in HOOK_TYPES if status.get(h) == "installed")
379
+ if installed_count == len(HOOK_TYPES):
380
+ version = status.get("UserPromptSubmit_version", "?")
381
+ console.print(f"[green]✓ All hooks installed (v{version})[/green]")
382
+ elif installed_count > 0:
383
+ console.print(f"[yellow]⚠ Partial installation ({installed_count}/{len(HOOK_TYPES)})[/yellow]")
384
+ else:
385
+ console.print("[dim]No Invar hooks installed[/dim]")
386
+
387
+ return Success(status)
@@ -437,6 +437,7 @@ def rules(
437
437
 
438
438
 
439
439
  # DX-48b: Import commands from shell/commands/
440
+ from invar.shell.commands.hooks import app as hooks_app # DX-57
440
441
  from invar.shell.commands.init import init
441
442
  from invar.shell.commands.mutate import mutate # DX-28
442
443
  from invar.shell.commands.sync_self import sync_self # DX-49
@@ -448,6 +449,7 @@ app.command()(update)
448
449
  app.command()(test)
449
450
  app.command()(verify)
450
451
  app.command()(mutate) # DX-28: Mutation testing
452
+ app.add_typer(hooks_app, name="hooks") # DX-57: Claude Code hooks management
451
453
 
452
454
  # DX-56: Create dev subcommand group for developer commands
453
455
  dev_app = typer.Typer(
@@ -0,0 +1,74 @@
1
+ """
2
+ Hooks management command for Invar.
3
+
4
+ DX-57: Claude Code hooks installation, removal, and management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from returns.result import Failure, Result
13
+ from rich.console import Console
14
+
15
+ from invar.shell.claude_hooks import (
16
+ disable_claude_hooks,
17
+ enable_claude_hooks,
18
+ hooks_status,
19
+ install_claude_hooks,
20
+ remove_claude_hooks,
21
+ sync_claude_hooks,
22
+ )
23
+
24
+ console = Console()
25
+
26
+
27
+ def _handle_result(result: Result[object, str]) -> None:
28
+ """Print error message if result is Failure."""
29
+ if isinstance(result, Failure):
30
+ console.print(f"[red]Error:[/red] {result.failure()}")
31
+
32
+ app = typer.Typer(help="Manage Claude Code hooks")
33
+
34
+
35
+ # @invar:allow entry_point_too_thick: Typer command with options and docstring
36
+ @app.callback(invoke_without_command=True)
37
+ def hooks(
38
+ ctx: typer.Context,
39
+ path: Path = typer.Argument(Path(), help="Project root directory"),
40
+ remove: bool = typer.Option(False, "--remove", help="Remove Invar hooks"),
41
+ disable: bool = typer.Option(False, "--disable", help="Temporarily disable hooks"),
42
+ enable: bool = typer.Option(False, "--enable", help="Re-enable disabled hooks"),
43
+ install: bool = typer.Option(False, "--install", help="Install hooks"),
44
+ sync: bool = typer.Option(False, "--sync", help="Sync hooks with current INVAR.md"),
45
+ ) -> None:
46
+ """
47
+ Manage Claude Code hooks for Invar.
48
+
49
+ Without flags, shows current hooks status.
50
+
51
+ \b
52
+ Examples:
53
+ invar hooks # Show status
54
+ invar hooks --install # Install hooks
55
+ invar hooks --remove # Permanently remove hooks
56
+ invar hooks --disable # Temporarily disable
57
+ invar hooks --enable # Re-enable
58
+ invar hooks --sync # Update with current INVAR.md
59
+ """
60
+ path = path.resolve()
61
+
62
+ if remove:
63
+ _handle_result(remove_claude_hooks(path, console))
64
+ elif disable:
65
+ _handle_result(disable_claude_hooks(path, console))
66
+ elif enable:
67
+ _handle_result(enable_claude_hooks(path, console))
68
+ elif install:
69
+ _handle_result(install_claude_hooks(path, console))
70
+ elif sync:
71
+ _handle_result(sync_claude_hooks(path, console))
72
+ else:
73
+ # Default: show status
74
+ _handle_result(hooks_status(path, console))
@@ -5,6 +5,7 @@ Shell module: handles project initialization.
5
5
  DX-21B: Added --claude flag for Claude Code integration.
6
6
  DX-55: Unified idempotent init command with smart merge.
7
7
  DX-56: Uses unified template sync engine for file generation.
8
+ DX-57: Added Claude Code hooks installation.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -19,6 +20,10 @@ from rich.console import Console
19
20
 
20
21
  from invar.core.sync_helpers import SyncConfig
21
22
  from invar.core.template_parser import ClaudeMdState
23
+ from invar.shell.claude_hooks import (
24
+ install_claude_hooks,
25
+ sync_claude_hooks,
26
+ )
22
27
  from invar.shell.commands.merge import (
23
28
  ProjectState,
24
29
  detect_project_state,
@@ -201,6 +206,10 @@ def init(
201
206
  hooks: bool = typer.Option(
202
207
  True, "--hooks/--no-hooks", help="Install pre-commit hooks (default: ON)"
203
208
  ),
209
+ claude_hooks: bool = typer.Option(
210
+ None, "--claude-hooks/--no-claude-hooks",
211
+ help="Install Claude Code hooks (default: ON when --claude, DX-57)"
212
+ ),
204
213
  skills: bool = typer.Option(
205
214
  True, "--skills/--no-skills", help="Create .claude/skills/ (default: ON, use --no-skills for Cursor)"
206
215
  ),
@@ -241,6 +250,7 @@ def init(
241
250
  Use --mcp-method to specify MCP execution method (uvx, command, python).
242
251
  Use --dirs to always create directories, --no-dirs to skip.
243
252
  Use --no-hooks to skip pre-commit hooks installation.
253
+ Use --no-claude-hooks to skip Claude Code hooks (DX-57).
244
254
  Use --no-skills to skip .claude/skills/ creation (for Cursor users).
245
255
  Use --yes to accept defaults without prompting.
246
256
  """
@@ -406,6 +416,24 @@ def init(
406
416
  if hooks:
407
417
  install_hooks(path, console)
408
418
 
419
+ # DX-57: Handle Claude Code hooks
420
+ # Determine if we should install/update Claude hooks
421
+ should_install_claude_hooks = (
422
+ claude_hooks is True # Explicitly requested
423
+ or (claude_hooks is None and claude) # Default ON when --claude
424
+ )
425
+ should_skip_claude_hooks = claude_hooks is False
426
+
427
+ if should_install_claude_hooks and not should_skip_claude_hooks:
428
+ # Install Claude hooks
429
+ install_claude_hooks(path, console)
430
+ elif not should_skip_claude_hooks:
431
+ # Check if hooks already installed and need sync
432
+ claude_hooks_dir = path / ".claude" / "hooks"
433
+ if (claude_hooks_dir / "invar.UserPromptSubmit.sh").exists():
434
+ # Sync existing hooks (idempotent update)
435
+ sync_claude_hooks(path, console)
436
+
409
437
  if not config_added and not (path / "INVAR.md").exists():
410
438
  console.print("[yellow]Invar already configured.[/yellow]")
411
439
 
@@ -446,11 +474,13 @@ def _show_check_preview(state: ProjectState, path: Path, version: str) -> None:
446
474
  console.print(" - CLAUDE.md")
447
475
  console.print(" - .invar/context.md")
448
476
  console.print(" - .claude/skills/")
477
+ console.print(" - .claude/hooks/ (DX-57, with --claude)")
449
478
  console.print(" - .pre-commit-config.yaml")
450
479
  case "update":
451
480
  console.print("Would update:")
452
481
  console.print(f" - CLAUDE.md (managed section v{state.version} → v{version})")
453
482
  console.print(" - .claude/skills/* (refresh)")
483
+ console.print(" - .claude/hooks/* (refresh, if installed)")
454
484
  case "recover":
455
485
  console.print("[yellow]Would recover:[/yellow]")
456
486
  console.print(" - CLAUDE.md (restore regions, preserve content)")
@@ -22,7 +22,6 @@ from invar.core.sync_helpers import (
22
22
  should_skip_file,
23
23
  )
24
24
  from invar.core.template_parser import (
25
- detect_claude_md_state,
26
25
  format_preserved_content,
27
26
  parse_invar_regions,
28
27
  reconstruct_file,
@@ -282,20 +281,54 @@ def _merge_region_content(
282
281
  config: SyncConfig,
283
282
  ) -> str:
284
283
  """Merge existing content with new template based on DX-55 state."""
285
- state = detect_claude_md_state(existing_content)
286
284
  updates: dict[str, str] = {}
287
285
 
288
- if state.state == "intact":
289
- # Just update managed region, preserve user
286
+ # Reset mode: discard all user content, use fresh template
287
+ if config.reset:
288
+ # Only inject project additions for CLAUDE.md if available
289
+ if dest_rel == "CLAUDE.md" and project_additions:
290
+ parsed = parse_invar_regions(new_content)
291
+ if "project" in parsed.regions:
292
+ return reconstruct_file(parsed, {"project": project_additions})
293
+ return new_content
294
+
295
+ # Generic region detection: check if primary region markers exist
296
+ # This works for any region scheme (managed/user, skill/extensions, etc.)
297
+ primary_open = f"<!--invar:{primary_region}"
298
+ primary_close = f"<!--/invar:{primary_region}-->"
299
+ user_open = f"<!--invar:{user_region}-->"
300
+ user_close = f"<!--/invar:{user_region}-->"
301
+
302
+ has_primary_open = primary_open in existing_content
303
+ has_primary_close = primary_close in existing_content
304
+ has_user_open = user_open in existing_content
305
+ has_user_close = user_close in existing_content
306
+
307
+ primary_complete = has_primary_open and has_primary_close
308
+ user_complete = has_user_open and has_user_close
309
+
310
+ # Determine state based on generic region presence
311
+ if primary_complete and user_complete:
312
+ # Intact: update primary region, preserve user region
290
313
  existing_parsed = parse_invar_regions(existing_content)
291
314
  updates[primary_region] = new_parsed.regions[primary_region].content
315
+
316
+ # DX-58: Also update critical region if present (always overwrite from template)
317
+ if "critical" in new_parsed.regions and "critical" in existing_parsed.regions:
318
+ updates["critical"] = new_parsed.regions["critical"].content
319
+
292
320
  if dest_rel == "CLAUDE.md" and project_additions and "project" in existing_parsed.regions:
293
321
  updates["project"] = project_additions
294
322
  return reconstruct_file(existing_parsed, updates)
295
323
 
296
- elif state.state == "partial":
297
- # Corruption: salvage user content
298
- user_content = state.user_content or strip_invar_markers(existing_content)
324
+ elif has_primary_open or has_user_open:
325
+ # Partial: some markers present but incomplete - salvage user content
326
+ existing_parsed = parse_invar_regions(existing_content)
327
+ user_content = ""
328
+ if user_region in existing_parsed.regions:
329
+ user_content = existing_parsed.regions[user_region].content
330
+ if not user_content:
331
+ user_content = strip_invar_markers(existing_content)
299
332
  if user_content:
300
333
  user_content = format_preserved_content(user_content, date.today().isoformat())
301
334
  parsed = parse_invar_regions(new_content)
@@ -306,8 +339,8 @@ def _merge_region_content(
306
339
  return reconstruct_file(parsed, updates)
307
340
  return new_content
308
341
 
309
- elif state.state == "missing":
310
- # No Invar markers - preserve entire content as user content
342
+ else:
343
+ # Missing: no Invar markers - preserve entire content as user content
311
344
  preserved = format_preserved_content(existing_content, date.today().isoformat())
312
345
  parsed = parse_invar_regions(new_content)
313
346
  if user_region in parsed.regions:
@@ -317,8 +350,6 @@ def _merge_region_content(
317
350
  return reconstruct_file(parsed, updates)
318
351
  return new_content
319
352
 
320
- return new_content
321
-
322
353
 
323
354
  # @shell_complexity: File creation with multiple template types
324
355
  def _sync_create_only(
@@ -68,7 +68,7 @@ def test(
68
68
  console.print(f"[red]Error:[/red] {result.failure()}")
69
69
  raise typer.Exit(1)
70
70
 
71
- report = result.unwrap()
71
+ report, _coverage_data = result.unwrap()
72
72
  output = format_property_test_report(report, use_json)
73
73
  console.print(output)
74
74
 
invar/shell/templates.py CHANGED
@@ -434,7 +434,7 @@ Find your Python path: `python -c "import sys; print(sys.executable)"`
434
434
 
435
435
  ```bash
436
436
  # Recommended: use uvx (no installation needed)
437
- uvx invar-tools guard
437
+ uvx --from invar-tools invar guard
438
438
 
439
439
  # Or install globally
440
440
  pip install invar-tools
@@ -449,7 +449,7 @@ Run the MCP server directly:
449
449
 
450
450
  ```bash
451
451
  # Using uvx
452
- uvx invar-tools mcp
452
+ uvx --from invar-tools invar mcp
453
453
 
454
454
  # Or if installed
455
455
  invar mcp