delimit-cli 4.5.13 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +9 -8
  3. package/bin/delimit-cli.js +162 -1
  4. package/bin/delimit-setup.js +46 -6
  5. package/gateway/ai/_compile_status.py +154 -0
  6. package/gateway/ai/agent_dispatch.py +36 -0
  7. package/gateway/ai/backends/tools_infra.py +150 -10
  8. package/gateway/ai/daemon.py +10 -0
  9. package/gateway/ai/daily_digest.py +1 -2
  10. package/gateway/ai/delimit_daemon.py +67 -0
  11. package/gateway/ai/dispatch_gate.py +399 -0
  12. package/gateway/ai/hot_reload.py +1 -2
  13. package/gateway/ai/led193_daemon/executor.py +9 -0
  14. package/gateway/ai/ledger_manager.py +9 -0
  15. package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
  16. package/gateway/ai/notify.py +39 -0
  17. package/gateway/ai/outreach_substantive.py +676 -0
  18. package/gateway/ai/reaper.py +70 -0
  19. package/gateway/ai/reddit_scanner.py +10 -5
  20. package/gateway/ai/sensing/schema.py +1 -1
  21. package/gateway/ai/sensing/signal_store.py +0 -1
  22. package/gateway/ai/server.py +5171 -1462
  23. package/gateway/ai/social_capability/fit_floor.py +114 -12
  24. package/gateway/ai/tdqs_lint.py +611 -0
  25. package/gateway/ai/usage_allowlist.py +198 -0
  26. package/gateway/ai/workers/base.py +2 -2
  27. package/gateway/ai/workers/executor.py +32 -3
  28. package/gateway/ai/workers/outreach_drafter.py +0 -1
  29. package/gateway/ai/workers/pr_drafter.py +0 -1
  30. package/gateway/ai/x_ranker.py +12 -2
  31. package/gateway/core/json_schema_diff.py +25 -1
  32. package/lib/auth-signin.js +136 -0
  33. package/lib/auth-signout.js +169 -0
  34. package/lib/delimit-template.js +11 -0
  35. package/lib/migration-2092-banner.js +213 -0
  36. package/package.json +2 -2
  37. package/server.json +4 -4
@@ -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 ""
@@ -35,11 +35,10 @@ import logging
35
35
  import os
36
36
  import sys
37
37
  import threading
38
- import time
39
38
  import traceback
40
39
  from datetime import datetime, timezone
41
40
  from pathlib import Path
42
- from typing import Any, Callable, Dict, List, Optional, Set
41
+ from typing import Any, Dict, List, Optional, Set
43
42
 
44
43
  logger = logging.getLogger("delimit.ai.hot_reload")
45
44
 
@@ -346,11 +346,17 @@ def execute_format_fix(
346
346
  return out
347
347
  ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
348
348
  if not ok:
349
+ # Precondition mismatch (worktree dirty), not a profile failure.
350
+ # Mark skipped so the consecutive-failure breaker doesn't trip.
351
+ out.result = "skipped"
349
352
  out.reason = reason
350
353
  return out
351
354
 
352
355
  cmd = _detect_format_command(repo_path)
353
356
  if cmd is None:
357
+ # Repo has no formatter config — daemon can't run, but this is
358
+ # a setup gap not an executor failure. Skip rather than fail.
359
+ out.result = "skipped"
354
360
  out.reason = "no_formatter_detected"
355
361
  return out
356
362
 
@@ -429,11 +435,13 @@ def execute_lockfile_refresh(
429
435
  return out
430
436
  ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
431
437
  if not ok:
438
+ out.result = "skipped"
432
439
  out.reason = reason
433
440
  return out
434
441
 
435
442
  detected = _detect_lockfile_command(repo_path)
436
443
  if detected is None:
444
+ out.result = "skipped"
437
445
  out.reason = "no_lockfile_or_manager_detected"
438
446
  return out
439
447
  cmd, _lockfile = detected
@@ -551,6 +559,7 @@ def execute_docs_typo(
551
559
  return out
552
560
  ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
553
561
  if not ok:
562
+ out.result = "skipped"
554
563
  out.reason = reason
555
564
  return out
556
565
 
@@ -619,7 +619,16 @@ def update_item(
619
619
  if due_date:
620
620
  update["due_date"] = due_date
621
621
  if labels is not None:
622
+ # LED-2221: write to both `labels` and `tags`. The list_items
623
+ # reconstruction (around line ~870) merges update events into
624
+ # current state by checking the `tags` key only. Writing only
625
+ # `labels` silently drops the update at read time, which in
626
+ # particular meant the build daemon's `autonomous-build` tag
627
+ # check could never see tags written through the MCP. Keeping
628
+ # `labels` for any external consumer that reads the raw event
629
+ # stream; adding `tags` so the live state aggregator picks it up.
622
630
  update["labels"] = labels
631
+ update["tags"] = labels
623
632
  if blocked_by:
624
633
  update["blocked_by"] = blocked_by
625
634
  if blocks:
@@ -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