delimit-cli 4.1.53 → 4.3.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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +34 -3
  3. package/bin/delimit-cli.js +150 -2
  4. package/bin/delimit-setup.js +22 -7
  5. package/gateway/ai/agent_dispatch.py +79 -0
  6. package/gateway/ai/daily_digest.py +386 -0
  7. package/gateway/ai/ledger_manager.py +32 -0
  8. package/gateway/ai/license_core.py +2 -0
  9. package/gateway/ai/notify.py +17 -11
  10. package/gateway/ai/reddit_proxy.py +28 -9
  11. package/gateway/ai/sensing/__init__.py +35 -0
  12. package/gateway/ai/sensing/schema.py +107 -0
  13. package/gateway/ai/sensing/signal_store.py +348 -0
  14. package/gateway/ai/server.py +419 -6
  15. package/gateway/ai/supabase_sync.py +308 -0
  16. package/gateway/ai/work_order.py +216 -0
  17. package/gateway/ai/workers/__init__.py +32 -0
  18. package/gateway/ai/workers/base.py +154 -0
  19. package/gateway/ai/workers/executor.py +861 -0
  20. package/gateway/ai/workers/outreach_drafter.py +161 -0
  21. package/gateway/ai/workers/pr_drafter.py +148 -0
  22. package/lib/ai-sbom-engine.js +154 -0
  23. package/lib/trust-page-engine.js +179 -0
  24. package/lib/wrap-engine.js +431 -0
  25. package/package.json +14 -1
  26. package/adapters/codex-security.js +0 -64
  27. package/adapters/codex-skill.js +0 -78
  28. package/adapters/cursor-rules.js +0 -73
  29. package/gateway/ai/continuity.py +0 -462
  30. package/gateway/ai/inbox_daemon_runner.py +0 -217
  31. package/gateway/ai/loop_engine.py +0 -1303
  32. package/gateway/ai/social_cache.py +0 -341
  33. package/gateway/ai/social_daemon.py +0 -483
  34. package/gateway/ai/tweet_corpus_schema.sql +0 -76
  35. package/scripts/crosspost_devto.py +0 -304
  36. package/scripts/demo-v420-clean.sh +0 -267
  37. package/scripts/demo-v420-deliberation.sh +0 -217
  38. package/scripts/demo-v420.sh +0 -55
  39. package/scripts/sync-gateway.sh +0 -112
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)
package/README.md CHANGED
@@ -16,8 +16,7 @@ The universal command for the Delimit Swarm. When you say **"Think and Build"**,
16
16
  Works across any configuration — from a single model on a budget to an enterprise swarm of 4+ models.
17
17
 
