delimit-cli 3.9.1 → 3.9.3

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.
@@ -1,11 +1,403 @@
1
1
  """
2
2
  Delimit Governance Layer — the loop that keeps AI agents on track.
3
- The full engine is distributed as a compiled binary (.so/.pyd).
4
- Install compiled modules: npx delimit-cli setup
3
+
4
+ Every tool flows through governance. Governance:
5
+ 1. Logs what happened (evidence)
6
+ 2. Checks result against rules (thresholds, policies)
7
+ 3. Auto-creates ledger items for failures/warnings
8
+ 4. Suggests next steps (loops back to keep building)
9
+
10
+ This replaces _with_next_steps — governance IS the next step system.
5
11
  """
6
12
 
7
- def govern(tool_name, result, project_path="."):
8
- """Route tool result through governance. Requires compiled module."""
9
- # Minimal fallback: pass through without governance
10
- result["next_steps"] = result.get("next_steps", [])
11
- return result
13
+ import json
14
+ import logging
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ logger = logging.getLogger("delimit.governance")
20
+
21
+
22
+ # Governance rules — what triggers auto-ledger-creation
23
+ RULES = {
24
+ "test_coverage": {
25
+ "threshold_key": "line_coverage",
26
+ "threshold": 80,
27
+ "comparison": "below",
28
+ "ledger_title": "Test coverage below {threshold}% — currently {value}%",
29
+ "ledger_type": "fix",
30
+ "ledger_priority": "P1",
31
+ },
32
+ "security_audit": {
33
+ "trigger_key": "vulnerabilities",
34
+ "trigger_if_nonempty": True,
35
+ "ledger_title": "Security: {count} vulnerabilities found",
36
+ "ledger_type": "fix",
37
+ "ledger_priority": "P0",
38
+ },
39
+ "security_scan": {
40
+ "trigger_key": "vulnerabilities",
41
+ "trigger_if_nonempty": True,
42
+ "ledger_title": "Security scan: {count} issues detected",
43
+ "ledger_type": "fix",
44
+ "ledger_priority": "P0",
45
+ },
46
+ "lint": {
47
+ "trigger_key": "violations",
48
+ "trigger_if_nonempty": True,
49
+ "ledger_title": "API lint: {count} violations found",
50
+ "ledger_type": "fix",
51
+ "ledger_priority": "P1",
52
+ },
53
+ "deliberate": {
54
+ "trigger_key": "unanimous",
55
+ "trigger_if_true": True,
56
+ "extract_actions": True,
57
+ "ledger_title": "Deliberation consensus reached — action items pending",
58
+ "ledger_type": "strategy",
59
+ "ledger_priority": "P1",
60
+ },
61
+ "gov_health": {
62
+ "trigger_key": "status",
63
+ "trigger_values": ["not_initialized", "degraded"],
64
+ "ledger_title": "Governance health: {value} — needs attention",
65
+ "ledger_type": "fix",
66
+ "ledger_priority": "P1",
67
+ },
68
+ "docs_validate": {
69
+ "threshold_key": "coverage_percent",
70
+ "threshold": 50,
71
+ "comparison": "below",
72
+ "ledger_title": "Documentation coverage below {threshold}% — currently {value}%",
73
+ "ledger_type": "task",
74
+ "ledger_priority": "P2",
75
+ },
76
+ }
77
+
78
+ # Milestone rules — auto-create DONE ledger items for significant completions.
79
+ # Unlike threshold RULES (which create open items for problems), milestones
80
+ # record achievements so the ledger reflects what was shipped.
81
+ MILESTONES = {
82
+ "deploy_site": {
83
+ "trigger_key": "status",
84
+ "trigger_values": ["deployed"],
85
+ "ledger_title": "Deployed: {project}",
86
+ "ledger_type": "feat",
87
+ "ledger_priority": "P1",
88
+ "auto_done": True,
89
+ },
90
+ "deploy_npm": {
91
+ "trigger_key": "status",
92
+ "trigger_values": ["published"],
93
+ "ledger_title": "Published: {package}@{new_version}",
94
+ "ledger_type": "feat",
95
+ "ledger_priority": "P1",
96
+ "auto_done": True,
97
+ },
98
+ "deliberate": {
99
+ "trigger_key": "status",
100
+ "trigger_values": ["unanimous"],
101
+ "ledger_title": "Consensus reached: {question_short}",
102
+ "ledger_type": "strategy",
103
+ "ledger_priority": "P1",
104
+ "auto_done": True,
105
+ },
106
+ "test_generate": {
107
+ "threshold_key": "tests_generated",
108
+ "threshold": 10,
109
+ "comparison": "above",
110
+ "ledger_title": "Generated {value} tests",
111
+ "ledger_type": "feat",
112
+ "ledger_priority": "P2",
113
+ "auto_done": True,
114
+ },
115
+ "sensor_github_issue": {
116
+ "trigger_key": "has_new_activity",
117
+ "trigger_if_true": True,
118
+ "ledger_title": "Outreach response: new activity detected",
119
+ "ledger_type": "task",
120
+ "ledger_priority": "P1",
121
+ "auto_done": False, # needs follow-up
122
+ },
123
+ "zero_spec": {
124
+ "trigger_key": "success",
125
+ "trigger_if_true": True,
126
+ "ledger_title": "Zero-spec extracted: {framework} ({paths_count} paths)",
127
+ "ledger_type": "feat",
128
+ "ledger_priority": "P2",
129
+ "auto_done": True,
130
+ },
131
+ }
132
+
133
+ # Next steps registry — what to do after each tool
134
+ NEXT_STEPS = {
135
+ "lint": [
136
+ {"tool": "delimit_explain", "reason": "Get migration guide for violations", "premium": False},
137
+ {"tool": "delimit_semver", "reason": "Classify the version bump", "premium": False},
138
+ ],
139
+ "diff": [
140
+ {"tool": "delimit_semver", "reason": "Classify changes as MAJOR/MINOR/PATCH", "premium": False},
141
+ {"tool": "delimit_policy", "reason": "Check against governance policies", "premium": False},
142
+ ],
143
+ "semver": [
144
+ {"tool": "delimit_explain", "reason": "Generate human-readable changelog", "premium": False},
145
+ {"tool": "delimit_deploy_npm", "reason": "Publish the new version to npm", "premium": False},
146
+ ],
147
+ "init": [
148
+ {"tool": "delimit_gov_health", "reason": "Verify governance is set up correctly", "premium": True},
149
+ {"tool": "delimit_diagnose", "reason": "Check for any issues", "premium": False},
150
+ ],
151
+ "deploy_site": [
152
+ {"tool": "delimit_deploy_npm", "reason": "Publish npm package if applicable", "premium": False},
153
+ {"tool": "delimit_ledger_context", "reason": "Check what else needs deploying", "premium": False},
154
+ ],
155
+ "test_coverage": [
156
+ {"tool": "delimit_test_generate", "reason": "Generate tests for uncovered files", "premium": False},
157
+ ],
158
+ "security_audit": [
159
+ {"tool": "delimit_evidence_collect", "reason": "Collect evidence of findings", "premium": True},
160
+ ],
161
+ "gov_health": [
162
+ {"tool": "delimit_gov_status", "reason": "See detailed governance status", "premium": True},
163
+ {"tool": "delimit_repo_analyze", "reason": "Full repo health report", "premium": True},
164
+ ],
165
+ "deploy_npm": [
166
+ {"tool": "delimit_deploy_verify", "reason": "Verify the published package", "premium": True},
167
+ ],
168
+ "deploy_plan": [
169
+ {"tool": "delimit_deploy_build", "reason": "Build the deployment", "premium": True},
170
+ ],
171
+ "deploy_build": [
172
+ {"tool": "delimit_deploy_publish", "reason": "Publish the build", "premium": True},
173
+ ],
174
+ "deploy_publish": [
175
+ {"tool": "delimit_deploy_verify", "reason": "Verify the deployment", "premium": True},
176
+ ],
177
+ "deploy_verify": [
178
+ {"tool": "delimit_deploy_rollback", "reason": "Rollback if unhealthy", "premium": True},
179
+ ],
180
+ "repo_analyze": [
181
+ {"tool": "delimit_security_audit", "reason": "Scan for security issues", "premium": False},
182
+ {"tool": "delimit_gov_health", "reason": "Check governance status", "premium": True},
183
+ ],
184
+ "deliberate": [
185
+ {"tool": "delimit_ledger_context", "reason": "Review what's on the ledger after consensus", "premium": False},
186
+ ],
187
+ "ledger_add": [
188
+ {"tool": "delimit_ledger_context", "reason": "See updated ledger state", "premium": False},
189
+ ],
190
+ "diagnose": [
191
+ {"tool": "delimit_init", "reason": "Initialize governance if not set up", "premium": False},
192
+ ],
193
+ }
194
+
195
+
196
+ def govern(tool_name: str, result: Dict[str, Any], project_path: str = ".") -> Dict[str, Any]:
197
+ """
198
+ Run governance on a tool's result. This is the central loop.
199
+
200
+ 1. Check result against rules
201
+ 2. Auto-create ledger items if thresholds breached
202
+ 3. Add next_steps for the AI to continue
203
+ 4. Return enriched result
204
+
205
+ Every tool should call this before returning.
206
+ """
207
+ # Strip "delimit_" prefix for rule matching
208
+ clean_name = tool_name.replace("delimit_", "")
209
+
210
+ governed_result = dict(result)
211
+
212
+ # 1. Check governance rules
213
+ rule = RULES.get(clean_name)
214
+ auto_items = []
215
+
216
+ if rule:
217
+ triggered = False
218
+ context = {}
219
+
220
+ # Threshold check (e.g., coverage < 80%)
221
+ if "threshold_key" in rule:
222
+ value = _deep_get(result, rule["threshold_key"])
223
+ if value is not None:
224
+ threshold = rule["threshold"]
225
+ if rule.get("comparison") == "below" and value < threshold:
226
+ triggered = True
227
+ context = {"value": f"{value:.1f}" if isinstance(value, float) else str(value), "threshold": str(threshold)}
228
+
229
+ # Non-empty list check (e.g., vulnerabilities found)
230
+ if "trigger_key" in rule and "trigger_if_nonempty" in rule:
231
+ items = _deep_get(result, rule["trigger_key"])
232
+ if items and isinstance(items, list) and len(items) > 0:
233
+ triggered = True
234
+ context = {"count": str(len(items))}
235
+
236
+ # Value match check (e.g., status == "degraded")
237
+ if "trigger_key" in rule and "trigger_values" in rule:
238
+ value = _deep_get(result, rule["trigger_key"])
239
+ if value in rule["trigger_values"]:
240
+ triggered = True
241
+ context = {"value": str(value)}
242
+
243
+ # Boolean check (e.g., unanimous == True)
244
+ if "trigger_key" in rule and "trigger_if_true" in rule:
245
+ value = _deep_get(result, rule["trigger_key"])
246
+ if value:
247
+ triggered = True
248
+
249
+ if triggered:
250
+ title = rule["ledger_title"].format(**context) if context else rule["ledger_title"]
251
+ auto_items.append({
252
+ "title": title,
253
+ "type": rule.get("ledger_type", "task"),
254
+ "priority": rule.get("ledger_priority", "P1"),
255
+ "source": f"governance:{clean_name}",
256
+ })
257
+
258
+ # 1b. Check milestone rules (auto-create DONE items for achievements)
259
+ milestone = MILESTONES.get(clean_name)
260
+ if milestone:
261
+ m_triggered = False
262
+ m_context = {}
263
+
264
+ # Value match (e.g., status == "deployed")
265
+ if "trigger_key" in milestone and "trigger_values" in milestone:
266
+ value = _deep_get(result, milestone["trigger_key"])
267
+ if value in milestone["trigger_values"]:
268
+ m_triggered = True
269
+ m_context = {"value": str(value)}
270
+
271
+ # Boolean check (e.g., success == True)
272
+ if "trigger_key" in milestone and milestone.get("trigger_if_true"):
273
+ value = _deep_get(result, milestone["trigger_key"])
274
+ if value:
275
+ m_triggered = True
276
+
277
+ # Threshold above (e.g., tests_generated > 10)
278
+ if "threshold_key" in milestone:
279
+ value = _deep_get(result, milestone["threshold_key"])
280
+ if value is not None:
281
+ threshold = milestone["threshold"]
282
+ if milestone.get("comparison") == "above" and value > threshold:
283
+ m_triggered = True
284
+ m_context = {"value": str(value), "threshold": str(threshold)}
285
+
286
+ if m_triggered:
287
+ # Build context from result fields for title interpolation
288
+ for key in ("project", "package", "new_version", "framework", "paths_count", "repo"):
289
+ if key not in m_context:
290
+ v = _deep_get(result, key)
291
+ if v is not None:
292
+ m_context[key] = str(v)
293
+ # Special: short question for deliberations
294
+ if "question_short" not in m_context:
295
+ q = _deep_get(result, "question") or _deep_get(result, "note") or ""
296
+ m_context["question_short"] = str(q)[:80]
297
+
298
+ try:
299
+ title = milestone["ledger_title"].format(**m_context)
300
+ except (KeyError, IndexError):
301
+ title = milestone["ledger_title"]
302
+
303
+ auto_items.append({
304
+ "title": title,
305
+ "type": milestone.get("ledger_type", "feat"),
306
+ "priority": milestone.get("ledger_priority", "P1"),
307
+ "source": f"milestone:{clean_name}",
308
+ "auto_done": milestone.get("auto_done", True),
309
+ })
310
+
311
+ # 2. Auto-create ledger items (with dedup — skip if open item with same title exists)
312
+ if auto_items:
313
+ try:
314
+ from ai.ledger_manager import add_item, update_item, list_items
315
+ # Load existing open titles for dedup
316
+ existing = list_items(project_path=project_path)
317
+ # items can be a list or dict of lists (by ledger type)
318
+ all_items = []
319
+ raw_items = existing.get("items", [])
320
+ if isinstance(raw_items, dict):
321
+ for ledger_items in raw_items.values():
322
+ if isinstance(ledger_items, list):
323
+ all_items.extend(ledger_items)
324
+ elif isinstance(raw_items, list):
325
+ all_items = raw_items
326
+ open_titles = {
327
+ i.get("title", "")
328
+ for i in all_items
329
+ if isinstance(i, dict) and i.get("status") == "open"
330
+ }
331
+ created = []
332
+ for item in auto_items:
333
+ if item["title"] in open_titles:
334
+ logger.debug("Skipping duplicate ledger item: %s", item["title"])
335
+ continue
336
+ entry = add_item(
337
+ title=item["title"],
338
+ type=item["type"],
339
+ priority=item["priority"],
340
+ source=item["source"],
341
+ project_path=project_path,
342
+ )
343
+ item_id = entry.get("added", {}).get("id", "")
344
+ created.append(item_id)
345
+ # Auto-close milestone items
346
+ if item.get("auto_done") and item_id:
347
+ try:
348
+ update_item(item_id, status="done", project_path=project_path)
349
+ except Exception:
350
+ pass
351
+ governed_result["governance"] = {
352
+ "action": "ledger_items_created",
353
+ "items": created,
354
+ "reason": "Governance rule triggered by tool result",
355
+ }
356
+ except Exception as e:
357
+ logger.warning("Governance auto-ledger failed: %s", e)
358
+
359
+ # 3. Add governance-directed next steps
360
+ steps = NEXT_STEPS.get(clean_name, [])
361
+ if steps:
362
+ governed_result["next_steps"] = steps
363
+
364
+ # 4. GOVERNANCE LOOP: always route back to ledger_context
365
+ # This is not a suggestion — it's how the loop works.
366
+ # The AI should call ledger_context after every tool to check what's next.
367
+ if clean_name not in ("ledger_add", "ledger_done", "ledger_list", "ledger_context", "ventures", "version", "help", "diagnose", "activate", "license_status", "models", "scan"):
368
+ if "next_steps" not in governed_result:
369
+ governed_result["next_steps"] = []
370
+ # Don't duplicate
371
+ existing = {s.get("tool") for s in governed_result.get("next_steps", [])}
372
+ if "delimit_ledger_context" not in existing:
373
+ governed_result["next_steps"].insert(0, {
374
+ "tool": "delimit_ledger_context",
375
+ "reason": "GOVERNANCE LOOP: check ledger for next action",
376
+ "premium": False,
377
+ "required": True,
378
+ })
379
+ else:
380
+ # Excluded tools still get the next_steps field (empty) for schema consistency
381
+ if "next_steps" not in governed_result:
382
+ governed_result["next_steps"] = []
383
+
384
+ return governed_result
385
+
386
+
387
+ def _deep_get(d: Dict, key: str) -> Any:
388
+ """Get a value from a dict, supporting nested keys with dots."""
389
+ if "." in key:
390
+ parts = key.split(".", 1)
391
+ sub = d.get(parts[0])
392
+ if isinstance(sub, dict):
393
+ return _deep_get(sub, parts[1])
394
+ return None
395
+
396
+ # Check top-level and common nested locations
397
+ if key in d:
398
+ return d[key]
399
+ # Check inside 'data', 'result', 'overall_coverage'
400
+ for wrapper in ["data", "result", "overall_coverage", "summary"]:
401
+ if isinstance(d.get(wrapper), dict) and key in d[wrapper]:
402
+ return d[wrapper][key]
403
+ return None
@@ -1,133 +1,48 @@
1
1
  """
2
- Delimit license validation.
3
- Free tools work without a key. Premium tools check for a valid key.
4
- Validates against Lemon Squeezy API when online, falls back to local cache.
2
+ Delimit license — thin shim.
3
+ The enforcement logic is in license_core (shipped as compiled binary).
4
+ This shim handles imports and provides fallback error messages.
5
5
  """
