invar-tools 1.12.0__py3-none-any.whl → 1.14.0__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.
invar/core/feedback.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ Feedback anonymization logic (Core).
3
+
4
+ DX-79 Phase D: Pure string transformations for privacy protection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from deal import post, pre
12
+
13
+
14
+ @pre(lambda content: len(content) > 0)
15
+ @post(lambda result: len(result) > 0)
16
+ def anonymize_feedback_content(content: str) -> str:
17
+ """
18
+ Anonymize feedback content by removing identifying information.
19
+
20
+ Removes:
21
+ - Project names
22
+ - File paths (absolute and relative)
23
+ - Function/symbol names
24
+ - Error messages
25
+ - Email addresses
26
+ - IP addresses
27
+ - User home directories
28
+ - API tokens and secrets
29
+
30
+ Args:
31
+ content: Original feedback content
32
+
33
+ Returns:
34
+ Anonymized content with sensitive data redacted
35
+
36
+ >>> content = "**Project**: MyApp\\nFile: /Users/alice/src/app.py"
37
+ >>> result = anonymize_feedback_content(content)
38
+ >>> "MyApp" not in result
39
+ True
40
+ >>> "[redacted]" in result
41
+ True
42
+ >>> "/Users/alice" not in result
43
+ True
44
+
45
+ >>> content = "Error: Invalid token abc123def456\\nEmail: user@example.com"
46
+ >>> result = anonymize_feedback_content(content)
47
+ >>> "user@example.com" not in result
48
+ True
49
+ >>> "[email redacted]" in result
50
+ True
51
+ """
52
+ # Replace project name
53
+ content = re.sub(r"\*\*Project\*\*: .*", "**Project**: [redacted]", content)
54
+
55
+ # Replace email addresses
56
+ content = re.sub(
57
+ r"\b[\w.+-]+@[\w.-]+\.\w{2,}\b",
58
+ "[email redacted]",
59
+ content,
60
+ )
61
+
62
+ # Replace IP addresses
63
+ content = re.sub(
64
+ r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
65
+ "[IP redacted]",
66
+ content,
67
+ )
68
+
69
+ # Replace user home directories
70
+ content = re.sub(r"/Users/[\w.-]+/?", "[home]/", content)
71
+ content = re.sub(r"/home/[\w.-]+/?", "[home]/", content)
72
+ content = re.sub(r"C:\\Users\\[\w.-]+\\?", r"[home]\\", content)
73
+
74
+ # Replace absolute file paths (before specific replacements)
75
+ content = re.sub(r"/[\w/.-]+\.(py|ts|js|md|json|yaml)", "[path redacted]", content)
76
+
77
+ # Replace file paths (specific patterns)
78
+ content = re.sub(r"File: [^\s]+", "File: [path redacted]", content, flags=re.IGNORECASE)
79
+ content = re.sub(r"src/[\w/.-]+", "[path redacted]", content)
80
+
81
+ # Replace API tokens and secrets (long hex/base64 strings)
82
+ # Match 32+ character hex strings (likely tokens)
83
+ content = re.sub(r"\b[a-fA-F0-9]{32,}\b", "[token redacted]", content)
84
+ # Match base64-like strings (24+ chars with alphanumeric and +/=)
85
+ content = re.sub(r"\b[A-Za-z0-9+/]{24,}={0,2}\b", "[token redacted]", content)
86
+
87
+ # Replace function names in prose
88
+ content = re.sub(
89
+ r"function ['\"][\w_]+['\"]",
90
+ "function [name redacted]",
91
+ content,
92
+ flags=re.IGNORECASE,
93
+ )
94
+
95
+ # Replace symbol references (more targeted pattern)
96
+ # Only replace if it looks like a function call with parentheses
97
+ content = re.sub(
98
+ r"`([\w.]+)\([^)]*\)`",
99
+ lambda m: "`[symbol redacted]()`" if "." in m.group(1) or "_" in m.group(1) else m.group(0),
100
+ content,
101
+ )
102
+
103
+ # Replace error messages (in code blocks or after "Error:")
104
+ content = re.sub(
105
+ r"Error: .+",
106
+ "Error: [message redacted]",
107
+ content,
108
+ )
109
+
110
+ return content
@@ -193,6 +193,55 @@ def _register_hooks_in_settings(project_path: Path) -> Result[bool, str]:
193
193
  return Failure(f"Failed to update settings: {e}")
