delimit-cli 4.1.44 → 4.1.48

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.
@@ -94,6 +94,40 @@ def _register_venture(info: Dict[str, str]):
94
94
  CENTRAL_LEDGER_DIR = Path.home() / ".delimit" / "ledger"
95
95
 
96
96
 
97
+ def _detect_model() -> str:
98
+ """Auto-detect which AI model is running this session.
99
+
100
+ Checks environment variables set by various AI coding assistants:
101
+ - CLAUDE_MODEL / CLAUDE_CODE_MODEL: Claude Code
102
+ - CODEX_MODEL: OpenAI Codex CLI
103
+ - GEMINI_MODEL: Gemini CLI
104
+ - MCP_CLIENT_NAME: Generic MCP client identifier
105
+ Falls back to "unknown" if none are set.
106
+ """
107
+ # Claude Code
108
+ for var in ("CLAUDE_MODEL", "CLAUDE_CODE_MODEL"):
109
+ val = os.environ.get(var)
110
+ if val:
111
+ return val
112
+
113
+ # OpenAI Codex
114
+ val = os.environ.get("CODEX_MODEL")
115
+ if val:
116
+ return val
117
+
118
+ # Gemini
119
+ val = os.environ.get("GEMINI_MODEL")
120
+ if val:
121
+ return val
122
+
123
+ # Generic MCP client
124
+ val = os.environ.get("MCP_CLIENT_NAME")
125
+ if val:
126
+ return val
127
+
128
+ return "unknown"
129
+
130
+
97
131
  def _project_ledger_dir(project_path: str = ".") -> Path:
98
132
  """Get the ledger directory — ALWAYS uses central ~/.delimit/ledger/.
99
133
 
@@ -161,6 +195,7 @@ def add_item(
161
195
  context: str = "",
162
196
  tools_needed: Optional[List[str]] = None,
163
197
  estimated_complexity: str = "",
198
+ worked_by: str = "",
164
199
  ) -> Dict[str, Any]:
165
200
  """Add a new item to the project's strategy or operational ledger.
166
201
 
@@ -191,6 +226,7 @@ def add_item(
191
226
  "venture": venture["name"],
192
227
  "status": "open",
193
228
  "tags": tags or [],
229
+ "worked_by": worked_by or _detect_model(),
194
230
  }
195
231
  # LED-189: Optional acceptance criteria
196
232
  if acceptance_criteria:
@@ -244,6 +280,7 @@ def update_item(
244
280
  blocked_by: Optional[str] = None,
245
281
  blocks: Optional[str] = None,
246
282
  project_path: str = ".",
283
+ worked_by: str = "",
247
284
  ) -> Dict[str, Any]:
248
285
  """Update an existing ledger item's fields."""
249
286
  _ensure(project_path)
@@ -281,6 +318,7 @@ def update_item(
281
318
  "id": item_id,
282
319
  "type": "update",
283
320
  "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
321
+ "worked_by": worked_by or _detect_model(),
284
322
  }
285
323
  if status:
286
324
  update["status"] = status
@@ -348,6 +386,8 @@ def list_items(
348
386
  state[item_id]["last_note"] = item["note"]
349
387
  if "priority" in item:
350
388
  state[item_id]["priority"] = item["priority"]
389
+ if "worked_by" in item:
390
+ state[item_id]["last_worked_by"] = item["worked_by"]
351
391
  state[item_id]["updated_at"] = item.get("updated_at")
352
392
  else:
353
393
  state[item_id] = {**item}
@@ -14,6 +14,9 @@ try:
14
14
  check_premium as is_premium,
15
15
  gate_tool as require_premium,
16
16
  activate as activate_license,
17
+ needs_revalidation,
18
+ revalidate_license,
19
+ is_license_valid,
17
20
  PRO_TOOLS as _CORE_PRO_TOOLS,
18
21
  FREE_TRIAL_LIMITS,
19
22
  )
@@ -78,17 +81,114 @@ except ImportError:
78
81
  })
79
82
  FREE_TRIAL_LIMITS = {"delimit_deliberate": 3}
80
83
 
