delimit-cli 4.5.6 → 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/scan_bridge/__init__.py +39 -0
- package/gateway/ai/scan_bridge/bridge.py +473 -0
- package/gateway/ai/scan_bridge/dedup.py +335 -0
- package/gateway/ai/scan_bridge/digest.py +151 -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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""LED-1264: scan → strategy-ledger auto-promote bridge.
|
|
2
|
+
|
|
3
|
+
Pure consumer of ``~/.delimit/social_targets.jsonl`` (the existing
|
|
4
|
+
``delimit_social_target`` output). Promotes a tightly-gated subset of
|
|
5
|
+
strategic signals into the strategy ledger so the founder reviews them
|
|
6
|
+
via a daily digest instead of inbox-spam pings.
|
|
7
|
+
|
|
8
|
+
Panel decision (UNANIMOUS R3, 2026-05-07): tight guards
|
|
9
|
+
(strategic + confidence ≥ 0.85 + dedup against open / 60-day-closed),
|
|
10
|
+
P2 priority (review, not auto-action), one daily digest email.
|
|
11
|
+
|
|
12
|
+
Public entry points:
|
|
13
|
+
|
|
14
|
+
- :func:`bridge.promote_recent_signals` — main work function
|
|
15
|
+
- :func:`digest.build_daily_digest` — assemble last-24h digest text
|
|
16
|
+
- :func:`bridge.backfill_from` — one-time idempotent backfill walker
|
|
17
|
+
|
|
18
|
+
The bridge is invoked by ``scripts/scan_bridge_cron.py`` on a 6-hour
|
|
19
|
+
crontab cadence (founder applies manually). Direct in-process calls to
|
|
20
|
+
``ai.ledger_manager.add_item`` — no MCP subprocess.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from ai.scan_bridge.bridge import (
|
|
24
|
+
backfill_from,
|
|
25
|
+
promote_recent_signals,
|
|
26
|
+
)
|
|
27
|
+
from ai.scan_bridge.dedup import (
|
|
28
|
+
extract_topic_fingerprint,
|
|
29
|
+
is_duplicate,
|
|
30
|
+
)
|
|
31
|
+
from ai.scan_bridge.digest import build_daily_digest
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"backfill_from",
|
|
35
|
+
"build_daily_digest",
|
|
36
|
+
"extract_topic_fingerprint",
|
|
37
|
+
"is_duplicate",
|
|
38
|
+
"promote_recent_signals",
|
|
39
|
+
]
|