delimit-cli 4.6.0 → 4.6.1

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.
@@ -20,12 +20,77 @@ try:
20
20
  PRO_TOOLS as _CORE_PRO_TOOLS,
21
21
  FREE_TRIAL_LIMITS,
22
22
  )
23
- # Extend compiled PRO_TOOLS with tools added after last binary build
23
+ # Extend compiled PRO_TOOLS with tools added after last binary build.
24
+ # LED-1260: keep this in lockstep with the fallback set below — any tool
25
+ # in the fallback PRO_TOOLS that's NOT in the compiled set must be added
26
+ # here, otherwise customers with the binary get those tools FREE while
27
+ # customers without the binary pay for them (regression-on-success).
28
+ # The runtime test in tests/test_license.py asserts both sets are
29
+ # equal. LED-1410 makes this stronger: the extension set below is
30
+ # CODEGEN from ai/pro_tools.yaml (same SSoT as the compiled
31
+ # set), so the two are equal by construction. The | union with
32
+ # _CORE_PRO_TOOLS is preserved so OLDER compiled .so files that
33
+ # were built before a YAML addition still pick up the new tool
34
+ # at runtime.
24
35
  PRO_TOOLS = _CORE_PRO_TOOLS | frozenset({
25
- "delimit_social_approve",
26
- # Autonomous build loop
27
- "delimit_next_task", "delimit_task_complete",
28
- "delimit_loop_status", "delimit_loop_config",
36
+ # CODEGEN-START: EXTENSION_PRO_TOOLS
37
+ "delimit_agent_complete",
38
+ "delimit_agent_dispatch",
39
+ "delimit_agent_handoff",
40
+ "delimit_agent_status",
41
+ "delimit_cost_alert",
42
+ "delimit_cost_analyze",
43
+ "delimit_cost_optimize",
44
+ "delimit_deliberate",
45
+ "delimit_deploy_build",
46
+ "delimit_deploy_npm",
47
+ "delimit_deploy_plan",
48
+ "delimit_deploy_publish",
49
+ "delimit_deploy_rollback",
50
+ "delimit_deploy_site",
51
+ "delimit_deploy_status",
52
+ "delimit_deploy_verify",
53
+ "delimit_evidence_collect",
54
+ "delimit_evidence_verify",
55
+ "delimit_executor",
56
+ "delimit_gov_evaluate",
57
+ "delimit_gov_new_task",
58
+ "delimit_gov_policy",
59
+ "delimit_gov_run",
60
+ "delimit_gov_verify",
61
+ "delimit_loop_config",
62
+ "delimit_loop_status",
63
+ "delimit_memory_search",
64
+ "delimit_models",
65
+ "delimit_next_task",
66
+ "delimit_notify",
67
+ "delimit_obs_logs",
68
+ "delimit_obs_metrics",
69
+ "delimit_obs_status",
70
+ "delimit_os_gates",
71
+ "delimit_os_plan",
72
+ "delimit_os_status",
73
+ "delimit_release_plan",
74
+ "delimit_release_status",
75
+ "delimit_release_sync",
76
+ "delimit_repo_analyze",
77
+ "delimit_repo_config_audit",
78
+ "delimit_repo_config_validate",
79
+ "delimit_repo_diagnose",
80
+ "delimit_screen_record",
81
+ "delimit_screenshot",
82
+ "delimit_security_deliberate",
83
+ "delimit_security_ingest",
84
+ "delimit_social_approve",
85
+ "delimit_social_generate",
86
+ "delimit_social_history",
87
+ "delimit_social_post",
88
+ "delimit_task_complete",
89
+ "delimit_test_coverage",
90
+ "delimit_vault_health",
91
+ "delimit_vault_search",
92
+ "delimit_vault_snapshot",
93
+ # CODEGEN-END: EXTENSION_PRO_TOOLS
29
94
  })
30
95
  except ImportError:
31
96
  # license_core not available — three known cases:
@@ -50,49 +115,69 @@ except ImportError:
50
115
 
51
116
  LICENSE_FILE = Path.home() / ".delimit" / "license.json"
52
117
 
118
+ # LED-1410: CODEGEN from ai/pro_tools.yaml — same SSoT as the
119
+ # compiled set above. Memory note preserved here for source readers:
120
+ # delimit_memory_store + delimit_memory_recent are FREE (LED-193).
121
+ # Only delimit_memory_search is Pro.
53
122
  PRO_TOOLS = frozenset({
54
- # Governance deep
55
- "delimit_gov_evaluate", "delimit_gov_policy", "delimit_gov_run", "delimit_gov_verify",
56
- "delimit_gov_new_task",
57
- # OS layer
58
- "delimit_os_plan", "delimit_os_status", "delimit_os_gates",
59
- # Deploy pipeline
60
- "delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
61
- "delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
62
- "delimit_deploy_site", "delimit_deploy_npm",
63
- # Memory (search is Pro; store + recent are free)
64
- "delimit_memory_search",
65
- "delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
66
- # Evidence
67
- "delimit_evidence_collect", "delimit_evidence_verify",
68
- # Deliberation + Models
69
- "delimit_deliberate", "delimit_models",
70
- # Security orchestrator
71
- "delimit_security_ingest", "delimit_security_deliberate",
72
- # Observability
73
- "delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
74
- # Release
75
- "delimit_release_plan", "delimit_release_status", "delimit_release_sync",
76
- # Cost
77
- "delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
78
- # Social
79
- "delimit_social_post", "delimit_social_generate", "delimit_social_history",
80
- "delimit_social_approve",
81
- # Repo deep
82
- "delimit_repo_analyze", "delimit_repo_config_audit", "delimit_repo_config_validate",
83
- "delimit_repo_diagnose",
84
- # Test
85
- "delimit_test_coverage",
86
- # Screen recording
87
- "delimit_screen_record", "delimit_screenshot",
88
- # Notifications
89
- "delimit_notify",
90
- # Agent orchestration
91
- "delimit_agent_dispatch", "delimit_agent_status",
92
- "delimit_agent_complete", "delimit_agent_handoff",
93
- # Autonomous build loop
94
- "delimit_next_task", "delimit_task_complete",
95
- "delimit_loop_status", "delimit_loop_config",
123
+ # CODEGEN-START: FALLBACK_PRO_TOOLS
124
+ "delimit_agent_complete",
125
+ "delimit_agent_dispatch",
126
+ "delimit_agent_handoff",
127
+ "delimit_agent_status",
128
+ "delimit_cost_alert",
129
+ "delimit_cost_analyze",
130
+ "delimit_cost_optimize",
131
+ "delimit_deliberate",
132
+ "delimit_deploy_build",
133
+ "delimit_deploy_npm",
134
+ "delimit_deploy_plan",
135
+ "delimit_deploy_publish",
136
+ "delimit_deploy_rollback",
137
+ "delimit_deploy_site",
138
+ "delimit_deploy_status",
139
+ "delimit_deploy_verify",
140
+ "delimit_evidence_collect",
141
+ "delimit_evidence_verify",
142
+ "delimit_executor",
143
+ "delimit_gov_evaluate",
144
+ "delimit_gov_new_task",
145
+ "delimit_gov_policy",
146
+ "delimit_gov_run",
147
+ "delimit_gov_verify",
148
+ "delimit_loop_config",
149
+ "delimit_loop_status",
150
+ "delimit_memory_search",
151
+ "delimit_models",
152
+ "delimit_next_task",
153
+ "delimit_notify",
154
+ "delimit_obs_logs",
155
+ "delimit_obs_metrics",
156
+ "delimit_obs_status",
157
+ "delimit_os_gates",
158
+ "delimit_os_plan",
159
+ "delimit_os_status",
160
+ "delimit_release_plan",
161
+ "delimit_release_status",
162
+ "delimit_release_sync",
163
+ "delimit_repo_analyze",
164
+ "delimit_repo_config_audit",
165
+ "delimit_repo_config_validate",
166
+ "delimit_repo_diagnose",
167
+ "delimit_screen_record",
168
+ "delimit_screenshot",
169
+ "delimit_security_deliberate",
170
+ "delimit_security_ingest",
171
+ "delimit_social_approve",
172
+ "delimit_social_generate",
173
+ "delimit_social_history",
174
+ "delimit_social_post",
175
+ "delimit_task_complete",
176
+ "delimit_test_coverage",
177
+ "delimit_vault_health",
178
+ "delimit_vault_search",
179
+ "delimit_vault_snapshot",
180
+ # CODEGEN-END: FALLBACK_PRO_TOOLS
96
181
  })
97
182
  FREE_TRIAL_LIMITS = {"delimit_deliberate": 3}
98
183
 
@@ -7,7 +7,7 @@ import os
7
7
  from pathlib import Path
8
8
  import hashlib
9
9
 
10
- PRO_TOOLS = frozenset({'delimit_gov_evaluate', 'delimit_gov_policy', 'delimit_gov_run', 'delimit_gov_verify', 'delimit_os_plan', 'delimit_os_status', 'delimit_os_gates', 'delimit_deploy_plan', 'delimit_deploy_build', 'delimit_deploy_publish', 'delimit_deploy_verify', 'delimit_deploy_rollback', 'delimit_deploy_status', 'delimit_deploy_site', 'delimit_deploy_npm', 'delimit_memory_search', 'delimit_vault_search', 'delimit_vault_snapshot', 'delimit_vault_health', 'delimit_evidence_collect', 'delimit_evidence_verify', 'delimit_deliberate', 'delimit_models', 'delimit_obs_metrics', 'delimit_obs_logs', 'delimit_obs_status', 'delimit_release_plan', 'delimit_release_status', 'delimit_release_sync', 'delimit_cost_analyze', 'delimit_cost_optimize', 'delimit_cost_alert', 'delimit_social_post', 'delimit_social_generate', 'delimit_social_history', 'delimit_screen_record', 'delimit_screenshot', 'delimit_notify', 'delimit_agent_dispatch', 'delimit_agent_status', 'delimit_agent_complete', 'delimit_agent_handoff', 'delimit_executor'})
10
+ PRO_TOOLS = frozenset({'delimit_agent_complete', 'delimit_agent_dispatch', 'delimit_agent_handoff', 'delimit_agent_status', 'delimit_cost_alert', 'delimit_cost_analyze', 'delimit_cost_optimize', 'delimit_deliberate', 'delimit_deploy_build', 'delimit_deploy_npm', 'delimit_deploy_plan', 'delimit_deploy_publish', 'delimit_deploy_rollback', 'delimit_deploy_site', 'delimit_deploy_status', 'delimit_deploy_verify', 'delimit_evidence_collect', 'delimit_evidence_verify', 'delimit_executor', 'delimit_gov_evaluate', 'delimit_gov_policy', 'delimit_gov_run', 'delimit_gov_verify', 'delimit_memory_search', 'delimit_models', 'delimit_notify', 'delimit_obs_logs', 'delimit_obs_metrics', 'delimit_obs_status', 'delimit_os_gates', 'delimit_os_plan', 'delimit_os_status', 'delimit_release_plan', 'delimit_release_status', 'delimit_release_sync', 'delimit_screen_record', 'delimit_screenshot', 'delimit_social_generate', 'delimit_social_history', 'delimit_social_post', 'delimit_vault_health', 'delimit_vault_search', 'delimit_vault_snapshot'})
11
11
  def needs_revalidation(data: dict) -> bool:
12
12
  ...
13
13
  def revalidate_license(data: dict) -> dict:
