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.
Files changed (53) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +9 -8
  3. package/bin/delimit-cli.js +179 -4
  4. package/bin/delimit-setup.js +46 -6
  5. package/gateway/ai/_compile_status.py +154 -0
  6. package/gateway/ai/agent_dispatch.py +41 -0
  7. package/gateway/ai/backends/git_health.py +175 -0
  8. package/gateway/ai/backends/tools_infra.py +163 -10
  9. package/gateway/ai/cli_contract.py +185 -0
  10. package/gateway/ai/daemon.py +10 -0
  11. package/gateway/ai/daily_digest.py +1 -2
  12. package/gateway/ai/delimit_daemon.py +67 -0
  13. package/gateway/ai/dispatch_gate.py +399 -0
  14. package/gateway/ai/governance.py +181 -0
  15. package/gateway/ai/heartbeat.py +290 -0
  16. package/gateway/ai/hot_reload.py +1 -2
  17. package/gateway/ai/led193_daemon/executor.py +9 -0
  18. package/gateway/ai/ledger_manager.py +90 -4
  19. package/gateway/ai/ledger_proof.py +127 -0
  20. package/gateway/ai/license.py +132 -47
  21. package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
  22. package/gateway/ai/license_core.pyi +1 -1
  23. package/gateway/ai/notify.py +39 -0
  24. package/gateway/ai/outreach_loop_daemon.py +349 -0
  25. package/gateway/ai/outreach_substantive.py +1437 -0
  26. package/gateway/ai/pro_tools.yaml +167 -0
  27. package/gateway/ai/reaper.py +70 -0
  28. package/gateway/ai/reddit_scanner.py +17 -6
  29. package/gateway/ai/sensing/schema.py +1 -1
  30. package/gateway/ai/sensing/signal_store.py +0 -1
  31. package/gateway/ai/server.py +5490 -1602
  32. package/gateway/ai/social_capability/fit_floor.py +114 -12
  33. package/gateway/ai/social_queue.py +166 -10
  34. package/gateway/ai/tdqs_lint.py +611 -0
  35. package/gateway/ai/tenant_auth.py +329 -0
  36. package/gateway/ai/tenant_data.py +339 -0
  37. package/gateway/ai/tenant_paths.py +150 -0
  38. package/gateway/ai/usage_allowlist.py +198 -0
  39. package/gateway/ai/workers/base.py +2 -2
  40. package/gateway/ai/workers/executor.py +32 -3
  41. package/gateway/ai/workers/outreach_drafter.py +0 -1
  42. package/gateway/ai/workers/pr_drafter.py +0 -1
  43. package/gateway/ai/x_ranker.py +12 -2
  44. package/gateway/core/json_schema_diff.py +25 -1
  45. package/lib/auth-signin.js +136 -0
  46. package/lib/auth-signout.js +169 -0
  47. package/lib/delimit-template.js +11 -0
  48. package/lib/migration-2092-banner.js +213 -0
  49. package/package.json +5 -2
  50. package/server.json +4 -4
  51. package/scripts/build-license-core.sh +0 -85
  52. package/scripts/security-check.sh +0 -66
  53. 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 ""
@@ -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: