invar-tools 1.14.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.
@@ -23,6 +23,7 @@ from invar.shell.mcp_config import (
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:",
@@ -390,10 +414,7 @@ def init(
390
414
  """
391
415
  from invar import __version__
392
416
 
393
- # Mutual exclusivity check
394
- if claude and pi:
395
- console.print("[red]Error:[/red] Cannot use --claude and --pi together.")
396
- raise typer.Exit(1)
417
+ # DX-81: Multi-agent support - removed mutual exclusivity check
397
418
 
398
419
  if mcp_only and (claude or pi):
399
420
  console.print("[red]Error:[/red] --mcp-only cannot be combined with --claude or --pi.")
@@ -458,8 +479,10 @@ def init(
458
479
  console.print(f"[red]Error:[/red] Invalid language '{language}'. Must be one of: {valid}")
459
480
  raise typer.Exit(1)
460
481
 
461
- # Header
462
- 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:
463
486
  console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Claude Code)[/bold]")
464
487
  elif pi:
465
488
  console.print(f"\n[bold]Invar v{__version__} - Quick Setup (Pi)[/bold]")
@@ -468,27 +491,30 @@ def init(
468
491
  console.print("=" * 45)
469
492
  console.print(f"[dim]Language: {language} | Existing files will be MERGED.[/dim]")
470
493
 
471
- # Determine agents and files
472
- if claude:
473
- # Quick mode: Claude Code defaults
474
- 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
475
504
  selected_files: dict[str, bool] = {}
476
- for category in ["optional", "claude"]:
477
- for file, _ in FILE_CATEGORIES.get(category, []):
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]")
483
- elif pi:
484
- # Quick mode: Pi defaults
485
- agents = ["pi"]
486
- selected_files = {}
487
- for category in ["optional", "pi"]:
505
+ for agent in agents:
506
+ category = AGENT_CONFIGS[agent]["category"]
488
507
  for file, _ in FILE_CATEGORIES.get(category, []):
489
508
  selected_files[file] = True
509
+
510
+ # Add optional files
511
+ for file, _ in FILE_CATEGORIES["optional"]:
512
+ selected_files[file] = True
513
+
490
514
  # DX-79: Default feedback enabled for quick mode
491
515
  feedback_enabled = True
516
+ if len(agents) > 1:
517
+ console.print(f"\n[dim]📊 Configuring for {len(agents)} agents: {', '.join(agents)}[/dim]")
492
518
  console.print("\n[dim]📊 Feedback collection enabled by default (stored locally in .invar/feedback/)[/dim]")
493
519
  console.print("[dim] To disable: Set feedback.enabled=false in .claude/settings.local.json[/dim]")
494
520
  else:
@@ -598,6 +624,10 @@ def init(
598
624
  if "pi" in agents and selected_files.get(".pi/hooks/", True):
599
625
  install_pi_hooks(path, console)
600
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
+
601
631
  # Add feedback configuration (DX-79 Phase C)
602
632
  if "claude" in agents or "pi" in agents:
603
633
  feedback_result = add_feedback_config(path, feedback_enabled, console)
@@ -622,7 +652,7 @@ def init(
622
652
  # Completion message
623
653
  console.print(f"\n[bold green]✓ Initialized Invar v{__version__}[/bold green]")
624
654
 
625
- # Show agent-specific tips
655
+ # Show agent-specific tips (DX-81: show all relevant tips)
626
656
  if "claude" in agents:
627
657
  console.print()
628
658
  console.print(
@@ -633,7 +663,7 @@ def init(
633
663
  border_style="dim",
634
664
  )
635
665
  )
636
- elif "pi" in agents:
666
+ if "pi" in agents:
637
667
  console.print()
638
668
  console.print(
639
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)
@@ -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;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Invar Custom Tools for Pi Coding Agent
3
+ *
4
+ * Wraps Invar CLI commands as Pi tools for better LLM integration.
5
+ * Installed via: invar init --pi
6
+ */
7
+
8
+ import { Type } from "@sinclair/typebox";
9
+ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
10
+
11
+ const factory: CustomToolFactory = (pi) => {
12
+ // Helper to check if invar is available
13
+ async function checkInvarInstalled(): Promise<boolean> {
14
+ try {
15
+ const result = await pi.exec("which", ["invar"]);
16
+ return result.exitCode === 0;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ // Helper to validate path/target parameters (defense-in-depth)
23
+ function isValidPath(p: string): boolean {
24
+ // Reject shell metacharacters (including newline injection) and path traversal
25
+ if (/[;&|`$"'\\<>\n\r\0]/.test(p)) {
26
+ return false;
27
+ }
28
+ if (p.includes('..')) {
29
+ return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ return [
35
+ // =========================================================================
36
+ // invar_guard - Smart verification (static + doctests + symbolic)
37
+ // =========================================================================
38
+ {
39
+ name: "invar_guard",
40
+ label: "Invar Guard",
41
+ description: "Verify code quality with static analysis, doctests, CrossHair symbolic execution, and Hypothesis testing. Use this instead of pytest/crosshair. By default checks git-modified files; use --all for full project check.",
42
+ parameters: Type.Object({
43
+ changed: Type.Optional(Type.Boolean({
44
+ description: "Check only git-modified files (default: true)",
45
+ default: true,
46
+ })),
47
+ contracts_only: Type.Optional(Type.Boolean({
48
+ description: "Contract coverage check only (skip tests)",
49
+ default: false,
50
+ })),
51
+ coverage: Type.Optional(Type.Boolean({
52
+ description: "Collect branch coverage from doctest + hypothesis",
53
+ default: false,
54
+ })),
55
+ strict: Type.Optional(Type.Boolean({
56
+ description: "Treat warnings as errors",
57
+ default: false,
58
+ })),
59
+ }),
60
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
61
+ const installed = await checkInvarInstalled();
62
+ if (!installed) {
63
+ throw new Error("Invar not installed. Run: pip install invar-tools");
64
+ }
65
+
66
+ const args = ["guard"];
67
+
68
+ // Default is --changed (check modified files)
69
+ if (params.changed === false) {
70
+ args.push("--all");
71
+ }
72
+
73
+ if (params.contracts_only) {
74
+ args.push("-c");
75
+ }
76
+ if (params.coverage) {
77
+ args.push("--coverage");
78
+ }
79
+ if (params.strict) {
80
+ args.push("--strict");
81
+ }
82
+
83
+ const result = await pi.exec("invar", args, { cwd: pi.cwd, signal });
84
+
85
+ if (result.killed) {
86
+ throw new Error("Guard verification was cancelled");
87
+ }
88
+
89
+ const output = result.stdout + result.stderr;
90
+
91
+ return {
92
+ content: [{ type: "text", text: output || "Guard completed" }],
93
+ details: {
94
+ exitCode: result.exitCode,
95
+ passed: result.exitCode === 0,
96
+ },
97
+ };
98
+ },
99
+ },
100
+
101
+ // =========================================================================
102
+ // invar_sig - Show function signatures and contracts
103
+ // =========================================================================
104
+ {
105
+ name: "invar_sig",
106
+ label: "Invar Sig",
107
+ description: "Show function signatures and contracts (@pre/@post). Use this INSTEAD of Read() when you want to understand file structure without reading full implementation.",
108
+ parameters: Type.Object({
109
+ target: Type.String({
110
+ description: "File path or file::symbol path (e.g., 'src/foo.py' or 'src/foo.py::MyClass')",
111
+ }),
112
+ }),
113
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
114
+ const installed = await checkInvarInstalled();
115
+ if (!installed) {
116
+ throw new Error("Invar not installed. Run: pip install invar-tools");
117
+ }
118
+
119
+ if (!isValidPath(params.target)) {
120
+ throw new Error("Invalid target path: contains unsafe characters or path traversal");
121
+ }
122
+
123
+ const result = await pi.exec("invar", ["sig", params.target], {
124
+ cwd: pi.cwd,
125
+ signal,
126
+ });
127
+
128
+ if (result.killed) {
129
+ throw new Error("Sig command was cancelled");
130
+ }
131
+
132
+ if (result.exitCode !== 0) {
133
+ throw new Error(`Failed to get signatures: ${result.stderr}`);
134
+ }
135
+
136
+ return {
137
+ content: [{ type: "text", text: result.stdout }],
138
+ details: {
139
+ target: params.target,
140
+ },
141
+ };
142
+ },
143
+ },
144
+
145
+ // =========================================================================
146
+ // invar_map - Symbol map with reference counts
147
+ // =========================================================================
148
+ {
149
+ name: "invar_map",
150
+ label: "Invar Map",
151
+ description: "Symbol map with reference counts. Use this INSTEAD of Grep for 'def ' to find entry points and most-referenced symbols.",
152
+ parameters: Type.Object({
153
+ path: Type.Optional(Type.String({
154
+ description: "Project path (default: current directory)",
155
+ default: ".",
156
+ })),
157
+ top: Type.Optional(Type.Number({
158
+ description: "Show top N symbols by reference count",
159
+ default: 10,
160
+ })),
161
+ }),
162
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
163
+ const installed = await checkInvarInstalled();
164
+ if (!installed) {
165
+ throw new Error("Invar not installed. Run: pip install invar-tools");
166
+ }
167
+
168
+ if (params.path && params.path !== "." && !isValidPath(params.path)) {
169
+ throw new Error("Invalid path: contains unsafe characters or path traversal");
170
+ }
171
+
172
+ const args = ["map"];
173
+
174
+ if (params.path && params.path !== ".") {
175
+ args.push(params.path);
176
+ }
177
+
178
+ if (params.top) {
179
+ args.push("--top", params.top.toString());
180
+ }
181
+
182
+ const result = await pi.exec("invar", args, {
183
+ cwd: pi.cwd,
184
+ signal,
185
+ });
186
+
187
+ if (result.killed) {
188
+ throw new Error("Map command was cancelled");
189
+ }
190
+
191
+ if (result.exitCode !== 0) {
192
+ throw new Error(`Failed to generate map: ${result.stderr}`);
193
+ }
194
+
195
+ return {
196
+ content: [{ type: "text", text: result.stdout }],
197
+ details: {
198
+ path: params.path || ".",
199
+ top: params.top || 10,
200
+ },
201
+ };
202
+ },
203
+ },
204
+ ];
205
+ };
206
+
207
+ export default factory;
@@ -16,6 +16,46 @@ The `/invar-reflect` skill can be triggered:
16
16
 
17
17
  ## Proposed Hook Schema
18
18
 
19
+
20
+ ### Message Count Trigger (Implemented - DX-79)
21
+
22
+ **Status**: ✅ Implemented in v1.15.0
23
+
24
+ Both Claude Code and Pi now support automatic feedback triggering via **message count threshold**.
25
+
26
+ **Configuration in `.claude/settings.local.json`**:
27
+
28
+ ```json
29
+ {
30
+ "feedback": {
31
+ "enabled": true,
32
+ "min_messages": 30
33
+ }
34
+ }
35
+ ```
36
+
37
+ **Hook Parameters**:
38
+
39
+ | Parameter | Type | Default | Description |
40
+ |-----------|------|---------|-------------|
41
+ | `enabled` | boolean | true | Enable feedback collection |
42
+ | `min_messages` | number | 30 | Minimum messages before trigger |
43
+
44
+ **How it works**:
45
+
46
+ 1. **Message counting**: Both hooks track message count per session
47
+ 2. **Threshold trigger**: At `min_messages`, hook displays reminder
48
+ 3. **User action**: Agent sees reminder and can run `/invar-reflect`
49
+
50
+ **Cross-platform implementation**:
51
+
52
+ | Platform | Hook File | Mechanism |
53
+ |----------|-----------|-----------|
54
+ | **Claude Code** | `.claude/hooks/invar.UserPromptSubmit.sh` | Bash script with jq config parsing |
55
+ | **Pi** | `.pi/hooks/invar.ts` | TypeScript with fs config reading |
56
+
57
+ Both read the same `.claude/settings.local.json` configuration file.
58
+
19
59
  ### PostTaskCompletion Hook (Waiting for Claude Code Support)
20
60
 
21
61
  **Proposed configuration in `.claude/settings.json`**:
@@ -69,24 +109,46 @@ The `/invar-reflect` skill can be triggered:
69
109
 
70
110
  ## Triggering Conditions
71
111
 
72
- The hook triggers when **ALL** conditions are met:
73
112
 
74
- 1. **Task completed** - User finished major work (natural stopping point)
75
- 2. ✅ **Message count >= 30** - Sufficient context for meaningful feedback
76
- 3. ✅ **Duration >= 2 hours** - Non-trivial session (avoids quick fixes)
113
+ The hook triggers when the message count reaches the configured threshold (default: 30).
77
114
 
78
- **No hard frequency cap**: Same-day sessions merge into single file (see SKILL.md for merge logic).
115
+ **Conditions**:
79
116
 
80
- ---
117
+ 1. ✅ **Message count >= min_messages** (default: 30)
118
+ 2. ✅ **Feedback enabled** (`feedback.enabled = true`)
119
+
120
+ **No hard frequency cap**: Users can run `/invar-reflect` manually at any time.
121
+
122
+ **Customizing threshold**:
123
+
124
+ ```json
125
+ {
126
+ "feedback": {
127
+ "enabled": true,
128
+ "min_messages": 50 // Trigger at 50 messages instead of 30
129
+ }
130
+ }
131
+ ```
81
132
 
82
133
  ## Silent Mode
83
134
 
84
- When `mode: "silent"`:
85
- - Feedback generation runs in background
86
- - No interruption to current conversation
87
- - User sees notification only: `✓ Feedback saved to .invar/feedback/feedback-{date}.md`
88
135
 
89
- ---
136
+ The hook displays a **reminder** when the threshold is reached:
137
+
138
+ ```
139
+ <system-reminder>
140
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
141
+ 📊 Invar: Auto-triggering usage feedback (30 messages)
142
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
143
+
144
+ Session has reached 30 messages. Consider running /invar-reflect
145
+ to generate usage feedback.
146
+
147
+ To disable: Set feedback.enabled=false in .claude/settings.local.json
148
+ </system-reminder>
149
+ ```
150
+
151
+ **Note**: The agent sees this reminder and can choose to invoke `/invar-reflect` or continue working.
90
152
 
91
153
  ## User Control
92
154
 
@@ -144,7 +206,68 @@ rm .invar/feedback/feedback-2026-01-03.md
144
206
 
145
207
  ## Workaround: Using Stop Hook (Until PostTaskCompletion is Available)
146
208
 
147
- Since Claude Code doesn't yet support `PostTaskCompletion` hook, you can use the `Stop` hook as a temporary workaround.
209
+
210
+ ### Implementation Details
211
+
212
+ **Claude Code Hook** (`.claude/hooks/invar.UserPromptSubmit.sh`):
213
+
214
+ ```bash
215
+ # DX-79: Feedback trigger at threshold
216
+ FEEDBACK_ENABLED=true
217
+ MIN_MESSAGES=30
218
+
219
+ if [[ -f ".claude/settings.local.json" ]]; then
220
+ if command -v jq &> /dev/null; then
221
+ FEEDBACK_ENABLED=$(jq -r '.feedback.enabled // true' .claude/settings.local.json)
222
+ MIN_MESSAGES=$(jq -r '.feedback.min_messages // 30' .claude/settings.local.json)
223
+ fi
224
+ fi
225
+
226
+ if [[ "$FEEDBACK_ENABLED" == "true" && $COUNT -eq $MIN_MESSAGES ]]; then
227
+ echo "<system-reminder>"
228
+ echo "📊 Invar: Auto-triggering usage feedback ($COUNT messages)"
229
+ echo "Consider running /invar-reflect to generate usage feedback."
230
+ echo "</system-reminder>"
231
+ fi
232
+ ```
233
+
234
+ **Pi Hook** (`.pi/hooks/invar.ts`):
235
+
236
+ ```typescript
237
+ // DX-79: Helper to read feedback configuration
238
+ function readFeedbackConfig() {
239
+ try {
240
+ const fs = require("fs");
241
+ const settingsPath = ".claude/settings.local.json";
242
+ if (fs.existsSync(settingsPath)) {
243
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
244
+ return {
245
+ enabled: settings.feedback?.enabled ?? true,
246
+ min_messages: settings.feedback?.min_messages ?? 30,
247
+ };
248
+ }
249
+ } catch {
250
+ // Ignore errors, use defaults
251
+ }
252
+ return { enabled: true, min_messages: 30 };
253
+ }
254
+
255
+ pi.on("agent_start", async () => {
256
+ msgCount++;
257
+
258
+ // ... protocol refresh logic ...
259
+
260
+ const feedbackConfig = readFeedbackConfig();
261
+ if (msgCount === feedbackConfig.min_messages && feedbackConfig.enabled) {
262
+ pi.send(`<system-reminder>
263
+ 📊 Invar: Auto-triggering usage feedback (${msgCount} messages)
264
+ Consider running /invar-reflect to generate usage feedback.
265
+ </system-reminder>`);
266
+ }
267
+ });
268
+ ```
269
+
270
+ **Installation**: Hooks are automatically installed via `invar init --claude` or `invar init --pi`.
148
271
 
149
272
  ### Stop Hook Implementation
150
273
 
@@ -332,24 +455,26 @@ Enable automatic feedback collection? [Y/n]:
332
455
 
333
456
  ## Phase B Status
334
457
 
335
- **Completed**:
336
- - ✅ Hook schema designed
337
- - ✅ Configuration structure defined
338
- - ✅ User control mechanism specified
339
- - ✅ Stop hook workaround documented
340
458
 
341
- **Waiting for Claude Code Support**:
342
- - ⏸️ PostTaskCompletion hook type
343
- - ⏸️ Skill invocation from hooks
344
- - ⏸️ Session state tracking (message count, duration)
459
+ **Completed** (v1.15.0):
460
+ - Message Count trigger strategy designed
461
+ - Cross-platform implementation (Claude Code + Pi)
462
+ - Shared configuration structure
463
+ - ✅ Hook templates updated
464
+ - ✅ Installation via `invar init`
345
465
 
346
- **Next Steps**:
347
- - Implement Phase C: Init Integration (can be done independently)
348
- - Submit feature request to Claude Code for PostTaskCompletion hook
349
- - Test manual `/invar-reflect` invocation thoroughly
466
+ **Replaced PostTaskCompletion with Message Count** because:
467
+ - PostTaskCompletion hook not supported by Claude Code or Pi
468
+ - Message count is universally implementable
469
+ - Simpler, more predictable trigger mechanism
470
+ - User has full control via config
350
471
 
351
- ---
472
+ **Testing**:
473
+ - Manual `/invar-reflect` invocation: Works
474
+ - Hook trigger at threshold: Implemented
475
+ - Config disable: Honored by both hooks
476
+ - Multi-agent setup: Both hooks installed
352
477
 
353
- **Version**: 1.0 (Phase B - Proposed)
354
- **Updated**: 2026-01-03
355
- **Related**: DX-79 Invar Usage Feedback Collection
478
+ **Next Steps**:
479
+ - Monitor user feedback on threshold defaults
480
+ - Consider adding reminder messages at other checkpoints (e.g., 60, 90 messages)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: invar-tools
3
- Version: 1.14.0
3
+ Version: 1.15.0
4
4
  Summary: AI-native software engineering tools with design-by-contract verification
5
5
  Project-URL: Homepage, https://github.com/tefx/invar
6
6
  Project-URL: Documentation, https://github.com/tefx/invar#readme
@@ -190,8 +190,9 @@ cd your-project
190
190
  uvx invar-tools init
191
191
 
192
192
  # Or quick setup (skip prompts)
193
- uvx invar-tools init --claude # Claude Code
194
- uvx invar-tools init --pi # Pi Coding Agent
193
+ uvx invar-tools init --claude # Claude Code only
194
+ uvx invar-tools init --pi # Pi only
195
+ uvx invar-tools init --claude --pi # Both agents (DX-81)
195
196
  uvx invar-tools init --mcp-only # MCP tools only (legacy projects)
196
197
 
197
198
  # Add runtime contracts to your project
@@ -492,6 +493,7 @@ AlphaCodium · Parsel · Reflexion · Clover
492
493
  |-------|--------|-------|
493
494
  | **Claude Code** | ✅ Full | `invar init --claude` |
494
495
  | **[Pi](https://shittycodingagent.ai/)** | ✅ Full | `invar init --pi` |
496
+ | **Multi-Agent** | ✅ Full | `invar init --claude --pi` (DX-81) |
495
497
  | **Cursor** | ✅ MCP | `invar init` → select Other, add MCP config |
496
498
  | **Other** | 📝 Manual | `invar init` → select Other, include `AGENT.md` in prompt |
497
499
 
@@ -821,6 +823,7 @@ rules = ["missing_contract", "shell_result"]
821
823
  | `invar init` | Initialize or update project (interactive) |
822
824
  | `invar init --claude` | Quick setup for Claude Code |
823
825
  | `invar init --pi` | Quick setup for Pi agent |
826
+ | `invar init --claude --pi` | Setup for both agents (DX-81) |
824
827
  | `invar init --mcp-only` | MCP tools only (no framework files) |
825
828
  | `invar uninstall` | Remove Invar from project (preserves user content) |
826
829
  | `invar sig <file>` | Show signatures and contracts |
@@ -72,6 +72,7 @@ invar/shell/mcp_config.py,sha256=-hC7Y5BGuVs285b6gBARk7ZyzVxHwPgXSyt_GoN0jfs,458
72
72
  invar/shell/mutation.py,sha256=Lfyk2b8j8-hxAq-iwAgQeOhr7Ci6c5tRF1TXe3CxQCs,8914
73
73
  invar/shell/pattern_integration.py,sha256=pRcjfq3NvMW_tvQCnaXZnD1k5AVEWK8CYOE2jN6VTro,7842
74
74
  invar/shell/pi_hooks.py,sha256=ulZc1sP8mTRJTBsjwFHQzUgg-h8ajRIMp7iF1Y4UUtw,6885
75
+ invar/shell/pi_tools.py,sha256=_xTxE3zeEWSUm3IuuMziglkB_nL8NIco7kQ2nZkCMLU,3668
75
76
  invar/shell/property_tests.py,sha256=N9JreyH5PqR89oF5yLcX7ZAV-Koyg5BKo-J05-GUPsA,9109
76
77
  invar/shell/py_refs.py,sha256=Vjz50lmt9prDBcBv4nkkODdiJ7_DKu5zO4UPZBjAfmM,4638
77
78
  invar/shell/skill_manager.py,sha256=Mr7Mh9rxPSKSAOTJCAM5ZHiG5nfUf6KQVCuD4LBNHSI,12440
@@ -85,7 +86,7 @@ invar/shell/commands/doc.py,sha256=SOLDoCXXGxx_JU0PKXlAIGEF36PzconHmmAtL-rM6D4,1
85
86
  invar/shell/commands/feedback.py,sha256=lLxEeWW_71US_vlmorFrGXS8IARB9nbV6D0zruLs660,7640
86
87
  invar/shell/commands/guard.py,sha256=xTQ8cPp-x1xMCtufKxmMNUSpIpH31uUjziAB8ifCnC0,24837
87
88
  invar/shell/commands/hooks.py,sha256=W-SOnT4VQyUvXwipozkJwgEYfiOJGz7wksrbcdWegUg,2356
88
- invar/shell/commands/init.py,sha256=bYrQWkDXGKEqncLgCFicSRt6LAmA34XyBFJ31kip-1Q,22564
89
+ invar/shell/commands/init.py,sha256=vaPo0p7xBm3Nfgu9ytcvAjgk4dQBKvyEhrz_Cg1URMQ,23557
89
90
  invar/shell/commands/merge.py,sha256=nuvKo8m32-OL-SCQlS4SLKmOZxQ3qj-1nGCx1Pgzifw,8183
90
91
  invar/shell/commands/mutate.py,sha256=GwemiO6LlbGCBEQsBFnzZuKhF-wIMEl79GAMnKUWc8U,5765
91
92
  invar/shell/commands/perception.py,sha256=HewSv6Kv8Gw2UQqkGY2rP5YKlnwyC3LBrQ2hFVXXw30,19304
@@ -135,13 +136,14 @@ invar/templates/examples/typescript/workflow.md,sha256=5byADjA3WgOgiDbkEtVRKKGvl
135
136
  invar/templates/hooks/PostToolUse.sh.jinja,sha256=JHJGMdF3xp2qEqkPC9GaLp0NCa5gdRzqAmgRy4IldBg,3428
136
137
  invar/templates/hooks/PreToolUse.sh.jinja,sha256=tZb-FGFxOBtTprUfeChau7rZOMPII69_5HSF-i_WD4Q,3558
137
138
  invar/templates/hooks/Stop.sh.jinja,sha256=SD0PhBPeun7DTvn8Erbz11PBGAwGby4tMTd97yOJuTQ,981
138
- invar/templates/hooks/UserPromptSubmit.sh.jinja,sha256=sUsqBiXzIEM3C8NdN4s4C7KqPlamyVhk5_-3zd5TC9Y,2621
139
+ invar/templates/hooks/UserPromptSubmit.sh.jinja,sha256=5xs-ASw8s_tHLvtA30nm5J7PhbpIML-8Hhv-QTTuhKY,3981
139
140
  invar/templates/hooks/__init__.py,sha256=RnnMoQA-8eqbr8Y_1Vu9B8h5vAz4C-vmo8wgdcGYrz0,43
140
- invar/templates/hooks/pi/invar.ts.jinja,sha256=0V7kBxPfXx18wGbZRT4F1CcuaK09oHh2IJll62N4XVM,2620
141
+ invar/templates/hooks/pi/invar.ts.jinja,sha256=WLEYwO8tvdu9RakxFtcVRuBmaE3HgRAUFf9T5OmgP0s,4027
141
142
  invar/templates/onboard/assessment.md.jinja,sha256=EzqF0VUcxJZG2bVJLxTOyQlAERRbh9v9hXKVt6vcbxY,5850
142
143
  invar/templates/onboard/roadmap.md.jinja,sha256=gmvZk4Hdwe0l3qSFV15QGcsr-OPMhsc6-1K9F2SFSIQ,3939
143
144
  invar/templates/onboard/patterns/python.md,sha256=3wwucAcQz0DlggtpqYo-ZCnmrXgBQ0aBgUHN_EZ1VW0,8681
144
145
  invar/templates/onboard/patterns/typescript.md,sha256=yOVfHtdAdjKkWNh66_dR7z2xEA4sggbIcCKthW-fqac,11983
146
+ invar/templates/pi-tools/invar/index.ts,sha256=fhE3aGKk5VYcUdbeVMv-x3tqWHA0eVU641pytY06Bvg,6694
145
147
  invar/templates/protocol/INVAR.md.jinja,sha256=t2ZIQZJvzDTJMrRw_ijUo6ScZmeNK0-nV-H7ztTIyQQ,1464
146
148
  invar/templates/protocol/python/architecture-examples.md,sha256=O96LH9WFpk7G9MrhSbifLS5pyibTIDG-_EGFF7g3V4M,1175
147
149
  invar/templates/protocol/python/contracts-syntax.md,sha256=Q6supTQ3tChVrlN7xhcdb3Q8VGIESxQLA-mQvrNIZmo,1162
@@ -173,16 +175,16 @@ invar/templates/skills/extensions/security/SKILL.md,sha256=5mLwf4JP82Wq1vKkDIJwi
173
175
  invar/templates/skills/extensions/security/patterns/_common.yaml,sha256=75BvSABWUtO1VXFvdsMgqi86J1759T4ROhYYcizSygQ,3680
174
176
  invar/templates/skills/extensions/security/patterns/python.yaml,sha256=osyR8mWiyjW6tWjZA7QZfBIiim7XqgBYnrE45ktDx50,4658
175
177
  invar/templates/skills/extensions/security/patterns/typescript.yaml,sha256=qDEg-sxSE63Bis2IZG1y4L8m8g2ZYkC29o6t-J_LmUo,5508
176
- invar/templates/skills/invar-reflect/CONFIG.md,sha256=xBiKEy0TnoXNxqn55_GpkNGgmXoUg65RXwb_RdF-eGk,9291
178
+ invar/templates/skills/invar-reflect/CONFIG.md,sha256=G8KVNROQIm7CvjrB7cwzOANQOfJg3-1_0zRVZR1sX44,12852
177
179
  invar/templates/skills/invar-reflect/SKILL.md,sha256=z0Nyh9JasZr70XhE9Es7IslxL3C8wH2lf8RewQi4Lbs,11285
178
180
  invar/templates/skills/invar-reflect/template.md,sha256=Rr5hvbllvmd8jSLf_0ZjyKt6KOod0RlNdCtZJ3lYjiM,10470
179
181
  invar/templates/skills/investigate/SKILL.md.jinja,sha256=cp6TBEixBYh1rLeeHOR1yqEnFqv1NZYePORMnavLkQI,3231
180
182
  invar/templates/skills/propose/SKILL.md.jinja,sha256=6BuKiCqO1AEu3VtzMHy1QWGqr_xqG9eJlhbsKT4jev4,3463
181
183
  invar/templates/skills/review/SKILL.md.jinja,sha256=ET5mbdSe_eKgJbi2LbgFC-z1aviKcHOBw7J5Q28fr4U,14105
182
- invar_tools-1.14.0.dist-info/METADATA,sha256=1cKRlX8XrRzgAVc_9EJeCzXWrDBAUFooA8tSIktfclk,28409
183
- invar_tools-1.14.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
184
- invar_tools-1.14.0.dist-info/entry_points.txt,sha256=RwH_EhqgtFPsnO6RcrwrAb70Zyfb8Mh6uUtztWnUxGk,102
185
- invar_tools-1.14.0.dist-info/licenses/LICENSE,sha256=qeFksp4H4kfTgQxPCIu3OdagXyiZcgBlVfsQ6M5oFyk,10767
186
- invar_tools-1.14.0.dist-info/licenses/LICENSE-GPL,sha256=IvZfC6ZbP7CLjytoHVzvpDZpD-Z3R_qa1GdMdWlWQ6Q,35157
187
- invar_tools-1.14.0.dist-info/licenses/NOTICE,sha256=joEyMyFhFY8Vd8tTJ-a3SirI0m2Sd0WjzqYt3sdcglc,2561
188
- invar_tools-1.14.0.dist-info/RECORD,,
184
+ invar_tools-1.15.0.dist-info/METADATA,sha256=Xm0Suh3a3jfCfDBqzsuYpuVRwT4dWxRF5-PnZDMgYas,28595
185
+ invar_tools-1.15.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
186
+ invar_tools-1.15.0.dist-info/entry_points.txt,sha256=RwH_EhqgtFPsnO6RcrwrAb70Zyfb8Mh6uUtztWnUxGk,102
187
+ invar_tools-1.15.0.dist-info/licenses/LICENSE,sha256=qeFksp4H4kfTgQxPCIu3OdagXyiZcgBlVfsQ6M5oFyk,10767
188
+ invar_tools-1.15.0.dist-info/licenses/LICENSE-GPL,sha256=IvZfC6ZbP7CLjytoHVzvpDZpD-Z3R_qa1GdMdWlWQ6Q,35157
189
+ invar_tools-1.15.0.dist-info/licenses/NOTICE,sha256=joEyMyFhFY8Vd8tTJ-a3SirI0m2Sd0WjzqYt3sdcglc,2561
190
+ invar_tools-1.15.0.dist-info/RECORD,,