194
194
 
195
195
 
196
+ # @shell_complexity: Feedback config management in settings.local.json
197
+ def add_feedback_config(
198
+ project_path: Path,
199
+ enabled: bool = True,
200
+ console: Console | None = None,
201
+ ) -> Result[bool, str]:
202
+ """
203
+ Add feedback configuration to .claude/settings.local.json.
204
+
205
+ DX-79 Phase C: Init Integration for /invar-reflect skill.
206
+
207
+ Args:
208
+ project_path: Path to project root
209
+ enabled: Whether to enable feedback collection (default: True)
210
+ console: Optional Rich console for output
211
+
212
+ Returns:
213
+ Success(True) if config added/updated, Failure with error message otherwise
214
+ """
215
+ import json
216
+
217
+ settings_path = project_path / ".claude" / "settings.local.json"
218
+
219
+ try:
220
+ existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
221
+
222
+ # Add feedback configuration
223
+ existing["feedback"] = {
224
+ "enabled": enabled,
225
+ "auto_trigger": enabled, # Same as enabled
226
+ "retention_days": 90,
227
+ }
228
+
229
+ # Ensure .claude directory exists
230
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
231
+
232
+ # Write with indentation for readability
233
+ settings_path.write_text(json.dumps(existing, indent=2) + "\n")
234
+
235
+ if console:
236
+ status = "enabled" if enabled else "disabled"
237
+ console.print(f" [green]✓[/green] Feedback collection {status}")
238
+
239
+ return Success(True)
240
+
241
+ except (OSError, json.JSONDecodeError) as e:
242
+ return Failure(f"Failed to update settings: {e}")
243
+
244
+
196
245
  # @shell_complexity: Multi-file installation with backup/merge logic for user hooks