84
+ REVALIDATION_INTERVAL = 30 * 86400 # 30 days
85
+ GRACE_PERIOD = 7 * 86400
86
+ HARD_BLOCK = 14 * 86400
87
+
81
88
  def get_license() -> dict:
82
89
  if not LICENSE_FILE.exists():
83
90
  return {"tier": "free", "valid": True}
84
91
  try:
85
- return json.loads(LICENSE_FILE.read_text())
92
+ data = json.loads(LICENSE_FILE.read_text())
93
+ if data.get("expires_at") and data["expires_at"] < time.time():
94
+ return {"tier": "free", "valid": True, "expired": True}
95
+ if data.get("tier") in ("pro", "enterprise") and data.get("valid"):
96
+ if needs_revalidation(data):
97
+ result = revalidate_license(data)
98
+ data = result["updated_data"]
99
+ if result["status"] == "expired":
100
+ return {"tier": "free", "valid": True, "revoked": True,
101
+ "reason": result.get("reason", "License expired.")}
102
+ return data
86
103
  except Exception:
87
104
  return {"tier": "free", "valid": True}
88
105
 
106
+ def needs_revalidation(data: dict) -> bool:
107
+ if data.get("tier") not in ("pro", "enterprise"):
108
+ return False
109
+ last_validated = data.get("last_validated_at", data.get("activated_at", 0))
110
+ if last_validated == 0:
111
+ return True
112
+ return (time.time() - last_validated) > REVALIDATION_INTERVAL
113
+
114
+ def revalidate_license(data: dict) -> dict:
115
+ import hashlib
116
+ import urllib.request
117
+ key = data.get("key", "")
118
+ if not key or key.startswith("JAMSONS"):
119
+ data["last_validated_at"] = time.time()
120
+ data["validation_status"] = "current"
121
+ _write_license(data)
122
+ return {"status": "valid", "updated_data": data}
123
+
124
+ last_validated = data.get("last_validated_at", data.get("activated_at", 0))
125
+ elapsed = time.time() - last_validated
126
+ machine_hash = data.get("machine_hash", hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16])
127
+
128
+ api_valid = None
129
+ try:
130
+ req_data = json.dumps({"license_key": key, "instance_name": machine_hash}).encode()
131
+ req = urllib.request.Request(
132
+ "https://api.lemonsqueezy.com/v1/licenses/validate",
133
+ data=req_data,
134
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
135
+ method="POST",
136
+ )
137
+ with urllib.request.urlopen(req, timeout=10) as resp:
138
+ result = json.loads(resp.read())
139
+ api_valid = result.get("valid", False)
140
+ except Exception:
141
+ api_valid = None
142
+
143
+ if api_valid is True:
144
+ data["last_validated_at"] = time.time()
145
+ data["validation_status"] = "current"
146
+ data.pop("grace_days_remaining", None)
147
+ _write_license(data)
148
+ return {"status": "valid", "updated_data": data}
149
+
150
+ if elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
151
+ data["validation_status"] = "expired"
152
+ data["valid"] = False
153
+ _write_license(data)
154
+ return {"status": "expired", "updated_data": data,
155
+ "reason": "License expired — no successful re-validation in 44 days."}
156
+
157
+ if elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
158
+ days_left = max(0, int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400))
159
+ data["validation_status"] = "grace_period"
160
+ data["grace_days_remaining"] = days_left
161
+ _write_license(data)
162
+ return {"status": "grace", "updated_data": data, "grace_days_remaining": days_left}
163
+
164
+ data["validation_status"] = "revalidation_pending"
165
+ _write_license(data)
166
+ return {"status": "grace", "updated_data": data}
167
+
168
+ def is_license_valid(data: dict) -> bool:
169
+ if data.get("tier") not in ("pro", "enterprise"):
170
+ return False
171
+ if not data.get("valid", False):
172
+ return False
173
+ key = data.get("key", "")
174
+ if key.startswith("JAMSONS"):
175
+ return True
176
+ last_validated = data.get("last_validated_at", data.get("activated_at", 0))
177
+ if last_validated == 0:
178
+ return True
179
+ elapsed = time.time() - last_validated
180
+ return elapsed <= (REVALIDATION_INTERVAL + HARD_BLOCK)
181
+
182
+ def _write_license(data: dict) -> None:
183
+ try:
184
+ LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
185
+ LICENSE_FILE.write_text(json.dumps(data, indent=2))
186
+ except Exception:
187
+ pass
188
+
89
189
  def is_premium() -> bool:
90
190
  lic = get_license()
91
- return lic.get("tier") in ("pro", "enterprise") and lic.get("valid", False)
191
+ return is_license_valid(lic)
92
192
 
93
193
  def require_premium(tool_name: str) -> dict | None:
94
194
  full_name = tool_name if tool_name.startswith("delimit_") else f"delimit_{tool_name}"
@@ -121,7 +221,8 @@ except ImportError:
121
221
  # Store key for offline validation
122
222
  license_data = {
123
223
  "key": key, "tier": "pro", "valid": True,
124
- "activated_at": time.time(), "validated_via": "offline_fallback",
224
+ "activated_at": time.time(), "last_validated_at": time.time(),
225
+ "validated_via": "offline_fallback",
125
226
  }
126
227
  LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
127
228
  LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
@@ -45,8 +45,162 @@ FREE_TRIAL_LIMITS = {
45
45
  }
46
46
 
47
47
 
48
+ def needs_revalidation(data: dict) -> bool:
49
+ """Check if a license needs re-validation (30+ days since last check).
50
+
51
+ Args:
52
+ data: License data dict (from license.json).
53
+
54
+ Returns:
55
+ True if 30+ days have elapsed since last_validated_at (or activated_at
56
+ as fallback). Also returns True if neither timestamp exists (legacy
57
+ license.json files without last_validated_at).
58
+ """
59
+ if data.get("tier") not in ("pro", "enterprise"):
60
+ return False
61
+ last_validated = data.get("last_validated_at", data.get("activated_at", 0))
62
+ if last_validated == 0:
63
+ return True # Legacy file — treat as needing validation
64
+ return (time.time() - last_validated) > REVALIDATION_INTERVAL
65
+
66
+
67
+ def revalidate_license(data: dict) -> dict:
68
+ """Re-validate a license against Lemon Squeezy.
69
+
70
+ Privacy-preserving: only sends license_key and instance_name (machine hash).
71
+ Non-blocking: network failures return offline grace status, never crash.
72
+
73
+ Args:
74
+ data: License data dict (must contain 'key').
75
+
76
+ Returns:
77
+ Dict with 'status' key:
78
+ - "valid": API confirmed license is active, last_validated_at updated
79
+ - "grace": API unreachable or returned invalid, but within grace period
80
+ - "expired": beyond grace + hard block cutoff, Pro tools should be blocked
81
+ Also includes 'updated_data' with the (possibly modified) license data.
82
+ """
83
+ key = data.get("key", "")
84
+ # Internal/founder keys always pass
85
+ if not key or key.startswith("JAMSONS"):
86
+ data["last_validated_at"] = time.time()
87
+ data["validation_status"] = "current"
88
+ _write_license(data)
89
+ return {"status": "valid", "updated_data": data}
90
+
91
+ last_validated = data.get("last_validated_at", data.get("activated_at", 0))
92
+ elapsed = time.time() - last_validated
93
+
94
+ # Try API call
95
+ api_valid = _call_lemon_squeezy(data)
96
+
97
+ if api_valid is True:
98
+ data["last_validated_at"] = time.time()
99
+ data["validation_status"] = "current"
100
+ data.pop("grace_days_remaining", None)
101
+ _write_license(data)
102
+ return {"status": "valid", "updated_data": data}
103
+
104
+ # API said invalid or was unreachable — check grace/expiry windows
105
+ if elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
106
+ data["validation_status"] = "expired"
107
+ data["valid"] = False
108
+ _write_license(data)
109
+ return {
110
+ "status": "expired",
111
+ "updated_data": data,
112
+ "reason": "License expired — no successful re-validation in 44 days. Renew at https://delimit.ai/pricing",
113
+ }
114
+
115
+ if elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
116
+ days_left = max(0, int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400))
117
+ data["validation_status"] = "grace_period"
118
+ data["grace_days_remaining"] = days_left
119
+ _write_license(data)
120
+ return {
121
+ "status": "grace",
122
+ "updated_data": data,
123
+ "grace_days_remaining": days_left,
124
+ "message": f"License re-validation failed. {days_left} days until Pro features are disabled.",
125
+ }
126
+
127
+ # Within first 7 days after revalidation interval — soft pending
128
+ data["validation_status"] = "revalidation_pending"
129
+ _write_license(data)
130
+ return {"status": "grace", "updated_data": data}
131
+
132
+
133
+ def is_license_valid(data: dict) -> bool:
134
+ """Check if a license is currently valid for Pro tool access.
135
+
136
+ Returns True if:
137
+ - last_validated_at is within 30 days (current), OR
138
+ - last_validated_at is within 37 days (30 + 7 grace), OR
139
+ - last_validated_at is within 44 days (30 + 14 hard cutoff)
140
+ Returns False if beyond 44 days with no successful re-validation.
141
+
142
+ Backwards compatible: missing last_validated_at falls back to activated_at,
143
+ and missing both returns False (triggers re-validation).
144
+ """
145
+ if data.get("tier") not in ("pro", "enterprise"):
146
+ return False
147
+ if not data.get("valid", False):
148
+ return False
149
+ # Internal/founder keys always valid
150
+ key = data.get("key", "")
151
+ if key.startswith("JAMSONS"):
152
+ return True
153
+ last_validated = data.get("last_validated_at", data.get("activated_at", 0))
154
+ if last_validated == 0:
155
+ return True # Legacy — allow access but needs_revalidation will trigger check
156
+ elapsed = time.time() - last_validated
157
+ return elapsed <= (REVALIDATION_INTERVAL + HARD_BLOCK)
158
+
159
+
160
+ def _write_license(data: dict) -> None:
161
+ """Persist license data to disk."""
162
+ try:
163
+ LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
164
+ LICENSE_FILE.write_text(json.dumps(data, indent=2))
165
+ except Exception:
166
+ pass # Non-blocking — don't crash on disk errors
167
+
168
+
169
+ def _call_lemon_squeezy(data: dict) -> bool | None:
170
+ """Call Lemon Squeezy validation API. Privacy-preserving.
171
+
172
+ Only sends license_key and instance_name (machine hash).
173
+
174
+ Returns:
175
+ True if valid, False if invalid, None if unreachable.
176
+ """
177
+ key = data.get("key", "")
178
+ machine_hash = data.get("machine_hash", hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16])
179
+ try:
180
+ import urllib.request
181
+ req_data = json.dumps({
182
+ "license_key": key,
183
+ "instance_name": machine_hash,
184
+ }).encode()
185
+ req = urllib.request.Request(
186
+ LS_VALIDATE_URL, data=req_data,
187
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
188
+ method="POST",
189
+ )
190
+ with urllib.request.urlopen(req, timeout=10) as resp:
191
+ result = json.loads(resp.read())
192
+ return result.get("valid", False)
193
+ except Exception:
194
+ return None # Unreachable — caller should use grace period
195
+
196
+
48
197
  def load_license() -> dict:
