invar-tools 1.2.0__py3-none-any.whl → 1.3.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.
Files changed (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,345 @@
1
+ """DX-49: Template engine with I/O operations and Jinja2 support.
2
+
3
+ This module provides:
4
+ - File update with region preservation
5
+ - Jinja2 template rendering
6
+ - Manifest loading
7
+
8
+ Pure parsing logic is in core/template_parser.py.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from deal import pre
17
+ from returns.result import Failure, Result, Success
18
+
19
+ # Import pure logic from Core
20
+ from invar.core.template_parser import (
21
+ ParsedFile,
22
+ Region,
23
+ get_syntax_for_command,
24
+ parse_invar_regions,
25
+ reconstruct_file,
26
+ )
27
+
28
+ # Re-export for convenience
29
+ __all__ = [
30
+ "ParsedFile",
31
+ "Region",
32
+ "generate_from_manifest",
33
+ "get_syntax_for_command",
34
+ "get_templates_dir",
35
+ "is_invar_project",
36
+ "load_manifest",
37
+ "parse_invar_regions",
38
+ "reconstruct_file",
39
+ "render_template",
40
+ "render_template_file",
41
+ "update_file_with_regions",
42
+ ]
43
+
44
+
45
+ # =============================================================================
46
+ # File Update Logic
47
+ # =============================================================================
48
+
49
+
50
+ # @shell_complexity: File handling requires branching for exists/has_regions/new_project cases
51
+ @pre(lambda path, new_managed: isinstance(path, Path) and isinstance(new_managed, str))
52
+ def update_file_with_regions(
53
+ path: Path,
54
+ new_managed: str,
55
+ new_project: str | None = None,
56
+ ) -> Result[str, str]:
57
+ """Update file preserving user regions and unmarked content.
58
+
59
+ Args:
60
+ path: File path to update
61
+ new_managed: New content for managed region
62
+ new_project: New content for project region (sync-self only)
63
+
64
+ Returns:
65
+ Success with updated content, or Failure with error message
66
+
67
+ Examples:
68
+ >>> from pathlib import Path
69
+ >>> import tempfile
70
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
71
+ ... _ = f.write('''<!--invar:managed-->
72
+ ... old
73
+ ... <!--/invar:managed-->
74
+ ... <!--invar:user-->
75
+ ... keep this
76
+ ... <!--/invar:user-->''')
77
+ ... path = Path(f.name)
78
+ >>> result = update_file_with_regions(path, "NEW")
79
+ >>> isinstance(result, Success)
80
+ True
81
+ >>> "NEW" in result.unwrap()
82
+ True
83
+ >>> "keep this" in result.unwrap()
84
+ True
85
+ >>> path.unlink() # cleanup
86
+ """
87
+ if not path.exists():
88
+ # New file - just wrap in managed region
89
+ content = f"<!--invar:managed-->\n{new_managed}\n<!--/invar:managed-->\n"
90
+ if new_project:
91
+ content += f"\n<!--invar:project-->\n{new_project}\n<!--/invar:project-->\n"
92
+ content += "\n<!--invar:user-->\n\n<!--/invar:user-->\n"
93
+ return Success(content)
94
+
95
+ try:
96
+ current = path.read_text()
97
+ except OSError as e:
98
+ return Failure(f"Failed to read {path}: {e}")
99
+
100
+ parsed = parse_invar_regions(current)
101
+
102
+ if not parsed.has_regions:
103
+ # No markers - insert at top, preserve rest
104
+ content = f"<!--invar:managed-->\n{new_managed}\n<!--/invar:managed-->\n\n"
105
+ content += current
106
+ return Success(content)
107
+
108
+ # Build updates dict
109
+ updates: dict[str, str] = {"managed": new_managed}
110
+ if new_project is not None and "project" in parsed.regions:
111
+ updates["project"] = new_project
112
+
113
+ result = reconstruct_file(parsed, updates)
114
+ return Success(result)
115
+
116
+
117
+ # =============================================================================
118
+ # Jinja2 Template Rendering
119
+ # =============================================================================
120
+
121
+
122
+ def render_template(
123
+ template_content: str,
124
+ variables: dict[str, str],
125
+ ) -> Result[str, str]:
126
+ """Render a Jinja2 template with given variables.
127
+
128
+ Args:
129
+ template_content: Jinja2 template string
130
+ variables: Variables to inject (syntax, version, project_name, etc.)
131
+
132
+ Returns:
133
+ Success with rendered content, or Failure with error
134
+
135
+ Examples:
136
+ >>> result = render_template("Hello {{ name }}", {"name": "World"})
137
+ >>> result.unwrap()
138
+ 'Hello World'
139
+
140
+ >>> result = render_template("{% if x %}yes{% endif %}", {"x": True})
141
+ >>> result.unwrap()
142
+ 'yes'
143
+ """
144
+ try:
145
+ from jinja2 import BaseLoader, Environment, StrictUndefined
146
+
147
+ env = Environment(
148
+ loader=BaseLoader(),
149
+ undefined=StrictUndefined,
150
+ keep_trailing_newline=True,
151
+ )
152
+ template = env.from_string(template_content)
153
+ rendered = template.render(**variables)
154
+ return Success(rendered)
155
+ except ImportError:
156
+ return Failure("Jinja2 not installed. Run: pip install jinja2")
157
+ except Exception as e:
158
+ return Failure(f"Template rendering failed: {e}")
159
+
160
+
161
+ def render_template_file(
162
+ template_path: Path,
163
+ variables: dict[str, str],
164
+ ) -> Result[str, str]:
165
+ """Render a Jinja2 template file.
166
+
167
+ Examples:
168
+ >>> from pathlib import Path
169
+ >>> import tempfile
170
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.jinja', delete=False) as f:
171
+ ... _ = f.write("Version: {{ version }}")
172
+ ... path = Path(f.name)
173
+ >>> result = render_template_file(path, {"version": "5.0"})
174
+ >>> result.unwrap()
175
+ 'Version: 5.0'
176
+ >>> path.unlink()
177
+ """
178
+ try:
179
+ content = template_path.read_text()
180
+ except OSError as e:
181
+ return Failure(f"Failed to read template {template_path}: {e}")
182
+
183
+ return render_template(content, variables)
184
+
185
+
186
+ # =============================================================================
187
+ # Manifest Loading
188
+ # =============================================================================
189
+
190
+
191
+ # @shell_complexity: TOML loading with Python version fallback (tomllib vs tomli)
192
+ def load_manifest(templates_dir: Path) -> Result[dict, str]:
193
+ """Load manifest.toml from templates directory.
194
+
195
+ Examples:
196
+ >>> from pathlib import Path
197
+ >>> # Will fail if manifest doesn't exist
198
+ >>> result = load_manifest(Path("/nonexistent"))
199
+ >>> isinstance(result, Failure)
200
+ True
201
+ """
202
+ manifest_path = templates_dir / "manifest.toml"
203
+
204
+ if not manifest_path.exists():
205
+ return Failure(f"Manifest not found: {manifest_path}")
206
+
207
+ try:
208
+ import tomllib
209
+
210
+ content = manifest_path.read_text()
211
+ data = tomllib.loads(content)
212
+ return Success(data)
213
+ except ImportError:
214
+ # Python < 3.11
215
+ try:
216
+ import tomli as tomllib # type: ignore
217
+
218
+ content = manifest_path.read_text()
219
+ data = tomllib.loads(content)
220
+ return Success(data)
221
+ except ImportError:
222
+ return Failure("tomllib/tomli not available")
223
+ except Exception as e:
224
+ return Failure(f"Failed to parse manifest: {e}")
225
+
226
+
227
+ # =============================================================================
228
+ # Template Generation
229
+ # =============================================================================
230
+
231
+
232
+ def get_templates_dir() -> Path:
233
+ """Get the templates directory path."""
234
+ return Path(__file__).parent.parent / "templates"
235
+
236
+
237
+ # @shell_complexity: Template generation has multiple paths for copy/jinja/copy_dir types
238
+ def generate_from_manifest(
239
+ dest_root: Path,
240
+ syntax: str = "cli",
241
+ files_to_generate: list[str] | None = None,
242
+ ) -> Result[list[str], str]:
243
+ """Generate files from manifest.toml templates.
244
+
245
+ Args:
246
+ dest_root: Destination project root
247
+ syntax: "cli" or "mcp" for command syntax
248
+ files_to_generate: Optional list of files to generate (None = all from manifest)
249
+
250
+ Returns:
251
+ Success with list of generated files, or Failure with error
252
+ """
253
+ templates_dir = get_templates_dir()
254
+ manifest_result = load_manifest(templates_dir)
255
+ if isinstance(manifest_result, Failure):
256
+ return manifest_result
257
+
258
+ manifest = manifest_result.unwrap()
259
+ templates = manifest.get("templates", {})
260
+ # Copy to avoid mutating cached manifest
261
+ variables = {**manifest.get("variables", {}), "syntax": syntax}
262
+
263
+ generated: list[str] = []
264
+
265
+ for dest_path, config in templates.items():
266
+ # Skip if not in files_to_generate list
267
+ if files_to_generate is not None and dest_path not in files_to_generate:
268
+ continue
269
+
270
+ src = config.get("src", "")
271
+ template_type = config.get("type", "copy")
272
+ src_path = templates_dir / src
273
+
274
+ # Resolve destination
275
+ full_dest = dest_root / dest_path
276
+
277
+ # Ensure parent directories exist
278
+ full_dest.parent.mkdir(parents=True, exist_ok=True)
279
+
280
+ if template_type == "copy":
281
+ # Direct file copy
282
+ if not src_path.exists():
283
+ continue
284
+ if full_dest.exists():
285
+ continue # Don't overwrite existing
286
+ try:
287
+ full_dest.write_text(src_path.read_text())
288
+ generated.append(dest_path)
289
+ except OSError as e:
290
+ print(f"Warning: Failed to copy {dest_path}: {e}", file=sys.stderr)
291
+ continue
292
+
293
+ elif template_type == "jinja":
294
+ # Jinja2 template rendering
295
+ if not src_path.exists():
296
+ continue
297
+ if full_dest.exists():
298
+ continue # Don't overwrite existing
299
+ result = render_template_file(src_path, variables)
300
+ if isinstance(result, Success):
301
+ try:
302
+ full_dest.write_text(result.unwrap())
303
+ generated.append(dest_path)
304
+ except OSError as e:
305
+ print(f"Warning: Failed to write {dest_path}: {e}", file=sys.stderr)
306
+ continue
307
+
308
+ elif template_type == "copy_dir":
309
+ # Directory copy
310
+ if not src_path.exists() or not src_path.is_dir():
311
+ continue
312
+ if full_dest.exists():
313
+ continue # Don't overwrite existing directory
314
+ try:
315
+ import shutil
316
+ shutil.copytree(src_path, full_dest)
317
+ generated.append(dest_path)
318
+ except OSError as e:
319
+ print(f"Warning: Failed to copy directory {dest_path}: {e}", file=sys.stderr)
320
+ continue
321
+
322
+ return Success(generated)
323
+
324
+
325
+ # =============================================================================
326
+ # Utility Functions
327
+ # =============================================================================
328
+
329
+
330
+ def is_invar_project(project_root: Path) -> bool:
331
+ """Check if this is the Invar project itself.
332
+
333
+ The Invar project has special handling via sync-self.
334
+
335
+ Examples:
336
+ >>> from pathlib import Path
337
+ >>> is_invar_project(Path("/some/random/project"))
338
+ False
339
+ """
340
+ # Check for Invar-specific markers
341
+ invar_markers = [
342
+ project_root / "src" / "invar" / "__init__.py",
343
+ project_root / "runtime" / "src" / "invar_runtime" / "__init__.py",
344
+ ]
345
+ return all(marker.exists() for marker in invar_markers)
invar/shell/templates.py CHANGED
@@ -168,6 +168,25 @@ def copy_commands_directory(dest: Path, console) -> Result[bool, str]:
168
168
  return Failure(f"Failed to copy commands: {e}")
169
169
 
170
170
 
171
+ # @shell_complexity: Directory copy for Claude skills (DX-36)
172
+ def copy_skills_directory(dest: Path, console) -> Result[bool, str]:
173
+ """Copy skills directory to .claude/skills/. Returns Success(True) if copied."""
174
+ import shutil
175
+ skills_dest = dest / ".claude" / "skills"
176
+ if skills_dest.exists():
177
+ return Success(False)
178
+ try:
179
+ skills_src = Path(str(resources.files("invar.templates").joinpath("skills")))
180
+ if not skills_src.exists():
181
+ return Failure("Skills template directory not found")
182
+ (dest / ".claude").mkdir(exist_ok=True)
183
+ shutil.copytree(skills_src, skills_dest)
184
+ console.print("[green]Created[/green] .claude/skills/ (workflow skills)")
185
+ return Success(True)
186
+ except OSError as e:
187
+ return Failure(f"Failed to copy skills: {e}")
188
+
189
+
171
190
  # Agent configuration for multi-agent support (DX-11, DX-17)
172
191
  AGENT_CONFIGS = {
173
192
  "claude": {
invar/shell/testing.py CHANGED
@@ -18,10 +18,12 @@ from pathlib import Path
18
18
  from returns.result import Failure, Result, Success
19
19
  from rich.console import Console
20
20
 
21
+ from invar.shell.prove.cache import ProveCache
22
+
21
23
  # DX-12: Import from prove module
22
24
  # DX-13: Added get_files_to_prove, run_crosshair_parallel
23
- # DX-13: ProveCache extracted to prove_cache.py
24
- from invar.shell.prove import (
25
+ # DX-48b: Relocated to shell/prove/
26
+ from invar.shell.prove.crosshair import (
25
27
  CrossHairStatus,
26
28
  get_files_to_prove,
27
29
  run_crosshair_on_files,
@@ -29,7 +31,7 @@ from invar.shell.prove import (
29
31
  run_hypothesis_fallback,
30
32
  run_prove_with_fallback,
31
33
  )
32
- from invar.shell.prove_cache import ProveCache
34
+ from invar.shell.subprocess_env import build_subprocess_env
33
35
 
34
36
  console = Console()
35
37
 
@@ -113,7 +115,10 @@ def get_available_verifiers() -> list[str]:
113
115
 
114
116
  # @shell_complexity: Doctest execution with subprocess and result parsing
115
117
  def run_doctests_on_files(
116
- files: list[Path], verbose: bool = False
118
+ files: list[Path],
119
+ verbose: bool = False,
120
+ timeout: int = 60,
121
+ collect_coverage: bool = False,
117
122
  ) -> Result[dict, str]:
118
123
  """
119
124
  Run doctests on a list of Python files.
@@ -121,6 +126,8 @@ def run_doctests_on_files(
121
126
  Args:
122
127
  files: List of Python file paths to test
123
128
  verbose: Show verbose output
129
+ timeout: Maximum time in seconds (default: 60, from RuleConfig.timeout_doctest)
130
+ collect_coverage: DX-37: If True, run with coverage.py and return coverage data
124
131
 
125
132
  Returns:
126
133
  Success with test results or Failure with error message
@@ -129,21 +136,45 @@ def run_doctests_on_files(
129
136
  return Success({"status": "skipped", "reason": "no files", "files": []})
130
137
 
131
138
  # Filter to Python files only
132
- py_files = [f for f in files if f.suffix == ".py" and f.exists()]
139
+ # Exclude: conftest.py (pytest config), templates/examples/ (source templates, not user examples)
140
+ py_files = [
141
+ f for f in files
142
+ if f.suffix == ".py"
143
+ and f.exists()
144
+ and f.name != "conftest.py"
145
+ and "templates/examples" not in str(f)
146
+ ]
133
147
  if not py_files:
134
148
  return Success({"status": "skipped", "reason": "no Python files", "files": []})
135
149
 
136
- # Build pytest command
137
- cmd = [
138
- sys.executable, "-m", "pytest",
139
- "--doctest-modules", "-x", "--tb=short",
140
- ]
150
+ # DX-37: Build command with optional coverage
151
+ if collect_coverage:
152
+ # Use coverage run to wrap pytest
153
+ cmd = [
154
+ sys.executable, "-m", "coverage", "run",
155
+ "--branch", # Enable branch coverage
156
+ "--parallel-mode", # For merging with hypothesis later
157
+ "-m", "pytest",
158
+ "--doctest-modules", "-x", "--tb=short",
159
+ ]
160
+ else:
161
+ cmd = [
162
+ sys.executable, "-m", "pytest",
163
+ "--doctest-modules", "-x", "--tb=short",
164
+ ]
141
165
  cmd.extend(str(f) for f in py_files)
142
166
  if verbose:
143
167
  cmd.append("-v")
144
168
 
145
169
  try:
146
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
170
+ # DX-52: Inject project venv site-packages for uvx compatibility
171
+ result = subprocess.run(
172
+ cmd,
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=timeout,
176
+ env=build_subprocess_env(),
177
+ )
147
178
  # Pytest exit codes: 0=passed, 5=no tests collected (also OK)
148
179
  is_passed = result.returncode in (0, 5)
149
180
  return Success({
@@ -152,16 +183,17 @@ def run_doctests_on_files(
152
183
  "exit_code": result.returncode,
153
184
  "stdout": result.stdout,
154
185
  "stderr": result.stderr,
186
+ "coverage_collected": collect_coverage, # DX-37: Flag for caller
155
187
  })
156
188
  except subprocess.TimeoutExpired:
157
- return Failure("Doctest timeout (120s)")
189
+ return Failure(f"Doctest timeout ({timeout}s)")
158
190
  except Exception as e:
159
191
  return Failure(f"Doctest error: {e}")
160
192
 
161
193
 
162
194
  # @shell_complexity: Property test orchestration with subprocess
163
195
  def run_test(
164
- target: str, json_output: bool = False, verbose: bool = False
196
+ target: str, json_output: bool = False, verbose: bool = False, timeout: int = 300
165
197
  ) -> Result[dict, str]:
166
198
  """
167
199
  Run property-based tests using Hypothesis via deal.cases.
@@ -170,6 +202,7 @@ def run_test(
170
202
  target: File path or module to test
171
203
  json_output: Output as JSON
172
204
  verbose: Show verbose output
205
+ timeout: Maximum time in seconds (default: 300, from RuleConfig.timeout_hypothesis)
173
206
 
174
207
  Returns:
175
208
  Success with test results or Failure with error message
@@ -188,7 +221,14 @@ def run_test(
188
221
  cmd.append("-v")
189
222
 
190
223
  try:
191
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
224
+ # DX-52: Inject project venv site-packages for uvx compatibility
225
+ result = subprocess.run(
226
+ cmd,
227
+ capture_output=True,
228
+ text=True,
229
+ timeout=timeout,
230
+ env=build_subprocess_env(),
231
+ )
192
232
  test_result = {
193
233
  "status": "passed" if result.returncode == 0 else "failed",
194
234
  "target": str(target_path),
@@ -212,14 +252,17 @@ def run_test(
212
252
 
213
253
  return Success(test_result)
214
254
  except subprocess.TimeoutExpired:
215
- return Failure(f"Test timeout (300s): {target}")
255
+ return Failure(f"Test timeout ({timeout}s): {target}")
216
256
  except Exception as e:
217
257
  return Failure(f"Test error: {e}")
218
258
 
219
259
 
220
260
  # @shell_complexity: CrossHair verification with subprocess
221
261
  def run_verify(
222
- target: str, json_output: bool = False, timeout: int = 30
262
+ target: str,
263
+ json_output: bool = False,
264
+ total_timeout: int = 300,
265
+ per_condition_timeout: int = 30,
223
266
  ) -> Result[dict, str]:
224
267
  """
225
268
  Run symbolic verification using CrossHair.
@@ -227,7 +270,8 @@ def run_verify(
227
270
  Args:
228
271
  target: File path or module to verify
229
272
  json_output: Output as JSON
230
- timeout: Timeout per function in seconds
273
+ total_timeout: Total timeout in seconds (default: 300, from RuleConfig.timeout_crosshair)
274
+ per_condition_timeout: Per-contract timeout (default: 30, from RuleConfig.timeout_crosshair_per_condition)
231
275
 
232
276
  Returns:
233
277
  Success with verification results or Failure with error message
@@ -248,11 +292,18 @@ def run_verify(
248
292
 
249
293
  cmd = [
250
294
  sys.executable, "-m", "crosshair", "check",
251
- str(target_path), f"--per_condition_timeout={timeout}",
295
+ str(target_path), f"--per_condition_timeout={per_condition_timeout}",
252
296
  ]
253
297
 
254
298
  try:
255
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * 10)
299
+ # DX-52: Inject project venv site-packages for uvx compatibility
300
+ result = subprocess.run(
301
+ cmd,
302
+ capture_output=True,
303
+ text=True,
304
+ timeout=total_timeout,
305
+ env=build_subprocess_env(),
306
+ )
256
307
 
257
308
  # CrossHair format: "file:line: error: Err when calling func(...)"
258
309
  counterexamples = [
@@ -281,6 +332,6 @@ def run_verify(
281
332
 
282
333
  return Success(verify_result)
283
334
  except subprocess.TimeoutExpired:
284
- return Failure(f"Verification timeout ({timeout * 10}s): {target}")
335
+ return Failure(f"Verification timeout ({total_timeout}s): {target}")
285
336
  except Exception as e:
286
337
  return Failure(f"Verification error: {e}")
@@ -7,21 +7,24 @@
7
7
  Your first message MUST display:
8
8
 
9
9
  ```
10
- ✓ Check-In: guard PASS | top: <entry1>, <entry2>
10
+ ✓ Check-In: [project] | [branch] | [clean/dirty]
11
11
  ```
12
12
 
13
- Execute `invar_guard(changed=true)` and `invar_map(top=10)`, then show this one-line summary.
13
+ Actions:
14
+ 1. Read `.invar/context.md` (Key Rules + Current State + Lessons Learned)
15
+ 2. Show one-line status
14
16
 
15
17
  Example:
16
18
  ```
17
- ✓ Check-In: guard PASS | top: parse_file, check_rules
19
+ ✓ Check-In: MyProject | main | clean
18
20
  ```
19
21
 
22
+ **Do NOT execute guard or map at Check-In.**
23
+ Guard is for VALIDATE phase and Final only.
24
+
20
25
  This is your sign-in. The user sees it immediately.
21
26
  No visible check-in = Session not started.
22
27
 
23
- Then read `.invar/context.md` for project state and lessons learned.
24
-
25
28
  ---
26
29
 
27
30
  ## Final
@@ -80,27 +83,45 @@ For complex tasks (3+ functions), show 3 checkpoints in TodoList:
80
83
 
81
84
  ---
82
85
 
83
- ## Agent Roles
86
+ ## Commands (User-Invokable)
84
87
 
85
- | Command | Role | Purpose |
86
- |---------|------|---------|
87
- | `/review` | Reviewer | Adversarial code review (DX-31) |
88
+ | Command | Purpose |
89
+ |---------|---------|
90
+ | `/audit` | Read-only code review (reports issues, no fixes) |
91
+ | `/guard` | Run Invar verification (reports results) |
88
92
 
89
- ### Review Modes (Auto-Selected)
93
+ ## Skills (Agent-Invoked)
90
94
 
91
- `/review` automatically selects mode based on Guard output:
95
+ | Skill | Triggers | Purpose |
96
+ |-------|----------|---------|
97
+ | `/investigate` | "why", "explain", vague tasks | Research mode, no code changes |
98
+ | `/propose` | "should we", "compare" | Decision facilitation |
99
+ | `/develop` | "add", "fix", "implement" | USBV implementation workflow |
100
+ | `/review` | After /develop, `review_suggested` | Adversarial review with fix loop |
92
101
 
93
- | Condition | Mode | Behavior |
94
- |-----------|------|----------|
95
- | `review_suggested` triggered | **Isolated** | Task tool sub-agent (fresh context) |
96
- | No trigger | **Quick** | Same-context adversarial review |
97
- | `--isolated` flag | **Isolated** | Force isolation |
98
- | `--quick` flag | **Quick** | Force same-context |
102
+ **Note:** Skills are invoked by agent based on context. Use `/audit` for user-initiated review.
99
103
 
100
104
  Guard triggers `review_suggested` for: security-sensitive files, escape hatches >= 3, contract coverage < 50%.
101
105
 
102
106
  ---
103
107
 
108
+ ## Workflow Routing (MANDATORY)
109
+
110
+ When user message contains these triggers, you MUST invoke the corresponding skill:
111
+
112
+ | Trigger Words | Skill | Notes |
113
+ |---------------|-------|-------|
114
+ | "review", "review and fix" | `/review` | Adversarial review with fix loop |
115
+ | "implement", "add", "fix", "update" | `/develop` | Unless in review context |
116
+ | "why", "explain", "investigate" | `/investigate` | Research mode, no code changes |
117
+ | "compare", "should we", "design" | `/propose` | Decision facilitation |
118
+
119
+ **Violation check (before writing ANY code):**
120
+ - "Am I in a workflow?"
121
+ - "Did I invoke the correct skill?"
122
+
123
+ ---
124
+
104
125
  ## Project-Specific Rules
105
126
 
106
127
  <!-- Add your team conventions below -->
@@ -14,9 +14,9 @@ system-prompt: |
14
14
 
15
15
  ## Check-In
16
16
  Your first message MUST display:
17
- ✓ Check-In: guard PASS | top: <entry1>, <entry2>
17
+ ✓ Check-In: [project] | [branch] | [clean/dirty]
18
18
 
19
- Execute: invar guard --changed && invar map --top 10
19
+ Read .invar/context.md first. Do NOT run guard/map at Check-In.
20
20
  This is your sign-in. No visible check-in = Session not started.
21
21
 
22
22
  ## Final