invar-tools 1.12.0__py3-none-any.whl → 1.15.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.
@@ -16,13 +16,14 @@ 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,
23
23
  get_recommended_method,
24
24
  )
25
25
  from invar.shell.pi_hooks import install_pi_hooks
26
+ from invar.shell.pi_tools import install_pi_tools
26
27
  from invar.shell.templates import (
27
28
  add_config,
28
29
  create_directories,
@@ -60,6 +61,7 @@ FILE_CATEGORIES: dict[str, list[tuple[str, str]]] = {
60
61
  ("CLAUDE.md", "Agent instructions (Pi compatible)"),
61
62
  (".claude/skills/", "Workflow automation (Pi compatible)"),
62
63
  (".pi/hooks/", "Pi-specific hooks"),
64
+ (".pi/tools/", "Pi custom tools (invar_guard, invar_sig, invar_map)"),
63
65
  ],
64
66
  }
65
67
 
@@ -153,29 +155,43 @@ def _get_prompt_style():
153
155
 
154
156
  # @shell_complexity: Interactive prompt with cursor selection
155
157
  def _prompt_agent_selection() -> list[str]:
156
- """Prompt user to select code agent using cursor navigation."""
158
+ """Prompt user to select agent(s) using checkbox (DX-81: multi-agent support)."""
157
159
  import questionary
158
160
 
159
- console.print("\n[bold]Select code agent:[/bold]")
160
- console.print("[dim]Use arrow keys to move, enter to select[/dim]\n")
161
+ console.print("\n[bold]Select agent(s) to configure:[/bold]")
162
+ console.print("[dim]Space to toggle, Enter to confirm (can select multiple)[/dim]\n")
161
163
 
162
164
  choices = [
163
- questionary.Choice("Claude Code (recommended)", value="claude"),
164
- questionary.Choice("Pi Coding Agent", value="pi"),
165
- questionary.Choice("Other (AGENT.md)", value="generic"),
165
+ questionary.Choice(
166
+ "Claude Code (recommended)",
167
+ value="claude",
168
+ checked=True # Default selection
169
+ ),
170
+ questionary.Choice(
171
+ "Pi Coding Agent",
172
+ value="pi",
173
+ checked=False
174
+ ),
175
+ questionary.Choice(
176
+ "Other (AGENT.md)",
177
+ value="generic",
178
+ checked=False
179
+ ),
166
180
  ]
167
181
 
