delimit-cli 4.3.4 → 4.5.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -18
  3. package/adapters/codex-security.js +64 -0
  4. package/adapters/codex-skill.js +78 -0
  5. package/adapters/cursor-rules.js +73 -0
  6. package/bin/delimit-setup.js +23 -0
  7. package/gateway/ai/backends/governance_bridge.py +168 -2
  8. package/gateway/ai/backends/memory_bridge.py +218 -3
  9. package/gateway/ai/backends/tools_design.py +563 -83
  10. package/gateway/ai/backends/tools_infra.py +21 -7
  11. package/gateway/ai/backends/tools_real.py +3 -1
  12. package/gateway/ai/content_grounding/__init__.py +98 -0
  13. package/gateway/ai/content_grounding/build.py +350 -0
  14. package/gateway/ai/content_grounding/consume.py +280 -0
  15. package/gateway/ai/content_grounding/features.py +218 -0
  16. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
  17. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
  18. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
  19. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
  20. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
  21. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
  22. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
  23. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
  24. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
  25. package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
  26. package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
  27. package/gateway/ai/content_grounding/schemas.py +276 -0
  28. package/gateway/ai/content_grounding/telemetry.py +221 -0
  29. package/gateway/ai/governance.py +89 -0
  30. package/gateway/ai/hot_reload.py +148 -7
  31. package/gateway/ai/inbox_drafts/__init__.py +61 -0
  32. package/gateway/ai/inbox_drafts/registry.py +412 -0
  33. package/gateway/ai/inbox_drafts/schema.py +374 -0
  34. package/gateway/ai/inbox_executor.py +565 -0
  35. package/gateway/ai/ledger_manager.py +1483 -25
  36. package/gateway/ai/license_core.py +3 -1
  37. package/gateway/ai/mcp_bridge.py +1 -1
  38. package/gateway/ai/reddit_proxy.py +8 -6
  39. package/gateway/ai/server.py +451 -9
  40. package/gateway/ai/supabase_sync.py +47 -7
  41. package/gateway/ai/swarm.py +1 -1
  42. package/gateway/ai/workers/executor.py +1 -1
  43. package/gateway/core/diff_engine_v2.py +45 -10
  44. package/gateway/core/zero_spec/express_extractor.py +1 -1
  45. package/lib/delimit-template.js +5 -0
  46. package/package.json +1 -1
