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
@@ -0,0 +1,861 @@
1
+ """LED-981: Worker Pool v2 executor.
2
+
3
+ Takes an approved work order and runs its `executable_actions` list
4
+ against a narrow whitelist of state-changing operations. This is where
5
+ the founder's "dashboard approve → autonomous execute" loop closes.
6
+
7
+ Safety model (defense in depth):
8
+
9
+ 1. Workers only emit actions from a hardcoded whitelist (ACTION_SPEC).
10
+ Any unknown action is rejected at draft time by validate_actions().
11
+ 2. The executor re-validates before running. A work order that was
12
+ approved by a human can never execute an action the executor can't
13
+ type-check.
14
+ 3. Each action has a fixed parameter shape; missing or extra params
15
+ are rejected.
16
+ 4. Execution never shells out to an arbitrary command. Every action
17
+ has a Python implementation that calls a specific subprocess or
18
+ library with bounded inputs.
19
+ 5. Every action (success or failure) is appended to execution_log and
20
+ synced to Supabase so the dashboard shows what the executor did.
21
+ 6. Dry-run mode is the DEFAULT. Live execution requires
22
+ `execute_approved(..., live=True)` — the MCP tool wrapper defaults
23
+ to live=False so an accidental call is observable without effect.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import shlex
30
+ import subprocess
31
+ import time
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ logger = logging.getLogger("delimit.workers.executor")
37
+
38
+ WORK_ORDERS_DIR = Path.home() / ".delimit" / "work-orders"
39
+ EXECUTOR_AUDIT = Path.home() / ".delimit" / "workers" / "audit" / "executor.jsonl"
40
+ # Kill switch. Matches the charter's kill-switch table — touch this file
41
+ # and the poller stops flipping approved work orders into live execution.
42
+ EXECUTOR_PAUSE_FILE = Path.home() / ".delimit" / "pause_executor"
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Action specification — THE whitelist. Add an action type by editing here.
47
+ # ---------------------------------------------------------------------------
48
+
49
+ ACTION_SPEC: Dict[str, Dict[str, Any]] = {
50
+ "gh_issue_create": {
51
+ "required_params": ("repo", "title", "body"),
52
+ "optional_params": ("labels",),
53
+ "description": "Open a GitHub issue on an external repo via gh CLI.",
54
+ },
55
+ "gh_pr_comment": {
56
+ "required_params": ("repo", "number", "body"),
57
+ "optional_params": (),
58
+ "description": "Add a comment to an open GitHub PR.",
59
+ },
60
+ "gh_issue_comment": {
61
+ "required_params": ("repo", "number", "body"),
62
+ "optional_params": (),
63
+ "description": "Add a comment to an open GitHub issue.",
64
+ },
65
+ "gh_issue_close": {
66
+ "required_params": ("repo", "number"),
67
+ "optional_params": ("comment", "reason"),
68
+ "description": "Close a GitHub issue, optionally with a closing comment and reason.",
69
+ },
70
+ "gh_issue_reopen": {
71
+ "required_params": ("repo", "number"),
72
+ "optional_params": ("comment",),
73
+ "description": "Reopen a closed GitHub issue, optionally with an explanatory comment.",
74
+ },
75
+ "gh_issue_label": {
76
+ "required_params": ("repo", "number", "labels"),
77
+ "optional_params": ("remove",),
78
+ "description": "Add labels (or remove when remove=true) from a GitHub issue or PR.",
79
+ },
80
+ "gh_pr_ready_for_review": {
81
+ "required_params": ("repo", "number"),
82
+ "optional_params": (),
83
+ "description": "Mark a draft PR as ready for review.",
84
+ },
85
+ "propose_pr": {
86
+ "required_params": ("repo_path", "branch", "title", "body", "files"),
87
+ "optional_params": ("tests_cmd", "base_branch", "draft", "commit_message"),
88
+ "description": (
89
+ "LED-988 autonomous build primitive: branch → write files → test → "
90
+ "commit → push → open draft PR. Stops at PR opened — merge and "
91
+ "tag-push stay human per 2026-04-07 postmortem."
92
+ ),
93
+ },
94
+ }
95
+
96
+
97
+ # LED-988: allowlist for propose_pr. Any repo path NOT in this set is
98
+ # rejected at runtime regardless of whether the caller claimed validation
99
+ # passed. Path-traversal-safe (resolved then checked against canonical).
100
+ PROPOSE_PR_ALLOWED_REPOS = frozenset({
101
+ "/home/delimit/delimit-gateway",
102
+ "/home/delimit/delimit-ui",
103
+ "/home/delimit/delimit-action",
104
+ "/home/delimit/npm-delimit",
105
+ "/root/governance-framework",
106
+ })
107
+ # Any branch created by propose_pr must carry this prefix so human branches
108
+ # are never clobbered and PRs are obviously agent-authored at a glance.
109
+ PROPOSE_PR_BRANCH_PREFIX = "delimit/"
110
+ # Commit author for autonomous commits. Bot-pattern email so GitHub
111
+ # counts contributions correctly without attributing to a human.
112
+ PROPOSE_PR_AUTHOR_NAME = "delimit-bot"
113
+ PROPOSE_PR_AUTHOR_EMAIL = "bot@delimit.ai"
114
+ # Hard cap on patch size — rejects accidental mega-diffs that would
115
+ # require a different review workflow anyway.
116
+ PROPOSE_PR_MAX_FILES = 50
117
+ PROPOSE_PR_MAX_FILE_BYTES = 256 * 1024 # 256 KiB / file
118
+ PROPOSE_PR_MAX_TOTAL_BYTES = 1024 * 1024 # 1 MiB / PR
119
+
120
+
121
+ class ActionError(Exception):
122
+ pass
123
+
124
+
125
+ # LED-988 (Polymarket/RunLobster deliberation): explicit category denylist.
126
+ # The executor's guardrail today is implicit ("only whitelisted actions run")
127
+ # which is necessary but not sufficient — a future whitelist extension could
128
+ # silently add a category that belongs behind a charter amendment. This
129
+ # denylist is a hard second gate. An action type OR any parameter value
130
+ # matching any token here is rejected at validate_actions() time with a
131
+ # loud error, regardless of whether it's in ACTION_SPEC.
132
+ #
133
+ # Match is substring on the lowercased action name, param key, and param
134
+ # string-value. Add a category here by editing this set; removing a
135
+ # category requires a charter amendment + deliberation (commit message
136
+ # must cite the amendment).
137
+ ACTION_DENYLIST_TOKENS = frozenset({
138
+ # Money / payments
139
+ "financial_transaction",
140
+ "payment_api",
141
+ "stripe_charge",
142
+ "stripe_transfer",
143
+ "wire_transfer",
144
+ "ach_transfer",
145
+ "lemonsqueezy_charge",
146
+ "plaid_link",
147
+ # Legal / identity
148
+ "llc_registration",
149
+ "ein_application",
150
+ "company_formation",
151
+ "identity_registration",
152
+ "kyc_submit",
153
+ "aml_submit",
154
+ # Credentials / auth handling
155
+ "private_key_export",
156
+ "private_key_generate",
157
+ "seed_phrase",
158
+ "api_key_rotate_external", # rotating user-owned keys not in our vault
159
+ # Autonomous deploy to prod outside our repos
160
+ "external_deploy",
161
+ "terraform_apply",
162
+ "kubectl_apply",
163
+ # Contract signing / binding legal action
164
+ "contract_sign",
165
+ "docusign_send",
166
+ "hello_sign",
167
+ })
168
+
169
+
170
+ def _denylist_hits(name: str, params: Dict[str, Any]) -> List[str]:
171
+ """Return every denylist token found in the action name or param values."""
172
+ hits: List[str] = []
173
+ haystack = [(name or "").lower()]
174
+ for k, v in (params or {}).items():
175
+ haystack.append(str(k).lower())
176
+ if isinstance(v, str):
177
+ haystack.append(v.lower())
178
+ blob = " ".join(haystack)
179
+ for token in ACTION_DENYLIST_TOKENS:
180
+ if token in blob:
181
+ hits.append(token)
182
+ return hits
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Validation
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def validate_actions(actions: List[Dict[str, Any]]) -> List[str]:
190
+ """Return a list of error strings — empty list means actions are valid."""
191
+ errors: List[str] = []
192
+ if not isinstance(actions, list):
193
+ return [f"executable_actions must be a list, got {type(actions).__name__}"]
194
+ for i, action in enumerate(actions):
195
+ if not isinstance(action, dict):
196
+ errors.append(f"action[{i}]: must be a dict")
197
+ continue
198
+ action_type = action.get("action")
199
+ # Denylist check BEFORE whitelist check. A hit here fails loud even
200
+ # if the action happens to be in ACTION_SPEC (belt + suspenders —
201
+ # accidental whitelist addition can't slip a denied category through).
202
+ params = action.get("params") or {}
203
+ if isinstance(params, dict):
204
+ deny_hits = _denylist_hits(action_type or "", params)
205
+ if deny_hits:
206
+ errors.append(
207
+ f"action[{i}]: DENYLIST HIT for {action_type!r} — tokens "
208
+ f"{sorted(deny_hits)} are prohibited categories per "
209
+ f"LED-988 / Polymarket deliberation. Removing one of "
210
+ f"these requires a charter amendment."
211
+ )
212
+ continue
213
+ if action_type not in ACTION_SPEC:
214
+ errors.append(
215
+ f"action[{i}]: unknown action '{action_type}'. "
216
+ f"Allowed: {sorted(ACTION_SPEC.keys())}"
217
+ )
218
+ continue
219
+ spec = ACTION_SPEC[action_type]
220
+ params = action.get("params") or {}
221
+ if not isinstance(params, dict):
222
+ errors.append(f"action[{i}]: params must be a dict")
223
+ continue
224
+ for required in spec["required_params"]:
225
+ if required not in params:
226
+ errors.append(
227
+ f"action[{i}] ({action_type}): missing required param '{required}'"
228
+ )
229
+ allowed = set(spec["required_params"]) | set(spec["optional_params"])
230
+ for provided in params:
231
+ if provided not in allowed:
232
+ errors.append(
233
+ f"action[{i}] ({action_type}): unknown param '{provided}'. "
234
+ f"Allowed: {sorted(allowed)}"
235
+ )
236
+ return errors
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Action implementations
241
+ # ---------------------------------------------------------------------------
242
+
243
+ def _run_gh(args: List[str], stdin: Optional[str] = None, timeout: int = 60) -> Dict[str, Any]:
244
+ """Run a gh subcommand with bounded inputs. Returns dict with stdout/stderr/rc."""
245
+ cmd = ["gh", *args]
246
+ logger.info("executor: running %s", " ".join(shlex.quote(a) for a in cmd))
247
+ try:
248
+ result = subprocess.run(
249
+ cmd,
250
+ input=stdin,
251
+ capture_output=True,
252
+ text=True,
253
+ timeout=timeout,
254
+ )
255
+ return {
256
+ "rc": result.returncode,
257
+ "stdout": (result.stdout or "")[:4000],
258
+ "stderr": (result.stderr or "")[:2000],
259
+ }
260
+ except subprocess.TimeoutExpired:
261
+ raise ActionError(f"gh timed out after {timeout}s")
262
+ except FileNotFoundError:
263
+ raise ActionError("gh CLI not installed")
264
+
265
+
266
+ def _act_gh_issue_create(params: Dict[str, Any]) -> Dict[str, Any]:
267
+ repo = params["repo"]
268
+ title = params["title"]
269
+ body = params["body"]
270
+ labels = params.get("labels") or []
271
+ args = ["issue", "create", "--repo", repo, "--title", title, "--body-file", "-"]
272
+ for label in labels:
273
+ args.extend(["--label", label])
274
+ result = _run_gh(args, stdin=body)
275
+ if result["rc"] != 0:
276
+ raise ActionError(f"gh issue create failed: {result['stderr']}")
277
+ return {"issue_url": result["stdout"].strip()}
278
+
279
+
280
+ def _act_gh_pr_comment(params: Dict[str, Any]) -> Dict[str, Any]:
281
+ repo = params["repo"]
282
+ number = str(params["number"])
283
+ body = params["body"]
284
+ result = _run_gh(
285
+ ["pr", "comment", number, "--repo", repo, "--body-file", "-"],
286
+ stdin=body,
287
+ )
288
+ if result["rc"] != 0:
289
+ raise ActionError(f"gh pr comment failed: {result['stderr']}")
290
+ return {"comment_url": result["stdout"].strip()}
291
+
292
+
293
+ def _act_gh_issue_comment(params: Dict[str, Any]) -> Dict[str, Any]:
294
+ repo = params["repo"]
295
+ number = str(params["number"])
296
+ body = params["body"]
297
+ result = _run_gh(
298
+ ["issue", "comment", number, "--repo", repo, "--body-file", "-"],
299
+ stdin=body,
300
+ )
301
+ if result["rc"] != 0:
302
+ raise ActionError(f"gh issue comment failed: {result['stderr']}")
303
+ return {"comment_url": result["stdout"].strip()}
304
+
305
+
306
+ def _act_gh_issue_close(params: Dict[str, Any]) -> Dict[str, Any]:
307
+ repo = params["repo"]
308
+ number = str(params["number"])
309
+ comment = params.get("comment")
310
+ # gh close --reason accepts: completed | not planned
311
+ reason = params.get("reason")
312
+ args = ["issue", "close", number, "--repo", repo]
313
+ if comment:
314
+ args.extend(["--comment", comment])
315
+ if reason:
316
+ if reason not in ("completed", "not planned"):
317
+ raise ActionError(f"reason must be 'completed' or 'not planned', got {reason!r}")
318
+ args.extend(["--reason", reason])
319
+ result = _run_gh(args)
320
+ if result["rc"] != 0:
321
+ raise ActionError(f"gh issue close failed: {result['stderr']}")
322
+ return {"closed": f"{repo}#{number}", "stdout": result["stdout"].strip()}
323
+
324
+
325
+ def _act_gh_issue_reopen(params: Dict[str, Any]) -> Dict[str, Any]:
326
+ repo = params["repo"]
327
+ number = str(params["number"])
328
+ comment = params.get("comment")
329
+ args = ["issue", "reopen", number, "--repo", repo]
330
+ if comment:
331
+ args.extend(["--comment", comment])
332
+ result = _run_gh(args)
333
+ if result["rc"] != 0:
334
+ raise ActionError(f"gh issue reopen failed: {result['stderr']}")
335
+ return {"reopened": f"{repo}#{number}", "stdout": result["stdout"].strip()}
336
+
337
+
338
+ def _act_gh_issue_label(params: Dict[str, Any]) -> Dict[str, Any]:
339
+ repo = params["repo"]
340
+ number = str(params["number"])
341
+ labels = params["labels"]
342
+ remove = bool(params.get("remove", False))
343
+ if not isinstance(labels, list) or not labels:
344
+ raise ActionError("labels must be a non-empty list")
345
+ # `gh issue edit` covers both add and remove and works for PRs too
346
+ flag = "--remove-label" if remove else "--add-label"
347
+ args = ["issue", "edit", number, "--repo", repo]
348
+ for label in labels:
349
+ if not isinstance(label, str) or not label:
350
+ raise ActionError(f"every label must be a non-empty string, got {label!r}")
351
+ args.extend([flag, label])
352
+ result = _run_gh(args)
353
+ if result["rc"] != 0:
354
+ raise ActionError(f"gh issue edit ({flag}) failed: {result['stderr']}")
355
+ return {"labeled": f"{repo}#{number}", "action": "remove" if remove else "add", "labels": labels}
356
+
357
+
358
+ def _act_gh_pr_ready_for_review(params: Dict[str, Any]) -> Dict[str, Any]:
359
+ repo = params["repo"]
360
+ number = str(params["number"])
361
+ # `gh pr ready` flips a draft PR to ready-for-review state
362
+ result = _run_gh(["pr", "ready", number, "--repo", repo])
363
+ if result["rc"] != 0:
364
+ raise ActionError(f"gh pr ready failed: {result['stderr']}")
365
+ return {"ready": f"{repo}#{number}", "stdout": result["stdout"].strip()}
366
+
367
+
368
+ def _run_git(cwd: str, args: List[str], timeout: int = 60) -> Dict[str, Any]:
369
+ """Run git in `cwd`. Returns {rc, stdout, stderr} — no raising here so
370
+ the caller decides which non-zero returns are fatal vs recoverable."""
371
+ cmd = ["git", "-C", cwd, *args]
372
+ try:
373
+ result = subprocess.run(
374
+ cmd, capture_output=True, text=True, timeout=timeout,
375
+ )
376
+ return {
377
+ "rc": result.returncode,
378
+ "stdout": (result.stdout or "")[:4000],
379
+ "stderr": (result.stderr or "")[:2000],
380
+ }
381
+ except subprocess.TimeoutExpired:
382
+ raise ActionError(f"git timed out after {timeout}s: {' '.join(args[:3])}")
383
+ except FileNotFoundError:
384
+ raise ActionError("git CLI not installed")
385
+
386
+
387
+ def _act_propose_pr(params: Dict[str, Any]) -> Dict[str, Any]:
388
+ """LED-988 autonomous build primitive.
389
+
390
+ Flow: (resolve + allowlist) → checkout base + pull → create branch →
391
+ write files → run tests if provided → commit with bot identity →
392
+ push → open draft PR → return PR URL. Stops there.
393
+
394
+ Safety invariants enforced at runtime, not just at validation time:
395
+ - repo_path must resolve to a path in PROPOSE_PR_ALLOWED_REPOS
396
+ - branch must carry PROPOSE_PR_BRANCH_PREFIX (never clobber human work)
397
+ - file paths must be relative, no `..`, no absolute, no symlink hops
398
+ - total patch size capped at 1 MiB / 50 files
399
+ - tests_cmd failure aborts before push — no broken PR ever opens
400
+ - PR opens as draft by default; gh_pr_ready_for_review is a separate
401
+ whitelisted action the founder can invoke after review
402
+ - bot identity is set via `git -c` (per-command) — never mutates
403
+ repo or global git config
404
+ """
405
+ from pathlib import Path as _Path
406
+
407
+ repo_path_raw = params["repo_path"]
408
+ branch = params["branch"]
409
+ title = params["title"]
410
+ body = params["body"]
411
+ files = params["files"]
412
+ tests_cmd = params.get("tests_cmd") or ""
413
+ base_branch = params.get("base_branch") or "main"
414
+ draft = params.get("draft", True)
415
+ commit_message = params.get("commit_message") or title
416
+
417
+ # 1. Allowlist the repo path (canonical, resolves symlinks)
418
+ try:
419
+ repo_path = str(_Path(repo_path_raw).resolve(strict=True))
420
+ except (FileNotFoundError, RuntimeError) as exc:
421
+ raise ActionError(f"repo_path not found: {repo_path_raw} ({exc})")
422
+ if repo_path not in PROPOSE_PR_ALLOWED_REPOS:
423
+ raise ActionError(
424
+ f"repo_path not in allowlist: {repo_path}. "
425
+ f"Allowed: {sorted(PROPOSE_PR_ALLOWED_REPOS)}"
426
+ )
427
+ if not (_Path(repo_path) / ".git").exists():
428
+ raise ActionError(f"repo_path is not a git repo: {repo_path}")
429
+
430
+ # 2. Branch prefix guard
431
+ if not isinstance(branch, str) or not branch.startswith(PROPOSE_PR_BRANCH_PREFIX):
432
+ raise ActionError(
433
+ f"branch must start with {PROPOSE_PR_BRANCH_PREFIX!r}, got {branch!r}"
434
+ )
435
+ if "/" not in branch[len(PROPOSE_PR_BRANCH_PREFIX):] and not branch[len(PROPOSE_PR_BRANCH_PREFIX):]:
436
+ raise ActionError("branch is empty after prefix")
437
+
438
+ # 3. File-list validation: size cap, no absolute paths, no `..`, required
439
+ # content for every entry.
440
+ if not isinstance(files, list) or not files:
441
+ raise ActionError("files must be a non-empty list")
442
+ if len(files) > PROPOSE_PR_MAX_FILES:
443
+ raise ActionError(f"files > {PROPOSE_PR_MAX_FILES} (got {len(files)})")
444
+ total = 0
445
+ for entry in files:
446
+ if not isinstance(entry, dict):
447
+ raise ActionError("each file entry must be a dict")
448
+ p = entry.get("path")
449
+ c = entry.get("content", "")
450
+ if not isinstance(p, str) or not p:
451
+ raise ActionError("file.path required + must be a string")
452
+ if p.startswith("/") or ".." in _Path(p).parts or p.startswith("~"):
453
+ raise ActionError(f"file.path must be relative + inside repo: {p!r}")
454
+ if not isinstance(c, str):
455
+ raise ActionError(f"file.content must be a string for {p!r}")
456
+ if len(c.encode("utf-8")) > PROPOSE_PR_MAX_FILE_BYTES:
457
+ raise ActionError(
458
+ f"{p!r} exceeds {PROPOSE_PR_MAX_FILE_BYTES} bytes"
459
+ )
460
+ total += len(c.encode("utf-8"))
461
+ if total > PROPOSE_PR_MAX_TOTAL_BYTES:
462
+ raise ActionError(
463
+ f"total patch {total}B exceeds {PROPOSE_PR_MAX_TOTAL_BYTES}B"
464
+ )
465
+
466
+ # 4. Confirm working tree clean + base branch exists + fetch
467
+ status = _run_git(repo_path, ["status", "--porcelain"])
468
+ if status["rc"] != 0:
469
+ raise ActionError(f"git status failed: {status['stderr']}")
470
+ if status["stdout"].strip():
471
+ raise ActionError(
472
+ f"repo working tree dirty — refusing to propose on top of uncommitted "
473
+ f"work:\n{status['stdout'][:500]}"
474
+ )
475
+
476
+ if _run_git(repo_path, ["fetch", "origin", base_branch])["rc"] != 0:
477
+ raise ActionError(f"could not fetch origin/{base_branch}")
478
+ checkout_base = _run_git(repo_path, ["checkout", base_branch])
479
+ if checkout_base["rc"] != 0:
480
+ raise ActionError(f"checkout {base_branch} failed: {checkout_base['stderr']}")
481
+ pull = _run_git(repo_path, ["pull", "--ff-only", "origin", base_branch])
482
+ if pull["rc"] != 0:
483
+ raise ActionError(f"pull --ff-only origin/{base_branch} failed: {pull['stderr']}")
484
+
485
+ # 5. Create the branch
486
+ if _run_git(repo_path, ["checkout", "-b", branch])["rc"] != 0:
487
+ # Maybe it already exists; switch + reset to base
488
+ if _run_git(repo_path, ["checkout", branch])["rc"] != 0:
489
+ raise ActionError(f"could not create or switch to {branch}")
490
+ reset = _run_git(repo_path, ["reset", "--hard", f"origin/{base_branch}"])
491
+ if reset["rc"] != 0:
492
+ raise ActionError(f"could not reset {branch} to base: {reset['stderr']}")
493
+
494
+ # 6. Write the files (create dirs as needed)
495
+ written: List[str] = []
496
+ try:
497
+ for entry in files:
498
+ dest = _Path(repo_path) / entry["path"]
499
+ dest.resolve().relative_to(repo_path) # defense in depth
500
+ dest.parent.mkdir(parents=True, exist_ok=True)
501
+ dest.write_text(entry["content"])
502
+ written.append(entry["path"])
503
+ except ValueError as exc:
504
+ raise ActionError(f"file path escaped repo: {exc}")
505
+
506
+ # 7. Stage + optional tests BEFORE commit
507
+ stage = _run_git(repo_path, ["add", *written])
508
+ if stage["rc"] != 0:
509
+ raise ActionError(f"git add failed: {stage['stderr']}")
510
+
511
+ if tests_cmd:
512
+ logger.info("propose_pr: running tests: %s", tests_cmd)
513
+ try:
514
+ tests_proc = subprocess.run(
515
+ tests_cmd, shell=True, cwd=repo_path,
516
+ capture_output=True, text=True, timeout=600,
517
+ )
518
+ except subprocess.TimeoutExpired:
519
+ raise ActionError("tests_cmd timed out after 600s")
520
+ if tests_proc.returncode != 0:
521
+ # Clean up so the working tree isn't left dirty on the branch.
522
+ _run_git(repo_path, ["reset", "--hard", f"origin/{base_branch}"])
523
+ _run_git(repo_path, ["checkout", base_branch])
524
+ _run_git(repo_path, ["branch", "-D", branch])
525
+ raise ActionError(
526
+ f"tests failed (rc={tests_proc.returncode}); branch {branch} "
527
+ f"discarded.\nstdout tail:\n{tests_proc.stdout[-2000:]}\n"
528
+ f"stderr tail:\n{tests_proc.stderr[-2000:]}"
529
+ )
530
+
531
+ # 8. Commit with the bot identity (per-command -c, never global)
532
+ commit_args = [
533
+ "-c", f"user.name={PROPOSE_PR_AUTHOR_NAME}",
534
+ "-c", f"user.email={PROPOSE_PR_AUTHOR_EMAIL}",
535
+ "commit",
536
+ "-m", commit_message,
537
+ ]
538
+ commit = _run_git(repo_path, commit_args)
539
+ if commit["rc"] != 0:
540
+ raise ActionError(f"commit failed: {commit['stderr']}")
541
+
542
+ # 9. Push (no --force, no --force-with-lease — branch is fresh)
543
+ push = _run_git(repo_path, ["push", "-u", "origin", branch])
544
+ if push["rc"] != 0:
545
+ raise ActionError(f"push origin {branch} failed: {push['stderr']}")
546
+
547
+ # 10. Open the PR via gh (draft by default — human flips it with
548
+ # gh_pr_ready_for_review after review)
549
+ gh_args = [
550
+ "pr", "create",
551
+ "--base", base_branch,
552
+ "--head", branch,
553
+ "--title", title,
554
+ "--body-file", "-",
555
+ ]
556
+ if draft:
557
+ gh_args.append("--draft")
558
+ pr_result = subprocess.run(
559
+ ["gh", *gh_args],
560
+ input=body,
561
+ capture_output=True, text=True, timeout=60,
562
+ cwd=repo_path,
563
+ )
564
+ if pr_result.returncode != 0:
565
+ raise ActionError(f"gh pr create failed: {pr_result.stderr[:400]}")
566
+ pr_url = (pr_result.stdout or "").strip()
567
+
568
+ # 11. Return to base branch so the repo is left in a clean, predictable
569
+ # state for the next caller.
570
+ _run_git(repo_path, ["checkout", base_branch])
571
+
572
+ return {
573
+ "pr_url": pr_url,
574
+ "branch": branch,
575
+ "base_branch": base_branch,
576
+ "files_written": written,
577
+ "tests_ran": bool(tests_cmd),
578
+ "draft": bool(draft),
579
+ }
580
+
581
+
582
+ ACTION_RUNNERS = {
583
+ "gh_issue_create": _act_gh_issue_create,
584
+ "gh_pr_comment": _act_gh_pr_comment,
585
+ "gh_issue_comment": _act_gh_issue_comment,
586
+ "gh_issue_close": _act_gh_issue_close,
587
+ "gh_issue_reopen": _act_gh_issue_reopen,
588
+ "gh_issue_label": _act_gh_issue_label,
589
+ "gh_pr_ready_for_review": _act_gh_pr_ready_for_review,
590
+ "propose_pr": _act_propose_pr,
591
+ }
592
+
593
+
594
+ # ---------------------------------------------------------------------------
595
+ # Executor
596
+ # ---------------------------------------------------------------------------
597
+
598
+ def _append_audit(record: Dict[str, Any]) -> None:
599
+ EXECUTOR_AUDIT.parent.mkdir(parents=True, exist_ok=True)
600
+ try:
601
+ with EXECUTOR_AUDIT.open("a") as fh:
602
+ fh.write(json.dumps(record) + "\n")
603
+ except Exception as exc:
604
+ logger.warning("executor: audit write failed: %s", exc)
605
+
606
+
607
+ def _load_work_order(wo_id: str) -> Optional[Dict[str, Any]]:
608
+ jf = WORK_ORDERS_DIR / f"{wo_id}.json"
609
+ if not jf.exists():
610
+ return None
611
+ try:
612
+ return json.loads(jf.read_text())
613
+ except Exception as exc:
614
+ logger.warning("executor: failed to load %s: %s", wo_id, exc)
615
+ return None
616
+
617
+
618
+ def _save_work_order(wo: Dict[str, Any]) -> None:
619
+ jf = WORK_ORDERS_DIR / f"{wo['id']}.json"
620
+ jf.write_text(json.dumps(wo, indent=2))
621
+
622
+
623
+ def execute_approved(wo_id: str, *, live: bool = False, executed_by: str = "") -> Dict[str, Any]:
624
+ """Execute an approved work order's executable_actions list.
625
+
626
+ Args:
627
+ wo_id: Work-order id (e.g. WO-2026-04-18-001).
628
+ live: When False (default) the executor returns what it WOULD do
629
+ without running any subprocess. A sanity check before flipping
630
+ the switch.
631
+ executed_by: Agent / user identifier for the audit log.
632
+
633
+ Returns a dict with the overall status plus a per-action log.
634
+ """
635
+ wo = _load_work_order(wo_id)
636
+ if wo is None:
637
+ return {"ok": False, "error": f"work order {wo_id} not found"}
638
+
639
+ if wo.get("status") != "approved":
640
+ return {
641
+ "ok": False,
642
+ "error": (
643
+ f"work order {wo_id} has status={wo.get('status')!r}; "
644
+ f"executor only runs work orders with status=approved"
645
+ ),
646
+ }
647
+
648
+ actions = wo.get("executable_actions") or []
649
+ if not actions:
650
+ return {
651
+ "ok": False,
652
+ "error": (
653
+ f"work order {wo_id} has no executable_actions. The founder "
654
+ f"still needs to run the human steps by hand."
655
+ ),
656
+ }
657
+
658
+ errors = validate_actions(actions)
659
+ if errors:
660
+ return {"ok": False, "error": "action validation failed", "details": errors}
661
+
662
+ now = datetime.now(timezone.utc).isoformat()
663
+ log: List[Dict[str, Any]] = []
664
+
665
+ if not live:
666
+ for i, action in enumerate(actions):
667
+ log.append({
668
+ "index": i,
669
+ "action": action["action"],
670
+ "dry_run": True,
671
+ "params_preview": {
672
+ k: (v[:200] if isinstance(v, str) else v)
673
+ for k, v in (action.get("params") or {}).items()
674
+ },
675
+ })
676
+ _append_audit({
677
+ "wo_id": wo_id,
678
+ "ts": now,
679
+ "mode": "dry_run",
680
+ "executed_by": executed_by,
681
+ "action_count": len(actions),
682
+ })
683
+ return {
684
+ "ok": True,
685
+ "mode": "dry_run",
686
+ "wo_id": wo_id,
687
+ "actions": len(actions),
688
+ "log": log,
689
+ }
690
+
691
+ # Live mode: flip status to executing, run each action in order,
692
+ # persist the log both to the local WO file and to Supabase.
693
+ wo["execution_status"] = "executing"
694
+ wo["executed_by"] = executed_by
695
+ _save_work_order(wo)
696
+
697
+ overall_ok = True
698
+ for i, action in enumerate(actions):
699
+ runner = ACTION_RUNNERS[action["action"]]
700
+ started = time.time()
701
+ entry = {"index": i, "action": action["action"], "started_at": datetime.now(timezone.utc).isoformat()}
702
+ try:
703
+ result = runner(action.get("params") or {})
704
+ entry.update({"ok": True, "result": result})
705
+ except ActionError as exc:
706
+ entry.update({"ok": False, "error": str(exc)})
707
+ overall_ok = False
708
+ except Exception as exc: # defensive — never crash the daemon
709
+ entry.update({"ok": False, "error": f"unexpected: {exc}"})
710
+ overall_ok = False
711
+ entry["elapsed_ms"] = int((time.time() - started) * 1000)
712
+ log.append(entry)
713
+ _append_audit({"wo_id": wo_id, **entry, "executed_by": executed_by})
714
+ if not overall_ok:
715
+ break
716
+
717
+ wo["execution_status"] = "executed" if overall_ok else "failed"
718
+ wo["execution_log"] = log
719
+ wo["executed_at"] = datetime.now(timezone.utc).isoformat()
720
+ wo["status"] = "executed" if overall_ok else "failed"
721
+ _save_work_order(wo)
722
+
723
+ # Supabase sync with the new fields.
724
+ try:
725
+ from ai.supabase_sync import sync_work_order
726
+ sync_work_order(wo)
727
+ except Exception:
728
+ pass
729
+
730
+ return {
731
+ "ok": overall_ok,
732
+ "mode": "live",
733
+ "wo_id": wo_id,
734
+ "actions": len(actions),
735
+ "log": log,
736
+ }
737
+
738
+
739
+ # ---------------------------------------------------------------------------
740
+ # Polling / autonomous path
741
+ # ---------------------------------------------------------------------------
742
+
743
+ def _is_paused_cloud() -> bool:
744
+ """Check the Supabase-backed executor_config flag.
745
+
746
+ Lets a Pro user toggle the kill switch from the dashboard without
747
+ shell access to the gateway host. Logical OR with the local file so
748
+ either surface can stop execution. Returns False on any error — the
749
+ LOCAL file remains the last-resort kill switch.
750
+ """
751
+ try:
752
+ from ai.supabase_sync import _get_client, SUPABASE_URL, SUPABASE_KEY
753
+ import urllib.request
754
+ except Exception:
755
+ return False
756
+ if _get_client() is None:
757
+ return False
758
+ try:
759
+ req = urllib.request.Request(
760
+ f"{SUPABASE_URL}/rest/v1/executor_config?id=eq.default&select=paused",
761
+ headers={"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}"},
762
+ )
763
+ with urllib.request.urlopen(req, timeout=5) as resp:
764
+ rows = json.loads(resp.read().decode())
765
+ return bool(rows and rows[0].get("paused"))
766
+ except Exception as exc:
767
+ logger.debug("executor cloud pause check failed: %s", exc)
768
+ return False
769
+
770
+
771
+ def is_paused() -> bool:
772
+ """Charter kill switch: return True if execution is paused.
773
+
774
+ Either the local file (`~/.delimit/pause_executor`) or the cloud
775
+ config flag (`executor_config.paused`) stops the autonomous loop.
776
+ Local wins any disagreement because it's the last-resort signal an
777
+ operator with shell access can trust.
778
+ """
779
+ if EXECUTOR_PAUSE_FILE.exists():
780
+ return True
781
+ return _is_paused_cloud()
782
+
783
+
784
+ def list_approved_pending() -> List[Dict[str, Any]]:
785
+ """Scan Supabase for approved work orders that haven't been executed yet.
786
+
787
+ Returns an empty list on any error (the poller must never crash the
788
+ daemon; a bad cloud read is a no-op).
789
+ """
790
+ try:
791
+ from ai.supabase_sync import _get_client, SUPABASE_URL, SUPABASE_KEY
792
+ import urllib.request
793
+ except Exception:
794
+ return []
795
+ client = _get_client()
796
+ if client is None:
797
+ return []
798
+ try:
799
+ url = (
800
+ f"{SUPABASE_URL}/rest/v1/work_orders"
801
+ "?status=eq.approved"
802
+ "&or=(execution_status.is.null,execution_status.eq.)"
803
+ "&select=id,status,execution_status,executable_actions"
804
+ "&order=created_at.asc"
805
+ "&limit=20"
806
+ )
807
+ req = urllib.request.Request(
808
+ url,
809
+ headers={
810
+ "apikey": SUPABASE_KEY,
811
+ "Authorization": f"Bearer {SUPABASE_KEY}",
812
+ },
813
+ )
814
+ with urllib.request.urlopen(req, timeout=10) as resp:
815
+ return json.loads(resp.read().decode())
816
+ except Exception as exc:
817
+ logger.debug("executor poller: supabase read failed: %s", exc)
818
+ return []
819
+
820
+
821
+ def poll_and_execute(*, live: bool = False, executed_by: str = "daemon") -> Dict[str, Any]:
822
+ """One tick of the autonomous executor loop.
823
+
824
+ Looks for approved work orders with a non-empty executable_actions list
825
+ that haven't been executed yet, and runs them. Returns a summary of
826
+ what was attempted this tick. Kill-switch aware.
827
+ """
828
+ if is_paused():
829
+ return {"paused": True, "reason": f"{EXECUTOR_PAUSE_FILE} exists"}
830
+
831
+ found = list_approved_pending()
832
+ results = []
833
+ for row in found:
834
+ wo_id = row.get("id")
835
+ actions = row.get("executable_actions") or []
836
+ if not wo_id or not actions:
837
+ continue
838
+ # Load local JSON sidecar (source of truth) — fall back to stub.
839
+ wo = _load_work_order(wo_id) or {
840
+ "id": wo_id,
841
+ "status": row.get("status", ""),
842
+ "executable_actions": actions,
843
+ }
844
+ if wo.get("status") != "approved":
845
+ continue
846
+ # Don't double-run anything that has an execution_status already.
847
+ if wo.get("execution_status"):
848
+ continue
849
+ try:
850
+ res = execute_approved(wo_id, live=live, executed_by=executed_by)
851
+ results.append({"wo_id": wo_id, "ok": res.get("ok"), "mode": res.get("mode")})
852
+ except Exception as exc:
853
+ logger.warning("executor poller: %s failed: %s", wo_id, exc)
854
+ results.append({"wo_id": wo_id, "ok": False, "error": str(exc)})
855
+
856
+ return {
857
+ "paused": False,
858
+ "candidates": len(found),
859
+ "attempted": len(results),
860
+ "results": results,
861
+ }