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.
- package/gateway/ai/backends/gateway_core.py +62 -1
- package/gateway/ai/led193_daemon/__init__.py +61 -0
- package/gateway/ai/led193_daemon/audit.py +174 -0
- package/gateway/ai/led193_daemon/cost.py +133 -0
- package/gateway/ai/led193_daemon/executor.py +683 -0
- package/gateway/ai/led193_daemon/gate.py +300 -0
- package/gateway/ai/led193_daemon/pause.py +83 -0
- package/gateway/ai/led193_daemon/picker.py +236 -0
- package/gateway/ai/social_capability/current_capabilities.yaml +1 -0
- package/gateway/ai/workers/executor.py +18 -9
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
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": [
|