49
- """Load and validate license with re-validation."""
198
+ """Load and validate license with periodic re-validation.
199
+
200
+ Re-validates against Lemon Squeezy every 30 days. On failure, provides
201
+ a 7-day grace period followed by a 7-day warning period. After 44 days
202
+ without successful re-validation, Pro tools are blocked.
203
+ """
50
204
  if not LICENSE_FILE.exists():
51
205
  return {"tier": "free", "valid": True}
52
206
  try:
@@ -55,33 +209,25 @@ def load_license() -> dict:
55
209
  return {"tier": "free", "valid": True, "expired": True}
56
210
 
57
211
  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:
212
+ if needs_revalidation(data):
213
+ result = revalidate_license(data)
214
+ data = result["updated_data"]
215
+ if result["status"] == "expired":
68
216
  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"
217
+ "reason": result.get("reason", "License expired. Renew at https://delimit.ai/pricing")}
76
218
  return data
77
219
  except Exception:
78
220
  return {"tier": "free", "valid": True}
79
221
 
80
222
 
81
223
  def check_premium() -> bool:
82
- """Check if user has a valid premium license."""
224
+ """Check if user has a valid premium license.
225
+
226
+ Uses load_license() which triggers re-validation if needed, then
227
+ checks is_license_valid() on the result.
228
+ """
83
229
  lic = load_license()
