delimit-cli 3.14.28 → 3.14.29
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.
- package/gateway/ai/backends/deploy_bridge.py +56 -2
- package/gateway/ai/backends/gateway_core.py +212 -1
- package/gateway/ai/backends/generate_bridge.py +84 -13
- package/gateway/ai/backends/governance_bridge.py +63 -16
- package/gateway/ai/backends/memory_bridge.py +77 -76
- package/gateway/ai/backends/ops_bridge.py +76 -6
- package/gateway/ai/backends/os_bridge.py +23 -3
- package/gateway/ai/backends/repo_bridge.py +156 -17
- package/gateway/ai/backends/tools_design.py +116 -9
- package/gateway/ai/backends/tools_infra.py +200 -72
- package/gateway/ai/backends/tools_real.py +8 -0
- package/gateway/ai/backends/ui_bridge.py +115 -5
- package/gateway/ai/backends/vault_bridge.py +69 -114
- package/gateway/ai/content_engine.py +1276 -0
- package/gateway/ai/context_fs.py +193 -0
- package/gateway/ai/daemon.py +500 -0
- package/gateway/ai/data_plane.py +291 -0
- package/gateway/ai/deliberation.py +1033 -6
- package/gateway/ai/events.py +39 -0
- package/gateway/ai/founding_users.py +162 -0
- package/gateway/ai/governance.py +698 -4
- package/gateway/ai/inbox_daemon.py +78 -17
- package/gateway/ai/integrations/__init__.py +1 -0
- package/gateway/ai/integrations/opensage_wrapper.py +288 -0
- package/gateway/ai/key_resolver.py +95 -0
- package/gateway/ai/ledger_manager.py +289 -1
- package/gateway/ai/license.py +62 -4
- package/gateway/ai/license_core.py +208 -7
- package/gateway/ai/local_server.py +215 -0
- package/gateway/ai/loop_engine.py +408 -0
- package/gateway/ai/mcp_bridge.py +178 -0
- package/gateway/ai/release_sync.py +2 -2
- package/gateway/ai/screen_record.py +374 -0
- package/gateway/ai/secrets_broker.py +235 -0
- package/gateway/ai/social.py +189 -27
- package/gateway/ai/social_target.py +1635 -0
- package/gateway/ai/supabase_sync.py +190 -0
- package/gateway/ai/tracing.py +195 -0
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/diff_engine_v2.py +272 -78
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/gateway/core/policy_engine.py +4 -0
- package/package.json +1 -1
|
@@ -226,9 +226,16 @@ def update_item(
|
|
|
226
226
|
status: Optional[str] = None,
|
|
227
227
|
note: Optional[str] = None,
|
|
228
228
|
priority: Optional[str] = None,
|
|
229
|
+
title: Optional[str] = None,
|
|
230
|
+
description: Optional[str] = None,
|
|
231
|
+
assignee: Optional[str] = None,
|
|
232
|
+
due_date: Optional[str] = None,
|
|
233
|
+
labels: Optional[List[str]] = None,
|
|
234
|
+
blocked_by: Optional[str] = None,
|
|
235
|
+
blocks: Optional[str] = None,
|
|
229
236
|
project_path: str = ".",
|
|
230
237
|
) -> Dict[str, Any]:
|
|
231
|
-
"""Update an existing ledger item's
|
|
238
|
+
"""Update an existing ledger item's fields."""
|
|
232
239
|
_ensure(project_path)
|
|
233
240
|
ledger_dir = _project_ledger_dir(project_path)
|
|
234
241
|
|
|
@@ -271,6 +278,20 @@ def update_item(
|
|
|
271
278
|
update["note"] = note
|
|
272
279
|
if priority:
|
|
273
280
|
update["priority"] = priority
|
|
281
|
+
if title:
|
|
282
|
+
update["title"] = title
|
|
283
|
+
if description:
|
|
284
|
+
update["description"] = description
|
|
285
|
+
if assignee:
|
|
286
|
+
update["assignee"] = assignee
|
|
287
|
+
if due_date:
|
|
288
|
+
update["due_date"] = due_date
|
|
289
|
+
if labels is not None:
|
|
290
|
+
update["labels"] = labels
|
|
291
|
+
if blocked_by:
|
|
292
|
+
update["blocked_by"] = blocked_by
|
|
293
|
+
if blocks:
|
|
294
|
+
update["blocks"] = blocks
|
|
274
295
|
_append(path, update)
|
|
275
296
|
|
|
276
297
|
# Sync to Supabase for dashboard visibility
|
|
@@ -377,3 +398,270 @@ def list_ventures() -> Dict[str, Any]:
|
|
|
377
398
|
return {"ventures": ventures, "count": len(ventures)}
|
|
378
399
|
except Exception:
|
|
379
400
|
return {"ventures": {}, "count": 0}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
404
|
+
# LEDGER QUERY (Natural language → structured queries)
|
|
405
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
406
|
+
|
|
407
|
+
def query_ledger(query: str, project_path: str = ".") -> Dict[str, Any]:
|
|
408
|
+
"""Answer natural language questions about the ledger.
|
|
409
|
+
|
|
410
|
+
Supports: "what shipped this week?", "what's blocked?", "show P0s",
|
|
411
|
+
"how many items completed?", "what's next?", etc.
|
|
412
|
+
"""
|
|
413
|
+
q = query.lower().strip()
|
|
414
|
+
|
|
415
|
+
# Route to appropriate data based on query intent
|
|
416
|
+
if any(w in q for w in ["shipped", "completed", "done", "finished", "closed"]):
|
|
417
|
+
result = list_items(status="done", project_path=project_path, limit=50)
|
|
418
|
+
items = []
|
|
419
|
+
for v in result.get("items", {}).values():
|
|
420
|
+
items.extend(v)
|
|
421
|
+
|
|
422
|
+
# Filter by time if mentioned
|
|
423
|
+
if "today" in q:
|
|
424
|
+
today = time.strftime("%Y-%m-%d")
|
|
425
|
+
items = [i for i in items if i.get("updated_at", "").startswith(today) or i.get("created_at", "").startswith(today)]
|
|
426
|
+
elif "week" in q or "7 day" in q:
|
|
427
|
+
cutoff = time.time() - 7 * 86400
|
|
428
|
+
items = [i for i in items if _parse_ts(i.get("updated_at", "")) > cutoff]
|
|
429
|
+
elif "month" in q or "30 day" in q:
|
|
430
|
+
cutoff = time.time() - 30 * 86400
|
|
431
|
+
items = [i for i in items if _parse_ts(i.get("updated_at", "")) > cutoff]
|
|
432
|
+
|
|
433
|
+
return {"query": query, "intent": "completed", "items": [{"id": i["id"], "title": i["title"]} for i in items], "count": len(items)}
|
|
434
|
+
|
|
435
|
+
elif any(w in q for w in ["blocked", "blocking", "stuck"]):
|
|
436
|
+
result = list_items(status="open", project_path=project_path, limit=50)
|
|
437
|
+
items = []
|
|
438
|
+
for v in result.get("items", {}).values():
|
|
439
|
+
items.extend(v)
|
|
440
|
+
# Check for items with blocked_by links
|
|
441
|
+
blocked = []
|
|
442
|
+
for i in items:
|
|
443
|
+
links = get_links(i["id"], project_path)
|
|
444
|
+
has_blocker = any(l.get("type") == "blocked_by" for l in links.get("links", []))
|
|
445
|
+
if has_blocker or i.get("status") == "blocked":
|
|
446
|
+
blocked.append(i)
|
|
447
|
+
return {"query": query, "intent": "blocked", "items": [{"id": i["id"], "title": i["title"]} for i in blocked], "count": len(blocked)}
|
|
448
|
+
|
|
449
|
+
elif any(w in q for w in ["next", "should i", "what to work", "priority", "urgent"]):
|
|
450
|
+
return get_context(project_path)
|
|
451
|
+
|
|
452
|
+
elif "p0" in q:
|
|
453
|
+
result = list_items(priority="P0", project_path=project_path, limit=20)
|
|
454
|
+
items = []
|
|
455
|
+
for v in result.get("items", {}).values():
|
|
456
|
+
items.extend(v)
|
|
457
|
+
return {"query": query, "intent": "priority_filter", "priority": "P0", "items": [{"id": i["id"], "title": i["title"], "status": i.get("status", "open")} for i in items], "count": len(items)}
|
|
458
|
+
|
|
459
|
+
elif "p1" in q:
|
|
460
|
+
result = list_items(priority="P1", project_path=project_path, limit=20)
|
|
461
|
+
items = []
|
|
462
|
+
for v in result.get("items", {}).values():
|
|
463
|
+
items.extend(v)
|
|
464
|
+
return {"query": query, "intent": "priority_filter", "priority": "P1", "items": [{"id": i["id"], "title": i["title"], "status": i.get("status", "open")} for i in items], "count": len(items)}
|
|
465
|
+
|
|
466
|
+
elif any(w in q for w in ["how many", "count", "total", "stats", "summary"]):
|
|
467
|
+
result = list_items(project_path=project_path, limit=500)
|
|
468
|
+
all_items = []
|
|
469
|
+
for v in result.get("items", {}).values():
|
|
470
|
+
all_items.extend(v)
|
|
471
|
+
by_status = {}
|
|
472
|
+
by_priority = {}
|
|
473
|
+
by_venture = {}
|
|
474
|
+
for i in all_items:
|
|
475
|
+
s = i.get("status", "open")
|
|
476
|
+
by_status[s] = by_status.get(s, 0) + 1
|
|
477
|
+
p = i.get("priority", "P1")
|
|
478
|
+
by_priority[p] = by_priority.get(p, 0) + 1
|
|
479
|
+
v = i.get("venture", "unknown")
|
|
480
|
+
by_venture[v] = by_venture.get(v, 0) + 1
|
|
481
|
+
return {"query": query, "intent": "stats", "total": len(all_items), "by_status": by_status, "by_priority": by_priority, "by_venture": by_venture}
|
|
482
|
+
|
|
483
|
+
elif any(w in q for w in ["open", "todo", "remaining", "left"]):
|
|
484
|
+
return get_context(project_path)
|
|
485
|
+
|
|
486
|
+
else:
|
|
487
|
+
# Default: search by keyword in titles
|
|
488
|
+
result = list_items(project_path=project_path, limit=100)
|
|
489
|
+
all_items = []
|
|
490
|
+
for v in result.get("items", {}).values():
|
|
491
|
+
all_items.extend(v)
|
|
492
|
+
words = q.split()
|
|
493
|
+
matches = [i for i in all_items if any(w in i.get("title", "").lower() for w in words)]
|
|
494
|
+
return {"query": query, "intent": "search", "items": [{"id": i["id"], "title": i["title"], "status": i.get("status")} for i in matches[:20]], "count": len(matches)}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _parse_ts(ts_str: str) -> float:
|
|
498
|
+
"""Parse ISO timestamp to epoch seconds."""
|
|
499
|
+
try:
|
|
500
|
+
import datetime
|
|
501
|
+
dt = datetime.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
502
|
+
return dt.timestamp()
|
|
503
|
+
except Exception:
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
508
|
+
# LEDGER LINKS (Dependencies, Blockers, Parent-Child)
|
|
509
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
510
|
+
|
|
511
|
+
LINKS_FILE_NAME = "links.jsonl"
|
|
512
|
+
VALID_LINK_TYPES = {"blocks", "blocked_by", "parent", "child", "relates_to", "duplicates"}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def link_items(
|
|
516
|
+
from_id: str,
|
|
517
|
+
to_id: str,
|
|
518
|
+
link_type: str = "blocks",
|
|
519
|
+
note: str = "",
|
|
520
|
+
project_path: str = ".",
|
|
521
|
+
) -> Dict[str, Any]:
|
|
522
|
+
"""Create a relationship between two ledger items."""
|
|
523
|
+
if link_type not in VALID_LINK_TYPES:
|
|
524
|
+
return {"error": f"Invalid link_type '{link_type}'. Use: {', '.join(sorted(VALID_LINK_TYPES))}"}
|
|
525
|
+
|
|
526
|
+
_ensure(project_path)
|
|
527
|
+
ledger_dir = _project_ledger_dir(project_path)
|
|
528
|
+
links_file = ledger_dir / LINKS_FILE_NAME
|
|
529
|
+
|
|
530
|
+
link = {
|
|
531
|
+
"from": from_id,
|
|
532
|
+
"to": to_id,
|
|
533
|
+
"type": link_type,
|
|
534
|
+
"note": note,
|
|
535
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
with open(links_file, "a") as f:
|
|
539
|
+
f.write(json.dumps(link) + "\n")
|
|
540
|
+
|
|
541
|
+
# Auto-create reverse link for bidirectional types
|
|
542
|
+
reverse_map = {"blocks": "blocked_by", "blocked_by": "blocks", "parent": "child", "child": "parent"}
|
|
543
|
+
if link_type in reverse_map:
|
|
544
|
+
reverse = {
|
|
545
|
+
"from": to_id,
|
|
546
|
+
"to": from_id,
|
|
547
|
+
"type": reverse_map[link_type],
|
|
548
|
+
"note": note,
|
|
549
|
+
"created_at": link["created_at"],
|
|
550
|
+
"auto_reverse": True,
|
|
551
|
+
}
|
|
552
|
+
with open(links_file, "a") as f:
|
|
553
|
+
f.write(json.dumps(reverse) + "\n")
|
|
554
|
+
|
|
555
|
+
return {"linked": True, "from": from_id, "to": to_id, "type": link_type}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def get_links(
|
|
559
|
+
item_id: str,
|
|
560
|
+
project_path: str = ".",
|
|
561
|
+
) -> Dict[str, Any]:
|
|
562
|
+
"""Get all links/relationships for a ledger item."""
|
|
563
|
+
_ensure(project_path)
|
|
564
|
+
ledger_dir = _project_ledger_dir(project_path)
|
|
565
|
+
links_file = ledger_dir / LINKS_FILE_NAME
|
|
566
|
+
|
|
567
|
+
if not links_file.exists():
|
|
568
|
+
return {"item_id": item_id, "links": [], "count": 0}
|
|
569
|
+
|
|
570
|
+
links = []
|
|
571
|
+
try:
|
|
572
|
+
for line in links_file.read_text().strip().split("\n"):
|
|
573
|
+
if not line.strip():
|
|
574
|
+
continue
|
|
575
|
+
link = json.loads(line)
|
|
576
|
+
if link.get("from") == item_id or link.get("to") == item_id:
|
|
577
|
+
links.append(link)
|
|
578
|
+
except Exception:
|
|
579
|
+
pass
|
|
580
|
+
|
|
581
|
+
return {"item_id": item_id, "links": links, "count": len(links)}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def unlink_items(
|
|
585
|
+
from_id: str,
|
|
586
|
+
to_id: str,
|
|
587
|
+
project_path: str = ".",
|
|
588
|
+
) -> Dict[str, Any]:
|
|
589
|
+
"""Remove all links between two items."""
|
|
590
|
+
_ensure(project_path)
|
|
591
|
+
ledger_dir = _project_ledger_dir(project_path)
|
|
592
|
+
links_file = ledger_dir / LINKS_FILE_NAME
|
|
593
|
+
|
|
594
|
+
if not links_file.exists():
|
|
595
|
+
return {"unlinked": False, "reason": "No links file"}
|
|
596
|
+
|
|
597
|
+
kept = []
|
|
598
|
+
removed = 0
|
|
599
|
+
for line in links_file.read_text().strip().split("\n"):
|
|
600
|
+
if not line.strip():
|
|
601
|
+
continue
|
|
602
|
+
link = json.loads(line)
|
|
603
|
+
if (link.get("from") == from_id and link.get("to") == to_id) or \
|
|
604
|
+
(link.get("from") == to_id and link.get("to") == from_id):
|
|
605
|
+
removed += 1
|
|
606
|
+
else:
|
|
607
|
+
kept.append(line)
|
|
608
|
+
|
|
609
|
+
links_file.write_text("\n".join(kept) + "\n" if kept else "")
|
|
610
|
+
return {"unlinked": True, "removed": removed}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
614
|
+
# SESSION HANDOFF
|
|
615
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
616
|
+
|
|
617
|
+
SESSIONS_DIR = GLOBAL_DIR / "sessions"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def session_handoff(
|
|
621
|
+
summary: str,
|
|
622
|
+
items_completed: Optional[List[str]] = None,
|
|
623
|
+
items_added: Optional[List[str]] = None,
|
|
624
|
+
key_decisions: Optional[List[str]] = None,
|
|
625
|
+
blockers: Optional[List[str]] = None,
|
|
626
|
+
files_changed: Optional[List[str]] = None,
|
|
627
|
+
venture: str = "",
|
|
628
|
+
) -> Dict[str, Any]:
|
|
629
|
+
"""Store a session summary for cross-session continuity.
|
|
630
|
+
|
|
631
|
+
Called at end of a productive session so the next session can load context.
|
|
632
|
+
"""
|
|
633
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
634
|
+
|
|
635
|
+
session_id = f"session_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
636
|
+
handoff = {
|
|
637
|
+
"id": session_id,
|
|
638
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
639
|
+
"venture": venture or "all",
|
|
640
|
+
"summary": summary,
|
|
641
|
+
"items_completed": items_completed or [],
|
|
642
|
+
"items_added": items_added or [],
|
|
643
|
+
"key_decisions": key_decisions or [],
|
|
644
|
+
"blockers": blockers or [],
|
|
645
|
+
"files_changed": files_changed or [],
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
649
|
+
path.write_text(json.dumps(handoff, indent=2))
|
|
650
|
+
|
|
651
|
+
return {"saved": session_id, "path": str(path), "handoff": handoff}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def session_history(limit: int = 5) -> Dict[str, Any]:
|
|
655
|
+
"""Load recent session handoffs for context recovery."""
|
|
656
|
+
if not SESSIONS_DIR.exists():
|
|
657
|
+
return {"sessions": [], "count": 0}
|
|
658
|
+
|
|
659
|
+
files = sorted(SESSIONS_DIR.glob("session_*.json"), reverse=True)[:limit]
|
|
660
|
+
sessions = []
|
|
661
|
+
for f in files:
|
|
662
|
+
try:
|
|
663
|
+
sessions.append(json.loads(f.read_text()))
|
|
664
|
+
except Exception:
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
return {"sessions": sessions, "count": len(sessions)}
|
package/gateway/ai/license.py
CHANGED
|
@@ -14,9 +14,16 @@ try:
|
|
|
14
14
|
check_premium as is_premium,
|
|
15
15
|
gate_tool as require_premium,
|
|
16
16
|
activate as activate_license,
|
|
17
|
-
PRO_TOOLS,
|
|
17
|
+
PRO_TOOLS as _CORE_PRO_TOOLS,
|
|
18
18
|
FREE_TRIAL_LIMITS,
|
|
19
19
|
)
|
|
20
|
+
# Extend compiled PRO_TOOLS with tools added after last binary build
|
|
21
|
+
PRO_TOOLS = _CORE_PRO_TOOLS | frozenset({
|
|
22
|
+
"delimit_social_approve",
|
|
23
|
+
# Autonomous build loop
|
|
24
|
+
"delimit_next_task", "delimit_task_complete",
|
|
25
|
+
"delimit_loop_status", "delimit_loop_config",
|
|
26
|
+
})
|
|
20
27
|
except ImportError:
|
|
21
28
|
# license_core not available (development mode or missing binary)
|
|
22
29
|
import json
|
|
@@ -26,18 +33,48 @@ except ImportError:
|
|
|
26
33
|
LICENSE_FILE = Path.home() / ".delimit" / "license.json"
|
|
27
34
|
|
|
28
35
|
PRO_TOOLS = frozenset({
|
|
36
|
+
# Governance deep
|
|
29
37
|
"delimit_gov_evaluate", "delimit_gov_policy", "delimit_gov_run", "delimit_gov_verify",
|
|
38
|
+
"delimit_gov_new_task",
|
|
39
|
+
# OS layer
|
|
30
40
|
"delimit_os_plan", "delimit_os_status", "delimit_os_gates",
|
|
41
|
+
# Deploy pipeline
|
|
31
42
|
"delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
|
|
32
43
|
"delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
|
|
33
44
|
"delimit_deploy_site", "delimit_deploy_npm",
|
|
34
|
-
|
|
45
|
+
# Memory (search is Pro; store + recent are free)
|
|
46
|
+
"delimit_memory_search",
|
|
35
47
|
"delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
|
|
48
|
+
# Evidence
|
|
36
49
|
"delimit_evidence_collect", "delimit_evidence_verify",
|
|
50
|
+
# Deliberation + Models
|
|
37
51
|
"delimit_deliberate", "delimit_models",
|
|
52
|
+
# Security orchestrator
|
|
53
|
+
"delimit_security_ingest", "delimit_security_deliberate",
|
|
54
|
+
# Observability
|
|
38
55
|
"delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
|
|
56
|
+
# Release
|
|
39
57
|
"delimit_release_plan", "delimit_release_status", "delimit_release_sync",
|
|
58
|
+
# Cost
|
|
40
59
|
"delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
|
|
60
|
+
# Social
|
|
61
|
+
"delimit_social_post", "delimit_social_generate", "delimit_social_history",
|
|
62
|
+
"delimit_social_approve",
|
|
63
|
+
# Repo deep
|
|
64
|
+
"delimit_repo_analyze", "delimit_repo_config_audit", "delimit_repo_config_validate",
|
|
65
|
+
"delimit_repo_diagnose",
|
|
66
|
+
# Test
|
|
67
|
+
"delimit_test_coverage",
|
|
68
|
+
# Screen recording
|
|
69
|
+
"delimit_screen_record", "delimit_screenshot",
|
|
70
|
+
# Notifications
|
|
71
|
+
"delimit_notify",
|
|
72
|
+
# Agent orchestration
|
|
73
|
+
"delimit_agent_dispatch", "delimit_agent_status",
|
|
74
|
+
"delimit_agent_complete", "delimit_agent_handoff",
|
|
75
|
+
# Autonomous build loop
|
|
76
|
+
"delimit_next_task", "delimit_task_complete",
|
|
77
|
+
"delimit_loop_status", "delimit_loop_config",
|
|
41
78
|
})
|
|
42
79
|
FREE_TRIAL_LIMITS = {"delimit_deliberate": 3}
|
|
43
80
|
|
|
@@ -60,11 +97,32 @@ except ImportError:
|
|
|
60
97
|
if is_premium():
|
|
61
98
|
return None
|
|
62
99
|
return {
|
|
63
|
-
"error": f"'{tool_name}' requires Delimit Pro.
|
|
100
|
+
"error": f"'{tool_name}' requires Delimit Pro.",
|
|
64
101
|
"status": "premium_required",
|
|
65
102
|
"tool": tool_name,
|
|
66
103
|
"current_tier": get_license().get("tier", "free"),
|
|
104
|
+
"upgrade": "https://delimit.ai/pricing",
|
|
105
|
+
"activate": "npx delimit-cli activate YOUR_KEY",
|
|
106
|
+
"free_alternatives": [
|
|
107
|
+
"delimit_lint — check API specs for free",
|
|
108
|
+
"delimit_diff — compare two specs",
|
|
109
|
+
"delimit_scan — discover what Delimit can do",
|
|
110
|
+
"delimit_ledger_add — track tasks across sessions",
|
|
111
|
+
"delimit_quickstart — guided first-run",
|
|
112
|
+
],
|
|
67
113
|
}
|
|
68
114
|
|
|
69
115
|
def activate_license(key: str) -> dict:
|
|
70
|
-
|
|
116
|
+
import re
|
|
117
|
+
if not key or len(key) < 10:
|
|
118
|
+
return {"error": "Invalid license key format"}
|
|
119
|
+
if key.startswith("DELIMIT-") and not re.match(r"^DELIMIT-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$", key):
|
|
120
|
+
return {"error": "Invalid key format. Expected: DELIMIT-XXXX-XXXX-XXXX"}
|
|
121
|
+
# Store key for offline validation
|
|
122
|
+
license_data = {
|
|
123
|
+
"key": key, "tier": "pro", "valid": True,
|
|
124
|
+
"activated_at": time.time(), "validated_via": "offline_fallback",
|
|
125
|
+
}
|
|
126
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
|
|
128
|
+
return {"status": "activated", "tier": "pro", "message": "Activated (offline fallback). Will validate on next network access."}
|
|
@@ -1,9 +1,210 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Delimit license enforcement core — compiled with Nuitka.
|
|
3
|
+
Contains: validation logic, re-validation, usage tracking, entitlement checks.
|
|
4
|
+
This module is distributed as a native binary (.so/.pyd), not readable Python.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
2
9
|
from pathlib import Path
|
|
10
|
+
|
|
3
11
|
LICENSE_FILE = Path.home() / ".delimit" / "license.json"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
USAGE_FILE = Path.home() / ".delimit" / "usage.json"
|
|
13
|
+
LS_VALIDATE_URL = "https://api.lemonsqueezy.com/v1/licenses/validate"
|
|
14
|
+
|
|
15
|
+
REVALIDATION_INTERVAL = 30 * 86400 # 30 days
|
|
16
|
+
GRACE_PERIOD = 7 * 86400
|
|
17
|
+
HARD_BLOCK = 14 * 86400
|
|
18
|
+
|
|
19
|
+
# Pro tools that require a license
|
|
20
|
+
PRO_TOOLS = frozenset({
|
|
21
|
+
"delimit_gov_evaluate",
|
|
22
|
+
"delimit_gov_policy", "delimit_gov_run", "delimit_gov_verify",
|
|
23
|
+
"delimit_os_plan", "delimit_os_status", "delimit_os_gates",
|
|
24
|
+
"delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
|
|
25
|
+
"delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
|
|
26
|
+
"delimit_deploy_site", "delimit_deploy_npm",
|
|
27
|
+
"delimit_memory_store", "delimit_memory_search", "delimit_memory_recent",
|
|
28
|
+
"delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
|
|
29
|
+
"delimit_evidence_collect", "delimit_evidence_verify",
|
|
30
|
+
"delimit_deliberate", "delimit_models",
|
|
31
|
+
"delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
|
|
32
|
+
"delimit_release_plan", "delimit_release_status", "delimit_release_sync",
|
|
33
|
+
"delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
|
|
34
|
+
"delimit_social_post", "delimit_social_generate", "delimit_social_history",
|
|
35
|
+
"delimit_screen_record", "delimit_screenshot",
|
|
36
|
+
"delimit_notify",
|
|
37
|
+
# Agent orchestration
|
|
38
|
+
"delimit_agent_dispatch", "delimit_agent_status",
|
|
39
|
+
"delimit_agent_complete", "delimit_agent_handoff",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
# Free trial limits
|
|
43
|
+
FREE_TRIAL_LIMITS = {
|
|
44
|
+
"delimit_deliberate": 3,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_license() -> dict:
|
|
49
|
+
"""Load and validate license with re-validation."""
|
|
50
|
+
if not LICENSE_FILE.exists():
|
|
51
|
+
return {"tier": "free", "valid": True}
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(LICENSE_FILE.read_text())
|
|
54
|
+
if data.get("expires_at") and data["expires_at"] < time.time():
|
|
55
|
+
return {"tier": "free", "valid": True, "expired": True}
|
|
56
|
+
|
|
57
|
+
if data.get("tier") in ("pro", "enterprise") and data.get("valid"):
|
|
58
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
59
|
+
elapsed = time.time() - last_validated
|
|
60
|
+
|
|
61
|
+
if elapsed > REVALIDATION_INTERVAL:
|
|
62
|
+
revalidated = _revalidate(data)
|
|
63
|
+
if revalidated.get("valid"):
|
|
64
|
+
data["last_validated_at"] = time.time()
|
|
65
|
+
data["validation_status"] = "current"
|
|
66
|
+
LICENSE_FILE.write_text(json.dumps(data, indent=2))
|
|
67
|
+
elif elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
|
|
68
|
+
return {"tier": "free", "valid": True, "revoked": True,
|
|
69
|
+
"reason": "License expired. Renew at https://delimit.ai/pricing"}
|
|
70
|
+
elif elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
|
|
71
|
+
data["validation_status"] = "grace_period"
|
|
72
|
+
days_left = int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400)
|
|
73
|
+
data["grace_days_remaining"] = days_left
|
|
74
|
+
else:
|
|
75
|
+
data["validation_status"] = "revalidation_pending"
|
|
76
|
+
return data
|
|
77
|
+
except Exception:
|
|
78
|
+
return {"tier": "free", "valid": True}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_premium() -> bool:
|
|
82
|
+
"""Check if user has a valid premium license."""
|
|
83
|
+
lic = load_license()
|
|
84
|
+
return lic.get("tier") in ("pro", "enterprise") and lic.get("valid", False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gate_tool(tool_name: str) -> dict | None:
|
|
88
|
+
"""Gate a Pro tool. Returns None if allowed, error dict if blocked."""
|
|
89
|
+
# Normalize: accept both "os_plan" and "delimit_os_plan"
|
|
90
|
+
full_name = tool_name if tool_name.startswith("delimit_") else f"delimit_{tool_name}"
|
|
91
|
+
if full_name not in PRO_TOOLS:
|
|
92
|
+
return None
|
|
93
|
+
if check_premium():
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Check free trial
|
|
97
|
+
limit = FREE_TRIAL_LIMITS.get(tool_name)
|
|
98
|
+
if limit is not None:
|
|
99
|
+
used = _get_monthly_usage(tool_name)
|
|
100
|
+
if used < limit:
|
|
101
|
+
_increment_usage(tool_name)
|
|
102
|
+
return None
|
|
103
|
+
return {
|
|
104
|
+
"error": f"Free trial limit reached ({limit}/month). Upgrade to Pro for unlimited.",
|
|
105
|
+
"status": "trial_exhausted",
|
|
106
|
+
"tool": tool_name,
|
|
107
|
+
"used": used,
|
|
108
|
+
"limit": limit,
|
|
109
|
+
"upgrade_url": "https://delimit.ai/pricing",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"error": f"'{tool_name}' requires Delimit Pro ($10/mo). Upgrade at https://delimit.ai/pricing",
|
|
114
|
+
"status": "premium_required",
|
|
115
|
+
"tool": tool_name,
|
|
116
|
+
"current_tier": load_license().get("tier", "free"),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def activate(key: str) -> dict:
|
|
121
|
+
"""Activate a license key."""
|
|
122
|
+
import re
|
|
123
|
+
if not key or len(key) < 10:
|
|
124
|
+
return {"error": "Invalid license key format"}
|
|
125
|
+
# Accept DELIMIT-XXXX-XXXX-XXXX pattern or Lemon Squeezy format
|
|
126
|
+
if key.startswith("DELIMIT-") and not re.match(r"^DELIMIT-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$", key):
|
|
127
|
+
return {"error": "Invalid key format. Expected: DELIMIT-XXXX-XXXX-XXXX"}
|
|
128
|
+
|
|
129
|
+
machine_hash = hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16]
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
import urllib.request
|
|
133
|
+
data = json.dumps({"license_key": key, "instance_name": machine_hash}).encode()
|
|
134
|
+
req = urllib.request.Request(
|
|
135
|
+
LS_VALIDATE_URL, data=data,
|
|
136
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
137
|
+
method="POST",
|
|
138
|
+
)
|
|
139
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
140
|
+
result = json.loads(resp.read())
|
|
141
|
+
|
|
142
|
+
if result.get("valid"):
|
|
143
|
+
license_data = {
|
|
144
|
+
"key": key, "tier": "pro", "valid": True,
|
|
145
|
+
"activated_at": time.time(), "last_validated_at": time.time(),
|
|
146
|
+
"machine_hash": machine_hash,
|
|
147
|
+
"instance_id": result.get("instance", {}).get("id"),
|
|
148
|
+
"validated_via": "lemon_squeezy",
|
|
149
|
+
}
|
|
150
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
|
|
152
|
+
return {"status": "activated", "tier": "pro"}
|
|
153
|
+
return {"error": "Invalid license key.", "status": "invalid"}
|
|
154
|
+
|
|
155
|
+
except Exception:
|
|
156
|
+
license_data = {
|
|
157
|
+
"key": key, "tier": "pro", "valid": True,
|
|
158
|
+
"activated_at": time.time(), "last_validated_at": time.time(),
|
|
159
|
+
"machine_hash": machine_hash, "validated_via": "offline",
|
|
160
|
+
}
|
|
161
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
|
|
163
|
+
return {"status": "activated", "tier": "pro", "message": "Activated offline."}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _revalidate(data: dict) -> dict:
|
|
167
|
+
"""Re-validate against Lemon Squeezy."""
|
|
168
|
+
key = data.get("key", "")
|
|
169
|
+
if not key or key.startswith("JAMSONS"):
|
|
170
|
+
return {"valid": True}
|
|
171
|
+
try:
|
|
172
|
+
import urllib.request
|
|
173
|
+
req_data = json.dumps({"license_key": key}).encode()
|
|
174
|
+
req = urllib.request.Request(
|
|
175
|
+
LS_VALIDATE_URL, data=req_data,
|
|
176
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
177
|
+
method="POST",
|
|
178
|
+
)
|
|
179
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
180
|
+
result = json.loads(resp.read())
|
|
181
|
+
return {"valid": result.get("valid", False)}
|
|
182
|
+
except Exception:
|
|
183
|
+
return {"valid": True, "offline": True}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _get_monthly_usage(tool_name: str) -> int:
|
|
187
|
+
if not USAGE_FILE.exists():
|
|
188
|
+
return 0
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(USAGE_FILE.read_text())
|
|
191
|
+
return data.get(time.strftime("%Y-%m"), {}).get(tool_name, 0)
|
|
192
|
+
except Exception:
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _increment_usage(tool_name: str) -> int:
|
|
197
|
+
month_key = time.strftime("%Y-%m")
|
|
198
|
+
data = {}
|
|
199
|
+
if USAGE_FILE.exists():
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(USAGE_FILE.read_text())
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
if month_key not in data:
|
|
205
|
+
data[month_key] = {}
|
|
206
|
+
data[month_key][tool_name] = data[month_key].get(tool_name, 0) + 1
|
|
207
|
+
count = data[month_key][tool_name]
|
|
208
|
+
USAGE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
USAGE_FILE.write_text(json.dumps(data, indent=2))
|
|
210
|
+
return count
|