168
- selected = questionary.select(
182
+ selected = questionary.checkbox(
169
183
  "",
170
184
  choices=choices,
171
185
  instruction="",
172
186
  style=_get_prompt_style(),
173
187
  ).ask()
174
188
 
175
- # Handle Ctrl+C
189
+ # Handle Ctrl+C or empty selection
176
190
  if not selected:
177
- return ["claude"] # Default to Claude Code
178
- return [selected]
191
+ console.print("[yellow]No agents selected, using Claude Code as default.[/yellow]")
192
+ return ["claude"]
193
+
194
+ return selected
179
195
 
180
196
 
181
197
  # @shell_complexity: Interactive file selection with cursor navigation
@@ -205,9 +221,10 @@ def _prompt_file_selection(agents: list[str]) -> dict[str, bool]:
205
221
  console.print()
206
222
  console.print("[dim]Use arrow keys to move, space to toggle, enter to confirm[/dim]\n")
207
223
 
208
- # Build choices with categories as separators
224
+ # Build choices with categories as separators (DX-81: deduplicate shared files)
209
225
  choices: list[questionary.Choice | questionary.Separator] = []
210
226
  file_list: list[str] = []
227
+ seen_files: set[str] = set()
211
228
 
212
229
  for category, files in available.items():
213
230
  if category == "required":
@@ -217,12 +234,19 @@ def _prompt_file_selection(agents: list[str]) -> dict[str, bool]:
217
234
  category_name = "Claude Code"
218
235
  elif category == "pi":
219
236
  category_name = "Pi Coding Agent"
220
- choices.append(questionary.Separator(f"── {category_name} ──"))
221
- for file, desc in files:
222
- choices.append(
223
- questionary.Choice(f"{file:28} {desc}", value=file, checked=True)
224
- )
225
- file_list.append(file)
237
+
238
+ # Filter out files already seen (shared between categories)
239
+ unique_files = [(f, d) for f, d in files if f not in seen_files]
240
+
241
+ # Only add separator if there are unique files to show
242
+ if unique_files:
243
+ choices.append(questionary.Separator(f"── {category_name} ──"))
244
+ for file, desc in unique_files:
245
+ choices.append(
246
+ questionary.Choice(f"{file:28} {desc}", value=file, checked=True)
247
+ )
248
+ file_list.append(file)
249
+ seen_files.add(file)
226
250
 
227
251
  selected = questionary.checkbox(
228
252
  "Select files to install:",
@@ -239,6 +263,40 @@ def _prompt_file_selection(agents: list[str]) -> dict[str, bool]:
239
263
  return {f: f in selected for f in file_list}
240
264
 
241
265
 
266
+ # @shell_complexity: Interactive consent prompt for feedback collection
267
+ def _prompt_feedback_consent() -> bool:
268
+ """
269
+ Prompt user for consent to enable automatic feedback collection.
270
+
271
+ DX-79 Phase C: Opt-out consent flow (default: enabled).
272
+
273
+ Returns:
274
+ True if user consents (or accepts default), False otherwise
275
+ """
276
+ from rich import print as rprint
277
+ from rich.prompt import Confirm
278
+
279
+ rprint()
280
+ rprint("[bold]━" * 40)
281
+ rprint("[bold]📊 Usage Feedback (Optional)")
282
+ rprint("[bold]━" * 40)
283
+ rprint()
284
+ rprint("Invar can automatically reflect on tool usage to help improve")
285
+ rprint("the framework. Feedback is:")
286
+ rprint(" • Stored locally in [cyan].invar/feedback/[/cyan]")
287
+ rprint(" • Never sent automatically")
288
+ rprint(" • You decide what (if anything) to share")
289
+ rprint()
290
+
291
+ # Opt-out: default is True (Y)
292
+ consent = Confirm.ask(
293
+ "Enable automatic feedback collection?",
294
+ default=True,
295
+ )
296
+
297
+ return consent
298
+
299
+
242
300
  def _show_execution_output(
243
301
  created: list[str],
244
302
  merged: list[str],
@@ -356,10 +414,7 @@ def init(
356
414
  """
357
415
  from invar import __version__
358
416
 
359
- # Mutual exclusivity check
360
- if claude and pi:
361
- console.print("[red]Error:[/red] Cannot use --claude and --pi together.")
362
- raise typer.Exit(1)
417
+ # DX-81: Multi-agent support - removed mutual exclusivity check
363
418
 
364
419
  if mcp_only and (claude or pi):
365
420
  console.print("[red]Error:[/red] --mcp-only cannot be combined with --claude or --pi.")
@@ -424,8 +479,10 @@ def init(
424
479
  console.print(f"[red]Error:[/red] Invalid language '{language}'. Must be one of: {valid}")
425
480
  raise typer.Exit(1)
426
481
 
427
- # Header
428
- if claude:
482
+ # Header (DX-81: Support multi-agent display)
483
+ if claude and pi:
484
+ console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Claude Code + Pi)[/bold]")
485
+ elif claude:
429
486
  console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Claude Code)[/bold]")
430
487
  elif pi:
431
488
  console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Pi)[/bold]")
@@ -434,21 +491,32 @@ def init(
434
491
  console.print("=" * 45)
435
492
  console.print(f"[dim]Language: {language} | Existing files will be MERGED.[/dim]")
436
493
 
437
- # Determine agents and files
438
- if claude:
439
- # Quick mode: Claude Code defaults
440
- agents = ["claude"]
494
+ # DX-81: Determine agents and files (multi-agent support)
495
+ if claude or pi:
496
+ # Quick mode: Build agent list from flags
497
+ agents = []
498
+ if claude:
499
+ agents.append("claude")
500
+ if pi:
501
+ agents.append("pi")
502
+
503
+ # Build selected_files from all agents' categories
441
504
  selected_files: dict[str, bool] = {}
442
- for category in ["optional", "claude"]:
443
- for file, _ in FILE_CATEGORIES.get(category, []):
444
- selected_files[file] = True
445
- elif pi:
446
- # Quick mode: Pi defaults
447
- agents = ["pi"]
448
- selected_files = {}
449
- for category in ["optional", "pi"]:
505
+ for agent in agents:
506
+ category = AGENT_CONFIGS[agent]["category"]
450
507
  for file, _ in FILE_CATEGORIES.get(category, []):
451
508
  selected_files[file] = True
509
+
510
+ # Add optional files
511
+ for file, _ in FILE_CATEGORIES["optional"]:
512
+ selected_files[file] = True
513
+
514
+ # DX-79: Default feedback enabled for quick mode
515
+ feedback_enabled = True
516
+ if len(agents) > 1:
517
+ console.print(f"\n[dim]📊 Configuring for {len(agents)} agents: {', '.join(agents)}[/dim]")
518
+ console.print("\n[dim]📊 Feedback collection enabled by default (stored locally in .invar/feedback/)[/dim]")
519
+ console.print("[dim] To disable: Set feedback.enabled=false in .claude/settings.local.json[/dim]")
452
520
  else:
453
521
  # Interactive mode
454
522
  if not _is_interactive():
@@ -457,6 +525,8 @@ def init(
457
525
 
458
526
  agents = _prompt_agent_selection()
459
527
  selected_files = _prompt_file_selection(agents)
528
+ # DX-79: Prompt for feedback consent (opt-out, default: enabled)
529
+ feedback_enabled = _prompt_feedback_consent()
460
530
 
461
531
  # Preview mode
462
532
  if preview:
@@ -554,6 +624,16 @@ def init(
554
624
  if "pi" in agents and selected_files.get(".pi/hooks/", True):
555
625
  install_pi_hooks(path, console)
556
626
 
627
+ # Install Pi custom tools if selected
628
+ if "pi" in agents and selected_files.get(".pi/tools/", True):
629
+ install_pi_tools(path, console)
630
+
631
+ # Add feedback configuration (DX-79 Phase C)
632
+ if "claude" in agents or "pi" in agents:
633
+ feedback_result = add_feedback_config(path, feedback_enabled, console)
634
+ if isinstance(feedback_result, Failure):
635
+ console.print(f"[yellow]Warning:[/yellow] {feedback_result.failure()}")
636
+
557
637
  # Create MCP setup guide
558
638
  mcp_setup = invar_dir / "mcp-setup.md"
559
639
  if not mcp_setup.exists():
@@ -572,7 +652,7 @@ def init(
572
652
  # Completion message
573
653
  console.print(f"\n[bold green]✓ Initialized Invar v{__version__}[/bold green]")
574
654
 
575
- # Show agent-specific tips
655
+ # Show agent-specific tips (DX-81: show all relevant tips)
576
656
  if "claude" in agents:
577
657
  console.print()
578
658
  console.print(
@@ -583,7 +663,7 @@ def init(
583
663
  border_style="dim",
584
664
  )
585
665
  )
586
- elif "pi" in agents:
666
+ if "pi" in agents:
587
667
  console.print()
588
668
  console.print(
589
669
  Panel(
@@ -0,0 +1,120 @@
1
+ """
2
+ Pi Coding Agent custom tools for Invar.
3
+
4
+ Provides Invar CLI commands as Pi custom tools for better LLM integration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from returns.result import Failure, Result, Success
14
+
15
+ if TYPE_CHECKING:
16
+ from rich.console import Console
17
+
18
+ # Pi tools directory
19
+ PI_TOOLS_DIR = ".pi/tools/invar"
20
+
21
+
22
+ def get_pi_tools_template_path() -> Path:
23
+ """Get the path to Pi tools template."""
24
+ return Path(__file__).parent.parent / "templates" / "pi-tools" / "invar"
25
+
26
+
27
+ def install_pi_tools(
28
+ project_path: Path,
29
+ console: Console,
30
+ ) -> Result[list[str], str]:
31
+ """
32
+ Install Pi custom tools for Invar.
33
+
34
+ Creates .pi/tools/invar/index.ts with:
35
+ - invar_guard: Wrapper for invar guard command
36
+ - invar_sig: Wrapper for invar sig command
37
+ - invar_map: Wrapper for invar map command
38
+ """
39
+ tools_dir = project_path / PI_TOOLS_DIR
40
+ tools_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ console.print("\n[bold]Installing Pi custom tools...[/bold]")
43
+ console.print(" Tools provide:")
44
+ console.print(" ✓ invar_guard - Smart verification (static + doctests + symbolic)")
45
+ console.print(" ✓ invar_sig - Show function signatures and contracts")
46
+ console.print(" ✓ invar_map - Symbol map with reference counts")
47
+ console.print("")
48
+
49
+ template_path = get_pi_tools_template_path()
50
+ tool_file = template_path / "index.ts"
51
+
52
+ if not tool_file.exists():
53
+ return Failure(f"Template not found: {tool_file}")
54
+
55
+ try:
56
+ # Copy the template file
57
+ dest_file = tools_dir / "index.ts"
58
+ shutil.copy2(tool_file, dest_file)
59
+
60
+ console.print(f" [green]Created[/green] {PI_TOOLS_DIR}/index.ts")
61
+ console.print("\n [bold green]✓ Pi custom tools installed[/bold green]")
62
+ console.print(" [dim]Pi will auto-discover tools in .pi/tools/[/dim]")
63
+ console.print(" [yellow]⚠ Restart Pi session for tools to take effect[/yellow]")
64
+
65
+ return Success(["index.ts"])
66
+ except Exception as e:
67
+ return Failure(f"Failed to install Pi tools: {e}")
68
+
69
+
70
+ def remove_pi_tools(
71
+ project_path: Path,
72
+ console: Console,
73
+ ) -> Result[None, str]:
74
+ """Remove Pi custom tools."""
75
+ tools_dir = project_path / PI_TOOLS_DIR
76
+ tool_file = tools_dir / "index.ts"
77
+
78
+ if tool_file.exists():
79
+ tool_file.unlink()
80
+ console.print(f" [red]Removed[/red] {PI_TOOLS_DIR}/index.ts")
81
+
82
+ # Remove directory if empty
83
+ try:
84
+ tools_dir.rmdir()
85
+ console.print(f" [red]Removed[/red] {PI_TOOLS_DIR}/")
86
+ except OSError:
87
+ pass # Directory not empty, keep it
88
+
89
+ console.print("[bold green]✓ Pi custom tools removed[/bold green]")
90
+ else:
91
+ console.print("[dim]No Pi custom tools installed[/dim]")
92
+
93
+ return Success(None)
94
+
95
+
96
+ def pi_tools_status(
97
+ project_path: Path,
98
+ console: Console,
99
+ ) -> Result[dict[str, str], str]:
100
+ """Check status of Pi custom tools."""
101
+ tools_dir = project_path / PI_TOOLS_DIR
102
+ tool_file = tools_dir / "index.ts"
103
+
104
+ status: dict[str, str] = {}
105
+
106
+ if not tool_file.exists():
107
+ console.print("[dim]No Pi custom tools installed[/dim]")
108
+ return Success({"status": "not_installed"})
109
+
110
+ status["status"] = "installed"
111
+
112
+ # Try to check file size (basic validation)
113
+ try:
114
+ size = tool_file.stat().st_size
115
+ status["size"] = f"{size} bytes"
116
+ console.print(f"[green]✓ Pi custom tools installed[/green] ({size} bytes)")
117
+ except OSError:
118
+ console.print("[green]✓ Pi custom tools installed[/green]")
119
+
120
+ return Success(status)
@@ -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
  ---
@@ -2,6 +2,7 @@
2
2
  # Invar UserPromptSubmit Hook
3
3
  # Protocol: v{{ protocol_version }} | Generated: {{ generated_date }}
4
4
  # DX-57: Protocol refresh with full INVAR.md injection
5
+ # DX-79: Automatic feedback trigger via message count
5
6
 
6
7
  USER_MESSAGE="$1"
7
8
 
@@ -82,3 +83,33 @@ if [[ $COUNT -ge 25 && $((COUNT % 10)) -eq 0 ]]; then
82
83
  INVAR_EOF
83
84
  echo "</system-reminder>"
84
85
  fi
86
+
87
+ # ============================================
88
+ # DX-79: Feedback trigger at threshold
89
+ # ============================================
90
+
91
+ # Read feedback configuration
92
+ FEEDBACK_ENABLED=true
93
+ MIN_MESSAGES=30
94
+
95
+ if [[ -f ".claude/settings.local.json" ]]; then
96
+ # Try to parse with jq if available, otherwise use defaults
97
+ if command -v jq &> /dev/null; then
98
+ FEEDBACK_ENABLED=$(jq -r '.feedback.enabled // true' .claude/settings.local.json 2>/dev/null)
99
+ MIN_MESSAGES=$(jq -r '.feedback.min_messages // 30' .claude/settings.local.json 2>/dev/null)
100
+ fi
101
+ fi
102
+
103
+ # Trigger feedback prompt at threshold
104
+ if [[ "$FEEDBACK_ENABLED" == "true" && $COUNT -eq $MIN_MESSAGES ]]; then
105
+ echo "<system-reminder>"
106
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
107
+ echo "📊 Invar: Auto-triggering usage feedback ($COUNT messages)"
108
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
109
+ echo ""
110
+ echo "Session has reached $COUNT messages. Consider running /invar-reflect"
111
+ echo "to generate usage feedback."
112
+ echo ""
113
+ echo "To disable: Set feedback.enabled=false in .claude/settings.local.json"
114
+ echo "</system-reminder>"
115
+ fi
@@ -2,13 +2,16 @@
2
2
  * Invar Pi Hook
3
3
  * Protocol: v{{ protocol_version }} | Generated: {{ generated_date }}
4
4
  * LX-04: Full feature parity with Claude Code hooks
5
+ * DX-79: Automatic feedback trigger via message count
5
6
  *
6
7
  * Features:
7
8
  * - pytest/crosshair blocking via tool_call
8
9
  * - Protocol injection via pi.send() for long conversations
10
+ * - Automatic /invar-reflect trigger at message threshold
9
11
  */
10
12
 
11
13
  import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
14
+ import * as fs from "fs";
12
15
 
13
16
  // Blocked commands (same as Claude Code)
14
17
  {% if language == "python" -%}
@@ -22,6 +25,21 @@ const ALLOWED_FLAGS = [/--inspect/, /--coverage/, /--debug/];
22
25
  // Protocol content for injection (escaped for JS)
23
26
  const INVAR_PROTOCOL = `{{ invar_protocol_escaped }}`;
24
27
 
28
+ // DX-79: Helper to read feedback configuration
29
+ function readFeedbackConfig() {
30
+ try {
31
+ const settingsPath = ".claude/settings.local.json";
32
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
33
+ return {
34
+ enabled: settings.feedback?.enabled ?? true,
35
+ min_messages: settings.feedback?.min_messages ?? 30,
36
+ };
37
+ } catch {
38
+ // File doesn't exist or other error, use defaults
39
+ }
40
+ return { enabled: true, min_messages: 30 };
41
+ }
42
+
25
43
  export default function (pi: HookAPI) {
26
44
  let msgCount = 0;
27
45
 
@@ -53,6 +71,21 @@ export default function (pi: HookAPI) {
53
71
  pi.send(`<system-reminder>
54
72
  === Protocol Refresh (message ${msgCount}) ===
55
73
  ${INVAR_PROTOCOL}
74
+ </system-reminder>`);
75
+ }
76
+
77
+ // DX-79: Feedback trigger at threshold
78
+ const feedbackConfig = readFeedbackConfig();
79
+ if (msgCount === feedbackConfig.min_messages && feedbackConfig.enabled) {
80
+ pi.send(`<system-reminder>
81
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
82
+ 📊 Invar: Auto-triggering usage feedback (${msgCount} messages)
83
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
84
+
85
+ Session has reached ${msgCount} messages. Consider running /invar-reflect
86
+ to generate usage feedback.
87
+
88
+ To disable: Set feedback.enabled=false in .claude/settings.local.json
56
89
  </system-reminder>`);
57
90
  }
58
91
  });
@@ -62,7 +95,8 @@ ${INVAR_PROTOCOL}
62
95
  // ============================================
63
96
  pi.on("tool_call", async (event) => {
64
97
  if (event.toolName !== "bash") return;
65
- const cmd = ((event.input as Record<string, unknown>).command as string || "").trim();
98
+ const input = event.input as Record<string, unknown>;
99
+ const cmd = (typeof input?.command === "string" ? input.command : "").trim();
66
100
 
67
101
  // Skip if not a blocked command
68
102
  if (!BLOCKED_CMDS.some((p) => p.test(cmd))) return;
@@ -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
  ]