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.
- invar/__init__.py +8 -0
- invar/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -0
- invar/core/language.py +88 -0
- invar/core/models.py +106 -0
- invar/core/patterns/detector.py +6 -1
- invar/core/patterns/p0_exhaustive.py +15 -3
- invar/core/patterns/p0_literal.py +15 -3
- invar/core/patterns/p0_newtype.py +15 -3
- invar/core/patterns/p0_nonempty.py +15 -3
- invar/core/patterns/p0_validation.py +15 -3
- invar/core/patterns/registry.py +5 -1
- invar/core/patterns/types.py +5 -1
- invar/core/property_gen.py +4 -0
- invar/core/rules.py +84 -18
- invar/core/sync_helpers.py +27 -1
- invar/core/ts_parsers.py +286 -0
- invar/core/ts_sig_parser.py +310 -0
- invar/mcp/handlers.py +408 -0
- invar/mcp/server.py +288 -143
- invar/node_tools/MANIFEST +7 -0
- invar/node_tools/__init__.py +51 -0
- invar/node_tools/fc-runner/cli.js +77 -0
- invar/node_tools/quick-check/cli.js +28 -0
- invar/node_tools/ts-analyzer/cli.js +480 -0
- invar/shell/claude_hooks.py +35 -12
- invar/shell/commands/doc.py +409 -0
- invar/shell/commands/guard.py +41 -1
- invar/shell/commands/init.py +154 -16
- invar/shell/commands/perception.py +157 -33
- invar/shell/commands/skill.py +187 -0
- invar/shell/commands/template_sync.py +65 -13
- invar/shell/commands/uninstall.py +60 -12
- invar/shell/commands/update.py +6 -14
- invar/shell/contract_coverage.py +1 -0
- invar/shell/doc_tools.py +459 -0
- invar/shell/fs.py +67 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +902 -0
- invar/shell/skill_manager.py +355 -0
- invar/shell/template_engine.py +28 -4
- invar/shell/templates.py +4 -4
- invar/templates/claude-md/python/critical-rules.md +33 -0
- invar/templates/claude-md/python/quick-reference.md +24 -0
- invar/templates/claude-md/typescript/critical-rules.md +40 -0
- invar/templates/claude-md/typescript/quick-reference.md +24 -0
- invar/templates/claude-md/universal/check-in.md +25 -0
- invar/templates/claude-md/universal/skills.md +73 -0
- invar/templates/claude-md/universal/workflow.md +55 -0
- invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
- invar/templates/config/AGENT.md.jinja +58 -0
- invar/templates/config/CLAUDE.md.jinja +16 -209
- invar/templates/config/context.md.jinja +19 -0
- invar/templates/examples/{README.md → python/README.md} +2 -0
- invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
- invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
- invar/templates/examples/python/core_shell.py +227 -0
- invar/templates/examples/python/functional.py +613 -0
- invar/templates/examples/typescript/README.md +31 -0
- invar/templates/examples/typescript/contracts.ts +163 -0
- invar/templates/examples/typescript/core_shell.ts +374 -0
- invar/templates/examples/typescript/functional.ts +601 -0
- invar/templates/examples/typescript/workflow.md +95 -0
- invar/templates/hooks/PostToolUse.sh.jinja +10 -1
- invar/templates/hooks/PreToolUse.sh.jinja +38 -0
- invar/templates/hooks/Stop.sh.jinja +1 -1
- invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
- invar/templates/hooks/pi/invar.ts.jinja +9 -0
- invar/templates/manifest.toml +7 -6
- invar/templates/onboard/assessment.md.jinja +214 -0
- invar/templates/onboard/patterns/python.md +347 -0
- invar/templates/onboard/patterns/typescript.md +452 -0
- invar/templates/onboard/roadmap.md.jinja +168 -0
- invar/templates/protocol/INVAR.md.jinja +51 -0
- invar/templates/protocol/python/architecture-examples.md +41 -0
- invar/templates/protocol/python/contracts-syntax.md +56 -0
- invar/templates/protocol/python/markers.md +44 -0
- invar/templates/protocol/python/tools.md +24 -0
- invar/templates/protocol/python/troubleshooting.md +38 -0
- invar/templates/protocol/typescript/architecture-examples.md +52 -0
- invar/templates/protocol/typescript/contracts-syntax.md +73 -0
- invar/templates/protocol/typescript/markers.md +48 -0
- invar/templates/protocol/typescript/tools.md +65 -0
- invar/templates/protocol/typescript/troubleshooting.md +104 -0
- invar/templates/protocol/universal/architecture.md +36 -0
- invar/templates/protocol/universal/completion.md +14 -0
- invar/templates/protocol/universal/contracts-concept.md +37 -0
- invar/templates/protocol/universal/header.md +17 -0
- invar/templates/protocol/universal/session.md +17 -0
- invar/templates/protocol/universal/six-laws.md +10 -0
- invar/templates/protocol/universal/usbv.md +14 -0
- invar/templates/protocol/universal/visible-workflow.md +25 -0
- invar/templates/skills/develop/SKILL.md.jinja +85 -3
- invar/templates/skills/extensions/_registry.yaml +93 -0
- invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
- invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
- invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
- invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
- invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
- invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
- invar/templates/skills/extensions/security/SKILL.md +382 -0
- invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
- invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
- invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
- invar/templates/skills/review/SKILL.md.jinja +220 -248
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
- invar_tools-1.11.0.dist-info/RECORD +178 -0
- invar/templates/examples/core_shell.py +0 -127
- invar/templates/protocol/INVAR.md +0 -310
- invar_tools-1.8.0.dist-info/RECORD +0 -116
- /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {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)
|
invar/shell/template_engine.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|