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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified Delimit Daemon (LED-193).
|
|
3
|
+
|
|
4
|
+
Consolidates three long-running daemons into a single process:
|
|
5
|
+
- inbox_daemon (5m cadence)
|
|
6
|
+
- social_daemon (15m cadence)
|
|
7
|
+
- self_repair_daemon (1h cadence)
|
|
8
|
+
|
|
9
|
+
Retains the individual modules' internal state files and thread-level
|
|
10
|
+
encapsulation to minimize blast radius and ensure existing MCP interfaces
|
|
11
|
+
(status checks) continue to work without modification.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
import logging
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from ai.inbox_daemon import start_daemon as start_inbox, stop_daemon as stop_inbox
|
|
20
|
+
from ai.social_daemon import start_daemon as start_social, stop_daemon as stop_social
|
|
21
|
+
from ai.self_repair_daemon import start_daemon as start_self_repair, stop_daemon as stop_self_repair
|
|
22
|
+
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO,
|
|
25
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
26
|
+
)
|
|
27
|
+
logger = logging.getLogger("delimit.daemon_runner")
|
|
28
|
+
|
|
29
|
+
def _handle_sigterm(signum, frame):
|
|
30
|
+
logger.info("Received SIGTERM, shutting down all daemons...")
|
|
31
|
+
try:
|
|
32
|
+
stop_inbox()
|
|
33
|
+
except Exception as e:
|
|
34
|
+
logger.error(f"Error stopping inbox: {e}")
|
|
35
|
+
try:
|
|
36
|
+
stop_social()
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Error stopping social: {e}")
|
|
39
|
+
try:
|
|
40
|
+
stop_self_repair()
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Error stopping self_repair: {e}")
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
47
|
+
signal.signal(signal.SIGINT, _handle_sigterm)
|
|
48
|
+
|
|
49
|
+
logger.info("Starting unified delimit_daemon (LED-193)...")
|
|
50
|
+
|
|
51
|
+
inbox_res = start_inbox()
|
|
52
|
+
logger.info(f"Inbox daemon: {inbox_res.get('status')}")
|
|
53
|
+
|
|
54
|
+
social_res = start_social()
|
|
55
|
+
logger.info(f"Social daemon: {social_res.get('status')}")
|
|
56
|
+
|
|
57
|
+
repair_res = start_self_repair()
|
|
58
|
+
logger.info(f"Self-repair daemon: {repair_res.get('status')}")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
while True:
|
|
62
|
+
time.sleep(60)
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
_handle_sigterm(None, None)
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""LED-1279: dispatcher anti-duplicate gate.
|
|
2
|
+
|
|
3
|
+
Before creating a new agent task tagged with an LED ID, check whether any
|
|
4
|
+
local repository's git history already contains a commit referencing that
|
|
5
|
+
LED. If it does, refuse the dispatch and auto-close the LED — yesterday's
|
|
6
|
+
AGT-65A61AD5 wasted three subagent cycles on LEDs already shipped in
|
|
7
|
+
commit 014fb5c (PR #106) on 2026-05-03.
|
|
8
|
+
|
|
9
|
+
Cost model: each duplicate dispatch burns 5-30 minutes of subagent + orchestrator
|
|
10
|
+
attention. This gate pays for itself within 1-2 future dispatches.
|
|
11
|
+
|
|
12
|
+
Design notes:
|
|
13
|
+
- LED-id parsing is conservative: r"LED-\\d+" only. AGT-... and STR-...
|
|
14
|
+
do not trigger the gate — only operational ledger items.
|
|
15
|
+
- Repo discovery: prefer caller-supplied list, then fall back to a small
|
|
16
|
+
static list of canonical Delimit / wire-report / livetube / dv repos.
|
|
17
|
+
A missing repo logs a warning and is skipped (don't fail dispatch on
|
|
18
|
+
infra issues — that's a worse failure mode than a false negative).
|
|
19
|
+
- Match scope: only commits on the *first-parent* line of `main` count
|
|
20
|
+
as "shipped" so feature-branch WIP that mentions an LED but never
|
|
21
|
+
merged doesn't trigger a false positive. PR-merge commits qualify.
|
|
22
|
+
- Time window: only commits with author/commit date >= LED.created_at
|
|
23
|
+
are considered. An LED can't have been "shipped" before it existed.
|
|
24
|
+
- Multiple matches: the FIRST match (oldest commit since created_at) wins.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Iterable, Optional
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Default repos to search. Discovered at runtime first via the venture
|
|
40
|
+
# registry; this list is the safety net if discovery returns nothing.
|
|
41
|
+
DEFAULT_REPOS = (
|
|
42
|
+
"/home/delimit/delimit-gateway",
|
|
43
|
+
"/home/delimit/delimit-action",
|
|
44
|
+
"/home/delimit/npm-delimit",
|
|
45
|
+
"/home/delimit/delimit-ui",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Conservative LED-id matcher. Plain LED-NNN format.
|
|
49
|
+
LED_ID_RE = re.compile(r"\bLED-(\d+)\b", re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_led_id(*texts: str) -> Optional[str]:
|
|
53
|
+
"""Return the first LED-NNN id found in any of the supplied strings.
|
|
54
|
+
|
|
55
|
+
Used by the dispatcher to grab the LED tag from title/description/context
|
|
56
|
+
without forcing callers to plumb it as a separate parameter.
|
|
57
|
+
"""
|
|
58
|
+
for text in texts:
|
|
59
|
+
if not text:
|
|
60
|
+
continue
|
|
61
|
+
m = LED_ID_RE.search(text)
|
|
62
|
+
if m:
|
|
63
|
+
return f"LED-{m.group(1)}"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def discover_repos() -> list[str]:
|
|
68
|
+
"""Return the list of repo paths to search.
|
|
69
|
+
|
|
70
|
+
Reads the venture registry (~/.delimit/ventures.json) and unions in the
|
|
71
|
+
DEFAULT_REPOS safety net. Filters to existing directories that actually
|
|
72
|
+
contain a .git subdir — pseudo-ventures pointing at /tmp paths or stale
|
|
73
|
+
entries get dropped silently.
|
|
74
|
+
"""
|
|
75
|
+
seen: list[str] = []
|
|
76
|
+
seen_set: set[str] = set()
|
|
77
|
+
|
|
78
|
+
def _add(path: str) -> None:
|
|
79
|
+
if not path:
|
|
80
|
+
return
|
|
81
|
+
p = Path(path).resolve()
|
|
82
|
+
s = str(p)
|
|
83
|
+
if s in seen_set:
|
|
84
|
+
return
|
|
85
|
+
if not (p / ".git").exists():
|
|
86
|
+
return
|
|
87
|
+
seen_set.add(s)
|
|
88
|
+
seen.append(s)
|
|
89
|
+
|
|
90
|
+
# 1. Venture registry — ~/.delimit/ventures.json
|
|
91
|
+
try:
|
|
92
|
+
from ai.ledger_manager import VENTURES_FILE # late import: tests stub VENTURES_FILE
|
|
93
|
+
import json
|
|
94
|
+
|
|
95
|
+
if VENTURES_FILE.exists():
|
|
96
|
+
ventures = json.loads(VENTURES_FILE.read_text())
|
|
97
|
+
for info in ventures.values():
|
|
98
|
+
_add(info.get("path", ""))
|
|
99
|
+
except Exception as e: # pragma: no cover — best effort
|
|
100
|
+
logger.debug("dispatch_gate: venture registry read failed: %s", e)
|
|
101
|
+
|
|
102
|
+
# 2. Safety net defaults
|
|
103
|
+
for path in DEFAULT_REPOS:
|
|
104
|
+
_add(path)
|
|
105
|
+
|
|
106
|
+
return seen
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_iso_z(value: str) -> Optional[datetime]:
|
|
110
|
+
"""Parse an ISO-8601 timestamp (with optional trailing Z) to a tz-aware UTC datetime."""
|
|
111
|
+
if not value:
|
|
112
|
+
return None
|
|
113
|
+
v = value.strip()
|
|
114
|
+
if v.endswith("Z"):
|
|
115
|
+
v = v[:-1] + "+00:00"
|
|
116
|
+
try:
|
|
117
|
+
dt = datetime.fromisoformat(v)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return None
|
|
120
|
+
if dt.tzinfo is None:
|
|
121
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
122
|
+
return dt.astimezone(timezone.utc)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _git_log_first_parent_grep(
|
|
126
|
+
repo: str,
|
|
127
|
+
led_id: str,
|
|
128
|
+
since_iso: Optional[str],
|
|
129
|
+
timeout_sec: float = 8.0,
|
|
130
|
+
) -> list[tuple[str, str, str]]:
|
|
131
|
+
"""Run `git log --first-parent main` filtered by --grep=<led_id>.
|
|
132
|
+
|
|
133
|
+
Returns a list of (sha, iso_date, subject) tuples, oldest first. Empty
|
|
134
|
+
list if the repo isn't a git checkout, the ref doesn't exist, or git
|
|
135
|
+
times out / errors. Errors are logged at DEBUG; we never raise — a
|
|
136
|
+
missing repo must NOT fail dispatch.
|
|
137
|
+
"""
|
|
138
|
+
repo_path = Path(repo)
|
|
139
|
+
if not (repo_path / ".git").exists():
|
|
140
|
+
logger.debug("dispatch_gate: repo missing or not a git checkout: %s", repo)
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
cmd = [
|
|
144
|
+
"git",
|
|
145
|
+
"-C",
|
|
146
|
+
str(repo_path),
|
|
147
|
+
"log",
|
|
148
|
+
"--first-parent",
|
|
149
|
+
"main",
|
|
150
|
+
f"--grep={led_id}",
|
|
151
|
+
"-i", # case-insensitive grep — match LED-1208 / led-1208 / Led-1208
|
|
152
|
+
"--pretty=%H%x09%cI%x09%s",
|
|
153
|
+
"--reverse", # oldest first so [0] is the FIRST match
|
|
154
|
+
]
|
|
155
|
+
if since_iso:
|
|
156
|
+
cmd.append(f"--since={since_iso}")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
cmd,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
timeout=timeout_sec,
|
|
164
|
+
check=False,
|
|
165
|
+
)
|
|
166
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
167
|
+
logger.warning("dispatch_gate: git log failed for %s: %s", repo, e)
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
# Try `master` as fallback for repos that haven't renamed yet, but
|
|
172
|
+
# only for the specific "unknown revision" failure — anything else
|
|
173
|
+
# is a real error we should silently skip.
|
|
174
|
+
stderr = result.stderr or ""
|
|
175
|
+
if "unknown revision" in stderr.lower() or "ambiguous argument 'main'" in stderr.lower():
|
|
176
|
+
cmd[5] = "master"
|
|
177
|
+
try:
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
cmd, capture_output=True, text=True, timeout=timeout_sec, check=False
|
|
180
|
+
)
|
|
181
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
182
|
+
logger.warning("dispatch_gate: git log master fallback failed for %s: %s", repo, e)
|
|
183
|
+
return []
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
logger.debug("dispatch_gate: git log non-zero for %s: %s", repo, result.stderr)
|
|
186
|
+
return []
|
|
187
|
+
else:
|
|
188
|
+
logger.debug("dispatch_gate: git log non-zero for %s: %s", repo, result.stderr)
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
matches: list[tuple[str, str, str]] = []
|
|
192
|
+
for line in (result.stdout or "").splitlines():
|
|
193
|
+
if not line:
|
|
194
|
+
continue
|
|
195
|
+
parts = line.split("\t", 2)
|
|
196
|
+
if len(parts) < 3:
|
|
197
|
+
continue
|
|
198
|
+
matches.append((parts[0], parts[1], parts[2]))
|
|
199
|
+
return matches
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_led_already_shipped(
|
|
203
|
+
led_id: str,
|
|
204
|
+
created_at: str = "",
|
|
205
|
+
repos: Optional[Iterable[str]] = None,
|
|
206
|
+
) -> tuple[bool, Optional[dict]]:
|
|
207
|
+
"""Search the given repos for a commit on `main`'s first-parent line that
|
|
208
|
+
mentions ``led_id`` and was committed at or after ``created_at``.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
led_id: e.g. "LED-1208" — must be the bare ID, not "LED-1208 fix something".
|
|
212
|
+
created_at: ISO-8601 timestamp of when the LED was opened. Commits
|
|
213
|
+
older than this are treated as unrelated mentions and ignored.
|
|
214
|
+
Empty string disables the time filter (used by the sweep, which
|
|
215
|
+
is willing to accept older matches at the cost of false positives).
|
|
216
|
+
repos: optional iterable of repo paths. Defaults to discover_repos().
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
(shipped: bool, details: dict | None)
|
|
220
|
+
details, when shipped, is:
|
|
221
|
+
{
|
|
222
|
+
"repo": "/home/delimit/delimit-gateway",
|
|
223
|
+
"sha": "014fb5c...",
|
|
224
|
+
"short_sha": "014fb5c",
|
|
225
|
+
"date": "2026-05-03T17:13:45-04:00",
|
|
226
|
+
"subject": "fix(self-repair): ...",
|
|
227
|
+
}
|
|
228
|
+
"""
|
|
229
|
+
if not led_id or not LED_ID_RE.fullmatch(led_id):
|
|
230
|
+
return False, None
|
|
231
|
+
|
|
232
|
+
# Normalize LED ID to canonical "LED-NNN" so the grep is consistent.
|
|
233
|
+
norm_id = led_id.upper()
|
|
234
|
+
|
|
235
|
+
repo_list = list(repos) if repos is not None else discover_repos()
|
|
236
|
+
if not repo_list:
|
|
237
|
+
return False, None
|
|
238
|
+
|
|
239
|
+
since_iso: Optional[str] = None
|
|
240
|
+
since_dt = _parse_iso_z(created_at) if created_at else None
|
|
241
|
+
if since_dt is not None:
|
|
242
|
+
since_iso = since_dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
243
|
+
|
|
244
|
+
# Collect first match per repo, then pick the globally-oldest.
|
|
245
|
+
candidates: list[tuple[str, str, str, str]] = []
|
|
246
|
+
for repo in repo_list:
|
|
247
|
+
rows = _git_log_first_parent_grep(repo, norm_id, since_iso)
|
|
248
|
+
if not rows:
|
|
249
|
+
continue
|
|
250
|
+
# rows are oldest-first; defensively re-check the bare-id token is
|
|
251
|
+
# in the subject — `git log --grep` would already match but a stray
|
|
252
|
+
# "LED-12080" could match LED-1208's pattern if regex was loose.
|
|
253
|
+
# Our LED_ID_RE uses \b boundaries so we're safe; double-check anyway.
|
|
254
|
+
for sha, date_iso, subject in rows:
|
|
255
|
+
if not LED_ID_RE.search(subject):
|
|
256
|
+
continue
|
|
257
|
+
if since_dt is not None:
|
|
258
|
+
commit_dt = _parse_iso_z(date_iso)
|
|
259
|
+
if commit_dt is not None and commit_dt < since_dt:
|
|
260
|
+
continue
|
|
261
|
+
# Verify the LED token in the subject actually equals our target.
|
|
262
|
+
tokens = {f"LED-{m.group(1)}" for m in LED_ID_RE.finditer(subject)}
|
|
263
|
+
if norm_id not in tokens:
|
|
264
|
+
continue
|
|
265
|
+
candidates.append((repo, sha, date_iso, subject))
|
|
266
|
+
break # first match per repo
|
|
267
|
+
|
|
268
|
+
if not candidates:
|
|
269
|
+
return False, None
|
|
270
|
+
|
|
271
|
+
# Pick the oldest (smallest commit date) across all repos.
|
|
272
|
+
candidates.sort(key=lambda t: t[2])
|
|
273
|
+
repo, sha, date_iso, subject = candidates[0]
|
|
274
|
+
return True, {
|
|
275
|
+
"repo": repo,
|
|
276
|
+
"sha": sha,
|
|
277
|
+
"short_sha": sha[:7],
|
|
278
|
+
"date": date_iso,
|
|
279
|
+
"subject": subject,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def auto_close_shipped_led(led_id: str, details: dict) -> dict:
|
|
284
|
+
"""Mark an already-shipped LED as done with a note pointing to the commit.
|
|
285
|
+
|
|
286
|
+
Wraps ``ai.ledger_manager.update_item`` so the dispatcher's refusal path
|
|
287
|
+
has a single call site. Errors here MUST NOT propagate — if ledger update
|
|
288
|
+
fails for whatever reason (stale registry, missing file), we still want
|
|
289
|
+
to refuse the dispatch.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
from ai.ledger_manager import update_item
|
|
293
|
+
|
|
294
|
+
note = (
|
|
295
|
+
f"Auto-closed by dispatcher gate (LED-1279): shipped in "
|
|
296
|
+
f"{details.get('repo','?')}@{details.get('short_sha','?')} on "
|
|
297
|
+
f"{details.get('date','?')}. "
|
|
298
|
+
f"Refused duplicate AGT dispatch."
|
|
299
|
+
)
|
|
300
|
+
result = update_item(
|
|
301
|
+
item_id=led_id,
|
|
302
|
+
status="done",
|
|
303
|
+
note=note,
|
|
304
|
+
worked_by="dispatcher-gate",
|
|
305
|
+
)
|
|
306
|
+
return {"updated": True, "result": result}
|
|
307
|
+
except Exception as e: # pragma: no cover — defensive
|
|
308
|
+
logger.warning("dispatch_gate: auto-close of %s failed: %s", led_id, e)
|
|
309
|
+
return {"updated": False, "error": str(e)}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def evaluate_dispatch(
|
|
313
|
+
title: str,
|
|
314
|
+
description: str = "",
|
|
315
|
+
context: str = "",
|
|
316
|
+
led_created_at: str = "",
|
|
317
|
+
repos: Optional[Iterable[str]] = None,
|
|
318
|
+
) -> Optional[dict]:
|
|
319
|
+
"""Run the gate: extract an LED id, check if shipped, return refusal payload or None.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
- None when dispatch should proceed (no LED tag, or LED not shipped).
|
|
323
|
+
- A refusal dict when dispatch should be blocked:
|
|
324
|
+
{
|
|
325
|
+
"status": "refused",
|
|
326
|
+
"reason": "led_already_shipped",
|
|
327
|
+
"led_id": "LED-1208",
|
|
328
|
+
"shipped_in": {"repo": ..., "sha": ..., "short_sha": ..., "date": ..., "subject": ...},
|
|
329
|
+
"auto_close": {...},
|
|
330
|
+
"message": "...",
|
|
331
|
+
}
|
|
332
|
+
"""
|
|
333
|
+
led_id = extract_led_id(title, description, context)
|
|
334
|
+
if not led_id:
|
|
335
|
+
return None # No LED tag — orchestrator may dispatch generic work.
|
|
336
|
+
|
|
337
|
+
shipped, details = is_led_already_shipped(
|
|
338
|
+
led_id, created_at=led_created_at, repos=repos
|
|
339
|
+
)
|
|
340
|
+
if not shipped or not details:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
auto_close = auto_close_shipped_led(led_id, details)
|
|
344
|
+
return {
|
|
345
|
+
"status": "refused",
|
|
346
|
+
"reason": "led_already_shipped",
|
|
347
|
+
"led_id": led_id,
|
|
348
|
+
"shipped_in": details,
|
|
349
|
+
"auto_close": auto_close,
|
|
350
|
+
"message": (
|
|
351
|
+
f"Refused: {led_id} already shipped in "
|
|
352
|
+
f"{details['repo']}@{details['short_sha']} on {details['date'][:10]} "
|
|
353
|
+
f"({details['subject'][:80]}). LED auto-closed."
|
|
354
|
+
),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def lookup_led_created_at(led_id: str) -> str:
|
|
359
|
+
"""Look up the LED's created_at across all known ledger files.
|
|
360
|
+
|
|
361
|
+
Returns the original-create timestamp (genesis row, not the latest update).
|
|
362
|
+
Empty string when the LED isn't found anywhere — the gate then runs without
|
|
363
|
+
the time filter, accepting the small false-positive risk.
|
|
364
|
+
"""
|
|
365
|
+
if not led_id:
|
|
366
|
+
return ""
|
|
367
|
+
norm = led_id.upper()
|
|
368
|
+
try:
|
|
369
|
+
from ai.ledger_manager import LEDGER_V2_DIR
|
|
370
|
+
except Exception:
|
|
371
|
+
return ""
|
|
372
|
+
|
|
373
|
+
if not LEDGER_V2_DIR.exists():
|
|
374
|
+
return ""
|
|
375
|
+
|
|
376
|
+
import json
|
|
377
|
+
|
|
378
|
+
# Walk every ledger file under ledger-v2/* looking for the genesis row.
|
|
379
|
+
for jsonl in LEDGER_V2_DIR.rglob("*.jsonl"):
|
|
380
|
+
try:
|
|
381
|
+
with open(jsonl, "r") as f:
|
|
382
|
+
for line in f:
|
|
383
|
+
line = line.strip()
|
|
384
|
+
if not line:
|
|
385
|
+
continue
|
|
386
|
+
try:
|
|
387
|
+
row = json.loads(line)
|
|
388
|
+
except json.JSONDecodeError:
|
|
389
|
+
continue
|
|
390
|
+
if row.get("id") != norm:
|
|
391
|
+
continue
|
|
392
|
+
if row.get("type") == "update":
|
|
393
|
+
continue # only the genesis row carries created_at-of-LED
|
|
394
|
+
ts = row.get("created_at", "")
|
|
395
|
+
if ts:
|
|
396
|
+
return ts
|
|
397
|
+
except OSError:
|
|
398
|
+
continue
|
|
399
|
+
return ""
|
package/gateway/ai/governance.py
CHANGED
|
@@ -13,7 +13,10 @@ This replaces _with_next_steps — governance IS the next step system.
|
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
15
|
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
16
18
|
import time
|
|
19
|
+
from datetime import datetime, timezone
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
from typing import Any, Dict, List, Optional
|
|
19
22
|
|
|
@@ -826,6 +829,184 @@ def govern(tool_name: str, result: Dict[str, Any], project_path: str = ".") -> D
|
|
|
826
829
|
return governed_result
|
|
827
830
|
|
|
828
831
|
|
|
832
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
833
|
+
# LED-2214b-followup — sensor_github_issue sync impl
|
|
834
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
835
|
+
#
|
|
836
|
+
# The outreach daemon's monitor_phase needs to call the same logic that
|
|
837
|
+
# delimit_sensor_github_issue (MCP tool) runs, but synchronously and
|
|
838
|
+
# without the _with_next_steps wrapping. Before this extraction the
|
|
839
|
+
# daemon tried to import the impl from two paths that don't exist —
|
|
840
|
+
# `ai.governance._sensor_github_issue_impl` and
|
|
841
|
+
# `backends.governance_bridge.sensor_github_issue` — and silently fell
|
|
842
|
+
# back to "monitor skipped" on every tick, leaving the entire reply-
|
|
843
|
+
# tracking cycle dead.
|
|
844
|
+
#
|
|
845
|
+
# Now both callers share this function. The MCP tool wraps the result
|
|
846
|
+
# with `_with_next_steps`; the daemon consumes the raw dict.
|
|
847
|
+
|
|
848
|
+
_NEGATIVE_KEYWORDS = (
|
|
849
|
+
"not interested", "won't be", "will not", "don't need", "do not need",
|
|
850
|
+
"no thanks", "pass on", "not a fit", "not for us", "closing",
|
|
851
|
+
"won't adopt", "will not adopt", "reject", "declined",
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
_REPO_FORMAT_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
|
|
855
|
+
|
|
856
|
+
# Module-local guard so the warning fires at most once per process.
|
|
857
|
+
_REPO_ALLOWLIST_WARNED = False
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _check_repo_allowlist(repo: str) -> Optional[Dict[str, Any]]:
|
|
861
|
+
"""Return a refusal dict if the repo isn't in DELIMIT_ALLOWED_REPOS.
|
|
862
|
+
|
|
863
|
+
Duplicates the logic of ai.server._check_repo_allowlist intentionally:
|
|
864
|
+
importing from ai.server would create a circular import (server.py
|
|
865
|
+
imports from governance). Mirror with care — both copies must stay
|
|
866
|
+
in sync until LED-216 splits the allowlist into its own module.
|
|
867
|
+
"""
|
|
868
|
+
global _REPO_ALLOWLIST_WARNED
|
|
869
|
+
allowlist_raw = os.environ.get("DELIMIT_ALLOWED_REPOS", "").strip()
|
|
870
|
+
if not allowlist_raw:
|
|
871
|
+
if not _REPO_ALLOWLIST_WARNED:
|
|
872
|
+
logger.warning(
|
|
873
|
+
"DELIMIT_ALLOWED_REPOS unset — sensor_github_issue calls "
|
|
874
|
+
"pass through to gh api using the caller's token."
|
|
875
|
+
)
|
|
876
|
+
_REPO_ALLOWLIST_WARNED = True
|
|
877
|
+
return None
|
|
878
|
+
allowed = {entry.strip().lower() for entry in allowlist_raw.split(",") if entry.strip()}
|
|
879
|
+
if (repo or "").strip().lower() not in allowed:
|
|
880
|
+
return {
|
|
881
|
+
"error": "repo_not_allowlisted",
|
|
882
|
+
"repo": repo,
|
|
883
|
+
"allowed": sorted(allowed),
|
|
884
|
+
"hint": (
|
|
885
|
+
"Repo not in DELIMIT_ALLOWED_REPOS. Add it or use a tool "
|
|
886
|
+
"that does not reach external APIs."
|
|
887
|
+
),
|
|
888
|
+
}
|
|
889
|
+
return None
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _sensor_github_issue_impl(
|
|
893
|
+
repo: str,
|
|
894
|
+
issue_number: int,
|
|
895
|
+
since_comment_id: int = 0,
|
|
896
|
+
) -> Dict[str, Any]:
|
|
897
|
+
"""Sync implementation of the sensor_github_issue MCP tool.
|
|
898
|
+
|
|
899
|
+
Returns the RAW result dict (no _with_next_steps wrapping). Callers
|
|
900
|
+
that want the MCP wrapping apply it themselves. Returns
|
|
901
|
+
``{"error": ..., "has_new_activity": False}`` on any failure mode
|
|
902
|
+
rather than raising — the outreach daemon's monitor loop relies on
|
|
903
|
+
fail-soft behavior so one bad LED doesn't kill the whole tick.
|
|
904
|
+
|
|
905
|
+
Result schema (success path):
|
|
906
|
+
{
|
|
907
|
+
"repo": str, "issue_number": str,
|
|
908
|
+
"signal": {id, venture, metric, source, timestamp, severity},
|
|
909
|
+
"issue_state": "open" | "closed" | "unknown",
|
|
910
|
+
"new_comments": [{id, author, created_at, body}, ...],
|
|
911
|
+
"latest_comment_id": int,
|
|
912
|
+
"total_comments": int,
|
|
913
|
+
"has_new_activity": bool,
|
|
914
|
+
}
|
|
915
|
+
"""
|
|
916
|
+
# Validate inputs — defense-in-depth even though subprocess.run with
|
|
917
|
+
# list argv (no shell=True) makes classic injection inert.
|
|
918
|
+
if not _REPO_FORMAT_RE.match(repo or ""):
|
|
919
|
+
return {"error": f"Invalid repo format: {repo!r}. Use owner/repo.",
|
|
920
|
+
"has_new_activity": False}
|
|
921
|
+
if ".." in repo:
|
|
922
|
+
return {"error": "Invalid repo: path traversal sequences not allowed",
|
|
923
|
+
"has_new_activity": False}
|
|
924
|
+
if not isinstance(issue_number, int) or issue_number <= 0:
|
|
925
|
+
return {"error": f"Invalid issue number: {issue_number}",
|
|
926
|
+
"has_new_activity": False}
|
|
927
|
+
|
|
928
|
+
refusal = _check_repo_allowlist(repo)
|
|
929
|
+
if refusal is not None:
|
|
930
|
+
refusal.setdefault("has_new_activity", False)
|
|
931
|
+
return refusal
|
|
932
|
+
|
|
933
|
+
try:
|
|
934
|
+
# Fetch comments
|
|
935
|
+
comments_jq = (
|
|
936
|
+
"[.[] | {id: .id, author: .user.login, "
|
|
937
|
+
"created_at: .created_at, body: (.body | .[0:500])}]"
|
|
938
|
+
)
|
|
939
|
+
comments_proc = subprocess.run(
|
|
940
|
+
["gh", "api",
|
|
941
|
+
f"repos/{repo}/issues/{issue_number}/comments",
|
|
942
|
+
"--jq", comments_jq],
|
|
943
|
+
capture_output=True, text=True, timeout=30,
|
|
944
|
+
)
|
|
945
|
+
if comments_proc.returncode != 0:
|
|
946
|
+
return {
|
|
947
|
+
"error": f"gh api comments failed: {(comments_proc.stderr or '').strip()[:200]}",
|
|
948
|
+
"has_new_activity": False,
|
|
949
|
+
}
|
|
950
|
+
all_comments = json.loads(comments_proc.stdout) if comments_proc.stdout.strip() else []
|
|
951
|
+
new_comments = [c for c in all_comments if c.get("id", 0) > since_comment_id]
|
|
952
|
+
|
|
953
|
+
# Fetch issue state
|
|
954
|
+
issue_jq = "{state: .state, labels: [.labels[].name], reactions: .reactions.total_count}"
|
|
955
|
+
issue_proc = subprocess.run(
|
|
956
|
+
["gh", "api",
|
|
957
|
+
f"repos/{repo}/issues/{issue_number}",
|
|
958
|
+
"--jq", issue_jq],
|
|
959
|
+
capture_output=True, text=True, timeout=30,
|
|
960
|
+
)
|
|
961
|
+
if issue_proc.returncode != 0:
|
|
962
|
+
return {
|
|
963
|
+
"error": f"gh api issue failed: {(issue_proc.stderr or '').strip()[:200]}",
|
|
964
|
+
"has_new_activity": False,
|
|
965
|
+
}
|
|
966
|
+
issue_info = json.loads(issue_proc.stdout) if issue_proc.stdout.strip() else {}
|
|
967
|
+
issue_state = issue_info.get("state", "unknown")
|
|
968
|
+
|
|
969
|
+
# Severity classification — green default; amber on closed; red on
|
|
970
|
+
# negative keyword in any new comment body.
|
|
971
|
+
severity = "green"
|
|
972
|
+
combined_body = " ".join(c.get("body", "") or "" for c in new_comments).lower()
|
|
973
|
+
has_negative = any(kw in combined_body for kw in _NEGATIVE_KEYWORDS)
|
|
974
|
+
if has_negative:
|
|
975
|
+
severity = "red"
|
|
976
|
+
elif issue_state == "closed":
|
|
977
|
+
severity = "amber"
|
|
978
|
+
|
|
979
|
+
latest_comment_id = max((c.get("id", 0) for c in all_comments), default=since_comment_id)
|
|
980
|
+
repo_key = repo.replace("/", "_")
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
"repo": repo,
|
|
984
|
+
"issue_number": str(issue_number),
|
|
985
|
+
"signal": {
|
|
986
|
+
"id": f"sensor:github_issue:{repo_key}:{issue_number}",
|
|
987
|
+
"venture": "delimit",
|
|
988
|
+
"metric": "outreach_issue_activity",
|
|
989
|
+
"source": f"https://github.com/{repo}/issues/{issue_number}",
|
|
990
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
991
|
+
"severity": severity,
|
|
992
|
+
},
|
|
993
|
+
"issue_state": issue_state,
|
|
994
|
+
"new_comments": new_comments,
|
|
995
|
+
"latest_comment_id": latest_comment_id,
|
|
996
|
+
"total_comments": len(all_comments),
|
|
997
|
+
"has_new_activity": len(new_comments) > 0,
|
|
998
|
+
}
|
|
999
|
+
except subprocess.TimeoutExpired:
|
|
1000
|
+
return {"error": "gh command timed out after 30s",
|
|
1001
|
+
"has_new_activity": False}
|
|
1002
|
+
except json.JSONDecodeError as exc:
|
|
1003
|
+
return {"error": f"Failed to parse gh output: {exc}",
|
|
1004
|
+
"has_new_activity": False}
|
|
1005
|
+
except Exception as exc: # noqa: BLE001 — sensor must fail soft
|
|
1006
|
+
logger.error("sensor_github_issue impl error: %s", exc)
|
|
1007
|
+
return {"error": str(exc), "has_new_activity": False}
|
|
1008
|
+
|
|
1009
|
+
|
|
829
1010
|
def _deep_get(d: Dict, key: str) -> Any:
|
|
830
1011
|
"""Get a value from a dict, supporting nested keys with dots."""
|
|
831
1012
|
if "." in key:
|