84
- return lic.get("tier") in ("pro", "enterprise") and lic.get("valid", False)
230
+ return is_license_valid(lic)
85
231
 
86
232
 
87
233
  def gate_tool(tool_name: str) -> dict | None:
@@ -164,23 +310,18 @@ def activate(key: str) -> dict:
164
310
 
165
311
 
166
312
  def _revalidate(data: dict) -> dict:
167
- """Re-validate against Lemon Squeezy."""
168
- key = data.get("key", "")
169
- if not key or key.startswith("JAMSONS"):
313
+ """Re-validate against Lemon Squeezy (legacy wrapper).
314
+
315
+ Deprecated: use revalidate_license() for the full status/grace workflow.
316
+ Kept for backwards compatibility with any external callers.
317
+ """
318
+ result = _call_lemon_squeezy(data)
319
+ if result is True:
170
320
  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}
321
+ if result is False:
322
+ return {"valid": False}
323
+ # None = unreachable — grant offline grace
324
+ return {"valid": True, "offline": True}
184
325
 
185
326
 
186
327
  def _get_monthly_usage(tool_name: str) -> int:
@@ -0,0 +1,50 @@
1
+ # This file was generated by Nuitka
2
+
3
+ # Stubs included by default
4
+ from __future__ import annotations
5
+ from pathlib import Path
6
+ import hashlib
7
+ import json
8
+ import time
9
+
10
+ LICENSE_FILE = Path.home() / '.delimit' / 'license.json'
11
+ USAGE_FILE = Path.home() / '.delimit' / 'usage.json'
12
+ LS_VALIDATE_URL = 'https://api.lemonsqueezy.com/v1/licenses/validate'
13
+ REVALIDATION_INTERVAL = 30 * 86400
14
+ GRACE_PERIOD = 7 * 86400
15
+ HARD_BLOCK = 14 * 86400
16
+ PRO_TOOLS = frozenset({'delimit_gov_health', 'delimit_gov_status', 'delimit_gov_evaluate', 'delimit_gov_policy', 'delimit_gov_run', 'delimit_gov_verify', 'delimit_deploy_plan', 'delimit_deploy_build', 'delimit_deploy_publish', 'delimit_deploy_verify', 'delimit_deploy_rollback', 'delimit_deploy_site', 'delimit_deploy_npm', 'delimit_memory_store', 'delimit_memory_search', 'delimit_memory_recent', 'delimit_vault_search', 'delimit_vault_snapshot', 'delimit_vault_health', 'delimit_evidence_collect', 'delimit_evidence_verify', 'delimit_deliberate', 'delimit_models', 'delimit_obs_metrics', 'delimit_obs_logs', 'delimit_obs_status', 'delimit_release_plan', 'delimit_release_status', 'delimit_release_sync', 'delimit_cost_analyze', 'delimit_cost_optimize', 'delimit_cost_alert'})
17
+ FREE_TRIAL_LIMITS = {'delimit_deliberate': 3}
18
+ def load_license() -> dict:
19
+ ...
20
+
21
+ def check_premium() -> bool:
22
+ ...
23
+
24
+ def gate_tool(tool_name: str) -> dict | None:
25
+ ...
26
+
27
+ def activate(key: str) -> dict:
28
+ ...
29
+
30
+ def _revalidate(data: dict) -> dict:
31
+ ...
32
+
33
+ def _get_monthly_usage(tool_name: str) -> int:
34
+ ...
35
+
36
+ def _increment_usage(tool_name: str) -> int:
37
+ ...
38
+
39
+
40
+ __name__ = ...
41
+
42
+
43
+
44
+ # Modules used internally, to allow implicit dependencies to be seen:
45
+ import hashlib
46
+ import json
47
+ import time
48
+ import pathlib
49
+ import urllib
50
+ import urllib.request