delimit-cli 4.1.53 → 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.
@@ -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()
@@ -3698,6 +4068,11 @@ async def delimit_sensor_github_issue(
3698
4068
  if not isinstance(issue_number, int) or issue_number <= 0:
3699
4069
  return _with_next_steps("sensor_github_issue", {"error": f"Invalid issue number: {issue_number}"})
3700
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
+
3701
4076
  try:
3702
4077
  # Fetch comments
3703
4078
  comments_jq = (
@@ -3812,6 +4187,19 @@ def delimit_sensor_github_migrations(
3812
4187
  repos: List of GitHub repos in owner/repo format (e.g. ["chatwoot/chatwoot", "cal-com/cal.com"]).
3813
4188
  limit: Max migration signals per repo. Default 20.
3814
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
+
3815
4203
  try:
3816
4204
  from ai.social_target import scan_github_migrations
3817
4205
  signals = scan_github_migrations(repos=repos, limit=limit)
@@ -5446,6 +5834,7 @@ def delimit_deliberate(
5446
5834
  mode: str = "dialogue",
5447
5835
  max_rounds: int = 3,
5448
5836
  save_path: str = "",
5837
+ scope: str = "",
5449
5838
  ) -> Dict[str, Any]:
5450
5839
  """Run multi-model consensus via real AI-to-AI deliberation (Pro).
5451
5840
 
@@ -5460,6 +5849,11 @@ def delimit_deliberate(
5460
5849
  mode: "dialogue" (short turns) or "debate" (long essays).
5461
5850
  max_rounds: Maximum rounds (default 3 for debate, 6 for dialogue).
5462
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.
5463
5857
  """
5464
5858
  from ai.license import require_premium
5465
5859
  gate = require_premium("deliberate")
@@ -5472,8 +5866,14 @@ def delimit_deliberate(
5472
5866
  mode=mode,
5473
5867
  max_rounds=max_rounds,
5474
5868
  save_path=save_path or "",
5869
+ scope=scope or "",
5475
5870
  )
5476
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
+
5477
5877
  # Add summary for Claude to review
5478
5878
  rounds_count = len(result.get("rounds", []))
5479
5879
  unanimous = result.get("unanimous", False)
@@ -5486,12 +5886,21 @@ def delimit_deliberate(
5486
5886
  "transcript_saved": result.get("saved_to", save_path),
5487
5887
  "note": "Review the full transcript. As orchestrator, provide your own analysis and final synthesis.",
5488
5888
  }
5489
-
5490
- # Include last round responses for immediate review
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.
5491
5897
  if result.get("rounds"):
5492
5898
  last_round = result["rounds"][-1]
5493
- summary["gemini_final_response"] = last_round["responses"].get("gemini", "")[:2000]
5494
- summary["grok_final_response"] = last_round["responses"].get("grok", "")[:2000]
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]
5495
5904
 
5496
5905
  # Auto-create ledger items from deliberation findings
5497
5906
  if unanimous and result.get("rounds"):
@@ -7054,7 +7463,8 @@ def delimit_daemon_run(iterations: int = 1, dry_run: bool = True) -> Dict[str, A
7054
7463
  ))
7055
7464
 
7056
7465
  @mcp.tool()
7057
- def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str = "build") -> Dict[str, Any]:
7466
+ def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str = "build",
7467
+ cycle_mode: str = "full") -> Dict[str, Any]:
7058
7468
  """Execute a governed continuous loop (LED-239).
7059
7469
 
7060
7470
  Supports four loop types:
@@ -7069,6 +7479,9 @@ def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str
7069
7479
  action: 'init' to start a session, 'run' to execute one iteration.
7070
7480
  session_id: Optional session ID to continue.
7071
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'.
7072
7485
  """
7073
7486
  from ai.loop_engine import (
7074
7487
  create_governed_session, run_governed_iteration,
@@ -7081,7 +7494,7 @@ def delimit_build_loop(action: str = "run", session_id: str = "", loop_type: str
7081
7494
  if not session_id:
7082
7495
  session_id = create_governed_session(loop_type=loop_type)["session_id"]
7083
7496
  if loop_type == "cycle":
7084
- 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))
7085
7498
  elif loop_type == "social" or session_id.startswith("social-"):
7086
7499
  return _with_next_steps("build_loop", run_social_iteration(session_id))
7087
7500
  else: