delimit-cli 4.5.13 → 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.
- package/CHANGELOG.md +48 -0
- package/README.md +9 -8
- package/bin/delimit-cli.js +179 -4
- package/bin/delimit-setup.js +46 -6
- package/gateway/ai/_compile_status.py +154 -0
- package/gateway/ai/agent_dispatch.py +41 -0
- package/gateway/ai/backends/git_health.py +175 -0
- package/gateway/ai/backends/tools_infra.py +163 -10
- package/gateway/ai/cli_contract.py +185 -0
- package/gateway/ai/daemon.py +10 -0
- package/gateway/ai/daily_digest.py +1 -2
- package/gateway/ai/delimit_daemon.py +67 -0
- package/gateway/ai/dispatch_gate.py +399 -0
- package/gateway/ai/governance.py +181 -0
- package/gateway/ai/heartbeat.py +290 -0
- package/gateway/ai/hot_reload.py +1 -2
- package/gateway/ai/led193_daemon/executor.py +9 -0
- package/gateway/ai/ledger_manager.py +90 -4
- package/gateway/ai/ledger_proof.py +127 -0
- package/gateway/ai/license.py +132 -47
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/license_core.pyi +1 -1
- package/gateway/ai/notify.py +39 -0
- package/gateway/ai/outreach_loop_daemon.py +349 -0
- package/gateway/ai/outreach_substantive.py +1437 -0
- package/gateway/ai/pro_tools.yaml +167 -0
- package/gateway/ai/reaper.py +70 -0
- package/gateway/ai/reddit_scanner.py +17 -6
- package/gateway/ai/sensing/schema.py +1 -1
- package/gateway/ai/sensing/signal_store.py +0 -1
- package/gateway/ai/server.py +5490 -1602
- package/gateway/ai/social_capability/fit_floor.py +114 -12
- package/gateway/ai/social_queue.py +166 -10
- package/gateway/ai/tdqs_lint.py +611 -0
- package/gateway/ai/tenant_auth.py +329 -0
- package/gateway/ai/tenant_data.py +339 -0
- package/gateway/ai/tenant_paths.py +150 -0
- package/gateway/ai/usage_allowlist.py +198 -0
- package/gateway/ai/workers/base.py +2 -2
- package/gateway/ai/workers/executor.py +32 -3
- package/gateway/ai/workers/outreach_drafter.py +0 -1
- package/gateway/ai/workers/pr_drafter.py +0 -1
- package/gateway/ai/x_ranker.py +12 -2
- package/gateway/core/json_schema_diff.py +25 -1
- package/lib/auth-signin.js +136 -0
- package/lib/auth-signout.js +169 -0
- package/lib/delimit-template.js +11 -0
- package/lib/migration-2092-banner.js +213 -0
- package/package.json +5 -2
- package/server.json +4 -4
- package/scripts/build-license-core.sh +0 -85
- package/scripts/security-check.sh +0 -66
- package/scripts/test-license-core-so.sh +0 -107
package/gateway/ai/license.py
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
|
Binary file
|
|
@@ -7,7 +7,7 @@ import os
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
import hashlib
|
|
9
9
|
|
|
10
|
-
PRO_TOOLS = frozenset({'
|
|
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:
|
package/gateway/ai/notify.py
CHANGED
|
@@ -919,6 +919,45 @@ def send_email(
|
|
|
919
919
|
"intent_logged": True,
|
|
920
920
|
}
|
|
921
921
|
|
|
922
|
+
# RFC 2606 reserved test domains — never relay. A test fixture in
|
|
923
|
+
# tests/test_notify_routing.py once passed email_to="lead@example.com"
|
|
924
|
+
# through to a real SMTP send, generating 220 spam-bounces against
|
|
925
|
+
# pro@delimit.ai before the fixture was patched. Refuse here so any
|
|
926
|
+
# future regression fails fast instead of silently poisoning sender rep.
|
|
927
|
+
# Tests that mock smtplib can set DELIMIT_ALLOW_TEST_RECIPIENTS=1 to
|
|
928
|
+
# bypass this guard since their mocked transport doesn't actually relay.
|
|
929
|
+
_RFC2606_TEST_DOMAINS = (
|
|
930
|
+
"example.com", "example.net", "example.org",
|
|
931
|
+
"test", "invalid", "localhost",
|
|
932
|
+
)
|
|
933
|
+
recipient_domain = smtp_to.rsplit("@", 1)[-1].strip().lower()
|
|
934
|
+
is_reserved = (
|
|
935
|
+
recipient_domain in _RFC2606_TEST_DOMAINS
|
|
936
|
+
or recipient_domain.endswith(".example")
|
|
937
|
+
or recipient_domain.endswith(".test")
|
|
938
|
+
or recipient_domain.endswith(".invalid")
|
|
939
|
+
or recipient_domain.endswith(".localhost")
|
|
940
|
+
)
|
|
941
|
+
if is_reserved and not os.environ.get("DELIMIT_ALLOW_TEST_RECIPIENTS"):
|
|
942
|
+
record = {
|
|
943
|
+
"channel": "email",
|
|
944
|
+
"event_type": event_type,
|
|
945
|
+
"to": smtp_to,
|
|
946
|
+
"from": smtp_from,
|
|
947
|
+
"subject": subject,
|
|
948
|
+
"timestamp": timestamp,
|
|
949
|
+
"success": False,
|
|
950
|
+
"reason": "reserved_test_domain",
|
|
951
|
+
}
|
|
952
|
+
_record_notification(record)
|
|
953
|
+
return {
|
|
954
|
+
"channel": "email",
|
|
955
|
+
"delivered": False,
|
|
956
|
+
"timestamp": timestamp,
|
|
957
|
+
"error": f"Refusing to send: '{smtp_to}' is an RFC 2606 reserved test domain.",
|
|
958
|
+
"intent_logged": True,
|
|
959
|
+
}
|
|
960
|
+
|
|
922
961
|
subj = subject or f"Delimit: {event_type or 'Notification'}"
|
|
923
962
|
html_body = _render_html_email(subj, email_body, event_type)
|
|
924
963
|
|
|
@@ -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
|