6
- import hashlib
7
- import json
8
- import os
9
- import time
10
- from pathlib import Path
11
-
12
- LICENSE_FILE = Path.home() / ".delimit" / "license.json"
13
- LS_VALIDATE_URL = "https://api.lemonsqueezy.com/v1/licenses/validate"
14
-
15
-
16
- def get_license() -> dict:
17
- """Load license from ~/.delimit/license.json"""
18
- if not LICENSE_FILE.exists():
19
- return {"tier": "free", "valid": True}
20
- try:
21
- data = json.loads(LICENSE_FILE.read_text())
22
- if data.get("expires_at") and data["expires_at"] < time.time():
23
- return {"tier": "free", "valid": True, "expired": True}
24
- return data
25
- except Exception:
26
- return {"tier": "free", "valid": True}
27
-
28
-
29
- def is_premium() -> bool:
30
- """Check if user has a premium license."""
31
- lic = get_license()
32
- return lic.get("tier") in ("pro", "enterprise") and lic.get("valid", False)
33
-
34
-
35
- def require_premium(tool_name: str) -> dict | None:
36
- """Check premium access. Returns None if allowed, error dict if not."""
37
- if is_premium():
38
- return None
39
- return {
40
- "error": f"'{tool_name}' requires Delimit Pro. Upgrade at https://delimit.ai/pricing",
41
- "status": "premium_required",
42
- "tool": tool_name,
43
- "current_tier": get_license().get("tier", "free"),
44
- }
45
-
46
-
47
- def activate_license(key: str) -> dict:
48
- """Activate a license key via Lemon Squeezy API.
49
- Falls back to local validation if API is unreachable."""
50
- if not key or len(key) < 10:
51
- return {"error": "Invalid license key format"}
52
-
53
- machine_hash = hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16]
54
-
55
- # Try Lemon Squeezy remote validation
56
- try:
57
- import urllib.request
58
- import urllib.error
59
-
60
- data = json.dumps({
61
- "license_key": key,
62
- "instance_name": machine_hash,
63
- }).encode()
64
-
65
- req = urllib.request.Request(
66
- LS_VALIDATE_URL,
67
- data=data,
68
- headers={
69
- "Content-Type": "application/json",
70
- "Accept": "application/json",
71
- },
72
- method="POST",
73
- )
74
-
75
- with urllib.request.urlopen(req, timeout=10) as resp:
76
- result = json.loads(resp.read())
77
-
78
- if result.get("valid"):
79
- license_data = {
80
- "key": key,
81
- "tier": "pro",
82
- "valid": True,
83
- "activated_at": time.time(),
84
- "machine_hash": machine_hash,
85
- "instance_id": result.get("instance", {}).get("id"),
86
- "license_id": result.get("license_key", {}).get("id"),
87
- "customer_name": result.get("meta", {}).get("customer_name", ""),
88
- "validated_via": "lemon_squeezy",
89
- }
90
- LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
91
- LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
92
- return {"status": "activated", "tier": "pro", "message": "License activated successfully."}
93
- else:
94
- return {
95
- "error": "Invalid license key. Check your key and try again.",
96
- "status": "invalid",
97
- "detail": result.get("error", ""),
98
- }
99
-
100
- except (urllib.error.URLError, OSError):
101
- # API unreachable — accept key locally (offline activation)
102
- license_data = {
103
- "key": key,
104
- "tier": "pro",
105
- "valid": True,
106
- "activated_at": time.time(),
107
- "machine_hash": machine_hash,
108
- "validated_via": "offline",
109
- }
110
- LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
111
- LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
6
+ try:
7
+ from ai.license_core import (
8
+ load_license as get_license,
9
+ check_premium as is_premium,
10
+ gate_tool as require_premium,
11
+ activate as activate_license,
12
+ PRO_TOOLS,
13
+ FREE_TRIAL_LIMITS,
14
+ )
15
+ except ImportError:
16
+ # license_core not available (development mode or missing binary)
17
+ import json
18
+ import time
19
+ from pathlib import Path
20
+
21
+ LICENSE_FILE = Path.home() / ".delimit" / "license.json"
22
+
23
+ PRO_TOOLS = set()
24
+ FREE_TRIAL_LIMITS = {}
25
+
26
+ def get_license() -> dict:
27
+ if not LICENSE_FILE.exists():
28
+ return {"tier": "free", "valid": True}
29
+ try:
30
+ return json.loads(LICENSE_FILE.read_text())
31
+ except Exception:
32
+ return {"tier": "free", "valid": True}
33
+
34
+ def is_premium() -> bool:
35
+ lic = get_license()
36
+ return lic.get("tier") in ("pro", "enterprise") and lic.get("valid", False)
37
+
38
+ def require_premium(tool_name: str) -> dict | None:
39
+ if is_premium():
40
+ return None
112
41
  return {
113
- "status": "activated",
114
- "tier": "pro",
115
- "message": "License activated (offline). Will validate online next time.",
116
- }
117
- except Exception as e:
118
- # Unexpected error — still activate locally
119
- license_data = {
120
- "key": key,
121
- "tier": "pro",
122
- "valid": True,
123
- "activated_at": time.time(),
124
- "machine_hash": machine_hash,
125
- "validated_via": "fallback",
126
- }
127
- LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
128
- LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
129
- return {
130
- "status": "activated",
131
- "tier": "pro",
132
- "message": f"License activated (validation error: {e}). Will retry online later.",
42
+ "error": f"'{tool_name}' requires Delimit Pro. Upgrade at https://delimit.ai/pricing",
43
+ "status": "premium_required",
44
+ "tool": tool_name,
133
45
  }
46
+
47
+ def activate_license(key: str) -> dict:
48
+ return {"error": "License core not available. Reinstall: npx delimit-cli setup"}