@@ -0,0 +1,349 @@
1
+ """Single-responsibility cron for autonomous github outreach (LED-2214b).
2
+
3
+ The 2026-05-11 deliberation (transcript stored privately)
4
+ unanimously chose a NEW dedicated daemon over (a) extending the
5
+ existing social_daemon (different responsibility — inbound sensing
6
+ vs outbound engagement) and (b) composing via the generic
7
+ ``delimit_loop_config`` primitive (insufficient evidence for the
8
+ conditional branching the workflow needs). This is that daemon.
9
+
10
+ It is deliberately a single-tick function with no internal lifecycle
11
+ management — the file is imported by an external scheduler (cron,
12
+ ``loop_daemon``, manual MCP call). Lifecycle concerns (interval,
13
+ backoff, retries) live at the scheduler layer. This keeps the daemon
14
+ trivial to reason about, easy to roll back, and explicit-by-default
15
+ for the upcoming 30-day operating-model review (2026-05-30).
16
+
17
+ Single tick:
18
+
19
+ 1. Monitor phase — for every open intel-class outreach LED with a
20
+ resolvable github issue URL, call ``delimit_sensor_github_issue``.
21
+ New comments / state changes are appended to the LED.
22
+ 2. Scan phase — invoke the existing github scanner via
23
+ :func:`ai.social_target.scan_targets` and :func:`process_targets`.
24
+ The substantive-dispatch path in ``process_targets`` fires on
25
+ any target that yields a :class:`SubstantiveCandidate`.
26
+ 3. Cap — at most ``max_dispatch`` (default 3) new substantive
27
+ dispatches per tick, to bound fan-out (the bulk-29-cancel
28
+ pattern's lesson).
29
+ 4. Kill switch — either the env var
30
+ ``DELIMIT_GITHUB_OUTREACH_DISABLED`` set to a truthy value, or
31
+ the sentinel file ``~/.delimit/outreach_pause`` present,
32
+ short-circuits the tick at entry. No partial work, no state
33
+ mutations.
34
+
35
+ Public surface:
36
+
37
+ * :func:`tick` — run one cycle, return a summary dict.
38
+ * :func:`kill_switch_active` — check the kill-switch state.
39
+
40
+ The MCP-facing wrapper lives at :func:`ai.server.delimit_outreach_loop_tick`.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import datetime as _dt
46
+ import logging
47
+ import os
48
+ import re
49
+ from pathlib import Path
50
+ from typing import Any, Dict, List, Optional, Tuple
51
+
52
+ logger = logging.getLogger("delimit.ai.outreach_loop_daemon")
53
+
54
+ KILL_SWITCH_ENV = "DELIMIT_GITHUB_OUTREACH_DISABLED"
55
+ KILL_SWITCH_FILE = Path.home() / ".delimit" / "outreach_pause"
56
+
57
+ DEFAULT_MAX_DISPATCH = 3
58
+ DEFAULT_MAX_MONITOR = 50
59
+
60
+ # Tag set the intel-class outreach LEDs carry. We use this to retrieve
61
+ # the universe of items the daemon is responsible for monitoring.
62
+ _OUTREACH_INTEL_TAGS = ("intel", "github-scan")
63
+
64
+ # Issue / PR URL parser. We accept both /issues/N and /pull/N.
65
+ _ISSUE_URL_RE = re.compile(
66
+ r"^https?://github\.com/(?P<repo>[^/]+/[^/]+)/(?:issues|pull)/(?P<num>\d+)"
67
+ )
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Kill switch
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def kill_switch_active() -> Tuple[bool, str]:
76
+ """Return ``(active, reason)``.
77
+
78
+ Either the env var or the sentinel file is sufficient to halt the
79
+ tick. Env var wins for cleanliness in containerized environments;
80
+ the sentinel file is the emergency-stop founders or operators can
81
+ touch from any shell without restarting the parent process.
82
+ """
83
+ env = os.environ.get(KILL_SWITCH_ENV, "").strip().lower()
84
+ if env in {"1", "true", "yes", "on"}:
85
+ return True, f"env:{KILL_SWITCH_ENV}={env}"
86
+ if KILL_SWITCH_FILE.exists():
87
+ return True, f"file:{KILL_SWITCH_FILE}"
88
+ return False, ""
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Monitor phase
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _parse_issue_url(url: str) -> Optional[Tuple[str, int]]:
97
+ if not url:
98
+ return None
99
+ m = _ISSUE_URL_RE.match(url.strip())
100
+ if not m:
101
+ return None
102
+ try:
103
+ return m.group("repo"), int(m.group("num"))
104
+ except (TypeError, ValueError):
105
+ return None
106
+
107
+
108
+ def _open_intel_items(venture: str, limit: int) -> List[Dict[str, Any]]:
109
+ """Return open intel-class outreach LEDs for a venture.
110
+
111
+ Pulls from ai.ledger_manager.list_items with the same project_path
112
+ resolution social_target uses. Filters defensively in Python (the
113
+ list_items tag filter is "contains all" but we want to combine
114
+ tag + status, and the simpler path is post-filter).
115
+ """
116
+ try:
117
+ from ai.ledger_manager import list_items
118
+ from ai.social_target import _resolve_venture_project_path
119
+
120
+ project_path = _resolve_venture_project_path(venture)
121
+ except Exception as exc:
122
+ logger.warning("monitor_phase: resolver import failed: %s", exc)
123
+ return []
124
+
125
+ items: List[Dict[str, Any]] = []
126
+ try:
127
+ # Pull both ledgers — intel items may land on ``strategy`` or
128
+ # ``ops`` depending on category.
129
+ for ledger in ("strategy", "ops"):
130
+ page = list_items(
131
+ ledger=ledger,
132
+ project_path=project_path,
133
+ limit=max(limit, 1),
134
+ )
135
+ for it in page.get("items", {}).get(ledger, []):
136
+ tags = set(it.get("tags") or [])
137
+ if it.get("status") not in {"open", "in_progress"}:
138
+ continue
139
+ if not all(t in tags for t in _OUTREACH_INTEL_TAGS):
140
+ continue
141
+ items.append(it)
142
+ except Exception as exc:
143
+ logger.warning("monitor_phase: list_items failed: %s", exc)
144
+ return []
145
+ items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
146
+ return items[:limit]
147
+
148
+
149
+ def _extract_issue_url(item: Dict[str, Any]) -> Optional[str]:
150
+ desc = item.get("description") or ""
151
+ for line in desc.splitlines():
152
+ if line.lower().startswith("source:"):
153
+ url = line.split(":", 1)[1].strip()
154
+ return url
155
+ return None
156
+
157
+
158
+ def _monitor_phase(venture: str, max_items: int) -> List[Dict[str, Any]]:
159
+ """Call ``delimit_sensor_github_issue`` for each open outreach LED.
160
+
161
+ Returns a list of monitor records. Each record carries:
162
+ * ``item_id`` — LED id (e.g. ``LED-XXXX``)
163
+ * ``repo`` / ``issue_number`` — parsed target
164
+ * ``has_new_activity`` — sensor verdict
165
+ * ``signal`` — sensor signal dict (when activity present)
166
+ * ``error`` — exception text (when the call failed)
167
+
168
+ The function never raises — sensor failures are recorded and the
169
+ loop continues.
170
+ """
171
+ records: List[Dict[str, Any]] = []
172
+ items = _open_intel_items(venture, max_items)
173
+ if not items:
174
+ return records
175
+ try:
176
+ from ai.governance import _sensor_github_issue_impl # type: ignore
177
+ except Exception:
178
+ _sensor_github_issue_impl = None
179
+
180
+ if _sensor_github_issue_impl is None:
181
+ try:
182
+ from backends.governance_bridge import sensor_github_issue as _sensor_github_issue_impl # type: ignore
183
+ except Exception as exc:
184
+ logger.warning(
185
+ "monitor_phase: sensor import failed (%s) — monitor skipped",
186
+ exc,
187
+ )
188
+ return records
189
+
190
+ for item in items:
191
+ url = _extract_issue_url(item) or ""
192
+ parsed = _parse_issue_url(url)
193
+ if not parsed:
194
+ continue
195
+ repo, num = parsed
196
+ try:
197
+ signal = _sensor_github_issue_impl(repo=repo, issue_number=num)
198
+ except Exception as exc:
199
+ records.append({
200
+ "item_id": item.get("id"),
201
+ "repo": repo,
202
+ "issue_number": num,
203
+ "has_new_activity": False,
204
+ "error": str(exc),
205
+ })
206
+ continue
207
+ records.append({
208
+ "item_id": item.get("id"),
209
+ "repo": repo,
210
+ "issue_number": num,
211
+ "has_new_activity": bool(signal.get("has_new_activity")),
212
+ "signal": signal,
213
+ })
214
+ return records
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Scan phase
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def _scan_phase(
223
+ venture: str,
224
+ dispatch_cap: int,
225
+ ) -> Dict[str, Any]:
226
+ """Run the github scanner and let ``process_targets`` fire dispatches.
227
+
228
+ Returns a dict with:
229
+ * ``targets_scanned`` — count from ``scan_targets``
230
+ * ``processed`` — full ``process_targets`` result
231
+ * ``dispatches`` — list of agent_tasks emitted (capped to
232
+ ``dispatch_cap``; targets beyond cap are scanned but not
233
+ dispatched — their intel items still file normally)
234
+ * ``cap_hit`` — bool
235
+ * ``error`` — exception text on failure
236
+ """
237
+ out: Dict[str, Any] = {
238
+ "targets_scanned": 0,
239
+ "processed": {},
240
+ "dispatches": [],
241
+ "cap_hit": False,
242
+ }
243
+ try:
244
+ from ai.social_target import scan_targets, process_targets
245
+ except Exception as exc:
246
+ out["error"] = f"social_target import failed: {exc}"
247
+ return out
248
+
249
+ # LED-2214b followup: pass limit=30 so the github scanner's phase-2
250
+ # issue search actually runs. Default limit=10 lets phase-1 repo
251
+ # search saturate first (each query returns up to 10 repos), so
252
+ # phase-2 issue targets — the only kind that can carry technical
253
+ # anchors in their bodies — never reach the gate. With limit=30
254
+ # we typically see ~20 repos + ~4-6 issues per tick, which the
255
+ # per-tick dispatch cap (3) further constrains downstream.
256
+ try:
257
+ targets = scan_targets(platforms=["github"], venture=venture, limit=30) or []
258
+ except Exception as exc:
259
+ out["error"] = f"scan_targets failed: {exc}"
260
+ return out
261
+ out["targets_scanned"] = len(targets)
262
+
263
+ # Apply per-tick cap by truncating targets BEFORE process_targets so
264
+ # the fan-out cap is enforced in the daemon, not deep inside the
265
+ # scanner. The reason: process_targets is also called by the
266
+ # general social_daemon path, which has its own cap; mixing both
267
+ # caps inside process_targets would couple the two daemons.
268
+ #
269
+ # LED-2214b followup: sort issue targets to the FRONT before the
270
+ # truncation. The scanner returns phase-1 repo discoveries before
271
+ # phase-2 issue results, so a naive `targets[:3]` strips out the
272
+ # only target shape that can carry technical anchors. Issue-first
273
+ # ordering ensures the dispatch cap doesn't waste budget on
274
+ # repo-discovery targets that will all be rejected as anchor-less.
275
+ if dispatch_cap > 0 and len(targets) > dispatch_cap:
276
+ out["cap_hit"] = True
277
+ targets.sort(
278
+ key=lambda t: 0 if (t.get("fingerprint", "") or "").startswith("github:issue:") else 1
279
+ )
280
+ targets = targets[:dispatch_cap]
281
+
282
+ try:
283
+ processed = process_targets(
284
+ targets, draft_replies=True, create_ledger=True,
285
+ )
286
+ except Exception as exc:
287
+ out["error"] = f"process_targets failed: {exc}"
288
+ return out
289
+ out["processed"] = processed
290
+ out["dispatches"] = list(processed.get("agent_tasks", []))
291
+ return out
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Tick
296
+ # ---------------------------------------------------------------------------
297
+
298
+
299
+ def tick(
300
+ venture: str = "delimit",
301
+ max_dispatch: int = DEFAULT_MAX_DISPATCH,
302
+ max_monitor: int = DEFAULT_MAX_MONITOR,
303
+ ) -> Dict[str, Any]:
304
+ """Run one outreach-loop cycle.
305
+
306
+ Args:
307
+ venture: Sourcing venture (default ``delimit``).
308
+ max_dispatch: Per-tick cap on substantive dispatches. The
309
+ scanner may surface more targets than this; the excess
310
+ still files intel-class LEDs via ``process_targets`` on
311
+ subsequent ticks. Set to ``0`` to disable the cap (not
312
+ recommended — the cap is the spam-loop firewall).
313
+ max_monitor: Per-tick cap on monitor calls (one
314
+ ``delimit_sensor_github_issue`` per open outreach LED).
315
+
316
+ Returns:
317
+ Dict with ``venture``, ``started_at``, ``ended_at``,
318
+ ``kill_switch`` (active flag + reason), ``monitor`` (list of
319
+ per-LED records), ``scan`` (full scan-phase summary), and
320
+ ``dispatch_count``.
321
+ """
322
+ started_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
323
+ summary: Dict[str, Any] = {
324
+ "venture": venture,
325
+ "started_at": started_at,
326
+ "max_dispatch": max_dispatch,
327
+ "max_monitor": max_monitor,
328
+ }
329
+ active, reason = kill_switch_active()
330
+ summary["kill_switch"] = {"active": active, "reason": reason}
331
+ if active:
332
+ summary["status"] = "skipped"
333
+ summary["ended_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat()
334
+ logger.info("outreach_loop tick skipped: kill switch active (%s)", reason)
335
+ return summary
336
+
337
+ summary["monitor"] = _monitor_phase(venture=venture, max_items=max_monitor)
338
+ summary["scan"] = _scan_phase(venture=venture, dispatch_cap=max_dispatch)
339
+ summary["dispatch_count"] = len(summary["scan"].get("dispatches") or [])
340
+ summary["status"] = "ok"
341
+ summary["ended_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat()
342
+ logger.info(
343
+ "outreach_loop tick ok: venture=%s monitored=%d dispatched=%d cap_hit=%s",
344
+ venture,
345
+ len(summary["monitor"]),
346
+ summary["dispatch_count"],
347
+ summary["scan"].get("cap_hit"),
348
+ )
349
+ return summary