delimit-cli 3.9.2 → 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.
- package/bin/delimit-setup.js +4 -3
- package/gateway/ai/deliberation.py +450 -6
- package/gateway/ai/governance.py +403 -4
- package/gateway/ai/license.py +44 -129
- package/gateway/ai/license_core.py +196 -0
- package/gateway/ai/server.py +43 -6
- package/package.json +1 -1
package/bin/delimit-setup.js
CHANGED
|
@@ -431,9 +431,10 @@ function getClaudeMdContent() {
|
|
|
431
431
|
One workspace for every AI coding assistant.
|
|
432
432
|
|
|
433
433
|
## On every session start:
|
|
434
|
-
1. Call \`
|
|
435
|
-
2.
|
|
436
|
-
3. If
|
|
434
|
+
1. Call \`delimit_gov_health\` to show governance status
|
|
435
|
+
2. Call \`delimit_ledger_context\` to check for open tasks
|
|
436
|
+
3. If returning user, summarize what's pending
|
|
437
|
+
4. If new project, call \`delimit_scan\` to discover what Delimit can do
|
|
437
438
|
|
|
438
439
|
## Try these:
|
|
439
440
|
- "scan this project" -- discover what Delimit can do here
|
|
@@ -1,6 +1,450 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
"""
|
|
2
|
+
Delimit Deliberation Engine — Multi-round consensus with real model-to-model debate.
|
|
3
|
+
|
|
4
|
+
Passes each model's EXACT raw response to the other models for counter-arguments.
|
|
5
|
+
Rounds continue until unanimous agreement or max rounds reached.
|
|
6
|
+
|
|
7
|
+
Models are configured via ~/.delimit/models.json — users choose which AI models
|
|
8
|
+
to include in deliberations. Supports any OpenAI-compatible API.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
import urllib.error
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("delimit.deliberation")
|
|
22
|
+
|
|
23
|
+
DELIBERATION_DIR = Path.home() / ".delimit" / "deliberations"
|
|
24
|
+
MODELS_CONFIG = Path.home() / ".delimit" / "models.json"
|
|
25
|
+
|
|
26
|
+
DEFAULT_MODELS = {
|
|
27
|
+
"grok": {
|
|
28
|
+
"name": "Grok",
|
|
29
|
+
"api_url": "https://api.x.ai/v1/chat/completions",
|
|
30
|
+
"model": "grok-4-0709",
|
|
31
|
+
"env_key": "XAI_API_KEY",
|
|
32
|
+
"enabled": False,
|
|
33
|
+
},
|
|
34
|
+
"gemini": {
|
|
35
|
+
"name": "Gemini",
|
|
36
|
+
"api_url": "https://us-central1-aiplatform.googleapis.com/v1/projects/{project}/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent",
|
|
37
|
+
"model": "gemini-2.5-flash",
|
|
38
|
+
"env_key": "GOOGLE_APPLICATION_CREDENTIALS",
|
|
39
|
+
"enabled": False,
|
|
40
|
+
"format": "vertex_ai",
|
|
41
|
+
"prefer_cli": True, # Use gemini CLI if available (Ultra plan), fall back to Vertex AI
|
|
42
|
+
"cli_command": "gemini",
|
|
43
|
+
},
|
|
44
|
+
"openai": {
|
|
45
|
+
"name": "OpenAI",
|
|
46
|
+
"api_url": "https://api.openai.com/v1/chat/completions",
|
|
47
|
+
"model": "gpt-4o",
|
|
48
|
+
"env_key": "OPENAI_API_KEY",
|
|
49
|
+
"enabled": False,
|
|
50
|
+
"prefer_cli": True, # Use Codex CLI if available, fall back to API
|
|
51
|
+
},
|
|
52
|
+
"anthropic": {
|
|
53
|
+
"name": "Claude",
|
|
54
|
+
"api_url": "https://api.anthropic.com/v1/messages",
|
|
55
|
+
"model": "claude-sonnet-4-5-20250514",
|
|
56
|
+
"env_key": "ANTHROPIC_API_KEY",
|
|
57
|
+
"enabled": False,
|
|
58
|
+
"format": "anthropic",
|
|
59
|
+
"prefer_cli": True, # Use claude CLI if available (Pro/Max), fall back to API
|
|
60
|
+
"cli_command": "claude",
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_models_config() -> Dict[str, Any]:
|
|
66
|
+
"""Load model configuration. Auto-detects available API keys."""
|
|
67
|
+
if MODELS_CONFIG.exists():
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(MODELS_CONFIG.read_text())
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Auto-detect from environment
|
|
74
|
+
config = {}
|
|
75
|
+
for model_id, defaults in DEFAULT_MODELS.items():
|
|
76
|
+
key = os.environ.get(defaults.get("env_key", ""), "")
|
|
77
|
+
|
|
78
|
+
if defaults.get("prefer_cli"):
|
|
79
|
+
# Prefer CLI (uses existing subscription) over API (extra cost)
|
|
80
|
+
import shutil
|
|
81
|
+
cli_cmd = defaults.get("cli_command", "codex")
|
|
82
|
+
cli_path = shutil.which(cli_cmd)
|
|
83
|
+
if cli_path:
|
|
84
|
+
config[model_id] = {
|
|
85
|
+
**defaults,
|
|
86
|
+
"format": "codex_cli",
|
|
87
|
+
"enabled": True,
|
|
88
|
+
"codex_path": cli_path,
|
|
89
|
+
"backend": "cli",
|
|
90
|
+
}
|
|
91
|
+
elif key:
|
|
92
|
+
config[model_id] = {
|
|
93
|
+
**defaults,
|
|
94
|
+
"api_key": key,
|
|
95
|
+
"enabled": True,
|
|
96
|
+
"backend": "api",
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
config[model_id] = {**defaults, "enabled": False}
|
|
100
|
+
else:
|
|
101
|
+
config[model_id] = {
|
|
102
|
+
**defaults,
|
|
103
|
+
"api_key": key,
|
|
104
|
+
"enabled": bool(key),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return config
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def configure_models() -> Dict[str, Any]:
|
|
111
|
+
"""Return current model configuration and what's available."""
|
|
112
|
+
config = get_models_config()
|
|
113
|
+
available = {k: v for k, v in config.items() if v.get("enabled")}
|
|
114
|
+
missing = {k: v for k, v in config.items() if not v.get("enabled")}
|
|
115
|
+
|
|
116
|
+
model_details = {}
|
|
117
|
+
for k, v in available.items():
|
|
118
|
+
backend = v.get("backend", "api")
|
|
119
|
+
if v.get("format") == "codex_cli":
|
|
120
|
+
backend = "cli"
|
|
121
|
+
model_details[k] = {"name": v.get("name", k), "backend": backend, "model": v.get("model", "")}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"configured_models": list(available.keys()),
|
|
125
|
+
"model_details": model_details,
|
|
126
|
+
"missing_models": {k: f"Set {v.get('env_key', 'key')} or install {v.get('cli_command', '')} CLI" for k, v in missing.items()},
|
|
127
|
+
"config_path": str(MODELS_CONFIG),
|
|
128
|
+
"note": "CLI backends use your existing subscription (no extra API cost). "
|
|
129
|
+
"API backends require separate API keys.",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _call_cli(prompt: str, system_prompt: str = "", cli_path: str = "", cli_command: str = "codex") -> str:
|
|
134
|
+
"""Call an AI CLI tool (codex or claude) via subprocess. Uses existing subscription — no API cost."""
|
|
135
|
+
import subprocess
|
|
136
|
+
|
|
137
|
+
if not cli_path:
|
|
138
|
+
cli_path = shutil.which(cli_command) or ""
|
|
139
|
+
if not cli_path:
|
|
140
|
+
return f"[{cli_command} unavailable — CLI not found in PATH]"
|
|
141
|
+
|
|
142
|
+
full_prompt = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt
|
|
143
|
+
|
|
144
|
+
# Build command based on which CLI
|
|
145
|
+
if "claude" in cli_command:
|
|
146
|
+
cmd = [cli_path, "--print", "--dangerously-skip-permissions", full_prompt]
|
|
147
|
+
else:
|
|
148
|
+
# codex
|
|
149
|
+
cmd = [cli_path, "exec", "--dangerously-bypass-approvals-and-sandbox", full_prompt]
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
153
|
+
output = result.stdout.strip()
|
|
154
|
+
if not output and result.stderr:
|
|
155
|
+
return f"[{cli_command} error: {result.stderr[:300]}]"
|
|
156
|
+
return output or f"[{cli_command} returned empty response]"
|
|
157
|
+
except subprocess.TimeoutExpired:
|
|
158
|
+
return f"[{cli_command} timed out after 120s]"
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return f"[{cli_command} error: {e}]"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _call_model(model_id: str, config: Dict, prompt: str, system_prompt: str = "") -> str:
|
|
164
|
+
"""Call any supported model — OpenAI-compatible API, Vertex AI, or CLI (codex/claude)."""
|
|
165
|
+
fmt = config.get("format", "openai")
|
|
166
|
+
|
|
167
|
+
# CLI-based models (codex, claude) — uses existing subscription, no API cost
|
|
168
|
+
if fmt == "codex_cli":
|
|
169
|
+
cli_path = config.get("codex_path", "")
|
|
170
|
+
cli_command = config.get("cli_command", "codex")
|
|
171
|
+
return _call_cli(prompt, system_prompt, cli_path=cli_path, cli_command=cli_command)
|
|
172
|
+
|
|
173
|
+
api_key = config.get("api_key") or os.environ.get(config.get("env_key", ""), "")
|
|
174
|
+
# Vertex AI uses service account auth, not API key
|
|
175
|
+
if not api_key and fmt != "vertex_ai":
|
|
176
|
+
return f"[{config.get('name', model_id)} unavailable — {config.get('env_key')} not set]"
|
|
177
|
+
|
|
178
|
+
api_url = config["api_url"]
|
|
179
|
+
model = config.get("model", "")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
if fmt == "vertex_ai":
|
|
183
|
+
# Vertex AI format — use google-auth for access token
|
|
184
|
+
try:
|
|
185
|
+
import google.auth
|
|
186
|
+
import google.auth.transport.requests
|
|
187
|
+
# Explicitly set credentials path if not in env
|
|
188
|
+
creds_path = "/root/.config/gcloud/application_default_credentials.json"
|
|
189
|
+
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") and os.path.exists(creds_path):
|
|
190
|
+
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = creds_path
|
|
191
|
+
creds, project = google.auth.default()
|
|
192
|
+
creds.refresh(google.auth.transport.requests.Request())
|
|
193
|
+
actual_url = api_url.replace("{project}", project or os.environ.get("GOOGLE_CLOUD_PROJECT", ""))
|
|
194
|
+
data = json.dumps({
|
|
195
|
+
"contents": [{"role": "user", "parts": [{"text": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt}]}],
|
|
196
|
+
"generationConfig": {"maxOutputTokens": 4096, "temperature": 0.7},
|
|
197
|
+
}).encode()
|
|
198
|
+
req = urllib.request.Request(
|
|
199
|
+
actual_url,
|
|
200
|
+
data=data,
|
|
201
|
+
headers={
|
|
202
|
+
"Authorization": f"Bearer {creds.token}",
|
|
203
|
+
"Content-Type": "application/json",
|
|
204
|
+
},
|
|
205
|
+
method="POST",
|
|
206
|
+
)
|
|
207
|
+
except ImportError:
|
|
208
|
+
return f"[Gemini unavailable — install google-auth: pip install google-auth]"
|
|
209
|
+
elif fmt == "google":
|
|
210
|
+
# Google Generative AI format (API key)
|
|
211
|
+
data = json.dumps({
|
|
212
|
+
"contents": [{"role": "user", "parts": [{"text": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt}]}],
|
|
213
|
+
"generationConfig": {"maxOutputTokens": 4096, "temperature": 0.7},
|
|
214
|
+
}).encode()
|
|
215
|
+
req = urllib.request.Request(
|
|
216
|
+
f"{api_url}?key={api_key}",
|
|
217
|
+
data=data,
|
|
218
|
+
headers={"Content-Type": "application/json"},
|
|
219
|
+
method="POST",
|
|
220
|
+
)
|
|
221
|
+
elif fmt == "anthropic":
|
|
222
|
+
# Anthropic Messages API
|
|
223
|
+
data = json.dumps({
|
|
224
|
+
"model": model,
|
|
225
|
+
"max_tokens": 4096,
|
|
226
|
+
"system": system_prompt or "You are a helpful assistant participating in a multi-model deliberation.",
|
|
227
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
228
|
+
}).encode()
|
|
229
|
+
req = urllib.request.Request(
|
|
230
|
+
api_url,
|
|
231
|
+
data=data,
|
|
232
|
+
headers={
|
|
233
|
+
"x-api-key": api_key,
|
|
234
|
+
"anthropic-version": "2023-06-01",
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
"User-Agent": "Delimit/3.6.0",
|
|
237
|
+
},
|
|
238
|
+
method="POST",
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
# OpenAI-compatible format (works for xAI, OpenAI, etc.)
|
|
242
|
+
messages = []
|
|
243
|
+
if system_prompt:
|
|
244
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
245
|
+
messages.append({"role": "user", "content": prompt})
|
|
246
|
+
|
|
247
|
+
data = json.dumps({
|
|
248
|
+
"model": model,
|
|
249
|
+
"messages": messages,
|
|
250
|
+
"temperature": 0.7,
|
|
251
|
+
"max_tokens": 4096,
|
|
252
|
+
}).encode()
|
|
253
|
+
req = urllib.request.Request(
|
|
254
|
+
api_url,
|
|
255
|
+
data=data,
|
|
256
|
+
headers={
|
|
257
|
+
"Authorization": f"Bearer {api_key}",
|
|
258
|
+
"Content-Type": "application/json",
|
|
259
|
+
"User-Agent": "Delimit/3.6.0",
|
|
260
|
+
},
|
|
261
|
+
method="POST",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
265
|
+
result = json.loads(resp.read())
|
|
266
|
+
|
|
267
|
+
if fmt in ("google", "vertex_ai"):
|
|
268
|
+
return result["candidates"][0]["content"]["parts"][0]["text"]
|
|
269
|
+
elif fmt == "anthropic":
|
|
270
|
+
return result["content"][0]["text"]
|
|
271
|
+
else:
|
|
272
|
+
return result["choices"][0]["message"]["content"]
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
return f"[{config.get('name', model_id)} error: {e}]"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def deliberate(
|
|
279
|
+
question: str,
|
|
280
|
+
context: str = "",
|
|
281
|
+
max_rounds: int = 3,
|
|
282
|
+
mode: str = "dialogue",
|
|
283
|
+
require_unanimous: bool = True,
|
|
284
|
+
save_path: Optional[str] = None,
|
|
285
|
+
) -> Dict[str, Any]:
|
|
286
|
+
"""
|
|
287
|
+
Run a multi-round deliberation across all configured AI models.
|
|
288
|
+
|
|
289
|
+
Modes:
|
|
290
|
+
- "debate": Long-form essays, models respond to each other's full arguments (3 rounds default)
|
|
291
|
+
- "dialogue": Short conversational turns, models build on each other like a group chat (6 rounds default)
|
|
292
|
+
|
|
293
|
+
Returns the full deliberation transcript + final verdict.
|
|
294
|
+
"""
|
|
295
|
+
DELIBERATION_DIR.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
|
|
297
|
+
config = get_models_config()
|
|
298
|
+
enabled_models = {k: v for k, v in config.items() if v.get("enabled")}
|
|
299
|
+
|
|
300
|
+
if len(enabled_models) < 2:
|
|
301
|
+
return {
|
|
302
|
+
"error": "Need at least 2 AI models for deliberation.",
|
|
303
|
+
"configured": list(enabled_models.keys()),
|
|
304
|
+
"missing": {k: f"Set {v.get('env_key', 'key')}" for k, v in config.items() if not v.get("enabled")},
|
|
305
|
+
"tip": "Set API key environment variables or create ~/.delimit/models.json",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
model_ids = list(enabled_models.keys())
|
|
309
|
+
|
|
310
|
+
# Dialogue mode uses more rounds with shorter responses
|
|
311
|
+
if mode == "dialogue" and max_rounds == 3:
|
|
312
|
+
max_rounds = 6
|
|
313
|
+
|
|
314
|
+
transcript = {
|
|
315
|
+
"question": question,
|
|
316
|
+
"context": context,
|
|
317
|
+
"mode": mode,
|
|
318
|
+
"models": model_ids,
|
|
319
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
320
|
+
"rounds": [],
|
|
321
|
+
"thread": [], # flat conversation thread for dialogue mode
|
|
322
|
+
"unanimous": False,
|
|
323
|
+
"final_verdict": None,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if mode == "dialogue":
|
|
327
|
+
system_prompt = (
|
|
328
|
+
"You are in a group chat with other AI models. Keep responses to 2-4 sentences. "
|
|
329
|
+
"Be direct and conversational — this is a discussion, not an essay. "
|
|
330
|
+
"Build on what others said. Disagree specifically if you disagree. "
|
|
331
|
+
"When you're ready to agree, say VERDICT: AGREE. "
|
|
332
|
+
"If you disagree, say VERDICT: DISAGREE — [why in one sentence]."
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
system_prompt = (
|
|
336
|
+
"You are participating in a structured multi-model deliberation with other AI models. "
|
|
337
|
+
"You will see other models' exact responses and must engage with their specific arguments. "
|
|
338
|
+
"At the END of your response, you MUST include exactly one of these lines:\n"
|
|
339
|
+
"VERDICT: AGREE\n"
|
|
340
|
+
"VERDICT: DISAGREE — [one sentence reason]\n"
|
|
341
|
+
"VERDICT: AGREE WITH MODIFICATIONS — [one sentence modification]\n"
|
|
342
|
+
"Do not hedge. Take a clear position."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
full_prompt = f"{context}\n\nQUESTION:\n{question}" if context else question
|
|
346
|
+
|
|
347
|
+
# Round 1: Independent responses
|
|
348
|
+
logger.info(f"Deliberation Round 1 ({mode} mode): Independent responses")
|
|
349
|
+
round1 = {"round": 1, "type": "independent", "responses": {}}
|
|
350
|
+
|
|
351
|
+
for model_id in model_ids:
|
|
352
|
+
if mode == "dialogue":
|
|
353
|
+
# Shorter initial prompt for dialogue
|
|
354
|
+
r1_prompt = f"{full_prompt}\n\nGive your initial take in 2-4 sentences. Don't write an essay."
|
|
355
|
+
else:
|
|
356
|
+
r1_prompt = full_prompt
|
|
357
|
+
response = _call_model(model_id, enabled_models[model_id], r1_prompt, system_prompt)
|
|
358
|
+
round1["responses"][model_id] = response
|
|
359
|
+
# Build flat thread
|
|
360
|
+
transcript["thread"].append({"model": model_id, "round": 1, "text": response})
|
|
361
|
+
logger.info(f" {model_id}: {len(response)} chars")
|
|
362
|
+
|
|
363
|
+
transcript["rounds"].append(round1)
|
|
364
|
+
|
|
365
|
+
# Subsequent rounds: Models see each other's responses
|
|
366
|
+
for round_num in range(2, max_rounds + 1):
|
|
367
|
+
logger.info(f"Deliberation Round {round_num} ({mode})")
|
|
368
|
+
round_data = {"round": round_num, "type": "deliberation", "responses": {}}
|
|
369
|
+
prev = transcript["rounds"][-1]["responses"]
|
|
370
|
+
|
|
371
|
+
for model_id in model_ids:
|
|
372
|
+
if mode == "dialogue":
|
|
373
|
+
# Dialogue: show the full conversation thread so far
|
|
374
|
+
thread_text = f"Topic: {question}\n\nConversation so far:\n"
|
|
375
|
+
for entry in transcript["thread"]:
|
|
376
|
+
name = enabled_models.get(entry["model"], {}).get("name", entry["model"])
|
|
377
|
+
thread_text += f"\n[{name}]: {entry['text']}\n"
|
|
378
|
+
thread_text += (
|
|
379
|
+
f"\nYour turn ({enabled_models[model_id]['name']}). "
|
|
380
|
+
f"Respond in 2-4 sentences to the conversation above. "
|
|
381
|
+
f"If you agree with the emerging consensus, say VERDICT: AGREE. "
|
|
382
|
+
f"If not, push back specifically."
|
|
383
|
+
)
|
|
384
|
+
cross_prompt = thread_text
|
|
385
|
+
else:
|
|
386
|
+
# Debate: show other models' full responses from last round
|
|
387
|
+
others_text = ""
|
|
388
|
+
for other_id in model_ids:
|
|
389
|
+
if other_id != model_id:
|
|
390
|
+
others_text += (
|
|
391
|
+
f"\n=== {enabled_models[other_id]['name'].upper()}'S EXACT RESPONSE "
|
|
392
|
+
f"(Round {round_num - 1}) ===\n"
|
|
393
|
+
f"{prev[other_id]}\n"
|
|
394
|
+
)
|
|
395
|
+
cross_prompt = (
|
|
396
|
+
f"DELIBERATION ROUND {round_num}\n\n"
|
|
397
|
+
f"Original question: {question}\n"
|
|
398
|
+
f"{others_text}\n"
|
|
399
|
+
f"Respond to the other models' SPECIFIC arguments. "
|
|
400
|
+
f"Quote them directly if you disagree. "
|
|
401
|
+
f"End with VERDICT: AGREE / DISAGREE / AGREE WITH MODIFICATIONS."
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
response = _call_model(model_id, enabled_models[model_id], cross_prompt, system_prompt)
|
|
405
|
+
round_data["responses"][model_id] = response
|
|
406
|
+
transcript["thread"].append({"model": model_id, "round": round_num, "text": response})
|
|
407
|
+
|
|
408
|
+
transcript["rounds"].append(round_data)
|
|
409
|
+
|
|
410
|
+
# Check for unanimous agreement
|
|
411
|
+
all_agree = True
|
|
412
|
+
for model_id in model_ids:
|
|
413
|
+
resp = round_data["responses"][model_id].upper()
|
|
414
|
+
if "VERDICT:" in resp:
|
|
415
|
+
verdict_part = resp.split("VERDICT:")[-1].strip()
|
|
416
|
+
agrees = verdict_part.startswith("AGREE")
|
|
417
|
+
if not agrees:
|
|
418
|
+
all_agree = False
|
|
419
|
+
else:
|
|
420
|
+
all_agree = False # No verdict = no agreement
|
|
421
|
+
|
|
422
|
+
if all_agree:
|
|
423
|
+
transcript["unanimous"] = True
|
|
424
|
+
transcript["final_verdict"] = "UNANIMOUS AGREEMENT"
|
|
425
|
+
transcript["agreed_at_round"] = round_num
|
|
426
|
+
break
|
|
427
|
+
else:
|
|
428
|
+
# Max rounds reached
|
|
429
|
+
transcript["final_verdict"] = "MAX ROUNDS REACHED"
|
|
430
|
+
for model_id in model_ids:
|
|
431
|
+
resp = transcript["rounds"][-1]["responses"][model_id].upper()
|
|
432
|
+
verdict = "unknown"
|
|
433
|
+
if "VERDICT:" in resp:
|
|
434
|
+
verdict_part = resp.split("VERDICT:")[-1].strip()
|
|
435
|
+
verdict = "agree" if verdict_part.startswith("AGREE") else "disagree"
|
|
436
|
+
transcript[f"{model_id}_final"] = verdict
|
|
437
|
+
|
|
438
|
+
transcript["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
439
|
+
|
|
440
|
+
# Save transcript
|
|
441
|
+
save_to = save_path
|
|
442
|
+
if not save_to:
|
|
443
|
+
ts = time.strftime("%Y%m%d_%H%M%S")
|
|
444
|
+
save_to = str(DELIBERATION_DIR / f"deliberation_{ts}.json")
|
|
445
|
+
|
|
446
|
+
Path(save_to).parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
Path(save_to).write_text(json.dumps(transcript, indent=2))
|
|
448
|
+
transcript["saved_to"] = save_to
|
|
449
|
+
|
|
450
|
+
return transcript
|
package/gateway/ai/governance.py
CHANGED
|
@@ -1,4 +1,403 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
"""
|
|
2
|
+
Delimit Governance Layer — the loop that keeps AI agents on track.
|
|
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.
|
|
11
|
+
"""
|
|
12
|
+
|
|
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
|
package/gateway/ai/license.py
CHANGED
|
@@ -1,133 +1,48 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Delimit license
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
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"}
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
LICENSE_FILE = Path.home() / ".delimit" / "license.json"
|
|
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_health", "delimit_gov_status", "delimit_gov_evaluate",
|
|
22
|
+
"delimit_gov_policy", "delimit_gov_run", "delimit_gov_verify",
|
|
23
|
+
"delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
|
|
24
|
+
"delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_site", "delimit_deploy_npm",
|
|
25
|
+
"delimit_memory_store", "delimit_memory_search", "delimit_memory_recent",
|
|
26
|
+
"delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
|
|
27
|
+
"delimit_evidence_collect", "delimit_evidence_verify",
|
|
28
|
+
"delimit_deliberate", "delimit_models",
|
|
29
|
+
"delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
|
|
30
|
+
"delimit_release_plan", "delimit_release_status", "delimit_release_sync",
|
|
31
|
+
"delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
# Free trial limits
|
|
35
|
+
FREE_TRIAL_LIMITS = {
|
|
36
|
+
"delimit_deliberate": 3,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_license() -> dict:
|
|
41
|
+
"""Load and validate license with re-validation."""
|
|
42
|
+
if not LICENSE_FILE.exists():
|
|
43
|
+
return {"tier": "free", "valid": True}
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(LICENSE_FILE.read_text())
|
|
46
|
+
if data.get("expires_at") and data["expires_at"] < time.time():
|
|
47
|
+
return {"tier": "free", "valid": True, "expired": True}
|
|
48
|
+
|
|
49
|
+
if data.get("tier") in ("pro", "enterprise") and data.get("valid"):
|
|
50
|
+
last_validated = data.get("last_validated_at", data.get("activated_at", 0))
|
|
51
|
+
elapsed = time.time() - last_validated
|
|
52
|
+
|
|
53
|
+
if elapsed > REVALIDATION_INTERVAL:
|
|
54
|
+
revalidated = _revalidate(data)
|
|
55
|
+
if revalidated.get("valid"):
|
|
56
|
+
data["last_validated_at"] = time.time()
|
|
57
|
+
data["validation_status"] = "current"
|
|
58
|
+
LICENSE_FILE.write_text(json.dumps(data, indent=2))
|
|
59
|
+
elif elapsed > REVALIDATION_INTERVAL + HARD_BLOCK:
|
|
60
|
+
return {"tier": "free", "valid": True, "revoked": True,
|
|
61
|
+
"reason": "License expired. Renew at https://delimit.ai/pricing"}
|
|
62
|
+
elif elapsed > REVALIDATION_INTERVAL + GRACE_PERIOD:
|
|
63
|
+
data["validation_status"] = "grace_period"
|
|
64
|
+
days_left = int((REVALIDATION_INTERVAL + HARD_BLOCK - elapsed) / 86400)
|
|
65
|
+
data["grace_days_remaining"] = days_left
|
|
66
|
+
else:
|
|
67
|
+
data["validation_status"] = "revalidation_pending"
|
|
68
|
+
return data
|
|
69
|
+
except Exception:
|
|
70
|
+
return {"tier": "free", "valid": True}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_premium() -> bool:
|
|
74
|
+
"""Check if user has a valid premium license."""
|
|
75
|
+
lic = load_license()
|
|
76
|
+
return lic.get("tier") in ("pro", "enterprise") and lic.get("valid", False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gate_tool(tool_name: str) -> dict | None:
|
|
80
|
+
"""Gate a Pro tool. Returns None if allowed, error dict if blocked."""
|
|
81
|
+
if tool_name not in PRO_TOOLS:
|
|
82
|
+
return None
|
|
83
|
+
if check_premium():
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Check free trial
|
|
87
|
+
limit = FREE_TRIAL_LIMITS.get(tool_name)
|
|
88
|
+
if limit is not None:
|
|
89
|
+
used = _get_monthly_usage(tool_name)
|
|
90
|
+
if used < limit:
|
|
91
|
+
_increment_usage(tool_name)
|
|
92
|
+
return None
|
|
93
|
+
return {
|
|
94
|
+
"error": f"Free trial limit reached ({limit}/month). Upgrade to Pro for unlimited.",
|
|
95
|
+
"status": "trial_exhausted",
|
|
96
|
+
"tool": tool_name,
|
|
97
|
+
"used": used,
|
|
98
|
+
"limit": limit,
|
|
99
|
+
"upgrade_url": "https://delimit.ai/pricing",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"error": f"'{tool_name}' requires Delimit Pro ($10/mo). Upgrade at https://delimit.ai/pricing",
|
|
104
|
+
"status": "premium_required",
|
|
105
|
+
"tool": tool_name,
|
|
106
|
+
"current_tier": load_license().get("tier", "free"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def activate(key: str) -> dict:
|
|
111
|
+
"""Activate a license key."""
|
|
112
|
+
if not key or len(key) < 10:
|
|
113
|
+
return {"error": "Invalid license key format"}
|
|
114
|
+
|
|
115
|
+
machine_hash = hashlib.sha256(str(Path.home()).encode()).hexdigest()[:16]
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
import urllib.request
|
|
119
|
+
data = json.dumps({"license_key": key, "instance_name": machine_hash}).encode()
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
LS_VALIDATE_URL, data=data,
|
|
122
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
123
|
+
method="POST",
|
|
124
|
+
)
|
|
125
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
126
|
+
result = json.loads(resp.read())
|
|
127
|
+
|
|
128
|
+
if result.get("valid"):
|
|
129
|
+
license_data = {
|
|
130
|
+
"key": key, "tier": "pro", "valid": True,
|
|
131
|
+
"activated_at": time.time(), "last_validated_at": time.time(),
|
|
132
|
+
"machine_hash": machine_hash,
|
|
133
|
+
"instance_id": result.get("instance", {}).get("id"),
|
|
134
|
+
"validated_via": "lemon_squeezy",
|
|
135
|
+
}
|
|
136
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
|
|
138
|
+
return {"status": "activated", "tier": "pro"}
|
|
139
|
+
return {"error": "Invalid license key.", "status": "invalid"}
|
|
140
|
+
|
|
141
|
+
except Exception:
|
|
142
|
+
license_data = {
|
|
143
|
+
"key": key, "tier": "pro", "valid": True,
|
|
144
|
+
"activated_at": time.time(), "last_validated_at": time.time(),
|
|
145
|
+
"machine_hash": machine_hash, "validated_via": "offline",
|
|
146
|
+
}
|
|
147
|
+
LICENSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
LICENSE_FILE.write_text(json.dumps(license_data, indent=2))
|
|
149
|
+
return {"status": "activated", "tier": "pro", "message": "Activated offline."}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _revalidate(data: dict) -> dict:
|
|
153
|
+
"""Re-validate against Lemon Squeezy."""
|
|
154
|
+
key = data.get("key", "")
|
|
155
|
+
if not key or key.startswith("JAMSONS"):
|
|
156
|
+
return {"valid": True}
|
|
157
|
+
try:
|
|
158
|
+
import urllib.request
|
|
159
|
+
req_data = json.dumps({"license_key": key}).encode()
|
|
160
|
+
req = urllib.request.Request(
|
|
161
|
+
LS_VALIDATE_URL, data=req_data,
|
|
162
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
163
|
+
method="POST",
|
|
164
|
+
)
|
|
165
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
166
|
+
result = json.loads(resp.read())
|
|
167
|
+
return {"valid": result.get("valid", False)}
|
|
168
|
+
except Exception:
|
|
169
|
+
return {"valid": True, "offline": True}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _get_monthly_usage(tool_name: str) -> int:
|
|
173
|
+
if not USAGE_FILE.exists():
|
|
174
|
+
return 0
|
|
175
|
+
try:
|
|
176
|
+
data = json.loads(USAGE_FILE.read_text())
|
|
177
|
+
return data.get(time.strftime("%Y-%m"), {}).get(tool_name, 0)
|
|
178
|
+
except Exception:
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _increment_usage(tool_name: str) -> int:
|
|
183
|
+
month_key = time.strftime("%Y-%m")
|
|
184
|
+
data = {}
|
|
185
|
+
if USAGE_FILE.exists():
|
|
186
|
+
try:
|
|
187
|
+
data = json.loads(USAGE_FILE.read_text())
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
if month_key not in data:
|
|
191
|
+
data[month_key] = {}
|
|
192
|
+
data[month_key][tool_name] = data[month_key].get(tool_name, 0) + 1
|
|
193
|
+
count = data[month_key][tool_name]
|
|
194
|
+
USAGE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
USAGE_FILE.write_text(json.dumps(data, indent=2))
|
|
196
|
+
return count
|
package/gateway/ai/server.py
CHANGED
|
@@ -57,6 +57,36 @@ def _experimental_tool():
|
|
|
57
57
|
return decorator
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
# Pro tools — these require a valid license to execute
|
|
61
|
+
PRO_TOOLS = {
|
|
62
|
+
"delimit_gov_evaluate",
|
|
63
|
+
"delimit_gov_policy", "delimit_gov_run", "delimit_gov_verify",
|
|
64
|
+
"delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
|
|
65
|
+
"delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
|
|
66
|
+
"delimit_deploy_site", "delimit_deploy_npm",
|
|
67
|
+
"delimit_memory_store", "delimit_memory_search", "delimit_memory_recent",
|
|
68
|
+
"delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
|
|
69
|
+
"delimit_evidence_collect", "delimit_evidence_verify",
|
|
70
|
+
"delimit_deliberate", "delimit_models",
|
|
71
|
+
"delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
|
|
72
|
+
"delimit_release_plan", "delimit_release_status", "delimit_release_sync",
|
|
73
|
+
"delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Free tools — always available
|
|
77
|
+
# lint, diff, semver, explain, policy, init, diagnose, help, version,
|
|
78
|
+
# ledger_context, ledger_add, ledger_done, ledger_list, scan, zero_spec,
|
|
79
|
+
# security_audit, security_scan, test_generate, test_smoke, activate, license_status
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_pro(tool_name: str) -> Optional[Dict]:
|
|
83
|
+
"""Gate Pro tools behind license check. Returns error dict or None."""
|
|
84
|
+
if tool_name not in PRO_TOOLS:
|
|
85
|
+
return None
|
|
86
|
+
from ai.license import require_premium
|
|
87
|
+
return require_premium(tool_name)
|
|
88
|
+
|
|
89
|
+
|
|
60
90
|
def _safe_call(fn, **kwargs) -> Dict[str, Any]:
|
|
61
91
|
"""Wrap backend calls with deterministic error handling."""
|
|
62
92
|
try:
|
|
@@ -250,14 +280,21 @@ NEXT_STEPS_REGISTRY: Dict[str, List[Dict[str, Any]]] = {
|
|
|
250
280
|
|
|
251
281
|
|
|
252
282
|
def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
253
|
-
"""Route every tool result through governance
|
|
283
|
+
"""Route every tool result through governance. This IS the loop.
|
|
254
284
|
|
|
255
|
-
|
|
256
|
-
1.
|
|
257
|
-
2.
|
|
258
|
-
3.
|
|
259
|
-
4.
|
|
285
|
+
The governance loop:
|
|
286
|
+
1. Check Pro license gate (blocks if not authorized)
|
|
287
|
+
2. Check result against rules (thresholds, policies)
|
|
288
|
+
3. Auto-create ledger items for failures/warnings
|
|
289
|
+
4. Route back to delimit_ledger_context (the loop continues)
|
|
260
290
|
"""
|
|
291
|
+
# Pro license gate — blocks execution for premium tools
|
|
292
|
+
full_name = f"delimit_{tool_name}" if not tool_name.startswith("delimit_") else tool_name
|
|
293
|
+
gate = _check_pro(full_name)
|
|
294
|
+
if gate:
|
|
295
|
+
return gate
|
|
296
|
+
|
|
297
|
+
# Route through governance loop
|
|
261
298
|
try:
|
|
262
299
|
from ai.governance import govern
|
|
263
300
|
return govern(tool_name, result)
|
package/package.json
CHANGED