delimit-cli 4.1.52 → 4.2.0
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/CHANGELOG.md +46 -0
- package/bin/delimit-cli.js +1 -2
- package/bin/delimit-setup.js +22 -7
- package/gateway/ai/agent_dispatch.py +79 -0
- package/gateway/ai/daily_digest.py +386 -0
- package/gateway/ai/ledger_manager.py +32 -0
- package/gateway/ai/license_core.py +2 -0
- package/gateway/ai/notify.py +17 -11
- package/gateway/ai/reddit_proxy.py +28 -9
- package/gateway/ai/sensing/__init__.py +35 -0
- package/gateway/ai/sensing/schema.py +107 -0
- package/gateway/ai/sensing/signal_store.py +348 -0
- package/gateway/ai/server.py +423 -7
- package/gateway/ai/supabase_sync.py +308 -0
- package/gateway/ai/work_order.py +216 -0
- package/gateway/ai/workers/__init__.py +32 -0
- package/gateway/ai/workers/base.py +154 -0
- package/gateway/ai/workers/executor.py +861 -0
- package/gateway/ai/workers/outreach_drafter.py +161 -0
- package/gateway/ai/workers/pr_drafter.py +148 -0
- package/package.json +14 -1
- package/gateway/ai/continuity.py +0 -462
- package/gateway/ai/inbox_daemon_runner.py +0 -217
- package/gateway/ai/loop_engine.py +0 -1236
- package/gateway/ai/social_cache.py +0 -341
- package/gateway/ai/social_daemon.py +0 -483
- package/gateway/ai/tweet_corpus_schema.sql +0 -76
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/demo-v420-clean.sh +0 -267
- package/scripts/demo-v420-deliberation.sh +0 -217
- package/scripts/demo-v420.sh +0 -55
- package/scripts/sync-gateway.sh +0 -112
package/gateway/ai/server.py
CHANGED
|
@@ -136,6 +136,61 @@ def _sanitize_path(user_path: str, label: str = "path") -> Path:
|
|
|
136
136
|
return resolved
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
# LED-881: Confused-deputy guard for LLM-controlled repo parameters.
|
|
140
|
+
# Originally reported on delimit-ai/delimit-mcp-server#40 as command injection
|
|
141
|
+
# (not exploitable — argv list, no shell=True). The reporter's second-pass
|
|
142
|
+
# framing is the real concern: a prompt-injected LLM could ask a sensor tool
|
|
143
|
+
# to read issue comments from any repo the caller's gh token can see,
|
|
144
|
+
# including private ones. Confused deputy / resource scope creep.
|
|
145
|
+
#
|
|
146
|
+
# Mitigation: opt-in allowlist via DELIMIT_ALLOWED_REPOS env var.
|
|
147
|
+
# - Unset (default): pass through, emit a one-time warning per process.
|
|
148
|
+
# Preserves backwards-compat for existing Pro installs.
|
|
149
|
+
# - Set: CSV of owner/repo entries. Any repo not in the list is refused
|
|
150
|
+
# with a structured error so the LLM caller gets a legible refusal.
|
|
151
|
+
#
|
|
152
|
+
# The pass-through-unset posture is the add-don't-remove rule from CLAUDE.md.
|
|
153
|
+
_REPO_ALLOWLIST_WARNED = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _check_repo_allowlist(repo: str) -> Optional[Dict[str, Any]]:
|
|
157
|
+
"""Return a structured refusal dict when the repo is outside the
|
|
158
|
+
configured allowlist. Return None when the call should proceed.
|
|
159
|
+
|
|
160
|
+
Env var: DELIMIT_ALLOWED_REPOS = "owner/a,owner/b,org/c"
|
|
161
|
+
"""
|
|
162
|
+
global _REPO_ALLOWLIST_WARNED
|
|
163
|
+
allowlist_raw = os.environ.get("DELIMIT_ALLOWED_REPOS", "").strip()
|
|
164
|
+
if not allowlist_raw:
|
|
165
|
+
if not _REPO_ALLOWLIST_WARNED:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"DELIMIT_ALLOWED_REPOS is unset — LLM-controlled repo parameters "
|
|
168
|
+
"will pass through to gh api using the caller's token. Set "
|
|
169
|
+
"DELIMIT_ALLOWED_REPOS=\"owner/a,owner/b\" to scope which repos "
|
|
170
|
+
"sensor tools are permitted to reach. See delimit-mcp-server#40."
|
|
171
|
+
)
|
|
172
|
+
_REPO_ALLOWLIST_WARNED = True
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
allowed = {
|
|
176
|
+
entry.strip().lower()
|
|
177
|
+
for entry in allowlist_raw.split(",")
|
|
178
|
+
if entry.strip()
|
|
179
|
+
}
|
|
180
|
+
if (repo or "").strip().lower() not in allowed:
|
|
181
|
+
return {
|
|
182
|
+
"error": "repo_not_allowlisted",
|
|
183
|
+
"repo": repo,
|
|
184
|
+
"allowed": sorted(allowed),
|
|
185
|
+
"hint": (
|
|
186
|
+
"This repo is not in DELIMIT_ALLOWED_REPOS. Add it to the env "
|
|
187
|
+
"var or use a different tool that does not reach external APIs. "
|
|
188
|
+
"See delimit-ai/delimit-mcp-server#40 for context."
|
|
189
|
+
),
|
|
190
|
+
}
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
139
194
|
def _sanitize_subprocess_arg(value: str, label: str = "argument") -> str:
|
|
140
195
|
"""Sanitize a single subprocess argument against injection.
|
|
141
196
|
|
|
@@ -2394,6 +2449,321 @@ def delimit_intel_query(
|
|
|
2394
2449
|
return _with_next_steps("intel_query", _safe_call(intel_query, dataset_id=dataset_id, query=query, parameters=parameters))
|
|
2395
2450
|
|
|
2396
2451
|
|
|
2452
|
+
# ─── Digest (LED-966 founder daily summary) ─────────────────────────────
|
|
2453
|
+
|
|
2454
|
+
@mcp.tool()
|
|
2455
|
+
def delimit_digest(
|
|
2456
|
+
action: str = "run",
|
|
2457
|
+
window_hours: int = 24,
|
|
2458
|
+
send_email: bool = False,
|
|
2459
|
+
to: str = "",
|
|
2460
|
+
) -> Dict[str, Any]:
|
|
2461
|
+
"""Generate a daily digest of loop activity (LED-966).
|
|
2462
|
+
|
|
2463
|
+
Produces a structured summary of the last window_hours:
|
|
2464
|
+
- signals ingested by platform
|
|
2465
|
+
- deliberations held + consensus rate
|
|
2466
|
+
- ledger items opened/completed
|
|
2467
|
+
- swarm dispatches + stuck-task count
|
|
2468
|
+
- health (pause file, guard hits)
|
|
2469
|
+
|
|
2470
|
+
Always writes markdown + json artifacts to ~/.delimit/digest/
|
|
2471
|
+
so the founder can inspect without relying on email delivery.
|
|
2472
|
+
Optionally emails the digest via the notify pipeline.
|
|
2473
|
+
|
|
2474
|
+
Args:
|
|
2475
|
+
action: 'run' to build and write, 'latest' to return existing file paths.
|
|
2476
|
+
window_hours: lookback window. Default 24.
|
|
2477
|
+
send_email: if True, attempt to email the digest to `to` or
|
|
2478
|
+
DELIMIT_SMTP_TO. Requires DELIMIT_DIGEST_EMAIL=true
|
|
2479
|
+
in the env to actually send (pipeline gate).
|
|
2480
|
+
to: recipient email. Defaults to DELIMIT_SMTP_TO.
|
|
2481
|
+
"""
|
|
2482
|
+
from ai.daily_digest import write_digest, send_digest_email, DIGEST_DIR
|
|
2483
|
+
|
|
2484
|
+
if action == "latest":
|
|
2485
|
+
if not DIGEST_DIR.exists():
|
|
2486
|
+
return _with_next_steps("digest", {"error": "no digest directory yet"})
|
|
2487
|
+
mds = sorted(DIGEST_DIR.glob("digest-*.md"), reverse=True)
|
|
2488
|
+
if not mds:
|
|
2489
|
+
return _with_next_steps("digest", {"error": "no digest files written yet"})
|
|
2490
|
+
latest = mds[0]
|
|
2491
|
+
return _with_next_steps("digest", {
|
|
2492
|
+
"action": "latest",
|
|
2493
|
+
"markdown_path": str(latest),
|
|
2494
|
+
"json_path": str(latest.with_suffix(".json")),
|
|
2495
|
+
"preview": latest.read_text()[:2000],
|
|
2496
|
+
})
|
|
2497
|
+
|
|
2498
|
+
if action == "run":
|
|
2499
|
+
if send_email:
|
|
2500
|
+
result = _safe_call(send_digest_email, to=to)
|
|
2501
|
+
else:
|
|
2502
|
+
result = _safe_call(write_digest, window_hours=window_hours)
|
|
2503
|
+
return _with_next_steps("digest", result or {"error": "digest call failed"})
|
|
2504
|
+
|
|
2505
|
+
return _with_next_steps("digest", {
|
|
2506
|
+
"error": f"unknown action: {action!r}",
|
|
2507
|
+
"valid_actions": ["run", "latest"],
|
|
2508
|
+
})
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
# ─── Work Orders (STR-177 structured execution) ─────────────────────────
|
|
2512
|
+
|
|
2513
|
+
@mcp.tool()
|
|
2514
|
+
def delimit_work_orders(
|
|
2515
|
+
action: str = "list",
|
|
2516
|
+
status: str = "pending",
|
|
2517
|
+
wo_id: str = "",
|
|
2518
|
+
note: str = "",
|
|
2519
|
+
) -> Dict[str, Any]:
|
|
2520
|
+
"""Manage work orders — structured task artifacts for the founder (STR-177).
|
|
2521
|
+
|
|
2522
|
+
Work orders bridge strategy deliberations and interactive execution.
|
|
2523
|
+
Each is a copy-pasteable markdown file the founder can hand to a
|
|
2524
|
+
Claude Code session.
|
|
2525
|
+
|
|
2526
|
+
Args:
|
|
2527
|
+
action: 'list' (show pending), 'show' (read one), 'complete' (mark done).
|
|
2528
|
+
status: Filter for list: 'pending', 'completed', 'all'.
|
|
2529
|
+
wo_id: Work order ID for 'show' and 'complete'.
|
|
2530
|
+
note: Completion note for 'complete'.
|
|
2531
|
+
"""
|
|
2532
|
+
from ai.work_order import list_work_orders, complete_work_order, WORK_ORDERS_DIR
|
|
2533
|
+
|
|
2534
|
+
if action == "list":
|
|
2535
|
+
orders = _safe_call(list_work_orders, status=status)
|
|
2536
|
+
return _with_next_steps("work_orders", {
|
|
2537
|
+
"action": "list",
|
|
2538
|
+
"status": status,
|
|
2539
|
+
"count": len(orders) if isinstance(orders, list) else 0,
|
|
2540
|
+
"orders": orders,
|
|
2541
|
+
})
|
|
2542
|
+
|
|
2543
|
+
if action == "show":
|
|
2544
|
+
if not wo_id:
|
|
2545
|
+
return _with_next_steps("work_orders", {"error": "wo_id required for action=show"})
|
|
2546
|
+
md_path = WORK_ORDERS_DIR / f"{wo_id}.md"
|
|
2547
|
+
if not md_path.exists():
|
|
2548
|
+
return _with_next_steps("work_orders", {"error": f"{wo_id} not found"})
|
|
2549
|
+
return _with_next_steps("work_orders", {
|
|
2550
|
+
"action": "show",
|
|
2551
|
+
"id": wo_id,
|
|
2552
|
+
"content": md_path.read_text(),
|
|
2553
|
+
})
|
|
2554
|
+
|
|
2555
|
+
if action == "complete":
|
|
2556
|
+
if not wo_id:
|
|
2557
|
+
return _with_next_steps("work_orders", {"error": "wo_id required for action=complete"})
|
|
2558
|
+
result = _safe_call(complete_work_order, wo_id=wo_id, note=note)
|
|
2559
|
+
return _with_next_steps("work_orders", result or {"error": "complete failed"})
|
|
2560
|
+
|
|
2561
|
+
return _with_next_steps("work_orders", {
|
|
2562
|
+
"error": f"unknown action: {action!r}",
|
|
2563
|
+
"valid_actions": ["list", "show", "complete"],
|
|
2564
|
+
})
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
# ─── Executor (LED-981 Worker Pool v2) ──────────────────────────────────
|
|
2568
|
+
|
|
2569
|
+
@mcp.tool()
|
|
2570
|
+
def delimit_executor(
|
|
2571
|
+
action: str = "status",
|
|
2572
|
+
wo_id: str = "",
|
|
2573
|
+
live: bool = False,
|
|
2574
|
+
executed_by: str = "",
|
|
2575
|
+
) -> Dict[str, Any]:
|
|
2576
|
+
"""Run approved work orders from the dashboard inbox (Pro, Worker Pool v2).
|
|
2577
|
+
|
|
2578
|
+
Execution is bounded to a narrow whitelist of state-changing actions
|
|
2579
|
+
(gh_issue_create, gh_pr_comment, gh_issue_comment). Every invocation
|
|
2580
|
+
is logged to ~/.delimit/workers/audit/executor.jsonl. Dry-run is the
|
|
2581
|
+
default — pass live=True to actually fire the actions.
|
|
2582
|
+
|
|
2583
|
+
The dashboard Approve button flips a work order to status=approved.
|
|
2584
|
+
The poller (or a one-shot call with action=poll) then runs the
|
|
2585
|
+
typed executable_actions list. Touch ~/.delimit/pause_executor to
|
|
2586
|
+
stop the autonomous path at the next tick.
|
|
2587
|
+
|
|
2588
|
+
Args:
|
|
2589
|
+
action: 'run' (one work order), 'poll' (scan + run all approved),
|
|
2590
|
+
'status' (return paused + pending count), 'pause'/'resume'.
|
|
2591
|
+
wo_id: Required for action='run'.
|
|
2592
|
+
live: When False (default), dry-run — describes what would happen.
|
|
2593
|
+
executed_by: Identifier for the audit log (e.g. 'dashboard', 'cron').
|
|
2594
|
+
"""
|
|
2595
|
+
from ai.license import require_premium
|
|
2596
|
+
gate = require_premium("executor")
|
|
2597
|
+
if gate:
|
|
2598
|
+
return gate
|
|
2599
|
+
from ai.workers.executor import (
|
|
2600
|
+
execute_approved,
|
|
2601
|
+
poll_and_execute,
|
|
2602
|
+
is_paused,
|
|
2603
|
+
list_approved_pending,
|
|
2604
|
+
EXECUTOR_PAUSE_FILE,
|
|
2605
|
+
)
|
|
2606
|
+
|
|
2607
|
+
if action == "status":
|
|
2608
|
+
pending = list_approved_pending()
|
|
2609
|
+
return _with_next_steps("executor", {
|
|
2610
|
+
"paused": is_paused(),
|
|
2611
|
+
"pending_approved_count": len(pending),
|
|
2612
|
+
"pending_ids": [p.get("id") for p in pending[:10]],
|
|
2613
|
+
})
|
|
2614
|
+
|
|
2615
|
+
if action == "pause":
|
|
2616
|
+
EXECUTOR_PAUSE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
2617
|
+
EXECUTOR_PAUSE_FILE.touch()
|
|
2618
|
+
return _with_next_steps("executor", {"paused": True, "file": str(EXECUTOR_PAUSE_FILE)})
|
|
2619
|
+
|
|
2620
|
+
if action == "resume":
|
|
2621
|
+
try:
|
|
2622
|
+
EXECUTOR_PAUSE_FILE.unlink()
|
|
2623
|
+
except FileNotFoundError:
|
|
2624
|
+
pass
|
|
2625
|
+
return _with_next_steps("executor", {"paused": False})
|
|
2626
|
+
|
|
2627
|
+
if action == "run":
|
|
2628
|
+
if not wo_id:
|
|
2629
|
+
return _with_next_steps("executor", {"error": "wo_id required for action=run"})
|
|
2630
|
+
result = _safe_call(
|
|
2631
|
+
execute_approved,
|
|
2632
|
+
wo_id=wo_id,
|
|
2633
|
+
live=bool(live),
|
|
2634
|
+
executed_by=executed_by or "mcp",
|
|
2635
|
+
)
|
|
2636
|
+
return _with_next_steps("executor", result or {"error": "execute failed"})
|
|
2637
|
+
|
|
2638
|
+
if action == "poll":
|
|
2639
|
+
result = _safe_call(
|
|
2640
|
+
poll_and_execute,
|
|
2641
|
+
live=bool(live),
|
|
2642
|
+
executed_by=executed_by or "mcp_poll",
|
|
2643
|
+
)
|
|
2644
|
+
return _with_next_steps("executor", result or {"error": "poll failed"})
|
|
2645
|
+
|
|
2646
|
+
return _with_next_steps("executor", {
|
|
2647
|
+
"error": f"unknown action: {action!r}",
|
|
2648
|
+
"valid_actions": ["status", "run", "poll", "pause", "resume"],
|
|
2649
|
+
})
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
# ─── Sense (LED-877 signal corpus) ──────────────────────────────────────
|
|
2653
|
+
|
|
2654
|
+
@mcp.tool()
|
|
2655
|
+
def delimit_sense(
|
|
2656
|
+
action: str = "query",
|
|
2657
|
+
since_days: int = 1,
|
|
2658
|
+
platform: str = "",
|
|
2659
|
+
limit: int = 50,
|
|
2660
|
+
signal_id: str = "",
|
|
2661
|
+
ledger: str = "ops",
|
|
2662
|
+
priority: str = "P2",
|
|
2663
|
+
month: str = "",
|
|
2664
|
+
) -> Dict[str, Any]:
|
|
2665
|
+
"""Review and manage the signal corpus (LED-877).
|
|
2666
|
+
|
|
2667
|
+
Signals are sensed observations stored at ~/.delimit/intel/signals/,
|
|
2668
|
+
physically separated from the ledger. Use this tool to inspect,
|
|
2669
|
+
cluster, or explicitly promote a signal to a ledger item.
|
|
2670
|
+
|
|
2671
|
+
Args:
|
|
2672
|
+
action: One of 'query', 'digest', 'show', 'promote', 'freeze', 'status'.
|
|
2673
|
+
since_days: Lookback window in days (query/digest). Default 1 = last 24h.
|
|
2674
|
+
platform: Filter by source platform (reddit, x, github, hn). Empty = all.
|
|
2675
|
+
limit: Max rows to return (query). Default 50.
|
|
2676
|
+
signal_id: Signal id (SIG-XXXX) for 'show' and 'promote'.
|
|
2677
|
+
ledger: Target ledger for 'promote' (ops or strategy).
|
|
2678
|
+
priority: Priority for the promoted ledger item (P0/P1/P2).
|
|
2679
|
+
month: YYYY-MM for 'freeze' action (cold archive).
|
|
2680
|
+
|
|
2681
|
+
Examples:
|
|
2682
|
+
delimit_sense() # last 24h of signals
|
|
2683
|
+
delimit_sense(action="digest", since_days=7) # 7-day clusters
|
|
2684
|
+
delimit_sense(action="show", signal_id="SIG-ABC123")
|
|
2685
|
+
delimit_sense(action="promote", signal_id="SIG-ABC123", priority="P1")
|
|
2686
|
+
"""
|
|
2687
|
+
try:
|
|
2688
|
+
from ai.sensing import signal_store
|
|
2689
|
+
except ImportError as exc:
|
|
2690
|
+
return _with_next_steps("sense", {"error": f"signal store not available: {exc}"})
|
|
2691
|
+
|
|
2692
|
+
act = (action or "query").lower().strip()
|
|
2693
|
+
|
|
2694
|
+
if act == "query":
|
|
2695
|
+
rows = _safe_call(
|
|
2696
|
+
signal_store.query,
|
|
2697
|
+
since_days=since_days,
|
|
2698
|
+
platform=platform,
|
|
2699
|
+
limit=limit,
|
|
2700
|
+
)
|
|
2701
|
+
return _with_next_steps("sense", {
|
|
2702
|
+
"action": "query",
|
|
2703
|
+
"since_days": since_days,
|
|
2704
|
+
"platform": platform or "all",
|
|
2705
|
+
"count": len(rows) if isinstance(rows, list) else 0,
|
|
2706
|
+
"signals": rows,
|
|
2707
|
+
})
|
|
2708
|
+
|
|
2709
|
+
if act == "digest":
|
|
2710
|
+
result = _safe_call(signal_store.digest, since_days=since_days or 7, top_n=limit or 20)
|
|
2711
|
+
return _with_next_steps("sense", {"action": "digest", **(result or {})})
|
|
2712
|
+
|
|
2713
|
+
if act == "show":
|
|
2714
|
+
if not signal_id:
|
|
2715
|
+
return _with_next_steps("sense", {"error": "signal_id required for action=show"})
|
|
2716
|
+
found = _safe_call(signal_store._find_signal, signal_id=signal_id)
|
|
2717
|
+
if not found:
|
|
2718
|
+
return _with_next_steps("sense", {"error": f"signal {signal_id} not found"})
|
|
2719
|
+
return _with_next_steps("sense", {"action": "show", "signal": found})
|
|
2720
|
+
|
|
2721
|
+
if act == "promote":
|
|
2722
|
+
if not signal_id:
|
|
2723
|
+
return _with_next_steps("sense", {"error": "signal_id required for action=promote"})
|
|
2724
|
+
try:
|
|
2725
|
+
result = signal_store.promote_to_ledger(
|
|
2726
|
+
signal_id=signal_id,
|
|
2727
|
+
ledger=ledger,
|
|
2728
|
+
priority=priority,
|
|
2729
|
+
)
|
|
2730
|
+
except Exception as exc:
|
|
2731
|
+
return _with_next_steps("sense", {"error": f"promote failed: {exc}"})
|
|
2732
|
+
return _with_next_steps("sense", {"action": "promote", "result": result})
|
|
2733
|
+
|
|
2734
|
+
if act == "freeze":
|
|
2735
|
+
if not month:
|
|
2736
|
+
return _with_next_steps("sense", {"error": "month (YYYY-MM) required for action=freeze"})
|
|
2737
|
+
try:
|
|
2738
|
+
archive_path = signal_store.freeze_cold(month=month)
|
|
2739
|
+
except Exception as exc:
|
|
2740
|
+
return _with_next_steps("sense", {"error": f"freeze failed: {exc}"})
|
|
2741
|
+
return _with_next_steps("sense", {"action": "freeze", "month": month, "archive": archive_path})
|
|
2742
|
+
|
|
2743
|
+
if act == "status":
|
|
2744
|
+
try:
|
|
2745
|
+
from pathlib import Path
|
|
2746
|
+
shards = sorted(signal_store.SIGNALS_DIR.glob("*.jsonl")) if signal_store.SIGNALS_DIR.exists() else []
|
|
2747
|
+
archive = sorted((signal_store.SIGNALS_DIR / "archive").glob("*.jsonl")) if (signal_store.SIGNALS_DIR / "archive").exists() else []
|
|
2748
|
+
hot_shards = [p.name for p in shards if not p.name.startswith("_")]
|
|
2749
|
+
return _with_next_steps("sense", {
|
|
2750
|
+
"action": "status",
|
|
2751
|
+
"signals_dir": str(signal_store.SIGNALS_DIR),
|
|
2752
|
+
"hot_shards": hot_shards,
|
|
2753
|
+
"hot_shard_count": len(hot_shards),
|
|
2754
|
+
"archive_files": [p.name for p in archive],
|
|
2755
|
+
"hot_window_days": signal_store.HOT_WINDOW_DAYS,
|
|
2756
|
+
"warm_window_days": signal_store.WARM_WINDOW_DAYS,
|
|
2757
|
+
})
|
|
2758
|
+
except Exception as exc:
|
|
2759
|
+
return _with_next_steps("sense", {"error": f"status failed: {exc}"})
|
|
2760
|
+
|
|
2761
|
+
return _with_next_steps("sense", {
|
|
2762
|
+
"error": f"unknown action: {action!r}",
|
|
2763
|
+
"valid_actions": ["query", "digest", "show", "promote", "freeze", "status"],
|
|
2764
|
+
})
|
|
2765
|
+
|
|
2766
|
+
|
|
2397
2767
|
# ─── Generate ───────────────────────────────────────────────────────────
|
|
2398
2768
|
|
|
2399
2769
|
@mcp.tool()
|
|
@@ -3689,12 +4059,20 @@ async def delimit_sensor_github_issue(
|
|
|
3689
4059
|
since_comment_id: Last seen comment ID. Pass 0 to get all comments.
|
|
3690
4060
|
"""
|
|
3691
4061
|
import re as _re
|
|
3692
|
-
# Validate inputs
|
|
4062
|
+
# Validate inputs — defense-in-depth even though subprocess.run with
|
|
4063
|
+
# list argv (no shell=True) makes classic injection inert. See #40.
|
|
3693
4064
|
if not _re.match(r'^[\w.-]+/[\w.-]+$', repo):
|
|
3694
4065
|
return _with_next_steps("sensor_github_issue", {"error": f"Invalid repo format: {repo}. Use owner/repo."})
|
|
4066
|
+
if '..' in repo:
|
|
4067
|
+
return _with_next_steps("sensor_github_issue", {"error": f"Invalid repo: path traversal sequences not allowed"})
|
|
3695
4068
|
if not isinstance(issue_number, int) or issue_number <= 0:
|
|
3696
4069
|
return _with_next_steps("sensor_github_issue", {"error": f"Invalid issue number: {issue_number}"})
|
|
3697
4070
|
|
|
4071
|
+
# LED-881 / #40 confused-deputy guard
|
|
4072
|
+
refusal = _check_repo_allowlist(repo)
|
|
4073
|
+
if refusal is not None:
|
|
4074
|
+
return _with_next_steps("sensor_github_issue", refusal)
|
|
4075
|
+
|
|
3698
4076
|
try:
|
|
3699
4077
|
# Fetch comments
|
|
3700
4078
|
comments_jq = (
|
|
@@ -3809,6 +4187,19 @@ def delimit_sensor_github_migrations(
|
|
|
3809
4187
|
repos: List of GitHub repos in owner/repo format (e.g. ["chatwoot/chatwoot", "cal-com/cal.com"]).
|
|
3810
4188
|
limit: Max migration signals per repo. Default 20.
|
|
3811
4189
|
"""
|
|
4190
|
+
# LED-881 / #40 confused-deputy guard — applied per-repo.
|
|
4191
|
+
refusals = []
|
|
4192
|
+
for r in (repos or []):
|
|
4193
|
+
refusal = _check_repo_allowlist(r)
|
|
4194
|
+
if refusal is not None:
|
|
4195
|
+
refusals.append(refusal)
|
|
4196
|
+
if refusals:
|
|
4197
|
+
return _with_next_steps("sensor_github_migrations", {
|
|
4198
|
+
"error": "repo_not_allowlisted",
|
|
4199
|
+
"refused": refusals,
|
|
4200
|
+
"total_signals": 0,
|
|
4201
|
+
})
|
|
4202
|
+
|
|
3812
4203
|
try:
|
|
3813
4204
|
from ai.social_target import scan_github_migrations
|
|
3814
4205
|
signals = scan_github_migrations(repos=repos, limit=limit)
|
|
@@ -5443,6 +5834,7 @@ def delimit_deliberate(
|
|
|
5443
5834
|
mode: str = "dialogue",
|
|
5444
5835
|
max_rounds: int = 3,
|
|
5445
5836
|
save_path: str = "",
|
|
5837
|
+
scope: str = "",
|
|
5446
5838
|
) -> Dict[str, Any]:
|
|
5447
5839
|
"""Run multi-model consensus via real AI-to-AI deliberation (Pro).
|
|
5448
5840
|
|
|
@@ -5457,6 +5849,11 @@ def delimit_deliberate(
|
|
|
5457
5849
|
mode: "dialogue" (short turns) or "debate" (long essays).
|
|
5458
5850
|
max_rounds: Maximum rounds (default 3 for debate, 6 for dialogue).
|
|
5459
5851
|
save_path: Optional file path to save the full transcript.
|
|
5852
|
+
scope: Optional scope override — "strategic", "social", or
|
|
5853
|
+
"operational". When empty, the engine classifies from
|
|
5854
|
+
keywords in the question and context. Strategic and social
|
|
5855
|
+
scopes enforce the 3-model minimum (charter consensus-thresholds)
|
|
5856
|
+
and allow Grok as a tiebreaker on deadlock.
|
|
5460
5857
|
"""
|
|
5461
5858
|
from ai.license import require_premium
|
|
5462
5859
|
gate = require_premium("deliberate")
|
|
@@ -5469,8 +5866,14 @@ def delimit_deliberate(
|
|
|
5469
5866
|
mode=mode,
|
|
5470
5867
|
max_rounds=max_rounds,
|
|
5471
5868
|
save_path=save_path or "",
|
|
5869
|
+
scope=scope or "",
|
|
5472
5870
|
)
|
|
5473
5871
|
|
|
5872
|
+
# LED-978: a blocked deliberation returns an error dict; pass it straight
|
|
5873
|
+
# through so callers can act on it (widen scope, add models, etc).
|
|
5874
|
+
if result.get("error") and result.get("scope"):
|
|
5875
|
+
return result
|
|
5876
|
+
|
|
5474
5877
|
# Add summary for Claude to review
|
|
5475
5878
|
rounds_count = len(result.get("rounds", []))
|
|
5476
5879
|
unanimous = result.get("unanimous", False)
|
|
@@ -5483,12 +5886,21 @@ def delimit_deliberate(
|
|
|
5483
5886
|
"transcript_saved": result.get("saved_to", save_path),
|
|
5484
5887
|
"note": "Review the full transcript. As orchestrator, provide your own analysis and final synthesis.",
|
|
5485
5888
|
}
|
|
5486
|
-
|
|
5487
|
-
|
|
5889
|
+
if result.get("tiebreaker"):
|
|
5890
|
+
summary["tiebreaker"] = result["tiebreaker"]
|
|
5891
|
+
|
|
5892
|
+
# Include last round responses for immediate review. Only surface a
|
|
5893
|
+
# per-model field when the model actually spoke — otherwise we'd emit
|
|
5894
|
+
# empty "grok_final_response" strings for every deliberation that
|
|
5895
|
+
# ran without Grok (true on every chat-login-only config), which
|
|
5896
|
+
# misleads readers into thinking Grok was there but silent.
|
|
5488
5897
|
if result.get("rounds"):
|
|
5489
5898
|
last_round = result["rounds"][-1]
|
|
5490
|
-
|
|
5491
|
-
|
|
5899
|
+
last_responses = last_round.get("responses") or {}
|
|
5900
|
+
for model_name in ("gemini", "claude", "codex", "vertex", "grok"):
|
|
5901
|
+
text = last_responses.get(model_name)
|
|
5902
|
+
if text:
|
|
5903
|
+
summary[f"{model_name}_final_response"] = text[:2000]
|
|
5492
5904
|
|
|
5493
5905
|
# Auto-create ledger items from deliberation findings
|
|
5494
5906
|
if unanimous and result.get("rounds"):
|
|
@@ -7051,7 +7463,8 @@ def delimit_daemon_run(iterations: int = 1, dry_run: bool = True) -> Dict[str, A
|
|
|
7051
7463
|
))
|
|
7052
7464
|
|
|
7053
7465
|
@mcp.tool()
|
|
7054
|
-
def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str = "build"
|
|
7466
|
+
def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str = "build",
|
|
7467
|
+
cycle_mode: str = "full") -> Dict[str, Any]:
|
|
7055
7468
|
"""Execute a governed continuous loop (LED-239).
|
|
7056
7469
|
|
|
7057
7470
|
Supports four loop types:
|
|
@@ -7066,6 +7479,9 @@ def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str
|
|
|
7066
7479
|
action: 'init' to start a session, 'run' to execute one iteration.
|
|
7067
7480
|
session_id: Optional session ID to continue.
|
|
7068
7481
|
loop_type: 'cycle', 'build', 'social', or 'deploy' (default: build).
|
|
7482
|
+
cycle_mode: 'sense' (think+strategy), 'execute' (build+deploy),
|
|
7483
|
+
'full' (all stages). Only applies to loop_type='cycle'.
|
|
7484
|
+
Daemon uses 'sense', interactive sessions use 'full' or 'execute'.
|
|
7069
7485
|
"""
|
|
7070
7486
|
from ai.loop_engine import (
|
|
7071
7487
|
create_governed_session, run_governed_iteration,
|
|
@@ -7078,7 +7494,7 @@ def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str
|
|
|
7078
7494
|
if not session_id:
|
|
7079
7495
|
session_id = create_governed_session(loop_type=loop_type)["session_id"]
|
|
7080
7496
|
if loop_type == "cycle":
|
|
7081
|
-
return _with_next_steps("build_loop", run_full_cycle(session_id))
|
|
7497
|
+
return _with_next_steps("build_loop", run_full_cycle(session_id, cycle_mode=cycle_mode))
|
|
7082
7498
|
elif loop_type == "social" or session_id.startswith("social-"):
|
|
7083
7499
|
return _with_next_steps("build_loop", run_social_iteration(session_id))
|
|
7084
7500
|
else:
|