delimit-cli 4.5.13 → 4.6.1
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/CHANGELOG.md +48 -0
- package/README.md +9 -8
- package/bin/delimit-cli.js +179 -4
- package/bin/delimit-setup.js +46 -6
- package/gateway/ai/_compile_status.py +154 -0
- package/gateway/ai/agent_dispatch.py +41 -0
- package/gateway/ai/backends/git_health.py +175 -0
- package/gateway/ai/backends/tools_infra.py +163 -10
- package/gateway/ai/cli_contract.py +185 -0
- package/gateway/ai/daemon.py +10 -0
- package/gateway/ai/daily_digest.py +1 -2
- package/gateway/ai/delimit_daemon.py +67 -0
- package/gateway/ai/dispatch_gate.py +399 -0
- package/gateway/ai/governance.py +181 -0
- package/gateway/ai/heartbeat.py +290 -0
- package/gateway/ai/hot_reload.py +1 -2
- package/gateway/ai/led193_daemon/executor.py +9 -0
- package/gateway/ai/ledger_manager.py +90 -4
- package/gateway/ai/ledger_proof.py +127 -0
- package/gateway/ai/license.py +132 -47
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/license_core.pyi +1 -1
- package/gateway/ai/notify.py +39 -0
- package/gateway/ai/outreach_loop_daemon.py +349 -0
- package/gateway/ai/outreach_substantive.py +1437 -0
- package/gateway/ai/pro_tools.yaml +167 -0
- package/gateway/ai/reaper.py +70 -0
- package/gateway/ai/reddit_scanner.py +17 -6
- package/gateway/ai/sensing/schema.py +1 -1
- package/gateway/ai/sensing/signal_store.py +0 -1
- package/gateway/ai/server.py +5490 -1602
- package/gateway/ai/social_capability/fit_floor.py +114 -12
- package/gateway/ai/social_queue.py +166 -10
- package/gateway/ai/tdqs_lint.py +611 -0
- package/gateway/ai/tenant_auth.py +329 -0
- package/gateway/ai/tenant_data.py +339 -0
- package/gateway/ai/tenant_paths.py +150 -0
- package/gateway/ai/usage_allowlist.py +198 -0
- package/gateway/ai/workers/base.py +2 -2
- package/gateway/ai/workers/executor.py +32 -3
- package/gateway/ai/workers/outreach_drafter.py +0 -1
- package/gateway/ai/workers/pr_drafter.py +0 -1
- package/gateway/ai/x_ranker.py +12 -2
- package/gateway/core/json_schema_diff.py +25 -1
- package/lib/auth-signin.js +136 -0
- package/lib/auth-signout.js +169 -0
- package/lib/delimit-template.js +11 -0
- package/lib/migration-2092-banner.js +213 -0
- package/package.json +5 -2
- package/server.json +4 -4
- package/scripts/build-license-core.sh +0 -85
- package/scripts/security-check.sh +0 -66
- package/scripts/test-license-core-so.sh +0 -107
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Heartbeat liveness framework — Phase 1 local file-based (LED-1412).
|
|
2
|
+
|
|
3
|
+
Solves the silent-staleness class that the 2026-05-15 session exposed:
|
|
4
|
+
delimit-reddit-proxy.service was inactive/disabled for 13 days, all
|
|
5
|
+
reddit scans failed silently with 429/403, and the founder noticed via
|
|
6
|
+
"3 day old posts" — not the system. There was no central liveness
|
|
7
|
+
reporting and no alert.
|
|
8
|
+
|
|
9
|
+
Phase 1 (this module): every scheduled task writes a heartbeat file
|
|
10
|
+
when it runs. A central check tool walks the heartbeat directory and
|
|
11
|
+
flags anything stale. Local-only — Codex's correct caveat that
|
|
12
|
+
heartbeats can't catch a full-host outage motivates Phase 2 (external
|
|
13
|
+
deadman ping, tracked separately as LED-1414).
|
|
14
|
+
|
|
15
|
+
Heartbeat file format — one per service at ~/.delimit/heartbeats/<service>.json:
|
|
16
|
+
{
|
|
17
|
+
"service": "delimit-reddit-proxy",
|
|
18
|
+
"last_run": "2026-05-15T14:23:51Z",
|
|
19
|
+
"last_success": "2026-05-15T14:23:51Z", # may differ from last_run on partial failure
|
|
20
|
+
"status": "ok" | "degraded" | "failed",
|
|
21
|
+
"next_expected": "2026-05-15T15:23:51Z",
|
|
22
|
+
"detail": "string — optional one-line context for status != ok"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Memory anchor: feedback_corrupted_worktree_phantom_failures.md (sister
|
|
26
|
+
failure class — both surface as "system reports stale data because no-one
|
|
27
|
+
checks freshness").
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
# All heartbeats live under one directory. Override via env for tests.
|
|
39
|
+
DEFAULT_HEARTBEAT_DIR = Path.home() / ".delimit" / "heartbeats"
|
|
40
|
+
|
|
41
|
+
# Per-service staleness thresholds (seconds). Overridable via config file
|
|
42
|
+
# at ~/.delimit/heartbeats/_thresholds.json. Service names match the
|
|
43
|
+
# `service` key written by write_heartbeat().
|
|
44
|
+
DEFAULT_STALENESS_THRESHOLDS: Dict[str, int] = {
|
|
45
|
+
# Reddit scanner: hourly social loop. >2 hours = stale.
|
|
46
|
+
"delimit-reddit-proxy": 7200,
|
|
47
|
+
"delimit-social-loop": 7200,
|
|
48
|
+
# Inbox daemon: 5-min poll. >30 min = stale.
|
|
49
|
+
"delimit-inbox": 1800,
|
|
50
|
+
# License watch: daily timer. >36 hours = stale.
|
|
51
|
+
"delimit-license-watch": 129600,
|
|
52
|
+
# Drift check: daily. >36 hours = stale.
|
|
53
|
+
"delimit-drift-check": 129600,
|
|
54
|
+
# stake.one INJ-claim: daily 13:00 UTC. >30 hours = stale.
|
|
55
|
+
"stakeone-inj-claim": 108000,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Fallback for services not in the threshold map.
|
|
59
|
+
DEFAULT_FALLBACK_STALENESS = 86400 # 24 hours
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _heartbeat_dir(override: Optional[str] = None) -> Path:
|
|
63
|
+
"""Resolve the heartbeat directory. Honors:
|
|
64
|
+
- explicit override arg
|
|
65
|
+
- DELIMIT_HEARTBEAT_DIR env var
|
|
66
|
+
- default ~/.delimit/heartbeats/
|
|
67
|
+
"""
|
|
68
|
+
if override:
|
|
69
|
+
return Path(override)
|
|
70
|
+
env = os.environ.get("DELIMIT_HEARTBEAT_DIR")
|
|
71
|
+
if env:
|
|
72
|
+
return Path(env)
|
|
73
|
+
return DEFAULT_HEARTBEAT_DIR
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _now_iso() -> str:
|
|
77
|
+
"""Current UTC time as ISO 8601 with Z suffix (matches existing
|
|
78
|
+
delimit timestamp convention)."""
|
|
79
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_iso(ts: str) -> Optional[float]:
|
|
83
|
+
"""Parse an ISO 8601 timestamp to a unix epoch float. Returns None
|
|
84
|
+
on parse failure — callers treat None as 'unknown' (degraded but
|
|
85
|
+
not actionable)."""
|
|
86
|
+
if not ts:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
# %Y-%m-%dT%H:%M:%SZ — UTC, no fractional seconds.
|
|
90
|
+
return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone
|
|
91
|
+
except (ValueError, TypeError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_heartbeat(
|
|
96
|
+
service: str,
|
|
97
|
+
status: str = "ok",
|
|
98
|
+
next_expected_in: Optional[int] = None,
|
|
99
|
+
detail: str = "",
|
|
100
|
+
success: bool = True,
|
|
101
|
+
heartbeat_dir: Optional[str] = None,
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
"""Write a heartbeat for `service`.
|
|
104
|
+
|
|
105
|
+
Called by every scheduled task at the end of its run. On success,
|
|
106
|
+
pass status='ok' and success=True (default). On partial failure
|
|
107
|
+
(e.g., one of N subreddits 429'd but most succeeded), pass
|
|
108
|
+
status='degraded'. On total failure, status='failed' + success=False.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
service: stable service identifier (e.g., 'delimit-reddit-proxy').
|
|
112
|
+
Should match the systemd unit name where applicable.
|
|
113
|
+
status: 'ok' | 'degraded' | 'failed'.
|
|
114
|
+
next_expected_in: seconds until the next run is expected. Used
|
|
115
|
+
by check_staleness to compute next_expected timestamp.
|
|
116
|
+
detail: optional one-line context (printed to operators on stale).
|
|
117
|
+
success: True if the run achieved its primary purpose (independent
|
|
118
|
+
of `status` — a successful run can still be 'degraded' if
|
|
119
|
+
some optional sub-tasks failed). last_success only updates
|
|
120
|
+
when True.
|
|
121
|
+
heartbeat_dir: override the heartbeat directory (for tests).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Dict with the written record (also persisted to disk).
|
|
125
|
+
"""
|
|
126
|
+
target_dir = _heartbeat_dir(heartbeat_dir)
|
|
127
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
file_path = target_dir / f"{service}.json"
|
|
129
|
+
|
|
130
|
+
now = _now_iso()
|
|
131
|
+
next_expected = ""
|
|
132
|
+
if next_expected_in:
|
|
133
|
+
next_expected_epoch = time.time() + next_expected_in
|
|
134
|
+
next_expected = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(next_expected_epoch))
|
|
135
|
+
|
|
136
|
+
# Preserve last_success across runs (only update if this run succeeded).
|
|
137
|
+
last_success = now if success else ""
|
|
138
|
+
if not success and file_path.exists():
|
|
139
|
+
try:
|
|
140
|
+
prior = json.loads(file_path.read_text())
|
|
141
|
+
last_success = prior.get("last_success", "")
|
|
142
|
+
except (json.JSONDecodeError, OSError):
|
|
143
|
+
pass # Ignore corrupted prior; treat as no last_success known.
|
|
144
|
+
|
|
145
|
+
record = {
|
|
146
|
+
"service": service,
|
|
147
|
+
"last_run": now,
|
|
148
|
+
"last_success": last_success,
|
|
149
|
+
"status": status,
|
|
150
|
+
"next_expected": next_expected,
|
|
151
|
+
"detail": detail,
|
|
152
|
+
}
|
|
153
|
+
file_path.write_text(json.dumps(record, indent=2) + "\n")
|
|
154
|
+
return record
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def read_heartbeats(heartbeat_dir: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
158
|
+
"""Read every heartbeat file in the directory. Skips files that
|
|
159
|
+
don't parse as JSON (corrupted heartbeats are reported as a separate
|
|
160
|
+
'parse_error' entry so the operator sees them)."""
|
|
161
|
+
target_dir = _heartbeat_dir(heartbeat_dir)
|
|
162
|
+
if not target_dir.exists():
|
|
163
|
+
return []
|
|
164
|
+
out: List[Dict[str, Any]] = []
|
|
165
|
+
for path in sorted(target_dir.glob("*.json")):
|
|
166
|
+
# Skip the threshold config file
|
|
167
|
+
if path.name == "_thresholds.json":
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(path.read_text())
|
|
171
|
+
out.append(data)
|
|
172
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
173
|
+
out.append({
|
|
174
|
+
"service": path.stem,
|
|
175
|
+
"status": "parse_error",
|
|
176
|
+
"detail": f"heartbeat file {path.name} unreadable: {type(e).__name__}: {e}",
|
|
177
|
+
"last_run": "",
|
|
178
|
+
"last_success": "",
|
|
179
|
+
"next_expected": "",
|
|
180
|
+
})
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _load_thresholds(heartbeat_dir: Optional[str] = None) -> Dict[str, int]:
|
|
185
|
+
"""Merge defaults with the optional override at <dir>/_thresholds.json."""
|
|
186
|
+
thresholds = dict(DEFAULT_STALENESS_THRESHOLDS)
|
|
187
|
+
target_dir = _heartbeat_dir(heartbeat_dir)
|
|
188
|
+
override_path = target_dir / "_thresholds.json"
|
|
189
|
+
if override_path.exists():
|
|
190
|
+
try:
|
|
191
|
+
override = json.loads(override_path.read_text())
|
|
192
|
+
if isinstance(override, dict):
|
|
193
|
+
thresholds.update({k: int(v) for k, v in override.items() if isinstance(v, (int, float))})
|
|
194
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
195
|
+
pass
|
|
196
|
+
return thresholds
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_staleness(heartbeat_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
200
|
+
"""Walk all heartbeats and classify each by staleness.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
{
|
|
204
|
+
"checked_at": ISO8601 string,
|
|
205
|
+
"summary": {"ok": N, "stale": N, "degraded": N, "failed": N, "parse_error": N},
|
|
206
|
+
"services": [{service, status, last_run, last_success, age_seconds,
|
|
207
|
+
threshold_seconds, classification}],
|
|
208
|
+
"stale_services": [<service names that are stale>], # convenience for alerts
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
Classification rules (most-severe-first):
|
|
212
|
+
- parse_error: heartbeat file unreadable
|
|
213
|
+
- failed: status='failed' in the record
|
|
214
|
+
- stale: last_run older than threshold
|
|
215
|
+
- degraded: status='degraded' in the record
|
|
216
|
+
- ok: status='ok' AND last_run within threshold
|
|
217
|
+
- never_seen: heartbeat directory exists but service has no file
|
|
218
|
+
(only reported when a service is configured in thresholds but
|
|
219
|
+
has never written a heartbeat — surfaces "scheduled task never
|
|
220
|
+
ran since heartbeat instrumentation landed")
|
|
221
|
+
"""
|
|
222
|
+
now = time.time()
|
|
223
|
+
records = read_heartbeats(heartbeat_dir)
|
|
224
|
+
thresholds = _load_thresholds(heartbeat_dir)
|
|
225
|
+
|
|
226
|
+
by_service: Dict[str, Dict[str, Any]] = {}
|
|
227
|
+
for rec in records:
|
|
228
|
+
service = rec.get("service", "?unknown?")
|
|
229
|
+
last_run_epoch = _parse_iso(rec.get("last_run", ""))
|
|
230
|
+
threshold = thresholds.get(service, DEFAULT_FALLBACK_STALENESS)
|
|
231
|
+
if last_run_epoch is not None:
|
|
232
|
+
age_seconds = int(now - last_run_epoch)
|
|
233
|
+
else:
|
|
234
|
+
age_seconds = -1
|
|
235
|
+
|
|
236
|
+
# Classify (most-severe-first)
|
|
237
|
+
if rec.get("status") == "parse_error":
|
|
238
|
+
classification = "parse_error"
|
|
239
|
+
elif rec.get("status") == "failed":
|
|
240
|
+
classification = "failed"
|
|
241
|
+
elif age_seconds < 0:
|
|
242
|
+
classification = "unknown_age"
|
|
243
|
+
elif age_seconds > threshold:
|
|
244
|
+
classification = "stale"
|
|
245
|
+
elif rec.get("status") == "degraded":
|
|
246
|
+
classification = "degraded"
|
|
247
|
+
else:
|
|
248
|
+
classification = "ok"
|
|
249
|
+
|
|
250
|
+
by_service[service] = {
|
|
251
|
+
"service": service,
|
|
252
|
+
"status": rec.get("status", "?"),
|
|
253
|
+
"last_run": rec.get("last_run", ""),
|
|
254
|
+
"last_success": rec.get("last_success", ""),
|
|
255
|
+
"age_seconds": age_seconds,
|
|
256
|
+
"threshold_seconds": threshold,
|
|
257
|
+
"classification": classification,
|
|
258
|
+
"detail": rec.get("detail", ""),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Add never_seen entries for configured services that have no record
|
|
262
|
+
for service in thresholds.keys():
|
|
263
|
+
if service not in by_service:
|
|
264
|
+
by_service[service] = {
|
|
265
|
+
"service": service,
|
|
266
|
+
"status": "never_seen",
|
|
267
|
+
"last_run": "",
|
|
268
|
+
"last_success": "",
|
|
269
|
+
"age_seconds": -1,
|
|
270
|
+
"threshold_seconds": thresholds[service],
|
|
271
|
+
"classification": "never_seen",
|
|
272
|
+
"detail": "no heartbeat file found — service may not be instrumented yet",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
services = list(by_service.values())
|
|
276
|
+
summary = {"ok": 0, "stale": 0, "degraded": 0, "failed": 0, "parse_error": 0,
|
|
277
|
+
"never_seen": 0, "unknown_age": 0}
|
|
278
|
+
stale_services = []
|
|
279
|
+
for svc in services:
|
|
280
|
+
c = svc["classification"]
|
|
281
|
+
summary[c] = summary.get(c, 0) + 1
|
|
282
|
+
if c in ("stale", "failed", "parse_error", "never_seen"):
|
|
283
|
+
stale_services.append(svc["service"])
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"checked_at": _now_iso(),
|
|
287
|
+
"summary": summary,
|
|
288
|
+
"services": services,
|
|
289
|
+
"stale_services": stale_services,
|
|
290
|
+
}
|
package/gateway/ai/hot_reload.py
CHANGED
|
@@ -35,11 +35,10 @@ import logging
|
|
|
35
35
|
import os
|
|
36
36
|
import sys
|
|
37
37
|
import threading
|
|
38
|
-
import time
|
|
39
38
|
import traceback
|
|
40
39
|
from datetime import datetime, timezone
|
|
41
40
|
from pathlib import Path
|
|
42
|
-
from typing import Any,
|
|
41
|
+
from typing import Any, Dict, List, Optional, Set
|
|
43
42
|
|
|
44
43
|
logger = logging.getLogger("delimit.ai.hot_reload")
|
|
45
44
|
|
|
@@ -346,11 +346,17 @@ def execute_format_fix(
|
|
|
346
346
|
return out
|
|
347
347
|
ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
|
|
348
348
|
if not ok:
|
|
349
|
+
# Precondition mismatch (worktree dirty), not a profile failure.
|
|
350
|
+
# Mark skipped so the consecutive-failure breaker doesn't trip.
|
|
351
|
+
out.result = "skipped"
|
|
349
352
|
out.reason = reason
|
|
350
353
|
return out
|
|
351
354
|
|
|
352
355
|
cmd = _detect_format_command(repo_path)
|
|
353
356
|
if cmd is None:
|
|
357
|
+
# Repo has no formatter config — daemon can't run, but this is
|
|
358
|
+
# a setup gap not an executor failure. Skip rather than fail.
|
|
359
|
+
out.result = "skipped"
|
|
354
360
|
out.reason = "no_formatter_detected"
|
|
355
361
|
return out
|
|
356
362
|
|
|
@@ -429,11 +435,13 @@ def execute_lockfile_refresh(
|
|
|
429
435
|
return out
|
|
430
436
|
ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
|
|
431
437
|
if not ok:
|
|
438
|
+
out.result = "skipped"
|
|
432
439
|
out.reason = reason
|
|
433
440
|
return out
|
|
434
441
|
|
|
435
442
|
detected = _detect_lockfile_command(repo_path)
|
|
436
443
|
if detected is None:
|
|
444
|
+
out.result = "skipped"
|
|
437
445
|
out.reason = "no_lockfile_or_manager_detected"
|
|
438
446
|
return out
|
|
439
447
|
cmd, _lockfile = detected
|
|
@@ -551,6 +559,7 @@ def execute_docs_typo(
|
|
|
551
559
|
return out
|
|
552
560
|
ok, reason = _ensure_clean_worktree(repo_path, runner=runner)
|
|
553
561
|
if not ok:
|
|
562
|
+
out.result = "skipped"
|
|
554
563
|
out.reason = reason
|
|
555
564
|
return out
|
|
556
565
|
|
|
@@ -124,7 +124,16 @@ def _detect_venture(project_path: str = ".") -> Dict[str, str]:
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
def _register_venture(info: Dict[str, str]):
|
|
127
|
-
"""Silently register a venture in the global registry.
|
|
127
|
+
"""Silently register a venture in the global registry.
|
|
128
|
+
|
|
129
|
+
Phase C follow-up (2026-05-18): reject paths under /tmp/* or the
|
|
130
|
+
bare "/tmp" itself. Pytest tmp_path values leaked into the registry
|
|
131
|
+
as ventures (`tmp: /tmp`, `test_project: /tmp/pytest-of-root/...`),
|
|
132
|
+
causing every fresh tmp_path to match via path-prefix in
|
|
133
|
+
resolve_venture and breaking test_resolve_venture_unregistered_path.
|
|
134
|
+
The guard fails-silently — tests that pass tmp_path to functions
|
|
135
|
+
which auto-register simply don't pollute the registry going forward.
|
|
136
|
+
"""
|
|
128
137
|
GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
129
138
|
ventures = {}
|
|
130
139
|
if VENTURES_FILE.exists():
|
|
@@ -134,9 +143,19 @@ def _register_venture(info: Dict[str, str]):
|
|
|
134
143
|
pass
|
|
135
144
|
|
|
136
145
|
name = info["name"]
|
|
146
|
+
path = info.get("path", "")
|
|
147
|
+
# Guard against the specific test-state pollution that broke
|
|
148
|
+
# test_resolve_venture_unregistered_path: a `tmp: /tmp` venture
|
|
149
|
+
# caught EVERY pytest tmp_path via path-prefix in resolve_venture.
|
|
150
|
+
# Reject bare "/tmp" only. Deeper /tmp/<X> paths are fine — they
|
|
151
|
+
# only path-prefix-match their own subtree, not every tmp_path,
|
|
152
|
+
# AND legitimate test fixtures (e.g. test_ledger_proof) register
|
|
153
|
+
# subpaths during a single test run and need that to work.
|
|
154
|
+
if path == "/tmp" or path.rstrip("/") == "/tmp":
|
|
155
|
+
return
|
|
137
156
|
if name not in ventures:
|
|
138
157
|
ventures[name] = {
|
|
139
|
-
"path":
|
|
158
|
+
"path": path,
|
|
140
159
|
"repo": info.get("repo", ""),
|
|
141
160
|
"type": info.get("type", ""),
|
|
142
161
|
"registered_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
@@ -565,8 +584,18 @@ def update_item(
|
|
|
565
584
|
blocks: Optional[str] = None,
|
|
566
585
|
project_path: str = ".",
|
|
567
586
|
worked_by: str = "",
|
|
587
|
+
commit_sha: Optional[str] = None,
|
|
588
|
+
pr_url: Optional[str] = None,
|
|
568
589
|
) -> Dict[str, Any]:
|
|
569
|
-
"""Update an existing ledger item's fields.
|
|
590
|
+
"""Update an existing ledger item's fields.
|
|
591
|
+
|
|
592
|
+
LED-1408 Phase 1: when `status="done"` is requested, callers MAY provide
|
|
593
|
+
`commit_sha` and/or `pr_url` as proof that the work shipped to main.
|
|
594
|
+
The proof is recorded on the update event under `ship_proof` with a
|
|
595
|
+
`verified: bool` flag. Phase 1 does NOT enforce — items still
|
|
596
|
+
transition to `done` even without proof — but the flag lets future
|
|
597
|
+
audits and the Phase 2 reconciler find unverified-done items.
|
|
598
|
+
"""
|
|
570
599
|
_ensure(project_path)
|
|
571
600
|
ledger_dir = _project_ledger_dir(project_path)
|
|
572
601
|
|
|
@@ -619,11 +648,63 @@ def update_item(
|
|
|
619
648
|
if due_date:
|
|
620
649
|
update["due_date"] = due_date
|
|
621
650
|
if labels is not None:
|
|
651
|
+
# LED-2221: write to both `labels` and `tags`. The list_items
|
|
652
|
+
# reconstruction (around line ~870) merges update events into
|
|
653
|
+
# current state by checking the `tags` key only. Writing only
|
|
654
|
+
# `labels` silently drops the update at read time, which in
|
|
655
|
+
# particular meant the build daemon's `autonomous-build` tag
|
|
656
|
+
# check could never see tags written through the MCP. Keeping
|
|
657
|
+
# `labels` for any external consumer that reads the raw event
|
|
658
|
+
# stream; adding `tags` so the live state aggregator picks it up.
|
|
622
659
|
update["labels"] = labels
|
|
660
|
+
update["tags"] = labels
|
|
623
661
|
if blocked_by:
|
|
624
662
|
update["blocked_by"] = blocked_by
|
|
625
663
|
if blocks:
|
|
626
664
|
update["blocks"] = blocks
|
|
665
|
+
|
|
666
|
+
# LED-1408 Phase 1: attach ship_proof block when status transitions to
|
|
667
|
+
# `done` or `shipped_pending`. Verified=True iff commit_sha or pr_url
|
|
668
|
+
# was supplied (directly or scraped from the note). Phase 2's
|
|
669
|
+
# reconciler will use this to distinguish "trustworthy done" from
|
|
670
|
+
# "marked done but never verified on main."
|
|
671
|
+
if status in ("done", "shipped_pending"):
|
|
672
|
+
try:
|
|
673
|
+
from ai.ledger_proof import build_ship_proof
|
|
674
|
+
update["ship_proof"] = build_ship_proof(
|
|
675
|
+
commit_sha=commit_sha,
|
|
676
|
+
pr_url=pr_url,
|
|
677
|
+
note=note,
|
|
678
|
+
)
|
|
679
|
+
# LED-1420 Phase 2 strict-mode flip: when DELIMIT_LEDGER_STRICT_DONE=1,
|
|
680
|
+
# an unverified `done` transition is downgraded to `shipped_pending`
|
|
681
|
+
# so the nightly reconciler (scripts/delimit_ledger_reconciler.py)
|
|
682
|
+
# can promote it to `done` once a commit-trailer match shows up on
|
|
683
|
+
# origin/main. Off by default so existing workflows keep closing
|
|
684
|
+
# items without hitting an unexpected gate; flip when the
|
|
685
|
+
# reconciler has been observed running for ~1 week without
|
|
686
|
+
# surprises.
|
|
687
|
+
strict = os.environ.get("DELIMIT_LEDGER_STRICT_DONE") == "1"
|
|
688
|
+
if (
|
|
689
|
+
strict
|
|
690
|
+
and status == "done"
|
|
691
|
+
and not update["ship_proof"].get("verified")
|
|
692
|
+
):
|
|
693
|
+
update["status"] = "shipped_pending"
|
|
694
|
+
existing_note = update.get("note") or ""
|
|
695
|
+
suffix = (
|
|
696
|
+
"[LED-1420 strict-mode: downgraded done → shipped_pending — "
|
|
697
|
+
"no commit_sha/pr_url proof; reconciler will upgrade to "
|
|
698
|
+
"done when it finds a Ledger-Item: " + item_id + " trailer "
|
|
699
|
+
"on origin/main]"
|
|
700
|
+
)
|
|
701
|
+
update["note"] = (existing_note + " " + suffix).strip() if existing_note else suffix
|
|
702
|
+
except Exception:
|
|
703
|
+
# Soft-fail: a ship_proof bug must not break ledger close.
|
|
704
|
+
# The unverified state will be re-detectable from the missing
|
|
705
|
+
# key on the next audit pass.
|
|
706
|
+
pass
|
|
707
|
+
|
|
627
708
|
_append(path, update)
|
|
628
709
|
|
|
629
710
|
# Sync to Supabase for dashboard visibility
|
|
@@ -1297,7 +1378,12 @@ def session_history(limit: int = 5) -> Dict[str, Any]:
|
|
|
1297
1378
|
# `archive` is a soft transition (status="archived", appended to JSONL); items
|
|
1298
1379
|
# stay in replay forever. NO hard delete. Per-item failures don't block others.
|
|
1299
1380
|
BULK_ACTIONS = ("archive", "set_status", "set_priority", "add_tag", "mark_done", "cancel")
|
|
1300
|
-
|
|
1381
|
+
# LED-1408: `shipped_pending` is the intermediate state between "committed" and
|
|
1382
|
+
# "verified on main." Items transition to shipped_pending when a worker reports
|
|
1383
|
+
# completion (commit exists somewhere) but the orchestrator hasn't yet verified
|
|
1384
|
+
# the commit is reachable from origin/main. The reconciler (Phase 2) promotes
|
|
1385
|
+
# shipped_pending → done once reachability is confirmed.
|
|
1386
|
+
_VALID_BULK_STATUSES = ("open", "in_progress", "blocked", "shipped_pending", "done", "cancelled", "archived", "completed")
|
|
1301
1387
|
_VALID_BULK_PRIORITIES = ("P0", "P1", "P2", "P3")
|
|
1302
1388
|
|
|
1303
1389
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Ledger ship-state proof helpers (LED-1408 Phase 1).
|
|
2
|
+
|
|
3
|
+
When a ledger item transitions to `done`, we want auditable evidence that the
|
|
4
|
+
fix actually shipped. Two forms of proof:
|
|
5
|
+
|
|
6
|
+
1. **Commit-trailer binding** — the merge commit carries a `Ledger-Item:
|
|
7
|
+
LED-NNNN` trailer. Lets a webhook or reconciler walk `git log
|
|
8
|
+
origin/main` and find every commit-to-ledger link without naming
|
|
9
|
+
conventions or fuzzy matching.
|
|
10
|
+
|
|
11
|
+
2. **PR-URL linkage** — `https://github.com/<org>/<repo>/pull/<N>` plus a
|
|
12
|
+
verified `merged_at` timestamp. Fallback for items closed without a
|
|
13
|
+
trailer.
|
|
14
|
+
|
|
15
|
+
Phase 1 (this module): parse + record. Items closed with proof get
|
|
16
|
+
`verified: true` on the event; items closed without proof get
|
|
17
|
+
`verified: false` so a future audit can find them.
|
|
18
|
+
|
|
19
|
+
Phase 2 (separate LED): the reconciler enforces stricter semantics —
|
|
20
|
+
items without proof default to `shipped_pending`, not `done`.
|
|
21
|
+
|
|
22
|
+
Memory anchor: feedback_agent_dashboard_done_means_committed_not_merged.md
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from typing import Dict, Optional
|
|
29
|
+
|
|
30
|
+
# Match `Ledger-Item: LED-1234` (case-insensitive, leading whitespace allowed).
|
|
31
|
+
# Pattern intentionally tolerant of trailing whitespace + multiple LED IDs:
|
|
32
|
+
# we extract the FIRST LED-N on the line.
|
|
33
|
+
_LEDGER_TRAILER_RE = re.compile(
|
|
34
|
+
r"(?im)^\s*Ledger-Item\s*:\s*(LED-\d+)",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Match a GitHub PR URL: https://github.com/<owner>/<repo>/pull/<N>
|
|
38
|
+
_PR_URL_RE = re.compile(
|
|
39
|
+
r"https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_ledger_trailer(commit_message: str) -> Optional[str]:
|
|
44
|
+
"""Extract the `Ledger-Item: LED-NNNN` trailer value from a commit message.
|
|
45
|
+
|
|
46
|
+
Returns the LED id (e.g. `LED-1408`) or None if no trailer is present.
|
|
47
|
+
The trailer must be on its own line; mentions inside prose (e.g.
|
|
48
|
+
`mentions LED-1408 in passing`) do NOT match.
|
|
49
|
+
"""
|
|
50
|
+
if not commit_message:
|
|
51
|
+
return None
|
|
52
|
+
match = _LEDGER_TRAILER_RE.search(commit_message)
|
|
53
|
+
if match:
|
|
54
|
+
return match.group(1)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_pr_url(text: str) -> Optional[Dict[str, str]]:
|
|
59
|
+
"""Extract the first GitHub PR URL from any string.
|
|
60
|
+
|
|
61
|
+
Returns {owner, repo, number} or None.
|
|
62
|
+
"""
|
|
63
|
+
if not text:
|
|
64
|
+
return None
|
|
65
|
+
match = _PR_URL_RE.search(text)
|
|
66
|
+
if match:
|
|
67
|
+
return {
|
|
68
|
+
"owner": match.group(1),
|
|
69
|
+
"repo": match.group(2),
|
|
70
|
+
"number": match.group(3),
|
|
71
|
+
}
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_ship_proof(
|
|
76
|
+
commit_sha: Optional[str] = None,
|
|
77
|
+
pr_url: Optional[str] = None,
|
|
78
|
+
note: Optional[str] = None,
|
|
79
|
+
) -> Dict[str, object]:
|
|
80
|
+
"""Build a ship-proof block to attach to a ledger `done` event.
|
|
81
|
+
|
|
82
|
+
Inputs may come from explicit MCP-tool args OR from inline mentions
|
|
83
|
+
in the note (the caller might paste a PR URL into the note field
|
|
84
|
+
without realizing it's also queryable).
|
|
85
|
+
|
|
86
|
+
Returns a dict with keys:
|
|
87
|
+
- verified: bool — True iff commit_sha OR pr_url was provided
|
|
88
|
+
- commit_sha: str or None
|
|
89
|
+
- pr_url: str or None
|
|
90
|
+
- pr_owner / pr_repo / pr_number: str or None (parsed from pr_url)
|
|
91
|
+
- ledger_trailer: str or None (parsed from note, if present)
|
|
92
|
+
|
|
93
|
+
The `verified` flag is the primary downstream signal. A future
|
|
94
|
+
reconciler will refuse to transition `done` without verified=True;
|
|
95
|
+
Phase 1 only records the flag without enforcing.
|
|
96
|
+
"""
|
|
97
|
+
proof: Dict[str, object] = {
|
|
98
|
+
"verified": bool(commit_sha or pr_url),
|
|
99
|
+
"commit_sha": commit_sha or None,
|
|
100
|
+
"pr_url": pr_url or None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if pr_url:
|
|
104
|
+
parsed = parse_pr_url(pr_url)
|
|
105
|
+
if parsed:
|
|
106
|
+
proof["pr_owner"] = parsed["owner"]
|
|
107
|
+
proof["pr_repo"] = parsed["repo"]
|
|
108
|
+
proof["pr_number"] = parsed["number"]
|
|
109
|
+
|
|
110
|
+
# If pr_url not explicitly passed but appears in the note, capture it
|
|
111
|
+
if not pr_url and note:
|
|
112
|
+
parsed = parse_pr_url(note)
|
|
113
|
+
if parsed:
|
|
114
|
+
proof["pr_url"] = f"https://github.com/{parsed['owner']}/{parsed['repo']}/pull/{parsed['number']}"
|
|
115
|
+
proof["pr_owner"] = parsed["owner"]
|
|
116
|
+
proof["pr_repo"] = parsed["repo"]
|
|
117
|
+
proof["pr_number"] = parsed["number"]
|
|
118
|
+
proof["verified"] = True
|
|
119
|
+
|
|
120
|
+
# Capture any Ledger-Item trailer from the note (rare but possible —
|
|
121
|
+
# a worker might paste the commit message into the close note).
|
|
122
|
+
if note:
|
|
123
|
+
trailer = parse_ledger_trailer(note)
|
|
124
|
+
if trailer:
|
|
125
|
+
proof["ledger_trailer"] = trailer
|
|
126
|
+
|
|
127
|
+
return proof
|