invar-tools 1.8.0__py3-none-any.whl → 1.11.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 (117) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/doc_edit.py +187 -0
  3. invar/core/doc_parser.py +563 -0
  4. invar/core/language.py +88 -0
  5. invar/core/models.py +106 -0
  6. invar/core/patterns/detector.py +6 -1
  7. invar/core/patterns/p0_exhaustive.py +15 -3
  8. invar/core/patterns/p0_literal.py +15 -3
  9. invar/core/patterns/p0_newtype.py +15 -3
  10. invar/core/patterns/p0_nonempty.py +15 -3
  11. invar/core/patterns/p0_validation.py +15 -3
  12. invar/core/patterns/registry.py +5 -1
  13. invar/core/patterns/types.py +5 -1
  14. invar/core/property_gen.py +4 -0
  15. invar/core/rules.py +84 -18
  16. invar/core/sync_helpers.py +27 -1
  17. invar/core/ts_parsers.py +286 -0
  18. invar/core/ts_sig_parser.py +310 -0
  19. invar/mcp/handlers.py +408 -0
  20. invar/mcp/server.py +288 -143
  21. invar/node_tools/MANIFEST +7 -0
  22. invar/node_tools/__init__.py +51 -0
  23. invar/node_tools/fc-runner/cli.js +77 -0
  24. invar/node_tools/quick-check/cli.js +28 -0
  25. invar/node_tools/ts-analyzer/cli.js +480 -0
  26. invar/shell/claude_hooks.py +35 -12
  27. invar/shell/commands/doc.py +409 -0
  28. invar/shell/commands/guard.py +41 -1
  29. invar/shell/commands/init.py +154 -16
  30. invar/shell/commands/perception.py +157 -33
  31. invar/shell/commands/skill.py +187 -0
  32. invar/shell/commands/template_sync.py +65 -13
  33. invar/shell/commands/uninstall.py +60 -12
  34. invar/shell/commands/update.py +6 -14
  35. invar/shell/contract_coverage.py +1 -0
  36. invar/shell/doc_tools.py +459 -0
  37. invar/shell/fs.py +67 -13
  38. invar/shell/pi_hooks.py +6 -0
  39. invar/shell/prove/crosshair.py +3 -0
  40. invar/shell/prove/guard_ts.py +902 -0
  41. invar/shell/skill_manager.py +355 -0
  42. invar/shell/template_engine.py +28 -4
  43. invar/shell/templates.py +4 -4
  44. invar/templates/claude-md/python/critical-rules.md +33 -0
  45. invar/templates/claude-md/python/quick-reference.md +24 -0
  46. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  47. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  48. invar/templates/claude-md/universal/check-in.md +25 -0
  49. invar/templates/claude-md/universal/skills.md +73 -0
  50. invar/templates/claude-md/universal/workflow.md +55 -0
  51. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  52. invar/templates/config/AGENT.md.jinja +58 -0
  53. invar/templates/config/CLAUDE.md.jinja +16 -209
  54. invar/templates/config/context.md.jinja +19 -0
  55. invar/templates/examples/{README.md → python/README.md} +2 -0
  56. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  57. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  58. invar/templates/examples/python/core_shell.py +227 -0
  59. invar/templates/examples/python/functional.py +613 -0
  60. invar/templates/examples/typescript/README.md +31 -0
  61. invar/templates/examples/typescript/contracts.ts +163 -0
  62. invar/templates/examples/typescript/core_shell.ts +374 -0
  63. invar/templates/examples/typescript/functional.ts +601 -0
  64. invar/templates/examples/typescript/workflow.md +95 -0
  65. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  66. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  67. invar/templates/hooks/Stop.sh.jinja +1 -1
  68. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  69. invar/templates/hooks/pi/invar.ts.jinja +9 -0
  70. invar/templates/manifest.toml +7 -6
  71. invar/templates/onboard/assessment.md.jinja +214 -0
  72. invar/templates/onboard/patterns/python.md +347 -0
  73. invar/templates/onboard/patterns/typescript.md +452 -0
  74. invar/templates/onboard/roadmap.md.jinja +168 -0
  75. invar/templates/protocol/INVAR.md.jinja +51 -0
  76. invar/templates/protocol/python/architecture-examples.md +41 -0
  77. invar/templates/protocol/python/contracts-syntax.md +56 -0
  78. invar/templates/protocol/python/markers.md +44 -0
  79. invar/templates/protocol/python/tools.md +24 -0
  80. invar/templates/protocol/python/troubleshooting.md +38 -0
  81. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  82. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  83. invar/templates/protocol/typescript/markers.md +48 -0
  84. invar/templates/protocol/typescript/tools.md +65 -0
  85. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  86. invar/templates/protocol/universal/architecture.md +36 -0
  87. invar/templates/protocol/universal/completion.md +14 -0
  88. invar/templates/protocol/universal/contracts-concept.md +37 -0
  89. invar/templates/protocol/universal/header.md +17 -0
  90. invar/templates/protocol/universal/session.md +17 -0
  91. invar/templates/protocol/universal/six-laws.md +10 -0
  92. invar/templates/protocol/universal/usbv.md +14 -0
  93. invar/templates/protocol/universal/visible-workflow.md +25 -0
  94. invar/templates/skills/develop/SKILL.md.jinja +85 -3
  95. invar/templates/skills/extensions/_registry.yaml +93 -0
  96. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  97. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  98. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  99. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  100. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  101. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  102. invar/templates/skills/extensions/security/SKILL.md +382 -0
  103. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  104. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  105. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  106. invar/templates/skills/review/SKILL.md.jinja +220 -248
  107. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
  108. invar_tools-1.11.0.dist-info/RECORD +178 -0
  109. invar/templates/examples/core_shell.py +0 -127
  110. invar/templates/protocol/INVAR.md +0 -310
  111. invar_tools-1.8.0.dist-info/RECORD +0 -116
  112. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  113. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
  114. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
  115. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
  116. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
  117. {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,355 @@
1
+ """
2
+ Skill management for Invar extension skills.
3
+
4
+ LX-07: Extension Skills Architecture
5
+ - List available extension skills from registry
6
+ - Add/remove skills to/from project
7
+ - Update installed skills from templates
8
+
9
+ DX-71: Simplified to idempotent `add` command with region merge.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import shutil
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING
19
+
20
+ import yaml
21
+ from returns.result import Failure, Result, Success
22
+
23
+ from invar.core.template_parser import parse_invar_regions, reconstruct_file
24
+
25
+ if TYPE_CHECKING:
26
+ from rich.console import Console
27
+
28
+
29
+ SKILLS_REGISTRY = "extensions/_registry.yaml"
30
+ SKILLS_DIR = "extensions"
31
+ PROJECT_SKILLS_DIR = ".claude/skills"
32
+
33
+ # Core skills managed by Invar (shared with uninstall.py)
34
+ CORE_SKILLS = {"develop", "review", "investigate", "propose", "guard", "audit"}
35
+
36
+
37
+ # @shell_orchestration: Validation helper used only by shell add_skill/remove_skill
38
+ def _is_valid_skill_name(name: str) -> bool:
39
+ """Validate skill name to prevent path traversal and filesystem attacks."""
40
+ # Block path traversal characters and null bytes
41
+ if ".." in name or "/" in name or "\\" in name or "\x00" in name:
42
+ return False
43
+ # Block special names that could cause issues
44
+ if name in (".", ""):
45
+ return False
46
+ # Must not start with dot or underscore
47
+ return not name.startswith(".") and not name.startswith("_")
48
+
49
+
50
+ def _merge_md_file(src: Path, dst: Path) -> tuple[bool, str]:
51
+ """
52
+ Merge .md file preserving user's extensions region.
53
+
54
+ DX-71: Only updates <!--invar:skill--> region, preserves <!--invar:extensions-->.
55
+
56
+ Returns:
57
+ (merged, message) - True if merged, False if copied fresh
58
+ """
59
+ if not dst.exists():
60
+ shutil.copy2(src, dst)
61
+ return False, "Copied"
62
+
63
+ try:
64
+ new_content = src.read_text()
65
+ old_content = dst.read_text()
66
+
67
+ parsed_new = parse_invar_regions(new_content)
68
+ parsed_old = parse_invar_regions(old_content)
69
+
70
+ # If old file has regions and new file has skill region, merge
71
+ if parsed_old.has_regions and "skill" in parsed_new.regions:
72
+ updates = {"skill": parsed_new.regions["skill"].content}
73
+ merged = reconstruct_file(parsed_old, updates)
74
+ dst.write_text(merged)
75
+ return True, "Merged (extensions preserved)"
76
+
77
+ # No regions to merge - overwrite file
78
+ shutil.copy2(src, dst)
79
+ return False, "Updated"
80
+
81
+ except (OSError, UnicodeDecodeError, ValueError, KeyError) as e:
82
+ # On I/O or parse error, preserve existing file - don't silently lose user data
83
+ # Include error details for debugging
84
+ return False, f"Skipped (merge failed: {type(e).__name__}: {e})"
85
+
86
+
87
+ @dataclass
88
+ class SkillInfo:
89
+ """Information about an extension skill."""
90
+
91
+ name: str
92
+ description: str
93
+ tier: str
94
+ isolation: bool
95
+ status: str # "available", "pending_discussion", "installed"
96
+ files: list[str]
97
+
98
+
99
+ def get_templates_path() -> Path:
100
+ """Get the path to Invar templates directory."""
101
+ # Navigate from this file to templates/skills/
102
+ return Path(__file__).parent.parent / "templates" / "skills"
103
+
104
+
105
+ def load_registry() -> Result[dict, str]:
106
+ """Load the extension skills registry."""
107
+ registry_path = get_templates_path() / SKILLS_REGISTRY
108
+
109
+ if not registry_path.exists():
110
+ return Failure(f"Registry not found: {registry_path}")
111
+
112
+ try:
113
+ content = registry_path.read_text()
114
+ data = yaml.safe_load(content)
115
+ return Success(data)
116
+ except (yaml.YAMLError, OSError, UnicodeDecodeError) as e:
117
+ return Failure(f"Failed to parse registry: {e}")
118
+
119
+
120
+ # @shell_complexity: Iterates registry entries and checks installed status
121
+ def list_skills(
122
+ project_path: Path, console: Console
123
+ ) -> Result[list[SkillInfo], str]:
124
+ """
125
+ List all available extension skills.
126
+
127
+ Returns both available and installed skills with their status.
128
+ """
129
+ registry_result = load_registry()
130
+ if isinstance(registry_result, Failure):
131
+ return registry_result
132
+
133
+ registry = registry_result.unwrap()
134
+ extensions = registry.get("extensions", {})
135
+
136
+ # Check which skills are installed
137
+ installed_dir = project_path / PROJECT_SKILLS_DIR
138
+ installed_skills = set()
139
+ if installed_dir.exists():
140
+ for skill_dir in installed_dir.iterdir():
141
+ if skill_dir.is_dir() and not skill_dir.name.startswith("_"):
142
+ # Check if it's an extension (not a core skill)
143
+ if (skill_dir / "SKILL.md").exists():
144
+ installed_skills.add(skill_dir.name)
145
+
146
+ skills = []
147
+ for name, info in extensions.items():
148
+ status = info.get("status", "available")
149
+ if name in installed_skills:
150
+ status = "installed"
151
+
152
+ skills.append(
153
+ SkillInfo(
154
+ name=name,
155
+ description=info.get("description", ""),
156
+ tier=info.get("tier", "T0"),
157
+ isolation=info.get("isolation", False),
158
+ status=status,
159
+ files=info.get("files", ["SKILL.md"]),
160
+ )
161
+ )
162
+
163
+ return Success(skills)
164
+
165
+
166
+ # @shell_complexity: Validates skill, copies files/directories with error recovery
167
+ def add_skill(
168
+ skill_name: str, project_path: Path, console: Console
169
+ ) -> Result[str, str]:
170
+ """
171
+ Add or update an extension skill to the project.
172
+
173
+ DX-71: Idempotent - installs if missing, updates if exists.
174
+ For .md files, preserves <!--invar:extensions--> region.
175
+
176
+ Copies skill files from templates to .claude/skills/<name>/
177
+ """
178
+ # Validate skill name (defense in depth against path traversal)
179
+ if not _is_valid_skill_name(skill_name):
180
+ return Failure(
181
+ f"Invalid skill name: {skill_name}. "
182
+ "Names cannot contain '.', '/', '\\' or start with '_'"
183
+ )
184
+
185
+ # Load registry to validate skill exists
186
+ registry_result = load_registry()
187
+ if isinstance(registry_result, Failure):
188
+ return registry_result
189
+
190
+ registry = registry_result.unwrap()
191
+ extensions = registry.get("extensions", {})
192
+
193
+ if skill_name not in extensions:
194
+ available = ", ".join(extensions.keys())
195
+ return Failure(f"Unknown skill: {skill_name}. Available: {available}")
196
+
197
+ skill_info = extensions[skill_name]
198
+
199
+ # Check status
200
+ if skill_info.get("status") == "pending_discussion":
201
+ return Failure(
202
+ f"Skill '{skill_name}' is pending discussion (T1). "
203
+ "It will be available in a future release."
204
+ )
205
+
206
+ # Source and destination paths
207
+ source_dir = get_templates_path() / SKILLS_DIR / skill_name
208
+ dest_dir = project_path / PROJECT_SKILLS_DIR / skill_name
209
+
210
+ if not source_dir.exists():
211
+ return Failure(f"Skill template not found: {source_dir}")
212
+
213
+ # DX-71: Determine if this is install or update
214
+ is_update = dest_dir.exists()
215
+ action = "Updating" if is_update else "Adding"
216
+ console.print(f"{action} skill: {skill_name}")
217
+
218
+ # Copy/merge skill files
219
+ try:
220
+ dest_dir.mkdir(parents=True, exist_ok=True)
221
+
222
+ for file_path in skill_info.get("files", ["SKILL.md"]):
223
+ src = source_dir / file_path
224
+ dst = dest_dir / file_path
225
+
226
+ if src.is_file():
227
+ dst.parent.mkdir(parents=True, exist_ok=True)
228
+
229
+ # DX-71: Use merge for .md files when updating
230
+ if is_update and file_path.endswith(".md"):
231
+ _merged, msg = _merge_md_file(src, dst)
232
+ # DX-71 review: Show warning for merge failures
233
+ if msg.startswith("Skipped"):
234
+ console.print(f" [yellow]Warning: {msg}: {file_path}[/yellow]")
235
+ else:
236
+ console.print(f" [dim]{msg}: {file_path}[/dim]")
237
+ else:
238
+ shutil.copy2(src, dst)
239
+ action_msg = "Updated" if is_update else "Copied"
240
+ console.print(f" [dim]{action_msg}: {file_path}[/dim]")
241
+
242
+ elif src.is_dir():
243
+ # Handle directory (e.g., patterns/)
244
+ # DX-71 review: Use dirs_exist_ok=True for atomic update (no rmtree race)
245
+ shutil.copytree(src, dst, dirs_exist_ok=True)
246
+ action_msg = "Updated" if is_update else "Copied"
247
+ console.print(f" [dim]{action_msg}: {file_path}/[/dim]")
248
+
249
+ result_msg = "updated" if is_update else "installed"
250
+ return Success(f"Skill '{skill_name}' {result_msg} successfully")
251
+
252
+ except (OSError, shutil.Error) as e:
253
+ # Clean up on failure (only for fresh install)
254
+ # M3 note: Updates that fail mid-way may leave directory in partial state.
255
+ # This is acceptable because: (1) user extensions are preserved via merge,
256
+ # (2) re-running add will complete the update. Full atomicity would require
257
+ # temp directory + rename, adding complexity for rare failure cases.
258
+ if not is_update and dest_dir.exists():
259
+ shutil.rmtree(dest_dir)
260
+ return Failure(f"Failed to {'update' if is_update else 'install'} skill: {e}")
261
+
262
+
263
+ def has_user_extensions(skill_dir: Path) -> bool:
264
+ """Check if SKILL.md has user content in extensions region."""
265
+ skill_md = skill_dir / "SKILL.md"
266
+ if not skill_md.exists():
267
+ return False
268
+
269
+ # M1 fix: Narrow exception scope for better error handling
270
+ try:
271
+ content = skill_md.read_text()
272
+ except (OSError, UnicodeDecodeError):
273
+ # Cannot read file - assume extensions exist (safe default)
274
+ return True
275
+
276
+ try:
277
+ parsed = parse_invar_regions(content)
278
+
279
+ if "extensions" in parsed.regions:
280
+ ext_content = parsed.regions["extensions"].content
281
+
282
+ # Remove HTML comment blocks (the template content is inside comments)
283
+ # This preserves user content like markdown lists (- item)
284
+ cleaned = re.sub(r"<!--.*?-->", "", ext_content, flags=re.DOTALL)
285
+
286
+ # Check if any non-whitespace content remains
287
+ return bool(cleaned.strip())
288
+ except (ValueError, KeyError):
289
+ # Parse error - assume extensions exist (safe default)
290
+ return True
291
+
292
+ return False
293
+
294
+
295
+ # @shell_complexity: Validates core skill protection + user extensions check
296
+ def remove_skill(
297
+ skill_name: str, project_path: Path, console: Console, force: bool = False
298
+ ) -> Result[str, str]:
299
+ """
300
+ Remove an extension skill from the project.
301
+
302
+ DX-71: Warns if user has custom extensions content.
303
+ """
304
+ # Validate skill name (defense in depth against path traversal)
305
+ if not _is_valid_skill_name(skill_name):
306
+ return Failure(
307
+ f"Invalid skill name: {skill_name}. "
308
+ "Names cannot contain '.', '/', '\\' or start with '_'"
309
+ )
310
+
311
+ dest_dir = project_path / PROJECT_SKILLS_DIR / skill_name
312
+
313
+ if not dest_dir.exists():
314
+ return Failure(f"Skill not installed: {skill_name}")
315
+
316
+ # Protect core skills
317
+ if skill_name in CORE_SKILLS:
318
+ return Failure(
319
+ f"Cannot remove core skill: {skill_name}. "
320
+ "Only extension skills can be removed."
321
+ )
322
+
323
+ # DX-71: Check for user extensions
324
+ # Note: CLI also checks this for UX ordering (warn before confirm dialog).
325
+ # This check remains for programmatic API callers.
326
+ if not force and has_user_extensions(dest_dir):
327
+ console.print(
328
+ "[yellow]Warning:[/yellow] This skill has custom extensions content "
329
+ "that will be lost."
330
+ )
331
+ # M2 fix: API-appropriate message (not CLI --force)
332
+ return Failure(
333
+ "Skill has user extensions. Pass force=True to confirm removal."
334
+ )
335
+
336
+ try:
337
+ shutil.rmtree(dest_dir)
338
+ return Success(f"Skill '{skill_name}' removed successfully")
339
+ except (OSError, shutil.Error) as e:
340
+ return Failure(f"Failed to remove skill: {e}")
341
+
342
+
343
+ def update_skill(
344
+ skill_name: str, project_path: Path, console: Console
345
+ ) -> Result[str, str]:
346
+ """
347
+ Update an installed extension skill from templates.
348
+
349
+ DX-71: Deprecated - use `add_skill` instead (idempotent).
350
+ This function now delegates to add_skill with a deprecation notice.
351
+ """
352
+ console.print(
353
+ "[dim]Note: 'skill update' is deprecated, use 'skill add' instead[/dim]"
354
+ )
355
+ return add_skill(skill_name, project_path, console)
@@ -162,7 +162,9 @@ def render_template_file(
162
162
  template_path: Path,
163
163
  variables: dict[str, str],
164
164
  ) -> Result[str, str]:
165
- """Render a Jinja2 template file.
165
+ """Render a Jinja2 template file with {% include %} support.
166
+
167
+ Uses FileSystemLoader to resolve includes relative to templates directory.
166
168
 
167
169
  Examples:
168
170
  >>> from pathlib import Path
@@ -176,11 +178,33 @@ def render_template_file(
176
178
  >>> path.unlink()
177
179
  """
178
180
  try:
179
- content = template_path.read_text()
181
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
182
+
183
+ # Use FileSystemLoader for {% include %} support (LX-05)
184
+ templates_dir = get_templates_dir()
185
+ env = Environment(
186
+ loader=FileSystemLoader(str(templates_dir)),
187
+ undefined=StrictUndefined,
188
+ keep_trailing_newline=True,
189
+ )
190
+
191
+ # Get template path relative to templates directory
192
+ try:
193
+ rel_path = template_path.relative_to(templates_dir)
194
+ template = env.get_template(str(rel_path))
195
+ except ValueError:
196
+ # Template is not in templates_dir, fall back to direct rendering
197
+ content = template_path.read_text()
198
+ template = env.from_string(content)
199
+
200
+ rendered = template.render(**variables)
201
+ return Success(rendered)
180
202
  except OSError as e:
181
203
  return Failure(f"Failed to read template {template_path}: {e}")
182
-
183
- return render_template(content, variables)
204
+ except ImportError:
205
+ return Failure("Jinja2 not installed. Run: pip install jinja2")
206
+ except Exception as e:
207
+ return Failure(f"Template rendering failed: {e}")
184
208
 
185
209
 
186
210
  # =============================================================================
invar/shell/templates.py CHANGED
@@ -15,12 +15,12 @@ _DEFAULT_PYPROJECT_CONFIG = """\n# Invar Configuration
15
15
  [tool.invar.guard]
16
16
  core_paths = ["src/core"]
17
17
  shell_paths = ["src/shell"]
18
- max_file_lines = 300
18
+ max_file_lines = 500
19
19
  max_function_lines = 50
20
20
  require_contracts = true
21
21
  require_doctests = true
22
22
  forbidden_imports = ["os", "sys", "socket", "requests", "urllib", "subprocess", "shutil", "io", "pathlib"]
23
- exclude_paths = ["tests", "scripts", ".venv"]
23
+ exclude_paths = ["tests", "test", "scripts", ".venv", "venv", "__pycache__", ".pytest_cache", "node_modules", "dist", "build"]
24
24
  """
25
25
 
26
26
  _DEFAULT_INVAR_TOML = """# Invar Configuration
@@ -29,12 +29,12 @@ _DEFAULT_INVAR_TOML = """# Invar Configuration
29
29
  [guard]
30
30
  core_paths = ["src/core"]
31
31
  shell_paths = ["src/shell"]
32
- max_file_lines = 300
32
+ max_file_lines = 500
33
33
  max_function_lines = 50
34
34
  require_contracts = true
35
35
  require_doctests = true
36
36
  forbidden_imports = ["os", "sys", "socket", "requests", "urllib", "subprocess", "shutil", "io", "pathlib"]
37
- exclude_paths = ["tests", "scripts", ".venv"]
37
+ exclude_paths = ["tests", "test", "scripts", ".venv", "venv", "__pycache__", ".pytest_cache", "node_modules", "dist", "build"]
38
38
 
39
39
  # Pattern-based classification (optional, takes priority over paths)
40
40
  # core_patterns = ["**/domain/**", "**/models/**"]
@@ -0,0 +1,33 @@
1
+ <!--invar:critical-->
2
+ ## ⚡ Critical Rules
3
+
4
+ | Always | Remember |
5
+ |--------|----------|
6
+ {% if syntax == "mcp" -%}
7
+ | **Verify** | `invar_guard` — NOT pytest, NOT crosshair |
8
+ {% else -%}
9
+ | **Verify** | `invar guard` — NOT pytest, NOT crosshair |
10
+ {% endif -%}
11
+ | **Core** | `@pre/@post` + doctests, NO I/O imports |
12
+ | **Shell** | Returns `Result[T, E]` from `returns` library |
13
+ | **Flow** | USBV: Understand → Specify → Build → Validate |
14
+
15
+ ### Contract Rules (CRITICAL)
16
+
17
+ ```python
18
+ # ❌ WRONG: Lambda must include ALL parameters
19
+ @pre(lambda x: x >= 0)
20
+ def calc(x: int, y: int = 0): ...
21
+
22
+ # ✅ CORRECT: Include defaults too
23
+ @pre(lambda x, y=0: x >= 0)
24
+ def calc(x: int, y: int = 0): ...
25
+
26
+ # ❌ WRONG: @post cannot access parameters
27
+ @post(lambda result: result > x) # 'x' not available!
28
+
29
+ # ✅ CORRECT: @post only sees 'result'
30
+ @post(lambda result: result >= 0)
31
+ ```
32
+
33
+ <!--/invar:critical-->
@@ -0,0 +1,24 @@
1
+ ## Project Structure
2
+
3
+ ```
4
+ src/{project}/
5
+ ├── core/ # Pure logic (@pre/@post, doctests, no I/O)
6
+ └── shell/ # I/O operations (Result[T, E] return type)
7
+ ```
8
+
9
+ **Key insight:** Core receives data (strings), Shell handles I/O (paths, files).
10
+
11
+ ## Quick Reference
12
+
13
+ | Zone | Requirements |
14
+ |------|-------------|
15
+ | Core | `@pre`/`@post` + doctests, pure (no I/O) |
16
+ | Shell | Returns `Result[T, E]` from `returns` library |
17
+
18
+ ### Core vs Shell (Edge Cases)
19
+
20
+ - File/network/env vars → **Shell**
21
+ - `datetime.now()`, `random` → **Inject param** OR Shell
22
+ - Pure logic → **Core**
23
+
24
+ > Full decision tree: [INVAR.md#core-shell](./INVAR.md#decision-tree-core-vs-shell)
@@ -0,0 +1,40 @@
1
+ <!--invar:critical-->
2
+ ## ⚡ Critical Rules
3
+
4
+ | Always | Remember |
5
+ |--------|----------|
6
+ {% if syntax == "mcp" -%}
7
+ | **Verify** | `invar_guard` — NOT just `tsc`, NOT just `vitest` |
8
+ {% else -%}
9
+ | **Verify** | `invar guard` — NOT just `tsc`, NOT just `vitest` |
10
+ {% endif -%}
11
+ | **Core** | Zod schemas + JSDoc examples, NO I/O imports |
12
+ | **Shell** | Returns `Result<T, E>` from `neverthrow` library |
13
+ | **Flow** | USBV: Understand → Specify → Build → Validate |
14
+
15
+ ### Contract Rules (CRITICAL)
16
+
17
+ ```typescript
18
+ import { z } from 'zod';
19
+
20
+ // ❌ WRONG: No validation, just type annotation
21
+ function calc(x: number): number { ... }
22
+
23
+ // ✅ CORRECT: Zod schema validates at runtime
24
+ const CalcInput = z.number().positive();
25
+ const CalcOutput = z.number().nonnegative();
26
+
27
+ function calc(x: number): number {
28
+ const validated = CalcInput.parse(x);
29
+ const result = validated * 2;
30
+ return CalcOutput.parse(result);
31
+ }
32
+
33
+ // ❌ WRONG: Schema only checks type
34
+ const BadSchema = z.number();
35
+
36
+ // ✅ CORRECT: Schema checks domain constraints
37
+ const GoodSchema = z.number().positive().max(100);
38
+ ```
39
+
40
+ <!--/invar:critical-->
@@ -0,0 +1,24 @@
1
+ ## Project Structure
2
+
3
+ ```
4
+ src/{project}/
5
+ ├── core/ # Pure logic (Zod schemas, JSDoc examples, no I/O)
6
+ └── shell/ # I/O operations (Result<T, E> return type)
7
+ ```
8
+
9
+ **Key insight:** Core receives data (validated types), Shell handles I/O (files, network).
10
+
11
+ ## Quick Reference
12
+
13
+ | Zone | Requirements |
14
+ |------|-------------|
15
+ | Core | Zod schemas + JSDoc @example, pure (no I/O) |
16
+ | Shell | Returns `Result<T, E>` from `neverthrow` library |
17
+
18
+ ### Core vs Shell (Edge Cases)
19
+
20
+ - fs/path/http/fetch → **Shell**
21
+ - `Date.now()`, `Math.random()` → **Inject param** OR Shell
22
+ - Pure logic → **Core**
23
+
24
+ > Full decision tree: [INVAR.md#core-shell](./INVAR.md#decision-tree-core-vs-shell)
@@ -0,0 +1,25 @@
1
+ ## Check-In
2
+
3
+ > See [INVAR.md#check-in](./INVAR.md#check-in-required) for full protocol.
4
+
5
+ **Your first message MUST display:** `✓ Check-In: [project] | [branch] | [clean/dirty]`
6
+
7
+ **Actions:** Read `.invar/context.md`, then show status. Do NOT run guard at Check-In.
8
+
9
+ ---
10
+
11
+ ## Final
12
+
13
+ Your last message for an implementation task MUST display:
14
+
15
+ ```
16
+ ✓ Final: guard PASS | 0 errors, 2 warnings
17
+ ```
18
+
19
+ {% if syntax == "mcp" -%}
20
+ Execute `invar_guard()` and show this one-line summary.
21
+ {% else -%}
22
+ Execute `invar guard` and show this one-line summary.
23
+ {% endif %}
24
+
25
+ This is your sign-out. Completes the Check-In/Final pair.
@@ -0,0 +1,73 @@
1
+ ## Commands (User-Invokable)
2
+
3
+ | Command | Purpose |
4
+ |---------|---------|
5
+ | `/audit` | Read-only code review (reports issues, no fixes) |
6
+ | `/guard` | Run Invar verification (reports results) |
7
+
8
+ ## Skills (Agent-Invoked)
9
+
10
+ | Skill | Triggers | Purpose |
11
+ |-------|----------|---------|
12
+ | `/investigate` | "why", "explain", vague tasks | Research mode, no code changes |
13
+ | `/propose` | "should we", "compare" | Decision facilitation |
14
+ | `/develop` | "add", "fix", "implement" | USBV implementation workflow |
15
+ | `/review` | After /develop, `review_suggested` | Adversarial review with fix loop |
16
+
17
+ **Note:** Skills are invoked by agent based on context. Use `/audit` for user-initiated review.
18
+
19
+ Guard triggers `review_suggested` for: security-sensitive files, escape hatches >= 3, contract coverage < 50%.
20
+
21
+ ---
22
+
23
+ ## Workflow Routing (MANDATORY)
24
+
25
+ When user message contains these triggers, you MUST use the **Skill tool** to invoke the skill:
26
+
27
+ | Trigger Words | Skill Tool Call | Notes |
28
+ |---------------|-----------------|-------|
29
+ | "review", "review and fix" | `Skill(skill="review")` | Adversarial review with fix loop |
30
+ | "implement", "add", "fix", "update" | `Skill(skill="develop")` | Unless in review context |
31
+ | "why", "explain", "investigate" | `Skill(skill="investigate")` | Research mode, no code changes |
32
+ | "compare", "should we", "design" | `Skill(skill="propose")` | Decision facilitation |
33
+
34
+ **CRITICAL: You must call the Skill tool, not just follow the workflow mentally.**
35
+
36
+ The Skill tool reads `.claude/skills/<skill>/SKILL.md` which contains:
37
+ - Detailed phase instructions (USBV breakdown)
38
+ - Error handling rules
39
+ - Timeout policies
40
+ - Incremental development patterns (DX-63)
41
+
42
+ **Violation check (before writing ANY code):**
43
+ - "Did I call `Skill(skill="...")`?"
44
+ - "Am I following the SKILL.md instructions?"
45
+
46
+ ---
47
+
48
+ ## Routing Control (DX-42)
49
+
50
+ Agent announces routing decision before entering any workflow:
51
+
52
+ ```
53
+ 📍 Routing: /[skill] — [trigger or reason]
54
+ Task: [summary]
55
+ ```
56
+
57
+ **User can redirect with natural language:**
58
+ - "wait" / "stop" — pause and ask for direction
59
+ - "just do it" — proceed with /develop
60
+ - "let's discuss" — switch to /propose
61
+ - "explain first" — switch to /investigate
62
+
63
+ **Simple task optimization:** For simple tasks (single file, clear target, <50 lines), agent may offer:
64
+
65
+ ```
66
+ 📊 Simple task. Auto-orchestrate? [Y/N]
67
+ ```
68
+
69
+ - Y → Full cycle without intermediate confirmations
70
+ - N → Normal step-by-step workflow
71
+
72
+ **Auto-review (DX-41):** When Guard outputs `review_suggested`, agent automatically
73
+ enters /review. Say "skip" to bypass.