@@ -24,7 +24,9 @@ PRO_TOOLS = frozenset({
24
24
  "delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
25
25
  "delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
26
26
  "delimit_deploy_site", "delimit_deploy_npm",
27
- "delimit_memory_store", "delimit_memory_search", "delimit_memory_recent",
27
+ # delimit_memory_store + delimit_memory_recent are FREE (LED-193 — basic
28
+ # store + recent retrieval). Only delimit_memory_search is Pro.
29
+ "delimit_memory_search",
28
30
  "delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
29
31
  "delimit_evidence_collect", "delimit_evidence_verify",
30
32
  "delimit_deliberate", "delimit_models",
@@ -28,7 +28,7 @@ class MCPSubprocessClient:
28
28
  self._request_id = 0
29
29
 
30
30
  def start(self):
31
- self._proc = subprocess.Popen(
31
+ self._proc = subprocess.Popen( # nosec B-subprocess_shell: MCP bridge spawns user-configured CLI via shell; argv validated upstream
32
32
  self.command, shell=True,
33
33
  stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
34
34
  )
@@ -54,9 +54,10 @@ def fetch_subreddit(subreddit: str, sort: str = "new", limit: int = 10) -> List[
54
54
  try:
55
55
  fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
56
56
  headers = {"User-Agent": "Delimit/1.0"}
57
- token = proxy_cfg.get("token", "")
58
- if token:
59
- headers["Authorization"] = f"Bearer {token}"
57
+ # nosec B105 — reads proxy auth credential from config, not a hardcoded secret
58
+ auth_token = proxy_cfg.get("token", "")
59
+ if auth_token:
60
+ headers["Authorization"] = f"Bearer {auth_token}"
60
61
  req = urllib.request.Request(fetch_url, headers=headers)
61
62
  with urllib.request.urlopen(req, timeout=10) as resp:
62
63
  body = json.loads(resp.read().decode())
@@ -100,9 +101,10 @@ def fetch_thread(thread_id: str) -> Optional[Dict[str, Any]]:
100
101
  try:
101
102
  fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
102
103
  headers = {"User-Agent": "Delimit/1.0"}
103
- token = proxy_cfg.get("token", "")
104
- if token:
105
- headers["Authorization"] = f"Bearer {token}"
104
+ # nosec B105 — reads proxy auth credential from config, not a hardcoded secret
105
+ auth_token = proxy_cfg.get("token", "")
106
+ if auth_token:
107
+ headers["Authorization"] = f"Bearer {auth_token}"
106
108
  req = urllib.request.Request(fetch_url, headers=headers)
107
109
  with urllib.request.urlopen(req, timeout=10) as resp:
108
110
  data = json.loads(resp.read().decode())
@@ -2045,6 +2045,33 @@ def delimit_gov_verify(task_id: str = "", repo: str = ".") -> Dict[str, Any]:
2045
2045
  return _delimit_gov_impl(action="verify", task_id=task_id, repo=repo)
2046
2046
 
2047
2047
 
2048
+ @mcp.tool()
2049
+ def delimit_external_pr_check(
2050
+ repo: str,
2051
+ author: str = "",
2052
+ state: str = "all",
2053
+ ) -> Dict[str, Any]:
2054
+ """Pre-PR duplicate guard for external repos. Run BEFORE drafting.
2055
+
2056
+ Lists existing PRs from `author` against `repo` via gh CLI. Returns
2057
+ fail-closed verdict — any open PR or PR merged in the last 30 days
2058
+ yields verdict='duplicate' so the caller stops drafting before any
2059
+ deliberation or submission work.
2060
+
2061
+ Args:
2062
+ repo: External GitHub repo, e.g. "goharbor/harbor".
2063
+ author: GitHub username to filter by (recommended). Empty = all.
2064
+ state: "open" | "closed" | "merged" | "all". Default "all".
2065
+ """
2066
+ from backends.governance_bridge import external_pr_check
2067
+ return _safe_call(
2068
+ external_pr_check,
2069
+ repo=repo,
2070
+ author=author or None,
2071
+ state=state,
2072
+ )
2073
+
2074
+
2048
2075
  # ─── Memory ─────────────────────────────────────────────────────────────
2049
2076
 
2050
2077
  @mcp.tool()
@@ -2068,6 +2095,7 @@ def delimit_memory_store(
2068
2095
  content: str,
2069
2096
  tags: Optional[Union[str, List[str]]] = None,
2070
2097
  context: Optional[str] = None,
2098
+ hot_load: bool = False,
2071
2099
  ) -> Dict[str, Any]:
2072
2100
  """Store a memory entry for future retrieval.
2073
2101
 
@@ -2078,6 +2106,12 @@ def delimit_memory_store(
2078
2106
  content: The content to remember.
2079
2107
  tags: Optional categorization tags.
2080
2108
  context: Optional context about when/why this was stored.
2109
+ hot_load: LED-1165 Phase 2 #5: when True, mark the entry for
2110
+ one-way projection into the Claude Code auto-memory
2111
+ `MEMORY.md` hot-load index. Existing entries default to False
2112
+ (durable in delimit_memory, not projected). The projection
2113
+ writer that consumes this flag is shipped separately as PR-B;
2114
+ this PR only persists the flag.
2081
2115
  """
2082
2116
  # LED-193: memory_store is now free (basic store)
2083
2117
  try:
@@ -2085,7 +2119,10 @@ def delimit_memory_store(
2085
2119
  except ValueError as e:
2086
2120
  return _with_next_steps("memory_store", {"error": str(e)})
2087
2121
  from backends.memory_bridge import store
2088
- return _with_next_steps("memory_store", _safe_call(store, content=content, tags=tags, context=context))
2122
+ return _with_next_steps(
2123
+ "memory_store",
2124
+ _safe_call(store, content=content, tags=tags, context=context, hot_load=hot_load),
2125
+ )
2089
2126
 
2090
2127
 
2091
2128
  @mcp.tool()
@@ -2102,6 +2139,50 @@ def delimit_memory_recent(limit: int = 5) -> Dict[str, Any]:
2102
2139
  return _with_next_steps("memory_recent", _safe_call(get_recent, limit=limit))
2103
2140
 
2104
2141
 
2142
+ @mcp.tool()
2143
+ def delimit_memory_index(
2144
+ target_path: str = "",
2145
+ dry_run: bool = False,
2146
+ limit: int = 200,
2147
+ ) -> Dict[str, Any]:
2148
+ """Project delimit_memory hot entries into Claude Code's MEMORY.md.
2149
+
2150
+ LED-1165 Phase 2 #5 PR-B. One-way projection of all delimit_memory
2151
+ entries flagged `hot_load=True` into a managed section of the
2152
+ target Markdown file. Lets Claude Code see hot entries on session
2153
+ start without making delimit_memory dependent on Anthropic's
2154
+ auto-memory format.
2155
+
2156
+ Behavior:
2157
+ - If target_path's file has `<!-- delimit:start -->` /
2158
+ `<!-- delimit:end -->` markers, ONLY the content between them
2159
+ is replaced. Anything outside the markers is preserved.
2160
+ - If markers are missing, the managed section is APPENDED to the
2161
+ end of the file (existing content is never touched).
2162
+ - If the file doesn't exist, it's created with just the section.
2163
+ - **One-way projection only.** Reading MEMORY.md back into
2164
+ delimit_memory is explicitly out of scope (Anthropic owns the
2165
+ auto-memory format; format-drift risk).
2166
+
2167
+ Args:
2168
+ target_path: file to write. Empty = default
2169
+ ~/.claude/projects/-root/memory/MEMORY.md.
2170
+ dry_run: True returns the rendered content size without writing.
2171
+ limit: cap on entries projected. Default 200.
2172
+
2173
+ Returns:
2174
+ {target, dry_run, entries, wrote_chars or would_write_chars,
2175
+ had_existing_block, had_existing_file, preserved_user_content}
2176
+ """
2177
+ from backends.memory_bridge import project_to_memory_md
2178
+ from pathlib import Path
2179
+ target = Path(target_path) if target_path else None
2180
+ return _with_next_steps(
2181
+ "memory_index",
2182
+ _safe_call(project_to_memory_md, target_path=target, dry_run=dry_run, limit=limit),
2183
+ )
2184
+
2185
+
2105
2186
  # ─── Vault ──────────────────────────────────────────────────────────────
2106
2187
 
2107
2188
  @mcp.tool()
@@ -5436,26 +5517,309 @@ def delimit_ledger_done(item_id: str, note: str = "", venture: str = "") -> Dict
5436
5517
  return _with_next_steps("ledger_done", result)
5437
5518
 
5438
5519
 
5520
+ @mcp.tool()
5521
+ def delimit_ledger_bulk(
5522
+ item_ids: str,
5523
+ action: str,
5524
+ dry_run: bool = True,
5525
+ note: str = "",
5526
+ new_status: str = "",
5527
+ new_priority: str = "",
5528
+ tag: str = "",
5529
+ venture: str = "",
5530
+ ) -> Dict[str, Any]:
5531
+ """Apply one action to many ledger items in a single call.
5532
+
5533
+ LED-1145 Phase 1 PR-B. Default `dry_run=True` returns what would change
5534
+ without writing — callers must explicitly pass `dry_run=False` to apply.
5535
+ Per-item failures don't block other items in the batch.
5536
+
5537
+ Allowed actions:
5538
+ - archive -> sets status="archived" (soft, replay-preserving)
5539
+ - mark_done -> sets status="done"
5540
+ - cancel -> sets status="cancelled"
5541
+ - set_status -> sets status to `new_status`
5542
+ (one of open/in_progress/blocked/done/cancelled/archived/completed)
5543
+ - set_priority -> sets priority to `new_priority`
5544
+ (one of P0/P1/P2/P3)
5545
+ - add_tag -> appends `tag` if not already present (idempotent)
5546
+
5547
+ NO hard delete. Items remain in the append-only JSONL forever; archive is
5548
+ a status transition that can be reversed via set_status.
5549
+
5550
+ Args:
5551
+ item_ids: comma-separated LED ids (e.g. "LED-915,LED-916,LED-918")
5552
+ or a JSON array of strings.
5553
+ action: one of the actions above.
5554
+ dry_run: True (default) returns `would_change`; False applies and
5555
+ returns `changed`.
5556
+ note: optional note attached to every successful update event.
5557
+ new_status: required when action="set_status".
5558
+ new_priority: required when action="set_priority".
5559
+ tag: required when action="add_tag".
5560
+ venture: project name or path. Auto-detects if empty.
5561
+ """
5562
+ from ai.ledger_manager import bulk_action
5563
+ project = _resolve_venture(venture)
5564
+ result = bulk_action(
5565
+ item_ids=item_ids,
5566
+ action=action,
5567
+ dry_run=dry_run,
5568
+ note=note or None,
5569
+ new_status=new_status or None,
5570
+ new_priority=new_priority or None,
5571
+ tag=tag or None,
5572
+ project_path=project,
5573
+ )
5574
+ return _with_next_steps("ledger_bulk", result)
5575
+
5576
+
5577
+ @mcp.tool()
5578
+ def delimit_ledger_auto_close_external(
5579
+ venture: str = "",
5580
+ dry_run: bool = True,
5581
+ max_items: int = 200,
5582
+ ) -> Dict[str, Any]:
5583
+ """Walk open ledger items, find ones linked to a GitHub issue/PR, and
5584
+ close LEDs whose external counterpart already resolved.
5585
+
5586
+ LED-1145 Phase 2 #1. Fixes the "ledger drifts from external reality"
5587
+ problem (e.g., LED-1023 stayed open even though goharbor/harbor#23089
5588
+ merged 16 days earlier).
5589
+
5590
+ Detection scans description / context / last_note / tags for:
5591
+ - https://github.com/<owner>/<repo>/(issues|pull)/<num>
5592
+ - <owner>/<repo>#<num> (short form)
5593
+ - gh:<owner>/<repo>/<num> (explicit tag form)
5594
+
5595
+ Action map (per LED-1146 deliberation):
5596
+ - PR with merged=true → mark_done with merge SHA in note
5597
+ - issue/PR closed with state_reason="completed" → mark_done with closed_at
5598
+ - issue/PR closed with state_reason="not_planned" or no reason → archive
5599
+ - state="open" → leave alone
5600
+ - gh API error / 404 → leave alone, recorded in `errors`
5601
+
5602
+ Implementation re-uses bulk_action() under the hood; nothing new on the
5603
+ write path. dry_run=True (default) returns a plan; dry_run=False applies.
5604
+
5605
+ Args:
5606
+ venture: project name or path. Auto-detects if empty.
5607
+ dry_run: True (default) returns a plan without writing.
5608
+ max_items: hard cap on items processed in one call (default 200).
5609
+ When the candidate set exceeds this, the response is `truncated=True`.
5610
+ """
5611
+ from ai.ledger_manager import auto_close_linked_external
5612
+ project = _resolve_venture(venture)
5613
+ result = auto_close_linked_external(
5614
+ project_path=project,
5615
+ dry_run=dry_run,
5616
+ max_items=max_items,
5617
+ )
5618
+ return _with_next_steps("ledger_auto_close_external", result)
5619
+
5620
+
5621
+ @mcp.tool()
5622
+ def delimit_ledger_groom(
5623
+ venture: str = "",
5624
+ stale_days: int = 30,
5625
+ dup_min_count: int = 3,
5626
+ max_per_category: int = 50,
5627
+ ) -> Dict[str, Any]:
5628
+ """Read-only grooming proposal: surfaces stale, duplicate, and
5629
+ garbage-venture items for the founder to review.
5630
+
5631
+ LED-1145 Phase 2 #2. Risky operations (mass-cancellation, dedup-merge)
5632
+ must NOT be a single atomic action — this tool only PROPOSES; the
5633
+ founder applies via `delimit_ledger_bulk` after review. Each proposal
5634
+ in the response includes a copy-pasteable `ready_to_apply` invocation.
5635
+
5636
+ Categories detected:
5637
+ - stale_open: status open|in_progress|blocked AND updated_at older
5638
+ than `stale_days`. Suggested action: archive.
5639
+ - duplicate_titles: groups of >= `dup_min_count` items sharing the
5640
+ same normalised title prefix (50 chars, [BRACKETED] prefixes
5641
+ stripped, lowercased). Suggested: keep the most-recent item;
5642
+ archive the others.
5643
+ - garbage_venture: items in tmp* / test_* / venture_<letter> /
5644
+ custom-venture buckets. Suggested action: archive.
5645
+
5646
+ Categories NOT detected here (separate tools / future PRs):
5647
+ - linked-external resolved → use `delimit_ledger_auto_close_external`
5648
+ - P0 inflation review
5649
+ - cross-venture orphan cleanup
5650
+
5651
+ Args:
5652
+ venture: project name or path. Auto-detects if empty.
5653
+ stale_days: threshold for stale_open detector (default 30).
5654
+ dup_min_count: minimum group size for duplicate_titles (default 3).
5655
+ max_per_category: cap per category in the response (default 50).
5656
+ """
5657
+ from ai.ledger_manager import groom_proposal
5658
+ project = _resolve_venture(venture)
5659
+ result = groom_proposal(
5660
+ project_path=project,
5661
+ stale_days=stale_days,
5662
+ dup_min_count=dup_min_count,
5663
+ max_per_category=max_per_category,
5664
+ )
5665
+ return _with_next_steps("ledger_groom", result)
5666
+
5667
+
5668
+ @mcp.tool()
5669
+ def delimit_ledger_auto_cancel_stale(
5670
+ venture: str = "",
5671
+ threshold_days: int = 0,
5672
+ dry_run: bool = True,
5673
+ max_items: int = 200,
5674
+ ) -> Dict[str, Any]:
5675
+ """Auto-archive open ledger items dormant past the stale-TTL threshold.
5676
+
5677
+ LED-1145 Phase 2 #4. Composes the stale-detector logic with
5678
+ `bulk_action(action="archive")` from Phase 1 PR-B. Same dry_run-default
5679
+ safety pattern as `delimit_ledger_auto_close_external` — caller passes
5680
+ `dry_run=False` to apply.
5681
+
5682
+ Distinct from `delimit_ledger_groom`'s stale_open category:
5683
+ - The threshold is stricter (default 60 days vs groom's 30)
5684
+ - It auto-applies on dry_run=False (groom is purely propose)
5685
+ - Intended for nightly automation / scripted cleanup
5686
+
5687
+ Items go through bulk_action(archive) so the no-hard-delete invariant
5688
+ is preserved — the JSONL append-only log keeps the full record.
5689
+
5690
+ Args:
5691
+ venture: Project name or path. Auto-detects if empty.
5692
+ threshold_days: dormancy threshold in days. 0 = read default
5693
+ (60 from STALE_TTL_DEFAULT_DAYS or DELIMIT_STALE_TTL_DAYS env).
5694
+ Pass an int to override.
5695
+ dry_run: True (default) returns the plan; False applies via
5696
+ bulk_action(archive).
5697
+ max_items: cap items processed per call. When the candidate list
5698
+ exceeds this, response includes truncated=True so the caller
5699
+ can run again to drain.
5700
+ """
5701
+ from ai.ledger_manager import auto_cancel_stale
5702
+ project = _resolve_venture(venture)
5703
+ threshold = threshold_days if threshold_days > 0 else None
5704
+ result = auto_cancel_stale(
5705
+ project_path=project,
5706
+ threshold_days=threshold,
5707
+ dry_run=dry_run,
5708
+ max_items=max_items,
5709
+ )
5710
+ return _with_next_steps("ledger_auto_cancel_stale", result)
5711
+
5712
+
5713
+ @mcp.tool()
5714
+ def delimit_ledger_health(
5715
+ venture: str = "",
5716
+ stale_days: int = 30,
5717
+ dup_min_count: int = 3,
5718
+ ) -> Dict[str, Any]:
5719
+ """One-shot ledger health check — composes list_items + groom_proposal
5720
+ + the P0 quota helper into a single traffic-light verdict and a list
5721
+ of concrete next actions.
5722
+
5723
+ LED-1145 capstone — closes the loop on the entire ledger-tooling
5724
+ refactor. Designed for nightly/weekly review or session-start status
5725
+ snapshot. Returns:
5726
+ - totals (unresolved / open / in_progress / blocked)
5727
+ - p0 (count vs quota + health)
5728
+ - stale (count >stale_days + health)
5729
+ - duplicates (group count + total items + health)
5730
+ - garbage_venture (count + health)
5731
+ - overall_health (worst-of: green / yellow / red)
5732
+ - next_actions: pre-formatted list of {reason, tool, args, follow_up}
5733
+
5734
+ All Phase 1+2 tools are referenced in the suggested actions, so the
5735
+ response is self-contained for an AI agent that wants to act on it.
5736
+
5737
+ Args:
5738
+ venture: project name or path. Auto-detects if empty.
5739
+ stale_days: stale-detector threshold passed to groom_proposal.
5740
+ dup_min_count: duplicate-detector threshold passed to groom_proposal.
5741
+ """
5742
+ from ai.ledger_manager import health_summary
5743
+ project = _resolve_venture(venture)
5744
+ result = health_summary(
5745
+ project_path=project,
5746
+ stale_days=stale_days,
5747
+ dup_min_count=dup_min_count,
5748
+ )
5749
+ return _with_next_steps("ledger_health", result)
5750
+
5751
+
5439
5752
  @mcp.tool()
5440
5753
  def delimit_ledger_list(
5441
5754
  venture: str = "",
5442
5755
  ledger: str = "both",
5443
5756
  status: str = "",
5444
5757
  priority: str = "",
5758
+ status_in: str = "",
5759
+ priority_in: str = "",
5760
+ tags_contains_all: str = "",
5761
+ text: str = "",
5762
+ linked_external_id: str = "",
5763
+ created_before: str = "",
5764
+ created_after: str = "",
5765
+ updated_before: str = "",
5766
+ updated_after: str = "",
5767
+ sort: str = "updated_at",
5768
+ order: str = "desc",
5769
+ fields: str = "",
5445
5770
  limit: int = 20,
5771
+ cursor: str = "",
5446
5772
  ) -> Dict[str, Any]:
5447
5773
  """List ledger items for a venture/project.
5448
5774
 
5775
+ LED-1145 Phase 1 PR-A: extended with multi-value filters, sort + projection,
5776
+ and cursor pagination. Single-value `status` / `priority` remain for
5777
+ backward compatibility — old callers continue to work unchanged.
5778
+
5449
5779
  Args:
5450
5780
  venture: Project name or path. Auto-detects if empty.
5451
- ledger: "ops", "strategy", or "both".
5452
- status: Filter by status - "open", "done", "in_progress", or empty for all.
5453
- priority: Filter by priority - "P0", "P1", "P2", or empty for all.
5454
- limit: Max items to return.
5781
+ ledger: "ops" | "strategy" | "both".
5782
+ status: single-value status filter (back-compat).
5783
+ priority: single-value priority filter (back-compat).
5784
+ status_in: comma-separated statuses (e.g. "open,blocked,in_progress").
5785
+ priority_in: comma-separated priorities (e.g. "P0,P1").
5786
+ tags_contains_all: comma-separated tags; item must contain ALL.
5787
+ text: case-insensitive substring match against title + description.
5788
+ linked_external_id: substring match in description / tags / context
5789
+ (e.g. a github URL, a Linear ticket id, a Discord thread).
5790
+ created_before / created_after / updated_before / updated_after:
5791
+ ISO-8601 timestamp boundaries (e.g. "2026-04-01T00:00:00Z").
5792
+ sort: "updated_at" | "created_at" | "priority". Default updated_at.
5793
+ order: "asc" | "desc". Default desc.
5794
+ fields: response projection. "" or "*" = full (default, back-compat).
5795
+ "slim" = id+title+status+priority+type+venture+updated_at only.
5796
+ CSV of field names = those only. Unknown field names → ERROR.
5797
+ limit: page size (default 20).
5798
+ cursor: opaque pagination token from a prior call's `next_cursor`.
5799
+ Cursor is invalidated when filters change between calls.
5455
5800
  """
5456
5801
  from ai.ledger_manager import list_items
5457
5802
  project = _resolve_venture(venture)
5458
- result = list_items(ledger=ledger, status=status or None, priority=priority or None, limit=limit, project_path=project)
5803
+ result = list_items(
5804
+ ledger=ledger,
5805
+ status=status or None,
5806
+ priority=priority or None,
5807
+ status__in=status_in or None,
5808
+ priority__in=priority_in or None,
5809
+ tags__contains_all=tags_contains_all or None,
5810
+ text=text or None,
5811
+ linked_external_id=linked_external_id or None,
5812
+ created_before=created_before or None,
5813
+ created_after=created_after or None,
5814
+ updated_before=updated_before or None,
5815
+ updated_after=updated_after or None,
5816
+ sort=sort,
5817
+ order=order,
5818
+ fields=fields or None,
5819
+ limit=limit,
5820
+ cursor=cursor or None,
5821
+ project_path=project,
5822
+ )
5459
5823
  return _with_next_steps("ledger_list", result)
5460
5824
 
5461
5825
 
@@ -7876,7 +8240,11 @@ def delimit_changelog(old_spec: str = "", new_spec: str = "", format: str = "mar
7876
8240
  def delimit_notify(channel: str = "webhook", message: str = "",
7877
8241
  webhook_url: str = "", subject: str = "",
7878
8242
  event_type: str = "", to: str = "",
7879
- from_account: str = "") -> Dict[str, Any]:
8243
+ from_account: str = "",
8244
+ draft_kind: str = "",
8245
+ draft_payload: Optional[Union[str, Dict[str, Any]]] = None,
8246
+ draft_target: Optional[Union[str, Dict[str, Any]]] = None,
8247
+ led_ref: str = "") -> Dict[str, Any]:
7880
8248
  """Send a notification (Pro).
7881
8249
 
7882
8250
  IMPORTANT - AUTO-TRIGGER RULE:
@@ -7905,9 +8273,80 @@ def delimit_notify(channel: str = "webhook", message: str = "",
7905
8273
  Send to any address - leave empty for default.
7906
8274
  from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
7907
8275
  (e.g. 'notifications@example.com'). Email only.
8276
+
8277
+ Optional inbox-executor binding (LED-1129 Phase 1, no auto-execution yet):
8278
+ draft_kind: One of github_comment, social_post, ledger_done,
8279
+ notify_routing_update, deploy_publish_prevalidated_artifact.
8280
+ When set, registers a signed draft in the local SQLite registry
8281
+ so a future executor can match founder Ship-it replies against it.
8282
+ draft_payload: The action contents (e.g. {"body": "..."} for github_comment).
8283
+ JSON string or dict. Required when draft_kind is set.
8284
+ draft_target: Where the action lands (e.g. {"repo":"x/y","issue":1}).
8285
+ JSON string or dict. Required when draft_kind is set.
8286
+ led_ref: Optional LED-XXXX tag tying the draft to its tracking item.
8287
+ Surfaced in subject-line matching by the executor.
8288
+
8289
+ Returns the notify result. When a draft was registered, also includes
8290
+ a `draft` block: {draft_id, draft_kind, signature, registered}.
7908
8291
  """
7909
8292
  from ai.notify import send_notification
7910
- return _with_next_steps("notify", _safe_call(
8293
+
8294
+ draft_meta: Optional[Dict[str, Any]] = None
8295
+ if draft_kind:
8296
+ # LED-1129 Phase 1: register a signed draft alongside the email
8297
+ # send. Phase 2 will wire the executor to consume these.
8298
+ try:
8299
+ from ai.inbox_drafts import (
8300
+ DraftKind,
8301
+ insert_draft,
8302
+ sign_draft,
8303
+ )
8304
+
8305
+ # Validate kind against the allowlist enum.
8306
+ try:
8307
+ DraftKind(draft_kind)
8308
+ except ValueError:
8309
+ return _with_next_steps("notify", {
8310
+ "error": (
8311
+ f"draft_kind must be one of "
8312
+ f"{[k.value for k in DraftKind]}; got '{draft_kind}'"
8313
+ ),
8314
+ })
8315
+
8316
+ # Coerce string args to dicts for callers that pass JSON strings.
8317
+ payload = _coerce_dict_arg(draft_payload, "draft_payload",
8318
+ string_key="body") if draft_payload is not None else None
8319
+ target = _coerce_dict_arg(draft_target, "draft_target",
8320
+ string_key="target") if draft_target is not None else None
8321
+
8322
+ if payload is None or target is None:
8323
+ return _with_next_steps("notify", {
8324
+ "error": "draft_kind requires both draft_payload and draft_target",
8325
+ })
8326
+
8327
+ signed = sign_draft(
8328
+ draft_kind=draft_kind,
8329
+ target=target,
8330
+ payload=payload,
8331
+ )
8332
+ insert_draft(signed, led_ref=(led_ref or None))
8333
+ draft_meta = {
8334
+ "draft_id": signed.draft_id,
8335
+ "draft_kind": signed.draft_kind,
8336
+ "signature": signed.signature,
8337
+ "registered": True,
8338
+ "led_ref": led_ref or None,
8339
+ }
8340
+ except Exception as e:
8341
+ # Draft registration must not break the notify itself —
8342
+ # the email still goes out, the draft just isn't tracked.
8343
+ # Log the failure in the response so callers can audit.
8344
+ draft_meta = {
8345
+ "registered": False,
8346
+ "error": f"{type(e).__name__}: {e}",
8347
+ }
8348
+
8349
+ result = _safe_call(
7911
8350
  send_notification,
7912
8351
  channel=channel,
7913
8352
  message=message,
@@ -7916,7 +8355,10 @@ def delimit_notify(channel: str = "webhook", message: str = "",
7916
8355
  event_type=event_type,
7917
8356
  to=to,
7918
8357
  from_account=from_account,
7919
- ))
8358
+ )
8359
+ if draft_meta is not None:
8360
+ result["draft"] = draft_meta
8361
+ return _with_next_steps("notify", result)
7920
8362
 
7921
8363
 
7922
8364
  @mcp.tool()
@@ -1,7 +1,34 @@
1
- """Supabase sync -- writes gateway data to cloud for dashboard access.
2
-
3
- Writes are fire-and-forget (never blocks tool execution).
4
- If Supabase is unreachable, data stays in local files (always the source of truth).
1
+ """Supabase sync OPT-IN cloud mirror of local governance events.
2
+
3
+ This module is OFF BY DEFAULT. It only activates if the user supplies BOTH
4
+ of the following:
5
+ - SUPABASE_URL environment variable (or `url` key in ~/.delimit/secrets/supabase.json)
6
+ - SUPABASE_SERVICE_ROLE_KEY env var (or `service_role_key` key in the same file)
7
+
8
+ When activated, it mirrors locally-written events/ledger/work-order/deliberation
9
+ rows into the user's own Supabase project so they can view them in
10
+ app.delimit.ai. The local files under ~/.delimit/ remain the source of truth;
11
+ this is a read-side convenience, never a write-side requirement.
12
+
13
+ Data scope (what gets sent when enabled):
14
+ - events: tool name, timestamp, status, model id, venture tag, session id,
15
+ risk level, trace id, span id. NO source code. NO prompts. NO responses.
16
+ - ledger items: id, title, priority, venture, status, description snippet.
17
+ - work orders: id, metadata fields, status.
18
+ - deliberations: summary metadata.
19
+
20
+ KILL SWITCH:
21
+ Set DELIMIT_DISABLE_CLOUD_SYNC=1 in the environment to disable ALL cloud
22
+ mirroring even if Supabase credentials are present. The local files continue
23
+ to work. This is the same-session runtime equivalent of simply not configuring
24
+ SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY in the first place.
25
+
26
+ Transport:
27
+ Writes are fire-and-forget. Tool execution never blocks on Supabase
28
+ reachability. All sync_* functions swallow exceptions silently; the
29
+ failure mode is "nothing appears in your dashboard" not "nothing runs."
30
+
31
+ LED-1056: disclosure added per external issue #56 (delimit-ai/delimit-mcp-server).
5
32
  """
6
33
  import json
7
34
  import os
@@ -17,8 +44,14 @@ _init_attempted = False
17
44
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
18
45
  SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
19
46
 
20
- # Also check local secrets file
21
- if not SUPABASE_URL:
47
+ # LED-1056: explicit user kill switch. Overrides any credentials the user
48
+ # might have configured. Setting this env var forces ALL sync_* operations
49
+ # to no-op, making cloud sync unconditionally off for the session.
50
+ _CLOUD_SYNC_DISABLED = os.environ.get("DELIMIT_DISABLE_CLOUD_SYNC", "").strip().lower() in ("1", "true", "yes", "on")
51
+
52
+ # Also check local secrets file — only if env vars weren't already provided
53
+ # AND the kill switch is not set.
54
+ if not SUPABASE_URL and not _CLOUD_SYNC_DISABLED:
22
55
  secrets_file = Path.home() / ".delimit" / "secrets" / "supabase.json"
23
56
  if secrets_file.exists():
24
57
  try:
@@ -57,8 +90,15 @@ def _normalize_venture(value) -> str:
57
90
 
58
91
 
59
92
  def _get_client():
60
- """Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None."""
93
+ """Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None.
94
+
95
+ Returns None (disabled) if:
96
+ - DELIMIT_DISABLE_CLOUD_SYNC=1 is set (user kill switch, LED-1056), OR
97
+ - SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY are not configured.
98
+ """
61
99
  global _client, _init_attempted
100
+ if _CLOUD_SYNC_DISABLED:
101
+ return None
62
102
  if _client is not None:
63
103
  return _client
64
104
  if _init_attempted:
@@ -846,7 +846,7 @@ def create_tool(
846
846
 
847
847
  # Security scan — check for dangerous patterns
848
848
  dangerous = [
849
- "subprocess.call", "os.system", "exec(", "eval(",
849
+ "subprocess.call", "os.system", "exec(", "eval(", # nosec B-eval_usage: MCP tool dispatch — evaluates a whitelisted tool function reference
850
850
  "import socket", "import http.server",
851
851
  "__import__", "compile(",
852
852
  ]