delimit-cli 4.1.53 → 4.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.2.0] - 2026-04-21
4
+
5
+ ### Added (gateway sync — LED-987 through LED-1008)
6
+ - **Venture tagging (LED-1008)** — work orders, deliberations, and social drafts carry a canonical venture tag propagated through `save_draft` and the Supabase sync writers. `_normalize_venture` maps freeform strings (e.g., "DomainVested", "wire.report", "LT") to the canonical 4-member vocabulary (delimit / domainvested / wirereport / livetube). Unknown values pass through lowercased so they surface instead of dropping.
7
+ - **Warm-window filter for social targets (LED-998)** — 72h window on X scans, 14d on reddit. Env-tunable via `DELIMIT_X_WARM_HOURS` and `DELIMIT_REDDIT_WARM_HOURS`. Fail-open on missing timestamps so parser bugs don't silently drop everything.
8
+ - **X draft salvage (LED-997)** — `_try_trim_twitter_draft` trims LLM output at sentence boundary when it exceeds 3 sentences or 280 chars. Runs before the max-length check so a good-but-long draft becomes a good-and-short one instead of dying in the sanitizer. Proven live: salvaged a 626-char draft to 241 chars on the first post-deploy cycle.
9
+ - **Reversible stale cleanup (LED-990)** — weekly systemd timer demotes blocked items to a cold lane after 30d instead of deleting. Ledger update restores cold → blocked/open with a single status flip. First-run safe with DRY_RUN=1.
10
+ - **Warm-thread PR watcher (LED-989)** — 14-day warm window on outreach follow-ups; skip threads inactive longer than that. MAX_ACTIVE_THREADS=8 cap prevents dog-piling a single repository.
11
+ - **Lemon Squeezy → Supabase reconciler (LED-996)** — trial watcher now polls LS every 6h and upserts subscription_status + role into the Supabase users table. Catches webhook drops without manual SQL intervention.
12
+ - **ACTION_DENYLIST (LED-988)** — executor v2 gains an explicit denylist of prohibited action categories (money, legal/identity, credentials, deploy, contracts) that fires BEFORE the ACTION_SPEC whitelist check. Defense-in-depth against LLM-driven executor drift.
13
+ - **Reddit residential-IP proxy (LED-987)** — scoped service bypasses 429s on reddit34.p.rapidapi.com by routing through a residential IP. Systemd-managed, auto-restart, rate-limited.
14
+ - **propose_pr autonomous build primitive (LED-988)** — executor can propose PRs against an allowlisted repo set (`PROPOSE_PR_ALLOWED_REPOS`) with a fixed branch prefix (`delimit/`) and author (`delimit-bot`). Guarded by denylist + whitelist.
15
+
16
+ ### Fixed
17
+ - **Exit-shim counter undercounting** — previously missed commits outside `SESSION_CWD` and dropped Z-suffixed timestamps; both now captured.
18
+ - **Proprietary path leaks** — sync-gateway.sh EXCLUDE list hardened to keep Jamsons-portfolio-specific files (social.py, social_target.py, inbox_daemon.py, founding_users.py, deliberation.py) out of the npm bundle.
19
+
20
+ ### Tests
21
+ - Gateway: 163/163 passing on changed-file tests (social.py, social_target.py, supabase_sync).
22
+ - npm CLI: 134/134 passing (no CLI behavior changes — bundled gateway update).
23
+ - Security audit: 0 real findings across gateway + UI (false positives only — test fixtures and TypeScript `token:` parameter types).
24
+
25
+ ### Notes
26
+ - Companion dashboard changes at app.delimit.ai (LED-995 Billing + API Keys wiring, LED-997 Blocked drafts tab, LED-1008 venture chips + filters) shipped via delimit-ui/main.
27
+ - Supabase migration 025 (venture TEXT column on 4 tables) applied separately via Management API.
28
+
3
29
  ## [4.1.53] - 2026-04-10
4
30
 
5
31
  ### Fixed (cycle engine — think→build→deploy)
@@ -5768,8 +5768,7 @@ program
5768
5768
  // Try to run deliberation directly via the gateway
5769
5769
  const HOME = process.env.HOME || require('os').homedir();
5770
5770
  const gatewayScript = path.join(HOME, '.delimit', 'server', 'ai', 'deliberation.py');
5771
- const gatewayAlt = '/home/delimit/delimit-gateway/ai/deliberation.py';
5772
- const scriptPath = fs.existsSync(gatewayScript) ? gatewayScript : fs.existsSync(gatewayAlt) ? gatewayAlt : null;
5771
+ const scriptPath = fs.existsSync(gatewayScript) ? gatewayScript : null;
5773
5772
 
5774
5773
  if (scriptPath) {
5775
5774
  console.log(chalk.dim('Running multi-model deliberation...\n'));
@@ -780,15 +780,30 @@ delimit_exit_screen() {
780
780
  else
781
781
  DURATION="\${ELAPSED}s"
782
782
  fi
783
- # Count git commits made during session (@ prefix tells git the value is epoch)
783
+ # Count git commits made during session. SESSION_CWD is captured at shim
784
+ # launch; commits made in other repos during the session would be missed.
785
+ # Scan a best-effort set of known roots plus the launch cwd.
784
786
  COMMITS=0
785
- if [ -d "\$SESSION_CWD/.git" ] || git -C "\$SESSION_CWD" rev-parse --git-dir >/dev/null 2>&1; then
786
- COMMITS=\$(git -C "\$SESSION_CWD" log --oneline --after="@\$SESSION_START" --format="%H" 2>/dev/null | wc -l | tr -d ' ')
787
- fi
788
- # Count ledger items created during session (by timestamp)
787
+ # Customer-facing: scan launch cwd, its parent, and common project roots.
788
+ # If an org or solo dev keeps multiple repos in \$HOME or \$HOME/code, commits
789
+ # there during a session get counted.
790
+ REPO_ROOTS="\$SESSION_CWD"
791
+ for parent in "\$SESSION_CWD/.." "\$HOME" "\$HOME/code" "\$HOME/src" "\$HOME/projects"; do
792
+ [ -d "\$parent" ] || continue
793
+ for d in "\$parent"/*/.git; do
794
+ [ -d "\$d" ] && REPO_ROOTS="\$REPO_ROOTS \$(dirname \$d)"
795
+ done
796
+ done
797
+ for r in \$REPO_ROOTS; do
798
+ [ -d "\$r/.git" ] || continue
799
+ C=\$(git -C "\$r" log --after="@\$SESSION_START" --format="%H" 2>/dev/null | wc -l | tr -d ' ')
800
+ COMMITS=\$((COMMITS + C))
801
+ done
802
+ # Count ledger items created during session (by timestamp).
803
+ # Ledger JSON is written with ": " (space) between key and value, and ISO
804
+ # timestamps end with "Z" (UTC), so the regex must tolerate both.
789
805
  LEDGER_DIR="\$DELIMIT_HOME/ledger"
790
806
  LEDGER_ITEMS=0
791
- # Convert epoch SESSION_START to ISO prefix for string comparison
792
807
  SESSION_ISO=\$(date -u -d "@\$SESSION_START" +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -r "\$SESSION_START" +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "")
793
808
  if [ -d "\$LEDGER_DIR" ] && [ -n "\$SESSION_ISO" ]; then
794
809
  for lf in "\$LEDGER_DIR"/*.jsonl; do
@@ -796,7 +811,7 @@ delimit_exit_screen() {
796
811
  COUNT=\$(awk -v start="\$SESSION_ISO" '
797
812
  BEGIN { n=0 }
798
813
  {
799
- if (match(\$0, /"created_at":"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})"/, arr)) {
814
+ if (match(\$0, /"created_at"[[:space:]]*:[[:space:]]*"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})Z?"/, arr)) {
800
815
  if (arr[1] >= start) n++
801
816
  }
802
817
  }
@@ -18,11 +18,38 @@ from typing import Any, Dict, List, Optional
18
18
  AGENTS_DIR = Path.home() / ".delimit" / "agents"
19
19
  TASKS_FILE = AGENTS_DIR / "tasks.json"
20
20
  AUDIT_FILE = AGENTS_DIR / "audit.jsonl"
21
+ PAUSE_FILE = Path.home() / ".delimit" / "pause_dispatch"
21
22
 
22
23
  VALID_PRIORITIES = {"P0", "P1", "P2"}
23
24
  VALID_ASSIGNEES = {"claude", "codex", "gemini", "any"}
24
25
  VALID_STATUSES = {"dispatched", "in_progress", "done", "handed_off", "failed"}
25
26
 
27
+ # LED-876: auto-pause when dead-letter queue depth (stuck 'dispatched' tasks)
28
+ # hits this threshold. Prevents runaway dispatch when no workers are pulling.
29
+ DLQ_AUTO_PAUSE_THRESHOLD = 20
30
+
31
+ # LED-878: router table — resolves assignee='any' to a specific model at
32
+ # dispatch time based on task_type. This eliminates the dead-letter 'any'
33
+ # bucket without requiring a worker process to exist yet. The mapping is
34
+ # deliberately conservative: if the task type is unknown, fall through to
35
+ # gemini (cheapest, highest throughput) rather than pile onto claude.
36
+ TASK_TYPE_ROUTER = {
37
+ # Outreach and social work — Gemini Flash is fast and cheap
38
+ "outreach": "gemini",
39
+ "social": "gemini",
40
+ "content": "gemini",
41
+ "sensor": "gemini",
42
+ # Engineering — Claude / Codex for code, Claude for governance
43
+ "fix": "claude",
44
+ "feat": "claude",
45
+ "refactor": "claude",
46
+ "test": "codex",
47
+ "research": "gemini",
48
+ "strategy": "gemini",
49
+ "deliberation": "claude",
50
+ }
51
+ ROUTER_DEFAULT_ASSIGNEE = "gemini"
52
+
26
53
 
27
54
  def _ensure_dir():
28
55
  """Create the agents directory if it doesn't exist."""
@@ -74,10 +101,62 @@ def dispatch_task(
74
101
  if not title or not title.strip():
75
102
  return {"error": "title is required"}
76
103
 
104
+ # LED-876: reject ghost "[VENTURE] Engage: on x" titles with empty author
105
+ # slot. The social_target fix drops these at the scanner, but keep this as
106
+ # a belt-and-suspenders check since agent_dispatch has other callers too.
107
+ stripped = title.strip()
108
+ if "Engage: on " in stripped or "Engage: on " in stripped:
109
+ return {"error": f"rejected ghost engage task with empty author: {stripped!r}"}
110
+
111
+ # LED-876: manual kill switch. Touch ~/.delimit/pause_dispatch to halt all
112
+ # dispatches instantly without touching loop_config. Remove the file to
113
+ # resume. Kept deliberately simple so it works from any shell.
114
+ if PAUSE_FILE.exists():
115
+ _append_audit({
116
+ "action": "dispatch_rejected_paused",
117
+ "title": stripped,
118
+ "reason": str(PAUSE_FILE),
119
+ })
120
+ return {"error": f"dispatch paused: {PAUSE_FILE} exists"}
121
+
122
+ # LED-876: automatic circuit breaker. If the DLQ (count of 'dispatched'
123
+ # tasks that never moved to in_progress/done/failed) exceeds the threshold,
124
+ # auto-create the pause file and reject. This stops the cycle from growing
125
+ # the queue unboundedly when workers aren't consuming.
126
+ existing_tasks = _load_tasks()
127
+ dlq_depth = sum(1 for t in existing_tasks.values() if t.get("status") == "dispatched")
128
+ if dlq_depth >= DLQ_AUTO_PAUSE_THRESHOLD:
129
+ PAUSE_FILE.parent.mkdir(parents=True, exist_ok=True)
130
+ PAUSE_FILE.write_text(
131
+ f"auto-paused at {time.strftime('%Y-%m-%dT%H:%M:%SZ')} "
132
+ f"(dlq_depth={dlq_depth} >= {DLQ_AUTO_PAUSE_THRESHOLD})\n"
133
+ )
134
+ _append_audit({
135
+ "action": "dispatch_auto_paused",
136
+ "dlq_depth": dlq_depth,
137
+ "threshold": DLQ_AUTO_PAUSE_THRESHOLD,
138
+ })
139
+ return {
140
+ "error": (
141
+ f"auto-paused: DLQ depth {dlq_depth} >= {DLQ_AUTO_PAUSE_THRESHOLD}. "
142
+ f"Clear stuck tasks then delete {PAUSE_FILE} to resume."
143
+ )
144
+ }
145
+
77
146
  assignee = assignee.lower().strip() if assignee else "any"
78
147
  if assignee not in VALID_ASSIGNEES:
79
148
  return {"error": f"assignee must be one of: {', '.join(sorted(VALID_ASSIGNEES))}"}
80
149
 
150
+ # LED-878: resolve 'any' to a specific model via the router table so
151
+ # tasks never land in a bucket no worker pulls from. The mapping uses
152
+ # task_type as the primary key; if unknown, falls through to the
153
+ # default (gemini — cheapest + highest throughput).
154
+ if assignee == "any":
155
+ tt = (task_type or "").lower().strip()
156
+ routed = TASK_TYPE_ROUTER.get(tt, ROUTER_DEFAULT_ASSIGNEE)
157
+ if routed in VALID_ASSIGNEES and routed != "any":
158
+ assignee = routed
159
+
81
160
  priority = priority.upper().strip() if priority else "P1"
82
161
  if priority not in VALID_PRIORITIES:
83
162
  return {"error": f"priority must be one of: {', '.join(sorted(VALID_PRIORITIES))}"}
@@ -0,0 +1,386 @@
1
+ """Daily digest for the Delimit autonomous loop (LED-966).
2
+
3
+ Produces a structured summary of the last 24h:
4
+ - Cycle count (sense-only daemon ticks)
5
+ - Signals ingested (count by platform)
6
+ - Deliberations held (count + transcript refs)
7
+ - Ledger deltas (items opened, in_progress, done)
8
+ - Agent dispatches (by assignee, status)
9
+ - Pending approvals (drafts awaiting founder)
10
+ - Critical events (errors, timeouts, guard trips)
11
+
12
+ Writes:
13
+ - ~/.delimit/digest/digest-YYYY-MM-DD.md (file artifact, always)
14
+ - ~/.delimit/digest/digest-YYYY-MM-DD.json (machine-readable)
15
+ - Email to founder (if DELIMIT_DIGEST_EMAIL=true AND email pipeline healthy)
16
+
17
+ Call via MCP: delimit_digest(action="run") or scheduled cron.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import time
24
+ from collections import Counter
25
+ from datetime import datetime, timedelta, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ DIGEST_DIR = Path.home() / ".delimit" / "digest"
30
+ LEDGER_DIR = Path.home() / ".delimit" / "ledger"
31
+ DELIB_DIR = Path.home() / ".delimit" / "deliberations"
32
+ SIGNALS_DIR = Path.home() / ".delimit" / "intel" / "signals"
33
+ AGENTS_FILE = Path.home() / ".delimit" / "agents" / "tasks.json"
34
+
35
+
36
+ def _now() -> datetime:
37
+ return datetime.now(timezone.utc)
38
+
39
+
40
+ def _ensure_dir():
41
+ DIGEST_DIR.mkdir(parents=True, exist_ok=True)
42
+
43
+
44
+ def _count_signals(since: datetime) -> Dict[str, Any]:
45
+ """Count signals ingested in the window, grouped by platform."""
46
+ counts: Counter = Counter()
47
+ total = 0
48
+ if not SIGNALS_DIR.exists():
49
+ return {"total": 0, "by_platform": {}}
50
+ for shard in SIGNALS_DIR.glob("*.jsonl"):
51
+ if shard.name.startswith("_"):
52
+ continue
53
+ try:
54
+ shard_date = datetime.fromisoformat(shard.stem).date()
55
+ except ValueError:
56
+ continue
57
+ if shard_date < since.date():
58
+ continue
59
+ try:
60
+ for line in shard.read_text().splitlines():
61
+ if not line.strip():
62
+ continue
63
+ try:
64
+ row = json.loads(line)
65
+ except json.JSONDecodeError:
66
+ continue
67
+ try:
68
+ ts = datetime.fromisoformat(row.get("ingested_at", "").replace("Z", "+00:00"))
69
+ except Exception:
70
+ continue
71
+ if ts < since:
72
+ continue
73
+ counts[row.get("platform", "?")] += 1
74
+ total += 1
75
+ except OSError:
76
+ continue
77
+ return {"total": total, "by_platform": dict(counts.most_common())}
78
+
79
+
80
+ def _count_deliberations(since: datetime) -> Dict[str, Any]:
81
+ """Count deliberation transcripts created in the window."""
82
+ if not DELIB_DIR.exists():
83
+ return {"total": 0, "unanimous": 0, "no_consensus": 0, "recent": []}
84
+ total = 0
85
+ unanimous = 0
86
+ no_consensus = 0
87
+ recent = []
88
+ for f in sorted(DELIB_DIR.glob("deliberation_*.json"), reverse=True)[:50]:
89
+ try:
90
+ mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc)
91
+ if mtime < since:
92
+ continue
93
+ d = json.loads(f.read_text())
94
+ total += 1
95
+ verdict = (d.get("final_verdict") or "").upper()
96
+ if "UNANIMOUS" in verdict:
97
+ unanimous += 1
98
+ elif "NO CONSENSUS" in verdict or "MAX ROUNDS" in verdict:
99
+ no_consensus += 1
100
+ rounds_field = d.get("rounds", 0)
101
+ rounds_count = len(rounds_field) if isinstance(rounds_field, list) else rounds_field
102
+ recent.append({
103
+ "file": f.name,
104
+ "verdict": (d.get("final_verdict") or "?")[:60],
105
+ "status": d.get("status", "?"),
106
+ "rounds": rounds_count,
107
+ })
108
+ except Exception:
109
+ continue
110
+ return {
111
+ "total": total,
112
+ "unanimous": unanimous,
113
+ "no_consensus": no_consensus,
114
+ "recent": recent[:10],
115
+ }
116
+
117
+
118
+ def _count_ledger_deltas(since: datetime) -> Dict[str, Any]:
119
+ """Count ledger items opened / updated / done in the window."""
120
+ opened = 0
121
+ done = 0
122
+ new_items = []
123
+ done_items = []
124
+ if not LEDGER_DIR.exists():
125
+ return {"opened": 0, "done": 0, "new": [], "completed": []}
126
+ since_iso = since.isoformat().replace("+00:00", "Z")
127
+ for lf in LEDGER_DIR.glob("*.jsonl"):
128
+ try:
129
+ for line in lf.read_text().splitlines():
130
+ if not line.strip():
131
+ continue
132
+ try:
133
+ item = json.loads(line)
134
+ except json.JSONDecodeError:
135
+ continue
136
+ created = item.get("created_at", "")
137
+ updated = item.get("updated_at", created)
138
+ if created >= since_iso and item.get("type") != "update":
139
+ opened += 1
140
+ new_items.append({
141
+ "id": item.get("id"),
142
+ "title": (item.get("title") or "")[:80],
143
+ "priority": item.get("priority", "?"),
144
+ })
145
+ if item.get("type") == "update" and item.get("status") == "done" and updated >= since_iso:
146
+ done += 1
147
+ done_items.append({
148
+ "id": item.get("id"),
149
+ "note": (item.get("note") or "")[:120],
150
+ })
151
+ except OSError:
152
+ continue
153
+ return {
154
+ "opened": opened,
155
+ "done": done,
156
+ "new": new_items[-10:],
157
+ "completed": done_items[-10:],
158
+ }
159
+
160
+
161
+ def _count_dispatches(since: datetime) -> Dict[str, Any]:
162
+ """Count swarm dispatches and their current status."""
163
+ if not AGENTS_FILE.exists():
164
+ return {"total": 0, "by_status": {}, "by_assignee": {}, "stuck_over_24h": 0}
165
+ try:
166
+ tasks = json.loads(AGENTS_FILE.read_text())
167
+ except Exception:
168
+ return {"total": 0, "by_status": {}, "by_assignee": {}, "stuck_over_24h": 0}
169
+ status_counts: Counter = Counter()
170
+ assignee_counts: Counter = Counter()
171
+ stuck = 0
172
+ dispatched_recent = 0
173
+ since_iso = since.isoformat().replace("+00:00", "Z")
174
+ for tid, task in tasks.items():
175
+ status = task.get("status", "?")
176
+ status_counts[status] += 1
177
+ if task.get("created_at", "") >= since_iso:
178
+ dispatched_recent += 1
179
+ if status == "dispatched":
180
+ assignee_counts[task.get("assignee", "?")] += 1
181
+ try:
182
+ created = datetime.fromisoformat(task.get("created_at", "").replace("Z", "+00:00"))
183
+ if (_now() - created) > timedelta(hours=24):
184
+ stuck += 1
185
+ except Exception:
186
+ pass
187
+ return {
188
+ "total_tasks": len(tasks),
189
+ "dispatched_last_24h": dispatched_recent,
190
+ "by_status": dict(status_counts),
191
+ "dispatched_by_assignee": dict(assignee_counts),
192
+ "stuck_over_24h": stuck,
193
+ }
194
+
195
+
196
+ def _check_health(since: datetime) -> Dict[str, Any]:
197
+ """Check for errors, guard trips, timeouts in the window."""
198
+ health = {
199
+ "pause_file_exists": (Path.home() / ".delimit" / "pause_dispatch").exists(),
200
+ "signal_guard_shadow_hits": 0,
201
+ "daemon_stopped": False,
202
+ }
203
+ # Signal guard shadow log
204
+ shadow = Path.home() / ".delimit" / "logs" / "signal_guard_shadow.jsonl"
205
+ if shadow.exists():
206
+ since_iso = since.isoformat().replace("+00:00", "Z")
207
+ try:
208
+ for line in shadow.read_text().splitlines():
209
+ if not line.strip():
210
+ continue
211
+ try:
212
+ row = json.loads(line)
213
+ except json.JSONDecodeError:
214
+ continue
215
+ if row.get("ts", "") >= since_iso:
216
+ health["signal_guard_shadow_hits"] += 1
217
+ except OSError:
218
+ pass
219
+ return health
220
+
221
+
222
+ def build_digest(window_hours: int = 24) -> Dict[str, Any]:
223
+ """Collect all signals for the last window_hours into a single digest dict."""
224
+ since = _now() - timedelta(hours=window_hours)
225
+ return {
226
+ "generated_at": _now().isoformat(),
227
+ "window_hours": window_hours,
228
+ "window_start": since.isoformat(),
229
+ "signals": _count_signals(since),
230
+ "deliberations": _count_deliberations(since),
231
+ "ledger": _count_ledger_deltas(since),
232
+ "dispatches": _count_dispatches(since),
233
+ "health": _check_health(since),
234
+ }
235
+
236
+
237
+ def render_markdown(digest: Dict[str, Any]) -> str:
238
+ """Render the digest as a founder-readable markdown document."""
239
+ g = digest
240
+ s = g["signals"]
241
+ d = g["deliberations"]
242
+ l = g["ledger"]
243
+ dsp = g["dispatches"]
244
+ h = g["health"]
245
+
246
+ lines = [
247
+ f"# Delimit Daily Digest — {g['generated_at'][:10]}",
248
+ "",
249
+ f"Window: last {g['window_hours']}h (since {g['window_start'][:16]}Z)",
250
+ "",
251
+ "## Health",
252
+ "",
253
+ f"- Pause file: {'🔴 ACTIVE' if h['pause_file_exists'] else '🟢 clear'}",
254
+ f"- Signal guard shadow hits: {h['signal_guard_shadow_hits']}",
255
+ "",
256
+ "## Signals ingested",
257
+ "",
258
+ f"Total: **{s['total']}** signals",
259
+ ]
260
+ for platform, count in s.get("by_platform", {}).items():
261
+ lines.append(f"- {platform}: {count}")
262
+ lines.extend([
263
+ "",
264
+ "## Deliberations",
265
+ "",
266
+ f"- Total: **{d['total']}**",
267
+ f"- Unanimous: {d['unanimous']}",
268
+ f"- No consensus / max rounds: {d['no_consensus']}",
269
+ ])
270
+ if d.get("recent"):
271
+ lines.append("")
272
+ lines.append("Recent transcripts:")
273
+ for r in d["recent"]:
274
+ lines.append(f" - `{r['file']}` — {r['verdict']} ({r.get('rounds', '?')} rounds)")
275
+ lines.extend([
276
+ "",
277
+ "## Ledger deltas",
278
+ "",
279
+ f"- Items opened: **{l['opened']}**",
280
+ f"- Items completed: **{l['done']}**",
281
+ ])
282
+ if l.get("new"):
283
+ lines.append("")
284
+ lines.append("New items:")
285
+ for item in l["new"]:
286
+ lines.append(f" - {item['id']} [{item['priority']}] {item['title']}")
287
+ if l.get("completed"):
288
+ lines.append("")
289
+ lines.append("Completed:")
290
+ for item in l["completed"]:
291
+ lines.append(f" - {item['id']} — {item['note']}")
292
+ lines.extend([
293
+ "",
294
+ "## Swarm dispatches",
295
+ "",
296
+ f"- Total tasks ever: {dsp['total_tasks']}",
297
+ f"- New dispatches last 24h: **{dsp['dispatched_last_24h']}**",
298
+ f"- Stuck (dispatched >24h): {dsp['stuck_over_24h']}",
299
+ ])
300
+ if dsp.get("by_status"):
301
+ lines.append("")
302
+ lines.append("By status:")
303
+ for status, count in dsp["by_status"].items():
304
+ lines.append(f" - {status}: {count}")
305
+ if dsp.get("dispatched_by_assignee"):
306
+ lines.append("")
307
+ lines.append("Currently dispatched by assignee:")
308
+ for who, count in dsp["dispatched_by_assignee"].items():
309
+ lines.append(f" - {who}: {count}")
310
+ lines.extend([
311
+ "",
312
+ "## Pending founder actions",
313
+ "",
314
+ f"- Stuck dispatches (need worker): {dsp['stuck_over_24h']}",
315
+ f"- Pause file present: {'yes' if h['pause_file_exists'] else 'no'}",
316
+ f"- Guard shadow hits (investigate if >0): {h['signal_guard_shadow_hits']}",
317
+ "",
318
+ "---",
319
+ f"Digest generated at {g['generated_at']}",
320
+ ])
321
+ return "\n".join(lines)
322
+
323
+
324
+ def write_digest(window_hours: int = 24) -> Dict[str, str]:
325
+ """Generate the digest and write both markdown + json artifacts.
326
+
327
+ Returns paths to the created files so the founder can inspect them
328
+ from the interactive session even without email delivery.
329
+ """
330
+ _ensure_dir()
331
+ digest = build_digest(window_hours=window_hours)
332
+ date_slug = digest["generated_at"][:10]
333
+ md_path = DIGEST_DIR / f"digest-{date_slug}.md"
334
+ json_path = DIGEST_DIR / f"digest-{date_slug}.json"
335
+ md_path.write_text(render_markdown(digest))
336
+ json_path.write_text(json.dumps(digest, indent=2))
337
+ return {
338
+ "markdown_path": str(md_path),
339
+ "json_path": str(json_path),
340
+ "summary": f"{digest['signals']['total']} signals, {digest['deliberations']['total']} deliberations, {digest['ledger']['opened']} new ledger items, {digest['dispatches']['stuck_over_24h']} stuck dispatches",
341
+ }
342
+
343
+
344
+ def send_digest_email(to: str = "", from_account: str = "pro@delimit.ai") -> Dict[str, Any]:
345
+ """Send the most recent digest via the notify pipeline.
346
+
347
+ Gated on environment: returns a no-op result when DMARC is missing
348
+ and email would be filtered. Set DELIMIT_DIGEST_EMAIL=true to force
349
+ send attempts regardless. The digest markdown is always written to
350
+ disk so the founder can inspect it from the interactive session.
351
+ """
352
+ import os
353
+ result = write_digest(window_hours=24)
354
+ md_path = Path(result["markdown_path"])
355
+ if not md_path.exists():
356
+ return {"error": "digest not written", "files": result}
357
+
358
+ send_enabled = os.environ.get("DELIMIT_DIGEST_EMAIL", "").lower() in ("true", "1", "yes")
359
+ if not send_enabled:
360
+ return {
361
+ "status": "skipped_email",
362
+ "reason": "DELIMIT_DIGEST_EMAIL not set to true; digest written to disk only",
363
+ "files": result,
364
+ }
365
+
366
+ try:
367
+ from ai.notify import send_notification
368
+ body = md_path.read_text()
369
+ send_notification(
370
+ channel="email",
371
+ message=body,
372
+ subject=f"[DIGEST] Delimit — {result['summary']}",
373
+ to=to or os.environ.get("DELIMIT_SMTP_TO", ""),
374
+ from_account=from_account,
375
+ event_type="daily_digest",
376
+ )
377
+ return {
378
+ "status": "sent",
379
+ "files": result,
380
+ }
381
+ except Exception as exc:
382
+ return {
383
+ "status": "send_failed",
384
+ "error": str(exc),
385
+ "files": result,
386
+ }
@@ -202,7 +202,39 @@ def add_item(
202
202
  LED-189: Items can have acceptance_criteria (testable "done when" conditions).
203
203
  LED-190: Items can have context, tools_needed, and estimated_complexity
204
204
  for agent-executable task format.
205
+ LED-877: Signal guard — rejects source='social_scan' writes so sensed
206
+ observations cannot land in the ledger. Observations belong in the intel
207
+ signal store (ai/sensing/signal_store.py). Bypass via env var for the
208
+ promote_to_ledger path: _DELIMIT_SIGNAL_PROMOTED_BY=<who>.
205
209
  """
210
+ _src_norm = (source or "").strip().lower()
211
+ _promoted_by = os.environ.get("_DELIMIT_SIGNAL_PROMOTED_BY", "")
212
+ _guard_mode = os.environ.get("DELIMIT_SIGNAL_GUARD", "enforce").lower()
213
+ if _src_norm.startswith("social_scan") or _src_norm.startswith("social_strategy"):
214
+ if not _promoted_by:
215
+ msg = (
216
+ f"LED-877 guard: source={source!r} is a sensed observation, not "
217
+ f"a ledger item. Use ai.sensing.signal_store.ingest() instead. "
218
+ f"Promote explicitly via promote_to_ledger(signal_id=...)."
219
+ )
220
+ if _guard_mode == "shadow":
221
+ try:
222
+ _shadow_log = Path.home() / ".delimit" / "logs" / "signal_guard_shadow.jsonl"
223
+ _shadow_log.parent.mkdir(parents=True, exist_ok=True)
224
+ with _shadow_log.open("a") as _f:
225
+ _f.write(json.dumps({
226
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
227
+ "title": title,
228
+ "source": source,
229
+ "ledger": ledger,
230
+ "msg": msg,
231
+ }) + "\n")
232
+ except Exception:
233
+ pass
234
+ # fall through
235
+ else:
236
+ raise ValueError(msg)
237
+
206
238
  _ensure(project_path)
207
239
  venture = _detect_venture(project_path)
208
240
  ledger_dir = _project_ledger_dir(project_path)
@@ -37,6 +37,8 @@ PRO_TOOLS = frozenset({
37
37
  # Agent orchestration
38
38
  "delimit_agent_dispatch", "delimit_agent_status",
39
39
  "delimit_agent_complete", "delimit_agent_handoff",
40
+ # Worker Pool v2 executor (LED-981)
41
+ "delimit_executor",
40
42
  })
41
43
 
42
44
  # Free trial limits
@@ -1044,17 +1044,23 @@ def _enforce_email_protocol(subject: str, message: str, event_type: str) -> tupl
1044
1044
  # 1. Subject must have a valid prefix bracket
1045
1045
  if not any(subject.startswith(p) for p in _VALID_SUBJECT_PREFIXES):
1046
1046
  # Try to infer from event_type
1047
- prefix_map = {
1048
- "social_draft": "[APPROVE]",
1049
- "outreach": "[OUTREACH]",
1050
- "deploy": "[DEPLOY]",
1051
- "gate_failure": "[ALERT]",
1052
- "digest": "[DIGEST]",
1053
- "info": "[INFO]",
1054
- }
1055
- prefix = prefix_map.get(event_type, "[INFO]")
1056
- subject = f"{prefix} {subject}"
1057
- warnings.append(f"Subject prefix added: {prefix}")
1047
+ # LED-969: customer-facing emails should not get bracket prefixes.
1048
+ # Any event_type starting with "customer_" is external-facing and
1049
+ # the subject should be sent as-is (clean, professional).
1050
+ if event_type and event_type.startswith("customer_"):
1051
+ pass # no prefix for customer emails
1052
+ else:
1053
+ prefix_map = {
1054
+ "social_draft": "[APPROVE]",
1055
+ "outreach": "[OUTREACH]",
1056
+ "deploy": "[DEPLOY]",
1057
+ "gate_failure": "[ALERT]",
1058
+ "digest": "[DIGEST]",
1059
+ "info": "[INFO]",
1060
+ }
1061
+ prefix = prefix_map.get(event_type, "[INFO]")
1062
+ subject = f"{prefix} {subject}"
1063
+ warnings.append(f"Subject prefix added: {prefix}")
1058
1064
 
1059
1065
  # 2. Check required sections for this event_type
1060
1066
  required = _EMAIL_PROTOCOL.get(event_type, [])