18
18
  [![npm](https://img.shields.io/npm/v/delimit-cli)](https://www.npmjs.com/package/delimit-cli)
19
- [![Tests](https://img.shields.io/badge/tests-123%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
20
- [![MCP Tools](https://img.shields.io/badge/MCP%20tools-186-blue)](https://delimit.ai)
19
+ [![Tests](https://img.shields.io/badge/tests-134%20passing-brightgreen)](https://github.com/delimit-ai/delimit-mcp-server)
21
20
  [![GitHub Action](https://img.shields.io/badge/GitHub%20Action-v1.6.0-blue)](https://github.com/marketplace/actions/delimit-api-governance)
22
21
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
23
22
  [![Glama](https://glama.ai/mcp/servers/delimit-ai/delimit/badge)](https://glama.ai/mcp/servers/delimit-ai/delimit)
@@ -70,6 +69,34 @@ npx delimit-cli init # Sets up governance + drift baseline
70
69
 
71
70
  ---
72
71
 
72
+ ## What's New in v4.3
73
+
74
+ *Gate every AI-assisted invocation. Ship the receipts.*
75
+
76
+ - **`delimit wrap`** — pipe `claude -p`, `cursor`, `aider`, `codex`, or any AI-assisted CLI through a signed governance gate. Snapshots the git diff before/after, runs lint + tests, HMAC-signs an `att_*` attestation, emits a public replay URL. Advisory by default; `--enforce` blocks CI on policy violations; `--max-time <s>` is a kill switch that tags the attestation as a `liability_incident` and prints a cross-model handoff command.
77
+ - **`delimit trust-page`** — renders a directory of attestations into a static HTML trust page + JSON Feed 1.1 feed. Single file, no framework, offline-renderable. Deploy anywhere.
78
+ - **`delimit ai-sbom`** — aggregates attestations into a CycloneDX 1.6 bill-of-materials with AI-specific fields (detected models per vendor, tool-call surface, policy gate counts). Pipe straight into procurement.
79
+ - **Cross-model by construction** — `wrap` is agnostic to the producer. Same attestation schema whether the pipe upstream is Claude Code, Cursor, Aider, Codex, or Gemini CLI. Switch producers without losing the audit chain.
80
+
81
+ ```bash
82
+ # Gate any AI-assisted CLI
83
+ delimit wrap -- claude -p "add tests for payments"
84
+ # → att_7d556843c84fb881 signed, replay: https://delimit.ai/att/att_7d556843c84fb881
85
+
86
+ # Kill switch + handoff after 60s wall-clock
87
+ delimit wrap --max-time 60 -- cursor edit "refactor auth middleware"
88
+ # → if killed: kind=liability_incident
89
+ # → suggested: delimit wrap -- claude -p "refactor auth middleware"
90
+
91
+ # Render accumulated attestations as a public trust page
92
+ delimit trust-page -o ./trust
93
+ # → ./trust/index.html (+ feed.json)
94
+
95
+ # Build a CycloneDX-AI bill of materials
96
+ delimit ai-sbom -o ./ai-sbom.json
97
+ # → components: 4 models detected, 187 gates run
98
+ ```
99
+
73
100
  ## What's New in v4.20
74
101
 
75
102
  *The highest state of AI governance.*
@@ -121,7 +148,7 @@ delimit deliberate "Should we build rate limiting in-house or use a managed serv
121
148
  - **Cross-Model Audit** -- 3 lenses (security, correctness, governance) with deterministic synthesis
122
149
  - **4-model deliberation** -- Claude + Grok + Gemini + Codex debate until consensus
123
150
  - **Universal Swarm Triggers** -- "Think and Build", "Keep building", "Ask Delimit"
124
- - **187 MCP tools** -- governance, context, shipping, observability, orchestration, and swarm
151
+ - **Full governance toolkit** -- lint, diff, policy, evidence, drift, attestation, and swarm orchestration exposed as MCP tools and CLI subcommands
125
152
 
126
153
  ---
127
154
 
@@ -213,6 +240,10 @@ npx delimit-cli models --status # Show current model config
213
240
  npx delimit-cli status # Compact dashboard of your Delimit setup
214
241
  npx delimit-cli doctor # Check setup health
215
242
  npx delimit-cli uninstall --dry-run # Preview removal
243
+ npx delimit-cli wrap -- claude -p "..." # Gate any AI-assisted CLI + signed attestation (v4.3)
244
+ npx delimit-cli wrap --max-time 60 -- codex "..."# With kill switch + handoff on timeout
245
+ npx delimit-cli trust-page -o ./trust # Render attestations into a static trust page
246
+ npx delimit-cli ai-sbom -o ./ai-sbom.json # Build a CycloneDX-AI bill of materials
216
247
  ```
217
248
 
218
249
  ### What the MCP toolkit adds
@@ -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'));
@@ -6114,6 +6113,155 @@ program
6114
6113
  console.log(require('../package.json').version);
6115
6114
  });
6116
6115
 
6116
+ // LED-1048: delimit wrap — gate any AI-assisted CLI with signed attestation + replay
6117
+ // Surface 1 CLI-pipe extension. Cross-model-agnostic (claude -p, cursor, aider, codex, ...).
6118
+ // Advisory by default; opt-in --enforce to block on policy violations.
6119
+ program
6120
+ .command('wrap')
6121
+ .description('Gate an AI-assisted CLI invocation with signed attestation (advisory-first)')
6122
+ .argument('<cmd...>', 'The command to wrap (e.g. `claude -p "add tests"`)')
6123
+ .option('--enforce', 'Block exit on policy violation (default: advisory)', false)
6124
+ .option('--deliberate', 'Also run multi-model deliberation (advisory)', false)
6125
+ .option('--no-attest', 'Skip attestation emission (dry run)')
6126
+ .option('--max-time <seconds>', 'Kill switch: SIGKILL the wrapped command after N seconds (liability_incident attestation + handoff)', parseInt)
6127
+ .option('--json', 'Output result as JSON', false)
6128
+ .action(async (cmdParts, options) => {
6129
+ const { runWrap, replayUrl } = require('../lib/wrap-engine');
6130
+ try {
6131
+ const result = await runWrap(cmdParts, {
6132
+ enforce: !!options.enforce,
6133
+ deliberate: !!options.deliberate,
6134
+ attest: options.attest !== false,
6135
+ maxTimeSeconds: options.maxTime || 0,
6136
+ cwd: process.cwd(),
6137
+ });
6138
+
6139
+ if (result.error === 'quota_exceeded') {
6140
+ console.error(chalk.red(`\n [wrap] ${result.message}\n`));
6141
+ process.exit(1);
6142
+ return;
6143
+ }
6144
+
6145
+ if (options.json) {
6146
+ console.log(JSON.stringify(result, null, 2));
6147
+ process.exit(result.exit);
6148
+ return;
6149
+ }
6150
+
6151
+ console.log();
6152
+ const banner = result.kind === 'liability_incident'
6153
+ ? chalk.bold.red(` delimit wrap — ${result.attestation_id || '(no attestation)'} [liability_incident]`)
6154
+ : chalk.bold.cyan(` delimit wrap — ${result.attestation_id || '(no attestation)'}`);
6155
+ console.log(banner);
6156
+ console.log(chalk.gray(` wrapped exit: ${result.wrapped_exit} mode: ${result.advisory ? 'advisory' : 'enforce'} tier: ${result.tier || 'n/a'}`));
6157
+ if (result.killed_by_timeout) {
6158
+ console.log(chalk.yellow(` ⚠ kill switch fired — wrapped command exceeded --max-time`));
6159
+ }
6160
+ if (result.gates.length) {
6161
+ console.log();
6162
+ for (const g of result.gates) {
6163
+ const tag = g.exit === undefined
6164
+ ? chalk.gray('·')
6165
+ : (g.exit === 0 ? chalk.green('✓') : chalk.red('✗'));
6166
+ const name = g.name + (g.runner ? ':' + g.runner : '') + (g.spec ? ':' + path.basename(g.spec) : '');
6167
+ const note = g.result ? ` (${g.result})` : '';
6168
+ console.log(` ${tag} ${name}${note}`);
6169
+ }
6170
+ }
6171
+ if (result.violations.length) {
6172
+ console.log();
6173
+ console.log(chalk.yellow(` ${result.violations.length} violation(s):`));
6174
+ for (const v of result.violations) console.log(chalk.yellow(` - ${v}`));
6175
+ if (result.advisory) console.log(chalk.gray(' advisory mode — wrap exit unaffected. Re-run with --enforce to block.'));
6176
+ }
6177
+ if (result.attestation_id) {
6178
+ console.log();
6179
+ console.log(chalk.gray(` attestation: ${result.attestation_path || '(not saved)'}`));
6180
+ console.log(chalk.gray(` replay: ${result.replay_url}`));
6181
+ }
6182
+ if (result.handoff_suggestion) {
6183
+ console.log();
6184
+ console.log(chalk.bold(' cross-model handoff suggestion:'));
6185
+ console.log(` ${chalk.cyan(result.handoff_suggestion.suggested_command)}`);
6186
+ console.log(chalk.gray(` (alternates: ${result.handoff_suggestion.alternates.join(', ')})`));
6187
+ }
6188
+ console.log();
6189
+ process.exit(result.exit);
6190
+ } catch (e) {
6191
+ console.error(chalk.red(`\n [wrap] ${e.message || e}\n`));
6192
+ process.exit(1);
6193
+ }
6194
+ });
6195
+
6196
+ // LED-1018 Venture #6 MVP: trust-page + ai-sbom subcommands
6197
+ // Render aggregated attestations (from `delimit wrap`) into a public static
6198
+ // trust page (HTML + JSON Feed) and a CycloneDX-AI bill of materials.
6199
+ program
6200
+ .command('trust-page')
6201
+ .description('Render attestations into a public trust page (static HTML + JSON feed)')
6202
+ .option('-d, --dir <path>', 'Attestation directory', path.join(os.homedir(), '.delimit', 'attestations'))
6203
+ .option('-o, --out <path>', 'Output directory', './trust-page')
6204
+ .option('-t, --title <title>', 'Trust page title', 'Trust Page')
6205
+ .option('--json', 'Output result as JSON', false)
6206
+ .action(async (options) => {
6207
+ const { renderTrustPage } = require('../lib/trust-page-engine');
6208
+ try {
6209
+ const result = renderTrustPage(options.dir, options.out, options.title);
6210
+ if (options.json) {
6211
+ console.log(JSON.stringify(result, null, 2));
6212
+ return;
6213
+ }
6214
+ console.log();
6215
+ console.log(chalk.bold.cyan(` delimit trust-page`));
6216
+ console.log(chalk.gray(` source: ${options.dir}`));
6217
+ console.log(chalk.gray(` output: ${result.outDir}/`));
6218
+ console.log(chalk.gray(` attestations rendered: ${result.count}`));
6219
+ console.log(chalk.gray(` feed items: ${result.feed_items} html bytes: ${result.html_bytes}`));
6220
+ console.log();
6221
+ console.log(chalk.bold(` Open: ${path.resolve(result.outDir)}/index.html`));
6222
+ console.log();
6223
+ } catch (e) {
6224
+ console.error(chalk.red(`\n [trust-page] ${e.message || e}\n`));
6225
+ process.exit(1);
6226
+ }
6227
+ });
6228
+
6229
+ program
6230
+ .command('ai-sbom')
6231
+ .description('Build a CycloneDX-AI bill of materials from attestations')
6232
+ .option('-d, --dir <path>', 'Attestation directory', path.join(os.homedir(), '.delimit', 'attestations'))
6233
+ .option('-o, --out <path>', 'Output file', './ai-sbom.json')
6234
+ .option('-n, --name <name>', 'BOM subject name', 'ai-sbom')
6235
+ .option('-v, --package-version <v>', 'BOM subject version', '1.0.0')
6236
+ .option('--json', 'Print the SBOM to stdout instead of writing to file', false)
6237
+ .action(async (options) => {
6238
+ const { buildAISBOM } = require('../lib/ai-sbom-engine');
6239
+ try {
6240
+ const { sbom, aggregate, attestation_count } = buildAISBOM(options.dir, {
6241
+ name: options.name,
6242
+ version: options.packageVersion,
6243
+ });
6244
+ if (options.json) {
6245
+ console.log(JSON.stringify(sbom, null, 2));
6246
+ return;
6247
+ }
6248
+ fs.writeFileSync(options.out, JSON.stringify(sbom, null, 2));
6249
+ console.log();
6250
+ console.log(chalk.bold.cyan(` delimit ai-sbom`));
6251
+ console.log(chalk.gray(` source: ${options.dir}`));
6252
+ console.log(chalk.gray(` output: ${path.resolve(options.out)}`));
6253
+ console.log(chalk.gray(` attestations scanned: ${attestation_count}`));
6254
+ console.log(chalk.gray(` models detected: ${aggregate.models.length}`));
6255
+ console.log(chalk.gray(` tool-call surface: ${aggregate.tool_calls.length}`));
6256
+ console.log(chalk.gray(` total gates run: ${aggregate.total_gates_run}`));
6257
+ console.log(chalk.gray(` total violations: ${aggregate.total_violations}`));
6258
+ console.log();
6259
+ } catch (e) {
6260
+ console.error(chalk.red(`\n [ai-sbom] ${e.message || e}\n`));
6261
+ process.exit(1);
6262
+ }
6263
+ });
6264
+
6117
6265
  // Hide legacy/internal commands from --help
6118
6266
  ['install', 'mode', 'status', 'policy', 'auth', 'audit',
6119
6267
  'explain-decision', 'uninstall', 'proxy', 'hook'].forEach(name => {
@@ -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))}"}