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 +110 -0
- invar/shell/claude_hooks.py +49 -0
- invar/shell/commands/feedback.py +258 -0
- invar/shell/commands/guard.py +9 -1
- invar/shell/commands/init.py +51 -1
- invar/templates/claude-md/universal/tool-selection.md +110 -0
- invar/templates/config/CLAUDE.md.jinja +2 -0
- invar/templates/manifest.toml +8 -0
- invar/templates/protocol/python/tools.md +5 -2
- invar/templates/protocol/typescript/tools.md +5 -2
- invar/templates/skills/invar-reflect/CONFIG.md +355 -0
- invar/templates/skills/invar-reflect/SKILL.md +466 -0
- invar/templates/skills/invar-reflect/template.md +343 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/METADATA +1 -1
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/RECORD +20 -14
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.14.0.dist-info}/licenses/NOTICE +0 -0
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
|
invar/shell/claude_hooks.py
CHANGED
|
@@ -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)
|
invar/shell/commands/guard.py
CHANGED
|
@@ -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
|
-
|
|
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).
|
invar/shell/commands/init.py
CHANGED
|
@@ -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.
|
invar/templates/manifest.toml
CHANGED
|
@@ -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
|
]
|