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.
@@ -0,0 +1,300 @@
1
+ """LED-193 pre-push gate.
2
+
3
+ The PRODUCT INVARIANT: daemon AUTHORS code, NEVER merges. A daemon-
4
+ authored PR is opened only when ALL of the following local checks pass:
5
+
6
+ 1. Repo's existing pre-push hook succeeds (if installed).
7
+ The branch's commit must already pass the hook before push.
8
+ 2. ``delimit_security_audit`` reports no NEW critical/high vulns.
9
+ 3. ``delimit_test_smoke`` passes when tests exist (no_framework
10
+ counts as a pass — we don't block on the absence of tests).
11
+ 4. ``delimit_lint`` passes when the diff includes a spec change.
12
+
13
+ Any failure → do NOT open PR. Mark item ``failed``, audit, exit.
14
+
15
+ Self-eat dog food: each pass through this gate IS a Delimit attestation
16
+ of the daemon's own authorship — the panel called this load-bearing.
17
+ The gate output is JSON-serializable and lands in the audit log under
18
+ ``gate_results`` so the eventual PR description can link the local
19
+ attestation hash.
20
+
21
+ Implementation notes:
22
+ - We import the real backends directly (``tools_real.test_smoke``,
23
+ ``tools_infra.security_audit``). These are the same code paths
24
+ ``delimit_test_smoke`` and ``delimit_security_audit`` MCP tools
25
+ drive, so the local gate matches the merge gate.
26
+ - Lint runs only when the diff contains an OpenAPI spec file
27
+ (``.yaml``/``.yml``/``.json`` whose content matches openapi
28
+ heuristics). For the MVP profiles (format/lockfile/typo) lint
29
+ will almost never trigger — but the wiring is here for when
30
+ ``bounded_patch`` graduates.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import subprocess
37
+ from dataclasses import dataclass, field, asdict
38
+ from pathlib import Path
39
+ from typing import Any, Dict, List, Optional
40
+
41
+ logger = logging.getLogger("delimit.ai.led193_daemon.gate")
42
+
43
+
44
+ # ── Result containers ──────────────────────────────────────────────────
45
+
46
+
47
+ @dataclass
48
+ class GateResult:
49
+ ok: bool = True
50
+ reason: str = ""
51
+ security_audit: Dict[str, Any] = field(default_factory=dict)
52
+ test_smoke: Dict[str, Any] = field(default_factory=dict)
53
+ lint: Dict[str, Any] = field(default_factory=dict)
54
+ pre_push_hook: Dict[str, Any] = field(default_factory=dict)
55
+
56
+ def to_dict(self) -> Dict[str, Any]:
57
+ return asdict(self)
58
+
59
+
60
+ # ── Individual gates ───────────────────────────────────────────────────
61
+
62
+
63
+ def _run_security_audit(repo_path: Path) -> Dict[str, Any]:
64
+ """Run delimit_security_audit. Pass iff no critical/high vulns."""
65
+ try:
66
+ from ai.backends import tools_infra # local import to avoid heavy import on cold path
67
+ except Exception as exc: # pragma: no cover — gateway always has it
68
+ return {"ok": False, "error": f"backend_unavailable: {exc}"}
69
+ try:
70
+ result = tools_infra.security_audit(target=str(repo_path))
71
+ except Exception as exc:
72
+ return {"ok": False, "error": f"audit_raised: {exc}"}
73
+ severity_counts = result.get("severity_counts") or {}
74
+ critical = int(severity_counts.get("critical") or 0)
75
+ high = int(severity_counts.get("high") or 0)
76
+ ok = (critical == 0 and high == 0)
77
+ return {
78
+ "ok": ok,
79
+ "critical": critical,
80
+ "high": high,
81
+ "tools_used": result.get("tools_used") or [],
82
+ "severity_counts": severity_counts,
83
+ }
84
+
85
+
86
+ def _run_test_smoke(repo_path: Path) -> Dict[str, Any]:
87
+ """Run delimit_test_smoke. Absent test framework = PASS (we don't
88
+ block on the absence of tests)."""
89
+ try:
90
+ from ai.backends import tools_real
91
+ except Exception as exc: # pragma: no cover
92
+ return {"ok": False, "error": f"backend_unavailable: {exc}"}
93
+ try:
94
+ result = tools_real.test_smoke(project_path=str(repo_path))
95
+ except Exception as exc:
96
+ return {"ok": False, "error": f"smoke_raised: {exc}"}
97
+ status = result.get("status", "")
98
+ if status == "no_framework":
99
+ return {"ok": True, "status": "no_framework"}
100
+ if status == "error":
101
+ return {"ok": False, "status": "error", "error": result.get("error", "")}
102
+ failed = int(result.get("failed") or 0)
103
+ errors = int(result.get("errors") or 0)
104
+ passed = int(result.get("passed") or 0)
105
+ ok = (failed == 0 and errors == 0)
106
+ return {
107
+ "ok": ok,
108
+ "passed": passed,
109
+ "failed": failed,
110
+ "errors": errors,
111
+ "framework": result.get("framework", ""),
112
+ }
113
+
114
+
115
+ def _changed_files(repo_path: Path, runner=None) -> List[str]:
116
+ """Return the file paths changed in the staged commit (HEAD vs
117
+ HEAD~1) or in the working tree as a fallback. Never raises — returns
118
+ [] on any error so the caller can decide."""
119
+ cmds = [
120
+ ["git", "diff", "--name-only", "HEAD~1", "HEAD"],
121
+ ["git", "diff", "--name-only", "HEAD"],
122
+ ]
123
+ for cmd in cmds:
124
+ try:
125
+ if runner is not None:
126
+ proc = runner(cmd, cwd=str(repo_path))
127
+ stdout = getattr(proc, "stdout", "") or ""
128
+ rc = getattr(proc, "returncode", 0)
129
+ else:
130
+ p = subprocess.run(
131
+ cmd, cwd=str(repo_path), capture_output=True,
132
+ text=True, timeout=10, check=False,
133
+ )
134
+ stdout, rc = p.stdout, p.returncode
135
+ if rc == 0 and stdout.strip():
136
+ return [ln.strip() for ln in stdout.splitlines() if ln.strip()]
137
+ except (subprocess.TimeoutExpired, OSError):
138
+ continue
139
+ return []
140
+
141
+
142
+ def _looks_like_spec(path: Path) -> bool:
143
+ """Heuristic: file is OpenAPI-ish — yaml/yml/json AND content
144
+ contains ``openapi:``/``swagger:`` near the top."""
145
+ if path.suffix.lower() not in (".yaml", ".yml", ".json"):
146
+ return False
147
+ try:
148
+ head = path.read_text(encoding="utf-8", errors="replace")[:4096]
149
+ except OSError:
150
+ return False
151
+ sample = head.lower()
152
+ return ("openapi:" in sample) or ('"openapi"' in sample) or ("swagger:" in sample)
153
+
154
+
155
+ def _run_lint_if_applicable(
156
+ repo_path: Path,
157
+ *,
158
+ runner=None,
159
+ ) -> Dict[str, Any]:
160
+ """Run delimit_lint when the diff contains a spec change.
161
+
162
+ For MVP profiles this is almost always a no-op (format/lockfile/typo
163
+ don't touch specs). The wiring is here so when ``bounded_patch``
164
+ graduates, the gate is already complete.
165
+ """
166
+ changed = _changed_files(repo_path, runner=runner)
167
+ spec_files = []
168
+ for rel in changed:
169
+ p = repo_path / rel
170
+ if p.exists() and _looks_like_spec(p):
171
+ spec_files.append(rel)
172
+ if not spec_files:
173
+ return {"ok": True, "applicable": False, "reason": "no_spec_change"}
174
+ # We need a baseline to lint against. The daemon has no way to
175
+ # reliably reconstruct one in MVP without a checkout dance. For
176
+ # safety we DEFER: if a spec change shows up, we fail-closed and
177
+ # surface a "lint_required_baseline_unknown" reason. Founder reviews
178
+ # and either runs lint manually or graduates the daemon.
179
+ return {
180
+ "ok": False,
181
+ "applicable": True,
182
+ "reason": "lint_required_baseline_unknown",
183
+ "spec_files": spec_files,
184
+ }
185
+
186
+
187
+ def _run_pre_push_hook(repo_path: Path, runner=None) -> Dict[str, Any]:
188
+ """Run the repo's pre-push hook directly if installed.
189
+
190
+ Pre-push hooks are NOT exec'd by ``git`` until ``git push`` runs.
191
+ We invoke the hook script ourselves with empty stdin so the result
192
+ matches what ``git push`` would observe — surface failures BEFORE
193
+ we hit the network.
194
+
195
+ Spec-required check (LED-129 enforced via ``delimit setup hooks``).
196
+ Absent hook = pass (we don't enforce hook presence on third-party
197
+ repos in MVP).
198
+ """
199
+ hook = repo_path / ".git" / "hooks" / "pre-push"
200
+ if not hook.exists():
201
+ return {"ok": True, "ran": False, "reason": "hook_absent"}
202
+ if not hook.is_file():
203
+ return {"ok": True, "ran": False, "reason": "hook_not_file"}
204
+ cmd = [str(hook), "origin", "git@example.com:owner/repo.git"]
205
+ try:
206
+ if runner is not None:
207
+ proc = runner(cmd, cwd=str(repo_path))
208
+ stdout = getattr(proc, "stdout", "") or ""
209
+ stderr = getattr(proc, "stderr", "") or ""
210
+ rc = getattr(proc, "returncode", 1)
211
+ else:
212
+ p = subprocess.run(
213
+ cmd,
214
+ cwd=str(repo_path),
215
+ input="", # no refs being pushed in this dry run
216
+ capture_output=True,
217
+ text=True,
218
+ timeout=300,
219
+ check=False,
220
+ )
221
+ stdout, stderr, rc = p.stdout, p.stderr, p.returncode
222
+ except (subprocess.TimeoutExpired, OSError) as exc:
223
+ return {"ok": False, "ran": True, "reason": f"hook_failed: {exc}"}
224
+ return {
225
+ "ok": rc == 0,
226
+ "ran": True,
227
+ "returncode": rc,
228
+ "stdout_tail": (stdout or "")[-500:],
229
+ "stderr_tail": (stderr or "")[-500:],
230
+ }
231
+
232
+
233
+ # ── Public API ─────────────────────────────────────────────────────────
234
+
235
+
236
+ def run_pre_push_gate(
237
+ repo_path: Path,
238
+ *,
239
+ runner=None,
240
+ skip_pre_push_hook: bool = False,
241
+ skip_lint: bool = False,
242
+ ) -> GateResult:
243
+ """Run all gates against ``repo_path``. Return ``GateResult``.
244
+
245
+ Order is intentional: cheapest first, fail-fast.
246
+
247
+ 1. pre-push hook (subprocess, repo-local; skipped if absent)
248
+ 2. test_smoke (fast on small repos)
249
+ 3. security_audit (slower; pip-audit / npm audit network calls)
250
+ 4. lint (only if a spec changed)
251
+
252
+ Any failure short-circuits — we don't run downstream checks because
253
+ a single failure is enough to refuse the push.
254
+
255
+ Test hooks:
256
+ runner: subprocess shim used by the pre-push hook + git diff
257
+ invocations. The Python-import-driven test_smoke / audit /
258
+ lint backends are mocked separately by patching the modules.
259
+ skip_pre_push_hook: bypass when the test fixture didn't install
260
+ a hook.
261
+ skip_lint: bypass for tests that don't care about spec-change
262
+ detection (most of them).
263
+ """
264
+ result = GateResult()
265
+
266
+ # 1. Pre-push hook
267
+ if skip_pre_push_hook:
268
+ result.pre_push_hook = {"ok": True, "ran": False, "reason": "skipped"}
269
+ else:
270
+ result.pre_push_hook = _run_pre_push_hook(repo_path, runner=runner)
271
+ if not result.pre_push_hook.get("ok"):
272
+ result.ok = False
273
+ result.reason = "pre_push_hook_failed"
274
+ return result
275
+
276
+ # 2. test_smoke
277
+ result.test_smoke = _run_test_smoke(repo_path)
278
+ if not result.test_smoke.get("ok"):
279
+ result.ok = False
280
+ result.reason = "test_smoke_failed"
281
+ return result
282
+
283
+ # 3. security_audit
284
+ result.security_audit = _run_security_audit(repo_path)
285
+ if not result.security_audit.get("ok"):
286
+ result.ok = False
287
+ result.reason = "security_audit_failed"
288
+ return result
289
+
290
+ # 4. lint (only if spec changes detected)
291
+ if skip_lint:
292
+ result.lint = {"ok": True, "applicable": False, "reason": "skipped"}
293
+ else:
294
+ result.lint = _run_lint_if_applicable(repo_path, runner=runner)
295
+ if not result.lint.get("ok"):
296
+ result.ok = False
297
+ result.reason = "lint_failed"
298
+ return result
299
+
300
+ return result
@@ -0,0 +1,83 @@
1
+ """LED-193 pause-state management.
2
+
3
+ The daemon is paused when ``~/.delimit/led193_paused.json`` exists.
4
+ Founder must manually clear (e.g. ``rm ~/.delimit/led193_paused.json``)
5
+ to resume — the daemon will NEVER auto-clear its own pause state.
6
+
7
+ Triggered by:
8
+ - 3 consecutive failures (audit-log driven)
9
+ - explicit founder pause
10
+
11
+ The kill switch (env var ``DELIMIT_LED193_DAEMON_DISABLED=1``) is
12
+ checked separately at script start in ``scripts/led193_cron.py`` —
13
+ even a non-paused daemon is blocked when the kill switch is set.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Optional
24
+
25
+ logger = logging.getLogger("delimit.ai.led193_daemon.pause")
26
+
27
+ PAUSE_FILE = Path.home() / ".delimit" / "led193_paused.json"
28
+
29
+
30
+ def is_paused(*, pause_file: Optional[Path] = None) -> bool:
31
+ """True iff the pause file exists."""
32
+ target = pause_file or PAUSE_FILE
33
+ return target.exists()
34
+
35
+
36
+ def pause(
37
+ *,
38
+ reason: str,
39
+ pause_file: Optional[Path] = None,
40
+ extra: Optional[Dict[str, Any]] = None,
41
+ ) -> Dict[str, Any]:
42
+ """Create the pause file. Idempotent — overwrites if already present.
43
+
44
+ Returns the payload written.
45
+ """
46
+ target = pause_file or PAUSE_FILE
47
+ payload: Dict[str, Any] = {
48
+ "ts": datetime.now(timezone.utc).isoformat(),
49
+ "reason": reason,
50
+ }
51
+ if extra:
52
+ # Don't let extra clobber required keys.
53
+ for k, v in extra.items():
54
+ if k not in payload:
55
+ payload[k] = v
56
+ try:
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ target.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
59
+ except OSError as exc: # pragma: no cover
60
+ logger.warning("led193_daemon: failed to write pause file: %s", exc)
61
+ return payload
62
+
63
+
64
+ def clear(*, pause_file: Optional[Path] = None) -> bool:
65
+ """Remove the pause file. Returns True if a file was removed.
66
+
67
+ Founder-only intent — the daemon should never call this on itself.
68
+ Tests may call it for cleanup.
69
+ """
70
+ target = pause_file or PAUSE_FILE
71
+ if target.exists():
72
+ try:
73
+ target.unlink()
74
+ return True
75
+ except OSError as exc: # pragma: no cover
76
+ logger.warning("led193_daemon: failed to clear pause file: %s", exc)
77
+ return False
78
+
79
+
80
+ def kill_switch_active() -> bool:
81
+ """Check the env-var kill switch. True blocks all execution."""
82
+ val = (os.environ.get("DELIMIT_LED193_DAEMON_DISABLED") or "").strip().lower()
83
+ return val in ("1", "true", "yes", "on")
@@ -0,0 +1,236 @@
1
+ """LED-193 ledger-item picker.
2
+
3
+ Selects the next eligible item to execute. One at a time (concurrency=1
4
+ is enforced upstream by the lockfile in scripts/led193_cron.py).
5
+
6
+ Eligibility (ALL must hold):
7
+ 1. Tagged ``auto_execute=class_a:<profile>`` where profile is in
8
+ the whitelist {format_fix, lockfile_refresh, docs_typo}.
9
+ 2. Status is ``open`` (not done, in_progress, blocked, cancelled).
10
+ 3. Item is NOT marked ``worked_by=founder`` (founder claimed back).
11
+ 4. Item is younger than 7 days (created_at within window).
12
+ 5. No remote branch matching ``auto/{profile}-{item_id}-*``
13
+ already exists (in-flight check).
14
+
15
+ Preference order (top of list first):
16
+ P0 → P1 → P2 → P3, ties broken by oldest-first.
17
+
18
+ Returns ``None`` when no eligible item exists.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ import subprocess
26
+ from datetime import datetime, timedelta, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
29
+
30
+ logger = logging.getLogger("delimit.ai.led193_daemon.picker")
31
+
32
+ PROFILE_WHITELIST = {"format_fix", "lockfile_refresh", "docs_typo"}
33
+ TAG_PREFIX = "auto_execute=class_a:"
34
+ STALE_DAYS = 7
35
+ PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
36
+
37
+
38
+ # ── Tag parsing ────────────────────────────────────────────────────────
39
+
40
+
41
+ def parse_auto_execute_tag(tags: Iterable[str]) -> Optional[str]:
42
+ """Return the profile name from an ``auto_execute=class_a:<profile>``
43
+ tag, or ``None`` if no valid Class A tag is present.
44
+
45
+ Rejects unknown profiles even if the prefix matches — so a typo or
46
+ a future profile that hasn't been enrolled doesn't accidentally
47
+ pick up.
48
+ """
49
+ if not tags:
50
+ return None
51
+ for tag in tags:
52
+ if not isinstance(tag, str):
53
+ continue
54
+ if not tag.startswith(TAG_PREFIX):
55
+ continue
56
+ profile = tag[len(TAG_PREFIX):].strip()
57
+ if profile in PROFILE_WHITELIST:
58
+ return profile
59
+ return None
60
+
61
+
62
+ # ── Age check ──────────────────────────────────────────────────────────
63
+
64
+
65
+ def _parse_iso(value: str) -> Optional[datetime]:
66
+ if not value:
67
+ return None
68
+ try:
69
+ # Accept both "...Z" and "...+00:00"
70
+ v = value.replace("Z", "+00:00")
71
+ dt = datetime.fromisoformat(v)
72
+ if dt.tzinfo is None:
73
+ dt = dt.replace(tzinfo=timezone.utc)
74
+ return dt
75
+ except (ValueError, TypeError):
76
+ return None
77
+
78
+
79
+ def is_stale(item: Dict[str, Any], *, now: Optional[datetime] = None) -> bool:
80
+ """True iff item is older than ``STALE_DAYS`` from ``created_at``.
81
+
82
+ Falls back to ``updated_at`` when ``created_at`` is missing. If
83
+ NEITHER is parseable, treat as stale (fail-closed: don't run on
84
+ items with unknown age).
85
+ """
86
+ now = now or datetime.now(timezone.utc)
87
+ cutoff = now - timedelta(days=STALE_DAYS)
88
+ created = _parse_iso(item.get("created_at") or "") or _parse_iso(item.get("updated_at") or "")
89
+ if created is None:
90
+ return True
91
+ return created < cutoff
92
+
93
+
94
+ # ── In-flight check (remote branch presence) ───────────────────────────
95
+
96
+
97
+ def in_flight_branch_pattern(profile: str, item_id: str) -> str:
98
+ return f"auto/{profile}-{item_id}-"
99
+
100
+
101
+ def has_in_flight_branch(
102
+ *,
103
+ profile: str,
104
+ item_id: str,
105
+ repo_path: Path,
106
+ runner=None,
107
+ ) -> bool:
108
+ """Run ``git ls-remote --heads`` and return True iff any branch
109
+ matches the in-flight prefix.
110
+
111
+ ``runner`` is a test hook: callable taking (cmd_list, cwd) and
112
+ returning a ``subprocess.CompletedProcess``-shaped object with
113
+ ``stdout`` (str) and ``returncode`` (int). Defaults to real subprocess.
114
+ """
115
+ pattern = in_flight_branch_pattern(profile, item_id)
116
+ cmd = ["git", "ls-remote", "--heads", "origin"]
117
+ try:
118
+ if runner is not None:
119
+ res = runner(cmd, cwd=str(repo_path))
120
+ stdout = getattr(res, "stdout", "") or ""
121
+ rc = getattr(res, "returncode", 0)
122
+ else:
123
+ proc = subprocess.run(
124
+ cmd,
125
+ cwd=str(repo_path),
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=15,
129
+ check=False,
130
+ )
131
+ stdout = proc.stdout or ""
132
+ rc = proc.returncode
133
+ except (subprocess.TimeoutExpired, OSError) as exc:
134
+ # Fail-closed: if we can't tell whether a branch is in flight, skip
135
+ # the item. A spurious skip is recoverable; a duplicate PR is not.
136
+ logger.warning("led193_daemon: ls-remote failed (%s) — treating as in-flight", exc)
137
+ return True
138
+ if rc != 0:
139
+ return True # fail-closed
140
+ for line in stdout.splitlines():
141
+ # ls-remote line format: "<sha>\trefs/heads/<branch>"
142
+ parts = line.split("\t", 1)
143
+ if len(parts) != 2:
144
+ continue
145
+ ref = parts[1].strip()
146
+ if not ref.startswith("refs/heads/"):
147
+ continue
148
+ branch = ref[len("refs/heads/"):]
149
+ if branch.startswith(pattern):
150
+ return True
151
+ return False
152
+
153
+
154
+ # ── Sort + filter ──────────────────────────────────────────────────────
155
+
156
+
157
+ def _priority_rank(item: Dict[str, Any]) -> int:
158
+ return PRIORITY_RANK.get(item.get("priority", "P3"), 99)
159
+
160
+
161
+ def _created_ts(item: Dict[str, Any]) -> str:
162
+ # Lexical ISO compare — older is smaller.
163
+ return str(item.get("created_at") or item.get("updated_at") or "")
164
+
165
+
166
+ def filter_eligible(
167
+ items: Iterable[Dict[str, Any]],
168
+ *,
169
+ now: Optional[datetime] = None,
170
+ ) -> List[Tuple[str, Dict[str, Any]]]:
171
+ """Return [(profile, item)] for items that pass the static gates.
172
+
173
+ Static gates here = everything EXCEPT the in-flight remote-branch
174
+ check, which requires git network access and is applied per
175
+ candidate by the caller (cheaper to do it lazily on the top
176
+ candidates only).
177
+ """
178
+ out: List[Tuple[str, Dict[str, Any]]] = []
179
+ for item in items:
180
+ if not isinstance(item, dict):
181
+ continue
182
+ # Status gate — only "open" items execute.
183
+ if (item.get("status") or "").strip().lower() != "open":
184
+ continue
185
+ # Founder claimed-back gate.
186
+ worked_by = (item.get("worked_by") or "").strip().lower()
187
+ if worked_by == "founder":
188
+ continue
189
+ # Tag gate.
190
+ profile = parse_auto_execute_tag(item.get("tags") or [])
191
+ if profile is None:
192
+ continue
193
+ # Age gate.
194
+ if is_stale(item, now=now):
195
+ continue
196
+ out.append((profile, item))
197
+ # Sort: priority asc (P0 first), then created_at asc (oldest first).
198
+ out.sort(key=lambda pair: (_priority_rank(pair[1]), _created_ts(pair[1])))
199
+ return out
200
+
201
+
202
+ def pick_next_item(
203
+ items: Iterable[Dict[str, Any]],
204
+ *,
205
+ repo_path: Optional[Path] = None,
206
+ now: Optional[datetime] = None,
207
+ runner=None,
208
+ skip_in_flight_check: bool = False,
209
+ ) -> Optional[Tuple[str, Dict[str, Any]]]:
210
+ """Return the next eligible (profile, item) or ``None``.
211
+
212
+ The in-flight remote-branch check runs against ``repo_path`` (the
213
+ target repo). When ``repo_path`` is None or
214
+ ``skip_in_flight_check=True``, the check is bypassed (test hook).
215
+ """
216
+ eligible = filter_eligible(items, now=now)
217
+ for profile, item in eligible:
218
+ if skip_in_flight_check or repo_path is None:
219
+ return profile, item
220
+ item_id = item.get("id") or ""
221
+ if not item_id:
222
+ continue
223
+ if has_in_flight_branch(
224
+ profile=profile,
225
+ item_id=item_id,
226
+ repo_path=repo_path,
227
+ runner=runner,
228
+ ):
229
+ logger.info(
230
+ "led193_daemon: skipping %s — in-flight branch %s* exists",
231
+ item_id,
232
+ in_flight_branch_pattern(profile, item_id),
233
+ )
234
+ continue
235
+ return profile, item
236
+ return None
@@ -59,6 +59,7 @@ allowed_claims:
59
59
  - id: diff_engine
60
60
  surface_name: "27 breaking-change types"
61
61
  description: "Deterministic diff engine for OpenAPI spec changes."
62
+ evidence_link: https://delimit.ai/docs/changes
62
63
  - id: github_action
63
64
  surface_name: "delimit-ai/delimit-action GitHub Action"
64
65
  description: "On Marketplace, breaking-change detection on PRs."
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import json
28
28
  import logging
29
+ import os
29
30
  import shlex
30
31
  import subprocess
31
32
  import time
@@ -94,16 +95,24 @@ ACTION_SPEC: Dict[str, Dict[str, Any]] = {
94
95
  }
95
96
 
96
97
 
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
98
+ # LED-988 + LED-1258: allowlist for propose_pr. Any repo path NOT in this set
99
+ # is rejected at runtime regardless of whether the caller claimed validation
99
100
  # 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
- })
101
+ #
102
+ # Loaded from the DELIMIT_PROPOSE_PR_REPOS env var (comma-separated absolute
103
+ # paths), NOT hardcoded — hardcoding developer-machine paths in shipped source
104
+ # both leaks the dev directory layout to customers AND makes the allowlist
105
+ # dead-code on customer machines (their paths won't match). Empty / unset env
106
+ # var = empty allowlist = propose_pr fails closed for all repo paths.
107
+
108
+ def _load_propose_pr_allowed_repos() -> frozenset:
109
+ raw = os.environ.get("DELIMIT_PROPOSE_PR_REPOS", "").strip()
110
+ if not raw:
111
+ return frozenset()
112
+ return frozenset(p.strip() for p in raw.split(",") if p.strip())
113
+
114
+
115
+ PROPOSE_PR_ALLOWED_REPOS = _load_propose_pr_allowed_repos()
107
116
  # Any branch created by propose_pr must carry this prefix so human branches
108
117
  # are never clobbered and PRs are obviously agent-authored at a glance.
109
118
  PROPOSE_PR_BRANCH_PREFIX = "delimit/"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.5.7",
4
+ "version": "4.5.8",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [