delimit-cli 4.5.7 → 4.5.8

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.
@@ -8,6 +8,8 @@ Adapter Boundary Contract v1.0:
8
8
  - No schema forking (gateway types are canonical)
9
9
  """
10
10
 
11
+ import os
12
+ import re
11
13
  import sys
12
14
  import json
13
15
  import logging
@@ -16,6 +18,58 @@ from typing import Any, Dict, List, Optional
16
18
 
17
19
  logger = logging.getLogger("delimit.ai.gateway_core")
18
20
 
21
+
22
+ # LED-1265: identity-string redaction filter for changelog output. Patterns are
23
+ # loaded from the DELIMIT_CHANGELOG_REDACT_PATTERNS env var (semicolon-separated
24
+ # regex|replacement pairs, e.g. "FOO|[redacted];BAR|[redacted]"), NOT hardcoded.
25
+ # Why env-var-driven: hardcoding the patterns inline would itself constitute the
26
+ # leak the filter exists to prevent — the patterns must match the strings being
27
+ # redacted, so committing them to source recreates the leak. When the env var is
28
+ # unset (customer machines), this is a no-op pass-through. The internal gateway
29
+ # sets the env var at process start with the exact patterns to scrub before any
30
+ # auto-generated changelog reaches a public surface.
31
+
32
+
33
+ def _load_redaction_patterns() -> List[tuple]:
34
+ """Parse DELIMIT_CHANGELOG_REDACT_PATTERNS env var into compiled patterns.
35
+
36
+ Format: semicolon-separated `regex|replacement` pairs. Empty / unset returns [].
37
+ Invalid regexes are warned and skipped (filter is fail-open: better to skip a
38
+ bad pattern than block all output).
39
+ """
40
+ raw = os.environ.get("DELIMIT_CHANGELOG_REDACT_PATTERNS", "").strip()
41
+ if not raw:
42
+ return []
43
+ patterns = []
44
+ for entry in raw.split(";"):
45
+ entry = entry.strip()
46
+ if not entry or "|" not in entry:
47
+ continue
48
+ pat, _, repl = entry.partition("|")
49
+ try:
50
+ patterns.append((re.compile(pat), repl))
51
+ except re.error as exc:
52
+ logger.warning("ignoring invalid changelog redaction pattern %r: %s", pat, exc)
53
+ return patterns
54
+
55
+
56
+ _IDENTITY_STRING_PATTERNS = _load_redaction_patterns()
57
+
58
+
59
+ def _redact_identity_strings(text: str) -> str:
60
+ """Apply env-var-loaded redaction patterns to text. No-op when env var unset.
61
+
62
+ Defensive filter for changelog generation: immutable commit messages on
63
+ public repos may contain identity strings the auto-generated CHANGELOG would
64
+ re-leak to a fresh public surface. The filter is opt-in per gateway
65
+ deployment via env var; customers don't need to set it.
66
+ """
67
+ if not text or not _IDENTITY_STRING_PATTERNS:
68
+ return text
69
+ for pattern, replacement in _IDENTITY_STRING_PATTERNS:
70
+ text = pattern.sub(replacement, text)
71
+ return text
72
+
19
73
  # Add gateway root to path so we can import core modules
20
74
  GATEWAY_ROOT = Path(__file__).resolve().parent.parent.parent
21
75
  if str(GATEWAY_ROOT) not in sys.path:
@@ -582,6 +636,11 @@ def run_changelog_from_git(
582
636
  ctype = cat
583
637
  break
584
638
 
639
+ # LED-1265: redact founder-holdco identity strings from the commit
640
+ # subject (msg) and author before they enter any output path.
641
+ msg = _redact_identity_strings(msg)
642
+ author = _redact_identity_strings(author)
643
+
585
644
  bucket = ctype if ctype in categories else "other"
586
645
  entry = {"sha": sha[:8], "message": msg, "author": author, "category": bucket}
587
646
  categories[bucket].append(entry)
@@ -641,9 +700,11 @@ def run_changelog_from_git(
641
700
  continue
642
701
  except (ValueError, TypeError):
643
702
  pass # If parsing fails, include the item
703
+ # LED-1265: ledger titles may contain identity strings
704
+ # (e.g. LED items filed before the doctrine bound).
644
705
  ledger_items.append({
645
706
  "id": item_id,
646
- "title": item.get("title", ""),
707
+ "title": _redact_identity_strings(item.get("title", "")),
647
708
  "priority": item.get("priority", ""),
648
709
  })
649
710
  except Exception:
@@ -0,0 +1,61 @@
1
+ """LED-193 autonomous daemon (MVP).
2
+
3
+ Cron-spawn, stateless, append-only audit. Picks ledger items tagged
4
+ ``auto_execute=class_a:<profile>`` and executes a deterministic profile
5
+ (``format_fix``, ``lockfile_refresh``, ``docs_typo``) on a feature branch.
6
+ NEVER merges. Opens a PR for human review only after local pre-push gates
7
+ pass (security_audit + test_smoke + lint when applicable).
8
+
9
+ Panel decision (UNANIMOUS, 2026-05-07):
10
+ `/home/delimit/delimit-private/deliberations/2026-05-07-led-193-autonomous-daemon-shape.md`
11
+
12
+ Design siblings the cron pattern of LED-1264 ``scan_bridge``:
13
+ - cron-spawn (no long-running process)
14
+ - lockfile concurrency=1
15
+ - append-only audit log
16
+ - kill switch via env var
17
+ - circuit breakers (consecutive failures, daily caps)
18
+
19
+ Public entry points:
20
+
21
+ - :func:`picker.pick_next_item` — ledger-item selection
22
+ - :func:`executor.execute_item` — profile dispatch
23
+ - :func:`gate.run_pre_push_gate` — local pre-push validation
24
+ - :func:`audit.log_execution` — append-only execution log
25
+ - :func:`pause.is_paused` / :func:`pause.pause` / :func:`pause.clear`
26
+ - :func:`cost.check_caps` / :func:`cost.record_run`
27
+
28
+ The cron entry is :mod:`scripts.led193_cron`. Founder applies the
29
+ crontab line manually after review (NOT auto-installed).
30
+ """
31
+
32
+ from ai.led193_daemon.audit import log_execution
33
+ from ai.led193_daemon.cost import check_caps, record_run
34
+ from ai.led193_daemon.executor import execute_item
35
+ from ai.led193_daemon.gate import run_pre_push_gate
36
+ from ai.led193_daemon.pause import clear as clear_pause
37
+ from ai.led193_daemon.pause import is_paused, pause as pause_daemon
38
+ from ai.led193_daemon.picker import pick_next_item
39
+
40
+ # Re-export the submodules so callers can do
41
+ # ``from ai.led193_daemon import audit, cost, executor, gate, pause, picker``
42
+ # without the function-named exports above shadowing the ``pause`` module.
43
+ from ai.led193_daemon import audit, cost, executor, gate, pause, picker # noqa: E402,F401
44
+
45
+ __all__ = [
46
+ "audit",
47
+ "check_caps",
48
+ "clear_pause",
49
+ "cost",
50
+ "execute_item",
51
+ "executor",
52
+ "gate",
53
+ "is_paused",
54
+ "log_execution",
55
+ "pause",
56
+ "pause_daemon",
57
+ "pick_next_item",
58
+ "picker",
59
+ "record_run",
60
+ "run_pre_push_gate",
61
+ ]
@@ -0,0 +1,174 @@
1
+ """LED-193 append-only execution audit log.
2
+
3
+ Every pickup attempt logs one JSON line — success or failure — so
4
+ incidents can be replayed against the daemon's actual behaviour.
5
+
6
+ Schema:
7
+ {
8
+ "ts": ISO8601 UTC,
9
+ "item_id": str,
10
+ "profile": "format_fix" | "lockfile_refresh" | "docs_typo" | "",
11
+ "branch": str | "", # auto/{profile}-{item_id}-{short_hash}
12
+ "pr_url": str | "", # populated only on success
13
+ "result": "success" | "failed" | "noop" | "skipped" | "ci_failed_after_open",
14
+ "reason": str, # human-readable detail
15
+ "cost_estimate": float, # USD; 0.0 for deterministic profiles
16
+ "files_changed": int,
17
+ # optional, populated when known:
18
+ "elapsed_s": float,
19
+ "gate_results": dict,
20
+ }
21
+
22
+ The log is append-only — never rewritten. Path:
23
+ ``~/.delimit/led193_executions.jsonl``
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import os
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any, Dict, Optional
34
+
35
+ logger = logging.getLogger("delimit.ai.led193_daemon.audit")
36
+
37
+ AUDIT_LOG = Path.home() / ".delimit" / "led193_executions.jsonl"
38
+
39
+ VALID_RESULTS = {
40
+ "success",
41
+ "failed",
42
+ "noop",
43
+ "skipped",
44
+ "ci_failed_after_open",
45
+ }
46
+
47
+
48
+ def log_execution(
49
+ *,
50
+ item_id: str,
51
+ profile: str = "",
52
+ branch: str = "",
53
+ pr_url: str = "",
54
+ result: str = "failed",
55
+ reason: str = "",
56
+ cost_estimate: float = 0.0,
57
+ files_changed: int = 0,
58
+ elapsed_s: Optional[float] = None,
59
+ gate_results: Optional[Dict[str, Any]] = None,
60
+ audit_log_path: Optional[Path] = None,
61
+ ) -> Dict[str, Any]:
62
+ """Write one append-only audit line.
63
+
64
+ Returns the record actually written (useful for tests + the cron
65
+ summary). Best-effort — a write failure logs a warning but never
66
+ raises (the daemon must not crash on disk-full).
67
+ """
68
+ if result not in VALID_RESULTS:
69
+ # Don't reject — coerce to "failed" with a clarifying reason so
70
+ # we never silently drop an audit row over a typo in caller code.
71
+ reason = f"invalid_result={result!r}; original_reason={reason!r}"
72
+ result = "failed"
73
+
74
+ record: Dict[str, Any] = {
75
+ "ts": datetime.now(timezone.utc).isoformat(),
76
+ "item_id": item_id,
77
+ "profile": profile,
78
+ "branch": branch,
79
+ "pr_url": pr_url,
80
+ "result": result,
81
+ "reason": reason,
82
+ "cost_estimate": float(cost_estimate),
83
+ "files_changed": int(files_changed),
84
+ }
85
+ if elapsed_s is not None:
86
+ record["elapsed_s"] = round(float(elapsed_s), 3)
87
+ if gate_results is not None:
88
+ record["gate_results"] = gate_results
89
+
90
+ target = audit_log_path or AUDIT_LOG
91
+ try:
92
+ target.parent.mkdir(parents=True, exist_ok=True)
93
+ with target.open("a", encoding="utf-8") as fh:
94
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
95
+ except OSError as exc: # pragma: no cover — best-effort
96
+ logger.warning("led193_daemon: failed to write audit log %s: %s", target, exc)
97
+
98
+ return record
99
+
100
+
101
+ def recent_results(
102
+ *,
103
+ audit_log_path: Optional[Path] = None,
104
+ limit: int = 100,
105
+ ) -> list:
106
+ """Read the most recent N records from the audit log (newest first).
107
+
108
+ Used by the consecutive-failures circuit breaker and by the cron
109
+ summary. Returns ``[]`` when the file doesn't exist or is empty.
110
+ """
111
+ target = audit_log_path or AUDIT_LOG
112
+ if not target.exists():
113
+ return []
114
+ try:
115
+ lines = target.read_text(encoding="utf-8").splitlines()
116
+ except OSError:
117
+ return []
118
+ out = []
119
+ for line in reversed(lines):
120
+ line = line.strip()
121
+ if not line:
122
+ continue
123
+ try:
124
+ out.append(json.loads(line))
125
+ except (json.JSONDecodeError, ValueError):
126
+ continue
127
+ if len(out) >= limit:
128
+ break
129
+ return out
130
+
131
+
132
+ def consecutive_failures(
133
+ *,
134
+ audit_log_path: Optional[Path] = None,
135
+ ) -> int:
136
+ """Count CONSECUTIVE failures from the most recent record backward.
137
+
138
+ Stops at the first non-failure (success/noop/skipped). Used by the
139
+ 3-strike circuit breaker. ``ci_failed_after_open`` counts as a
140
+ failure for breaker purposes — if the daemon keeps opening PRs that
141
+ break CI, that's a signal to pause.
142
+ """
143
+ failures = 0
144
+ for rec in recent_results(audit_log_path=audit_log_path, limit=20):
145
+ if rec.get("result") in ("failed", "ci_failed_after_open"):
146
+ failures += 1
147
+ continue
148
+ break
149
+ return failures
150
+
151
+
152
+ def prs_opened_today(
153
+ *,
154
+ audit_log_path: Optional[Path] = None,
155
+ now: Optional[datetime] = None,
156
+ ) -> int:
157
+ """Count successful PRs opened in the last 24 hours.
158
+
159
+ Used by the action-volume circuit breaker (max 5 PRs / day).
160
+ """
161
+ now = now or datetime.now(timezone.utc)
162
+ cutoff = now.timestamp() - 86400.0
163
+ n = 0
164
+ for rec in recent_results(audit_log_path=audit_log_path, limit=200):
165
+ if rec.get("result") != "success" or not rec.get("pr_url"):
166
+ continue
167
+ ts = rec.get("ts") or ""
168
+ try:
169
+ rec_dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
170
+ if rec_dt.timestamp() >= cutoff:
171
+ n += 1
172
+ except (ValueError, TypeError):
173
+ continue
174
+ return n
@@ -0,0 +1,133 @@
1
+ """LED-193 cost tracking + circuit breakers.
2
+
3
+ MVP profiles are deterministic (no LLM): cost is always 0.0. The cost
4
+ infrastructure exists ahead of time so when Class C ``bounded_patch``
5
+ graduates, the breakers are wired and unit-tested rather than bolted
6
+ on under pressure.
7
+
8
+ Hard caps (panel-locked):
9
+ - Per-item LLM cost: $2 (DELIMIT_LED193_PER_ITEM_USD override)
10
+ - Daily LLM cost: $10 (DELIMIT_LED193_DAILY_USD override)
11
+
12
+ Daily window = trailing 24h, summed from the audit log
13
+ (``cost_estimate`` field). Per-item is enforced by callers BEFORE
14
+ incurring the cost — exceeded → return ``CapTriggered`` and the executor
15
+ short-circuits.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from ai.led193_daemon.audit import recent_results
26
+
27
+ DEFAULT_PER_ITEM_USD = 2.00
28
+ DEFAULT_DAILY_USD = 10.00
29
+
30
+
31
+ def per_item_cap() -> float:
32
+ raw = os.environ.get("DELIMIT_LED193_PER_ITEM_USD", "")
33
+ if raw:
34
+ try:
35
+ v = float(raw)
36
+ if v >= 0:
37
+ return v
38
+ except (TypeError, ValueError):
39
+ pass
40
+ return DEFAULT_PER_ITEM_USD
41
+
42
+
43
+ def daily_cap() -> float:
44
+ raw = os.environ.get("DELIMIT_LED193_DAILY_USD", "")
45
+ if raw:
46
+ try:
47
+ v = float(raw)
48
+ if v >= 0:
49
+ return v
50
+ except (TypeError, ValueError):
51
+ pass
52
+ return DEFAULT_DAILY_USD
53
+
54
+
55
+ def daily_spend(
56
+ *,
57
+ audit_log_path: Optional[Path] = None,
58
+ now: Optional[datetime] = None,
59
+ ) -> float:
60
+ """Sum of cost_estimate across audit records in the last 24h."""
61
+ now = now or datetime.now(timezone.utc)
62
+ cutoff = now.timestamp() - 86400.0
63
+ total = 0.0
64
+ for rec in recent_results(audit_log_path=audit_log_path, limit=500):
65
+ ts = rec.get("ts") or ""
66
+ try:
67
+ rec_dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
68
+ except (ValueError, TypeError):
69
+ continue
70
+ if rec_dt.timestamp() < cutoff:
71
+ continue
72
+ try:
73
+ total += float(rec.get("cost_estimate") or 0.0)
74
+ except (TypeError, ValueError):
75
+ continue
76
+ return total
77
+
78
+
79
+ def check_caps(
80
+ *,
81
+ estimated_cost: float = 0.0,
82
+ audit_log_path: Optional[Path] = None,
83
+ now: Optional[datetime] = None,
84
+ ) -> dict:
85
+ """Return ``{"ok": bool, "reason": str, ...}``.
86
+
87
+ Caller passes ``estimated_cost`` for the proposed item; we check
88
+ BOTH the per-item cap AND the projected daily total. Deterministic
89
+ profiles pass ``estimated_cost=0.0`` and always return ``ok=True``
90
+ unless the daily cap is already breached (which would only happen
91
+ under a misconfigured override).
92
+ """
93
+ per_cap = per_item_cap()
94
+ if estimated_cost > per_cap:
95
+ return {
96
+ "ok": False,
97
+ "reason": f"per_item_cap_exceeded: ${estimated_cost:.2f} > ${per_cap:.2f}",
98
+ "estimated_cost": estimated_cost,
99
+ "per_item_cap": per_cap,
100
+ }
101
+ spent = daily_spend(audit_log_path=audit_log_path, now=now)
102
+ d_cap = daily_cap()
103
+ projected = spent + estimated_cost
104
+ if projected > d_cap:
105
+ return {
106
+ "ok": False,
107
+ "reason": f"daily_cap_exceeded: ${spent:.2f} + ${estimated_cost:.2f} > ${d_cap:.2f}",
108
+ "daily_spend": spent,
109
+ "daily_cap": d_cap,
110
+ "estimated_cost": estimated_cost,
111
+ }
112
+ return {
113
+ "ok": True,
114
+ "reason": "",
115
+ "daily_spend": spent,
116
+ "daily_cap": d_cap,
117
+ "per_item_cap": per_cap,
118
+ "estimated_cost": estimated_cost,
119
+ }
120
+
121
+
122
+ def record_run(actual_cost: float) -> float:
123
+ """Pass-through for callers that want to declare an actual cost.
124
+
125
+ The actual cost lands in the audit log via the ``cost_estimate``
126
+ field on the record. This helper exists so executor call-sites read
127
+ consistently. Returns the validated, clamped cost.
128
+ """
129
+ try:
130
+ v = float(actual_cost)
131
+ except (TypeError, ValueError):
132
+ return 0.0
133
+ return max(0.0, v)