197
246
  def install_claude_hooks(
198
247
  project_path: Path,
@@ -0,0 +1,258 @@
1
+ """
2
+ CLI commands for feedback management.
3
+
4
+ DX-79 Phase D: Analysis Tools for /invar-reflect feedback.
5
+ Shell module: handles feedback file operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+ from returns.result import Failure, Result, Success
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from invar.core.feedback import anonymize_feedback_content
20
+
21
+ console = Console()
22
+
23
+ # Create feedback subcommand app
24
+ feedback_app = typer.Typer(
25
+ name="feedback",
26
+ help="Manage Invar usage feedback files.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+
31
+ # @shell_complexity: File discovery with filtering
32
+ def _get_feedback_files(
33
+ project_path: Path,
34
+ older_than_days: int | None = None,
35
+ ) -> Result[list[tuple[Path, datetime]], str]:
36
+ """
37
+ Get feedback files with optional age filtering.
38
+
39
+ Args:
40
+ project_path: Path to project root
41
+ older_than_days: Only return files older than N days (None = all)
42
+
43
+ Returns:
44
+ Success with list of (file_path, modified_time) tuples, or Failure
45
+ """
46
+ feedback_dir = project_path / ".invar" / "feedback"
47
+
48
+ if not feedback_dir.exists():
49
+ return Success([])
50
+
51
+ try:
52
+ files: list[tuple[Path, datetime]] = []
53
+ cutoff = datetime.now() - timedelta(days=older_than_days) if older_than_days else None
54
+
55
+ for file in feedback_dir.glob("feedback-*.md"):
56
+ if not file.is_file():
57
+ continue
58
+
59
+ mtime = datetime.fromtimestamp(file.stat().st_mtime)
60
+
61
+ if cutoff is None or mtime < cutoff:
62
+ files.append((file, mtime))
63
+
64
+ # Sort by modification time (newest first)
65
+ files.sort(key=lambda x: x[1], reverse=True)
66
+ return Success(files)
67
+
68
+ except OSError as e:
69
+ return Failure(f"Failed to read feedback directory: {e}")
70
+
71
+
72
+ # @invar:allow entry_point_too_thick: CLI display with table formatting and help text
73
+ @feedback_app.command(name="list")
74
+ def list_feedback(
75
+ path: Annotated[
76
+ Path,
77
+ typer.Argument(help="Project root directory (default: current directory)"),
78
+ ] = Path(),
79
+ ) -> None:
80
+ """
81
+ List all feedback files in the project.
82
+
83
+ Shows:
84
+ - File name
85
+ - Last modified time
86
+ - File size
87
+
88
+ Example:
89
+ invar feedback list
90
+ """
91
+ path = path.resolve()
92
+ result = _get_feedback_files(path)
93
+
94
+ if isinstance(result, Failure):
95
+ console.print(f"[red]Error:[/red] {result.failure()}")
96
+ raise typer.Exit(1)
97
+
98
+ files = result.unwrap()
99
+
100
+ if not files:
101
+ console.print("[dim]No feedback files found in .invar/feedback/[/dim]")
102
+ console.print("\n[dim]Tip: Use /invar-reflect to generate feedback[/dim]")
103
+ return
104
+
105
+ # Display table
106
+ table = Table(title="Invar Feedback Files")
107
+ table.add_column("File", style="cyan")
108
+ table.add_column("Last Modified", style="yellow")
109
+ table.add_column("Size", justify="right", style="dim")
110
+
111
+ for file, mtime in files:
112
+ size_kb = file.stat().st_size / 1024
113
+ table.add_row(
114
+ file.name,
115
+ mtime.strftime("%Y-%m-%d %H:%M"),
116
+ f"{size_kb:.1f} KB",
117
+ )
118
+
119
+ console.print(table)
120
+ console.print(f"\n[bold]{len(files)}[/bold] feedback file(s) found")
121
+
122
+
123
+ # @invar:allow entry_point_too_thick: CLI workflow with confirmation, deletion, and error handling
124
+ @feedback_app.command(name="cleanup")
125
+ def cleanup_feedback(
126
+ older_than: Annotated[
127
+ int,
128
+ typer.Option("--older-than", help="Delete files older than N days"),
129
+ ] = 90,
130
+ path: Annotated[
131
+ Path,
132
+ typer.Argument(help="Project root directory (default: current directory)"),
133
+ ] = Path(),
134
+ dry_run: Annotated[
135
+ bool,
136
+ typer.Option("--dry-run", help="Show what would be deleted without deleting"),
137
+ ] = False,
138
+ ) -> None:
139
+ """
140
+ Clean up old feedback files.
141
+
142
+ Default: Delete files older than 90 days.
143
+
144
+ Examples:
145
+ invar feedback cleanup # Delete files older than 90 days
146
+ invar feedback cleanup --older-than 30 # Delete files older than 30 days
147
+ invar feedback cleanup --dry-run # Preview what would be deleted
148
+ """
149
+ path = path.resolve()
150
+ result = _get_feedback_files(path, older_than_days=older_than)
151
+
152
+ if isinstance(result, Failure):
153
+ console.print(f"[red]Error:[/red] {result.failure()}")
154
+ raise typer.Exit(1)
155
+
156
+ files = result.unwrap()
157
+
158
+ if not files:
159
+ console.print(f"[green]✓[/green] No files older than {older_than} days")
160
+ return
161
+
162
+ # Display what will be deleted
163
+ console.print(f"[bold]Files older than {older_than} days:[/bold]\n")
164
+ for file, mtime in files:
165
+ age_days = (datetime.now() - mtime).days
166
+ console.print(f" [red]✗[/red] {file.name} ({age_days} days old)")
167
+
168
+ if dry_run:
169
+ console.print(f"\n[dim]Dry run: Would delete {len(files)} file(s)[/dim]")
170
+ console.print("[dim]Run without --dry-run to apply[/dim]")
171
+ return
172
+
173
+ # Confirm deletion
174
+ from rich.prompt import Confirm
175
+
176
+ if not Confirm.ask(f"\nDelete {len(files)} file(s)?", default=False):
177
+ console.print("[yellow]Cancelled[/yellow]")
178
+ return
179
+
180
+ # Delete files
181
+ deleted = 0
182
+ failed = 0
183
+ for file, _ in files:
184
+ try:
185
+ file.unlink()
186
+ deleted += 1
187
+ except OSError as e:
188
+ console.print(f" [red]Failed to delete {file.name}:[/red] {e}")
189
+ failed += 1
190
+
191
+ # Summary
192
+ console.print(f"\n[green]✓[/green] Deleted {deleted} file(s)")
193
+ if failed > 0:
194
+ console.print(f"[red]✗[/red] Failed to delete {failed} file(s)")
195
+
196
+
197
+ # @invar:allow entry_point_too_thick: CLI with file handling, error display, and multiple output modes
198
+ @feedback_app.command(name="anonymize")
199
+ def anonymize_feedback(
200
+ file: Annotated[
201
+ str,
202
+ typer.Argument(help="Feedback file name (e.g., feedback-2026-01-03.md)"),
203
+ ],
204
+ path: Annotated[
205
+ Path,
206
+ typer.Option("--path", help="Project root directory"),
207
+ ] = Path(),
208
+ output: Annotated[
209
+ Path | None,
210
+ typer.Option("--output", "-o", help="Output file (default: stdout)"),
211
+ ] = None,
212
+ ) -> None:
213
+ """
214
+ Anonymize feedback for sharing.
215
+
216
+ Removes:
217
+ - Project names
218
+ - File paths
219
+ - Function/symbol names
220
+ - Error messages
221
+
222
+ Examples:
223
+ invar feedback anonymize feedback-2026-01-03.md
224
+ invar feedback anonymize feedback-2026-01-03.md -o safe.md
225
+ invar feedback anonymize feedback-2026-01-03.md > share.md
226
+ """
227
+ path = path.resolve()
228
+ feedback_dir = path / ".invar" / "feedback"
229
+ input_file = feedback_dir / file
230
+
231
+ if not input_file.exists():
232
+ console.print(f"[red]Error:[/red] File not found: {input_file}")
233
+ console.print("\n[dim]Available files:[/dim]")
234
+
235
+ # Show available files
236
+ result = _get_feedback_files(path)
237
+ if isinstance(result, Success):
238
+ for f, _ in result.unwrap():
239
+ console.print(f" {f.name}")
240
+
241
+ raise typer.Exit(1)
242
+
243
+ # Read and anonymize
244
+ try:
245
+ content = input_file.read_text(encoding="utf-8")
246
+ anonymized = anonymize_feedback_content(content)
247
+
248
+ # Output
249
+ if output:
250
+ output.write_text(anonymized, encoding="utf-8")
251
+ console.print(f"[green]✓[/green] Anonymized feedback saved to: {output}")
252
+ else:
253
+ # Print to stdout
254
+ console.print(anonymized)
255
+
256
+ except OSError as e:
257
+ console.print(f"[red]Error:[/red] {e}")
258
+ raise typer.Exit(1)
@@ -41,6 +41,11 @@ from invar.shell.commands.doc import doc_app
41
41
 
42
42
  app.add_typer(doc_app, name="doc")
43
43
 
44
+ # DX-79: Register feedback subcommand
45
+ from invar.shell.commands.feedback import feedback_app
46
+
47
+ app.add_typer(feedback_app, name="feedback")
48
+
44
49
 
45
50
  # @shell_orchestration: Statistics helper for CLI guard output
46
51
  # @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
@@ -122,7 +127,7 @@ def guard(
122
127
  ),
123
128
  strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
124
129
  changed: bool = typer.Option(
125
- False, "--changed", help="Only check git-modified files"
130
+ True, "--changed/--all", help="Check git-modified files only (use --all for full check)"
126
131
  ),
127
132
  static: bool = typer.Option(
128
133
  False, "--static", help="Static analysis only, skip all runtime tests"
@@ -159,6 +164,9 @@ def guard(
159
164
  """Check project against Invar architecture rules.
160
165
 
161
166
  Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
167
+
168
+ By default, checks only git-modified files for fast feedback during development.
169
+ Use --all to check the entire project (useful for CI/release).
162
170
  Use --static for quick static-only checks (~0.5s vs ~5s full).
163
171
  Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
164
172
  Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
@@ -16,7 +16,7 @@ from rich.console import Console
16
16
  from rich.panel import Panel
17
17
 
18
18
  from invar.core.sync_helpers import VALID_LANGUAGES, SyncConfig
19
- from invar.shell.claude_hooks import install_claude_hooks
19
+ from invar.shell.claude_hooks import add_feedback_config, install_claude_hooks
20
20
  from invar.shell.commands.template_sync import sync_templates
21
21
  from invar.shell.mcp_config import (
22
22
  generate_mcp_json,
@@ -239,6 +239,40 @@ def _prompt_file_selection(agents: list[str]) -> dict[str, bool]:
239
239
  return {f: f in selected for f in file_list}
240
240
 
241
241
 
242
+ # @shell_complexity: Interactive consent prompt for feedback collection
243
+ def _prompt_feedback_consent() -> bool:
244
+ """
245
+ Prompt user for consent to enable automatic feedback collection.
246
+
247
+ DX-79 Phase C: Opt-out consent flow (default: enabled).
248
+
249
+ Returns:
250
+ True if user consents (or accepts default), False otherwise
251
+ """
252
+ from rich import print as rprint
253
+ from rich.prompt import Confirm
254
+
255
+ rprint()
256
+ rprint("[bold]━" * 40)
257
+ rprint("[bold]📊 Usage Feedback (Optional)")
258
+ rprint("[bold]━" * 40)
259
+ rprint()
260
+ rprint("Invar can automatically reflect on tool usage to help improve")
261
+ rprint("the framework. Feedback is:")
262
+ rprint(" • Stored locally in [cyan].invar/feedback/[/cyan]")
263
+ rprint(" • Never sent automatically")
264
+ rprint(" • You decide what (if anything) to share")
265
+ rprint()
266
+
267
+ # Opt-out: default is True (Y)
268
+ consent = Confirm.ask(
269
+ "Enable automatic feedback collection?",
270
+ default=True,
271
+ )
272
+
273
+ return consent
274
+
275
+
242
276
  def _show_execution_output(
243
277
  created: list[str],
244
278
  merged: list[str],
@@ -442,6 +476,10 @@ def init(
442
476
  for category in ["optional", "claude"]:
443
477
  for file, _ in FILE_CATEGORIES.get(category, []):
444
478
  selected_files[file] = True
479
+ # DX-79: Default feedback enabled for quick mode
480
+ feedback_enabled = True
481
+ console.print("\n[dim]📊 Feedback collection enabled by default (stored locally in .invar/feedback/)[/dim]")
482
+ console.print("[dim] To disable: Set feedback.enabled=false in .claude/settings.local.json[/dim]")
445
483
  elif pi:
446
484
  # Quick mode: Pi defaults
447
485
  agents = ["pi"]
@@ -449,6 +487,10 @@ def init(
449
487
  for category in ["optional", "pi"]:
450
488
  for file, _ in FILE_CATEGORIES.get(category, []):
451
489
  selected_files[file] = True
490
+ # DX-79: Default feedback enabled for quick mode
491
+ feedback_enabled = True
492
+ console.print("\n[dim]📊 Feedback collection enabled by default (stored locally in .invar/feedback/)[/dim]")
493
+ console.print("[dim] To disable: Set feedback.enabled=false in .claude/settings.local.json[/dim]")
452
494
  else:
453
495
  # Interactive mode
454
496
  if not _is_interactive():
@@ -457,6 +499,8 @@ def init(
457
499
 
458
500
  agents = _prompt_agent_selection()
459
501
  selected_files = _prompt_file_selection(agents)
502
+ # DX-79: Prompt for feedback consent (opt-out, default: enabled)
503
+ feedback_enabled = _prompt_feedback_consent()
460
504
 
461
505
  # Preview mode
462
506
  if preview:
@@ -554,6 +598,12 @@ def init(
554
598
  if "pi" in agents and selected_files.get(".pi/hooks/", True):
555
599
  install_pi_hooks(path, console)
556
600
 
601
+ # Add feedback configuration (DX-79 Phase C)
602
+ if "claude" in agents or "pi" in agents:
603
+ feedback_result = add_feedback_config(path, feedback_enabled, console)
604
+ if isinstance(feedback_result, Failure):
605
+ console.print(f"[yellow]Warning:[/yellow] {feedback_result.failure()}")
606
+
557
607
  # Create MCP setup guide
558
608
  mcp_setup = invar_dir / "mcp-setup.md"
559
609
  if not mcp_setup.exists():
@@ -0,0 +1,110 @@
1
+ ### Document Tools (DX-76)
2
+
3
+ | I want to... | Use |
4
+ |--------------|-----|
5
+ | View document structure | `{% if syntax == "mcp" %}invar_doc_toc(file="<file>"){% else %}invar doc toc <file> [--format text]{% endif %}` |
6
+ | Read specific section | `{% if syntax == "mcp" %}invar_doc_read(file="<file>", section="<section>"){% else %}invar doc read <file> <section>{% endif %}` |
7
+ | Search sections by title | `{% if syntax == "mcp" %}invar_doc_find(file="<file>", pattern="<pattern>"){% else %}invar doc find <pattern> <files...>{% endif %}` |
8
+ | Replace section content | `{% if syntax == "mcp" %}invar_doc_replace(file="<file>", section="<section>"){% else %}invar doc replace <file> <section>{% endif %}` |
9
+ | Insert new section | `{% if syntax == "mcp" %}invar_doc_insert(file="<file>", anchor="<anchor>"){% else %}invar doc insert <file> <anchor>{% endif %}` |
10
+ | Delete section | `{% if syntax == "mcp" %}invar_doc_delete(file="<file>", section="<section>"){% else %}invar doc delete <file> <section>{% endif %}` |
11
+
12
+ **Section addressing:** slug path (`requirements/auth`), fuzzy (`auth`), index (`#0/#1`), line (`@48`)
13
+
14
+ ## Tool Selection
15
+
16
+ ### Calling Methods (Priority Order)
17
+
18
+ Invar tools can be called in 3 ways. **Try in order:**
19
+
20
+ 1. **MCP tools** (Claude Code with MCP enabled)
21
+ - Direct function calls: `invar_guard()`, `invar_sig()`, etc.
22
+ - No Bash wrapper needed
23
+
24
+ 2. **CLI command** (if `invar` installed in PATH)
25
+ - Via Bash: `invar guard`, `invar sig`, etc.
26
+ - Install: `pip install invar-tools`
27
+
28
+ 3. **uvx fallback** (always available, no install needed)
29
+ - Via Bash: `uvx invar-tools guard`, `uvx invar-tools sig`, etc.
30
+
31
+ ---
32
+
33
+ ### Parameter Reference
34
+
35
+ **guard** - Verify code quality
36
+ ```{% if syntax == "mcp" %}python
37
+ # MCP
38
+ invar_guard() # Check changed files (default)
39
+ invar_guard(changed=False) # Check all files{% else %}bash
40
+ # CLI
41
+ invar guard # Check changed files (default)
42
+ invar guard --all # Check all files{% endif %}
43
+ ```
44
+
45
+ **sig** - Show function signatures and contracts
46
+ ```{% if syntax == "mcp" %}python
47
+ # MCP
48
+ invar_sig(target="src/foo.py"){% else %}bash
49
+ # CLI
50
+ invar sig src/foo.py
51
+ invar sig src/foo.py::function_name{% endif %}
52
+ ```
53
+
54
+ **map** - Find entry points
55
+ ```{% if syntax == "mcp" %}python
56
+ # MCP
57
+ invar_map(path=".", top=10){% else %}bash
58
+ # CLI
59
+ invar map [path] --top 10{% endif %}
60
+ ```
61
+
62
+ **refs** - Find all references to a symbol
63
+ ```{% if syntax == "mcp" %}python
64
+ # MCP
65
+ invar_refs(target="src/foo.py::MyClass"){% else %}bash
66
+ # CLI
67
+ invar refs src/foo.py::MyClass{% endif %}
68
+ ```
69
+
70
+ **doc*** - Document tools
71
+ ```{% if syntax == "mcp" %}python
72
+ # MCP
73
+ invar_doc_toc(file="docs/spec.md")
74
+ invar_doc_read(file="docs/spec.md", section="intro"){% else %}bash
75
+ # CLI
76
+ invar doc toc docs/spec.md
77
+ invar doc read docs/spec.md intro{% endif %}
78
+ ```
79
+
80
+ ---
81
+
82
+ ### Quick Examples
83
+
84
+ ```{% if syntax == "mcp" %}python
85
+ # Verify after changes (all three methods identical)
86
+ invar_guard() # MCP
87
+ bash("invar guard") # CLI
88
+ bash("uvx invar-tools guard") # uvx
89
+
90
+ # Full project check
91
+ invar_guard(changed=False) # MCP
92
+ bash("invar guard --all") # CLI
93
+
94
+ # See function contracts
95
+ invar_sig(target="src/core/parser.py")
96
+ bash("invar sig src/core/parser.py"){% else %}bash
97
+ # Verify after changes (all three methods identical)
98
+ invar guard # CLI
99
+ uvx invar-tools guard # uvx
100
+
101
+ # Full project check
102
+ invar guard --all # CLI
103
+ uvx invar-tools guard --all # uvx
104
+
105
+ # See function contracts
106
+ invar sig src/core/parser.py
107
+ uvx invar-tools sig src/core/parser.py{% endif %}
108
+ ```
109
+
110
+ **Note**: All three methods now have identical default behavior.
@@ -23,6 +23,8 @@
23
23
  {% include "claude-md/typescript/quick-reference.md" %}
24
24
  {% endif %}
25
25
 
26
+ {% include "claude-md/universal/tool-selection.md" %}
27
+
26
28
  {% include "claude-md/universal/workflow.md" %}
27
29
 
28
30
  ---
@@ -64,6 +64,11 @@ extensions = { action = "preserve" }
64
64
  ".claude/skills/propose/SKILL.md" = { src = "skills/propose/SKILL.md.jinja", type = "jinja" }
65
65
  ".claude/skills/review/SKILL.md" = { src = "skills/review/SKILL.md.jinja", type = "jinja" }
66
66
 
67
+ # DX-79: Invar usage feedback skill
68
+ ".claude/skills/invar-reflect/SKILL.md" = { src = "skills/invar-reflect/SKILL.md", type = "copy" }
69
+ ".claude/skills/invar-reflect/template.md" = { src = "skills/invar-reflect/template.md", type = "copy" }
70
+ ".claude/skills/invar-reflect/CONFIG.md" = { src = "skills/invar-reflect/CONFIG.md", type = "copy" }
71
+
67
72
  # Commands (Jinja2 templates for language-specific content)
68
73
  ".claude/commands/audit.md" = { src = "commands/audit.md.jinja", type = "jinja" }
69
74
  ".claude/commands/guard.md" = { src = "commands/guard.md", type = "copy" }
@@ -136,4 +141,7 @@ create_only = [
136
141
  ".pre-commit-config.yaml",
137
142
  ".claude/commands/audit.md",
138
143
  ".claude/commands/guard.md",
144
+ ".claude/skills/invar-reflect/SKILL.md",
145
+ ".claude/skills/invar-reflect/template.md",
146
+ ".claude/skills/invar-reflect/CONFIG.md",
139
147
  ]