delimit-cli 4.1.52 → 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 +46 -0
- package/bin/delimit-cli.js +1 -2
- package/bin/delimit-setup.js +22 -7
- package/gateway/ai/agent_dispatch.py +79 -0
- package/gateway/ai/daily_digest.py +386 -0
- package/gateway/ai/ledger_manager.py +32 -0
- package/gateway/ai/license_core.py +2 -0
- package/gateway/ai/notify.py +17 -11
- package/gateway/ai/reddit_proxy.py +28 -9
- package/gateway/ai/sensing/__init__.py +35 -0
- package/gateway/ai/sensing/schema.py +107 -0
- package/gateway/ai/sensing/signal_store.py +348 -0
- package/gateway/ai/server.py +423 -7
- package/gateway/ai/supabase_sync.py +308 -0
- package/gateway/ai/work_order.py +216 -0
- package/gateway/ai/workers/__init__.py +32 -0
- package/gateway/ai/workers/base.py +154 -0
- package/gateway/ai/workers/executor.py +861 -0
- package/gateway/ai/workers/outreach_drafter.py +161 -0
- package/gateway/ai/workers/pr_drafter.py +148 -0
- package/package.json +14 -1
- package/gateway/ai/continuity.py +0 -462
- package/gateway/ai/inbox_daemon_runner.py +0 -217
- package/gateway/ai/loop_engine.py +0 -1236
- package/gateway/ai/social_cache.py +0 -341
- package/gateway/ai/social_daemon.py +0 -483
- package/gateway/ai/tweet_corpus_schema.sql +0 -76
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/demo-v420-clean.sh +0 -267
- package/scripts/demo-v420-deliberation.sh +0 -217
- package/scripts/demo-v420.sh +0 -55
- package/scripts/sync-gateway.sh +0 -112
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
29
|
+
## [4.1.53] - 2026-04-10
|
|
30
|
+
|
|
31
|
+
### Fixed (cycle engine — think→build→deploy)
|
|
32
|
+
- **Strategy deliberation timeout waste** — strategy cycle ran every 4th iteration with a 120s timeout. Gemini CLI loads 187 MCP tools on startup, causing guaranteed timeouts. Now runs every 8th iteration and skips entirely if a successful deliberation exists within the last hour.
|
|
33
|
+
- **Empty social drafts** — `generate_tailored_draft` returned `""` when no models were enabled instead of firing the fallback template. Added diagnostic logging (model, response length, preview) and empty-response detection.
|
|
34
|
+
- **Stale deploy queue** — 15 items from 2026-04-08 were stuck as `pending`. Added `_expire_stale_deploys()` that archives items older than 48h to `expired.jsonl` before every deploy stage. Deploy stage also handles `ImportError` on server functions gracefully.
|
|
35
|
+
|
|
36
|
+
### Added (gateway sync)
|
|
37
|
+
- Unified think→build→deploy cycle (`run_full_cycle`, shipped earlier this session)
|
|
38
|
+
- Account-aware brand voice sanitizer + Twitter prompt v2 (LED-791/796)
|
|
39
|
+
- Swagger 2.0 `$ref` parameter fix in diff engine
|
|
40
|
+
- twttr241 fixes: wrong secrets file, 429 retry, flaky test (LED-763/781/783)
|
|
41
|
+
- Security: `..` path traversal rejection in `sensor_github_issue` (#40)
|
|
42
|
+
- Scanner FP allowlist for test fixture credentials (LED-817)
|
|
43
|
+
- Loop engine dispatch status fix (LED-814)
|
|
44
|
+
|
|
45
|
+
### Tests
|
|
46
|
+
- Gateway: 88/88 loop+social tests passing.
|
|
47
|
+
- npm CLI: 134/134 passing (no CLI changes — bundled gateway only).
|
|
48
|
+
|
|
3
49
|
## [4.1.52] - 2026-04-10
|
|
4
50
|
|
|
5
51
|
### Fixed (exit shim reporting zeros)
|
package/bin/delimit-cli.js
CHANGED
|
@@ -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
|
|
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'));
|
package/bin/delimit-setup.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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)
|