delimit-cli 4.7.2 → 4.7.4
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 +15 -0
- package/README.md +37 -2
- package/bin/delimit-cli.js +152 -1
- package/bin/delimit-setup.js +88 -6
- package/bin/delimit.js +10 -25
- package/gateway/ai/backends/governance_bridge.py +52 -0
- package/gateway/ai/backends/repo_bridge.py +12 -0
- package/gateway/ai/backends/tools_infra.py +43 -1
- package/gateway/ai/cli_contract.py +12 -0
- package/gateway/ai/custom_gemini_repl.py +80 -0
- package/gateway/ai/delimit_daemon.py +8 -0
- package/gateway/ai/gemini_vertex_shim.py +38 -0
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/release_sync.py +43 -8
- package/gateway/ai/route_daemon.py +98 -0
- package/gateway/ai/server.py +71 -1
- package/gateway/ai/session_phoenix.py +101 -136
- package/gateway/ai/supabase_sync.py +58 -0
- package/gateway/ai/swarm.py +2 -0
- package/gateway/ai/tui.py +81 -0
- package/gateway/core/ci_formatter.py +89 -61
- package/gateway/core/diff_engine_v2.py +208 -627
- package/gateway/core/explainer.py +67 -34
- package/lib/ai-sbom-engine.js +1 -0
- package/lib/auth-setup.js +10 -1
- package/lib/chat-repl.js +244 -0
- package/lib/cross-model-hooks.js +111 -0
- package/lib/timeline-engine.js +60 -0
- package/lib/wrap-engine.js +67 -11
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -62,6 +62,9 @@ class SessionSoul:
|
|
|
62
62
|
tokens_used: int = 0
|
|
63
63
|
context_fullness: float = 0.0
|
|
64
64
|
|
|
65
|
+
# Pointer to the last immutable ledger entry for Auto-Phoenix replay
|
|
66
|
+
ledger_pointer: str = ""
|
|
67
|
+
|
|
65
68
|
|
|
66
69
|
def _project_hash(project_path: str) -> str:
|
|
67
70
|
"""Stable hash for a project path, used as directory name."""
|
|
@@ -74,6 +77,45 @@ def _project_dir(project_path: str) -> Path:
|
|
|
74
77
|
return SOULS_BASE_DIR / _project_hash(project_path)
|
|
75
78
|
|
|
76
79
|
|
|
80
|
+
def _get_latest_ledger_pointer() -> str:
|
|
81
|
+
"""Find the latest entry hash/ID from the operations ledger."""
|
|
82
|
+
ledger_path = Path.home() / ".delimit" / "ledger" / "operations.jsonl"
|
|
83
|
+
if not ledger_path.exists():
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Read the last line (most recent event)
|
|
88
|
+
with open(ledger_path, 'rb') as f:
|
|
89
|
+
f.seek(0, os.SEEK_END)
|
|
90
|
+
size = f.tell()
|
|
91
|
+
if size == 0:
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
# Find last non-empty line
|
|
95
|
+
pos = size - 1
|
|
96
|
+
while pos > 0:
|
|
97
|
+
f.seek(pos)
|
|
98
|
+
if f.read(1) != b'\n':
|
|
99
|
+
break
|
|
100
|
+
pos -= 1
|
|
101
|
+
|
|
102
|
+
# Now find the start of this line
|
|
103
|
+
while pos > 0:
|
|
104
|
+
f.seek(pos - 1)
|
|
105
|
+
if f.read(1) == b'\n':
|
|
106
|
+
break
|
|
107
|
+
pos -= 1
|
|
108
|
+
|
|
109
|
+
f.seek(pos)
|
|
110
|
+
line = f.readline().decode('utf-8').strip()
|
|
111
|
+
if line:
|
|
112
|
+
data = json.loads(line)
|
|
113
|
+
return data.get('hash') or data.get('id') or ""
|
|
114
|
+
except (OSError, json.JSONDecodeError):
|
|
115
|
+
pass
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
|
|
77
119
|
def _run_git(args: List[str], cwd: str = "") -> str:
|
|
78
120
|
"""Run a git command and return stdout, or empty string on failure."""
|
|
79
121
|
try:
|
|
@@ -154,6 +196,7 @@ def capture_soul(
|
|
|
154
196
|
uncommitted_changes=git_state["uncommitted_changes"],
|
|
155
197
|
tokens_used=tokens_used,
|
|
156
198
|
context_fullness=context_fullness,
|
|
199
|
+
ledger_pointer=_get_latest_ledger_pointer(),
|
|
157
200
|
)
|
|
158
201
|
|
|
159
202
|
_store_soul(soul)
|
|
@@ -235,16 +278,10 @@ def get_latest_soul(project_path: str = "") -> Optional[SessionSoul]:
|
|
|
235
278
|
|
|
236
279
|
|
|
237
280
|
def _soul_sort_key(soul: SessionSoul, fallback_path: Path) -> str:
|
|
238
|
-
"""Sort key for global recency ranking.
|
|
239
|
-
created_at (ISO-8601, lexically sortable); fall back to the file's
|
|
240
|
-
mtime when created_at is missing so a malformed/legacy soul still
|
|
241
|
-
orders sensibly rather than sinking to the bottom unconditionally."""
|
|
281
|
+
"""Sort key for global recency ranking."""
|
|
242
282
|
if soul.created_at:
|
|
243
283
|
return soul.created_at
|
|
244
284
|
try:
|
|
245
|
-
# Fall back to the file mtime, rendered as an ISO-8601 string so it
|
|
246
|
-
# compares lexically against real created_at values on the same
|
|
247
|
-
# scale. Only reached when created_at is empty.
|
|
248
285
|
return datetime.fromtimestamp(
|
|
249
286
|
fallback_path.stat().st_mtime, timezone.utc
|
|
250
287
|
).isoformat()
|
|
@@ -255,28 +292,11 @@ def _soul_sort_key(soul: SessionSoul, fallback_path: Path) -> str:
|
|
|
255
292
|
def find_most_recent_soul_across_projects(
|
|
256
293
|
exclude_project_path: str = "",
|
|
257
294
|
) -> Optional[Dict[str, Any]]:
|
|
258
|
-
"""Scan every project-hash soul directory
|
|
259
|
-
return the globally-most-recent soul, with its originating project.
|
|
260
|
-
|
|
261
|
-
LED-218 FIX D: cross-venture fallback for `revive()` when the current
|
|
262
|
-
working directory resolves to a project that has no souls (e.g. running
|
|
263
|
-
from /root). Read-only; never writes. Returns None when no souls exist
|
|
264
|
-
anywhere.
|
|
265
|
-
|
|
266
|
-
Args:
|
|
267
|
-
exclude_project_path: if set, the soul directory for this project
|
|
268
|
-
is skipped (it already had no usable soul, so re-scanning it is
|
|
269
|
-
wasted work and could otherwise re-surface a stale latest.json).
|
|
270
|
-
|
|
271
|
-
Returns:
|
|
272
|
-
{"soul": SessionSoul, "project_hash": str, "project_path": str}
|
|
273
|
-
for the most recent soul found, or None.
|
|
274
|
-
"""
|
|
295
|
+
"""Scan every project-hash soul directory and return the globally-most-recent soul."""
|
|
275
296
|
if not SOULS_BASE_DIR.exists():
|
|
276
297
|
return None
|
|
277
298
|
|
|
278
299
|
exclude_hash = _project_hash(exclude_project_path) if exclude_project_path else None
|
|
279
|
-
|
|
280
300
|
best: Optional[SessionSoul] = None
|
|
281
301
|
best_key: str = ""
|
|
282
302
|
best_hash: str = ""
|
|
@@ -287,8 +307,6 @@ def find_most_recent_soul_across_projects(
|
|
|
287
307
|
if exclude_hash and proj_dir.name == exclude_hash:
|
|
288
308
|
continue
|
|
289
309
|
|
|
290
|
-
# Prefer the per-project latest.json; fall back to scanning the
|
|
291
|
-
# timestamped soul files if latest.json is absent/corrupt.
|
|
292
310
|
candidate: Optional[SessionSoul] = None
|
|
293
311
|
candidate_path: Optional[Path] = None
|
|
294
312
|
|
|
@@ -341,152 +359,99 @@ def _format_revival(soul: SessionSoul) -> str:
|
|
|
341
359
|
lines.append(f"Captured: {soul.created_at}")
|
|
342
360
|
lines.append(f"Source Model: {soul.source_model}")
|
|
343
361
|
lines.append(f"Project: {soul.project_path}")
|
|
344
|
-
|
|
362
|
+
|
|
363
|
+
if soul.ledger_pointer:
|
|
364
|
+
lines.append(f"Ledger Anchor: {soul.ledger_pointer}")
|
|
365
|
+
lines.append("")
|
|
366
|
+
lines.append("AUTO-PHOENIX INSTRUCTIONS:")
|
|
367
|
+
lines.append(f"1. Replay history from ledger checkpoint '{soul.ledger_pointer}'.")
|
|
368
|
+
lines.append("2. Verify current state matches the replayed audit trail.")
|
|
369
|
+
lines.append("3. Continue the task from the identified next steps.")
|
|
370
|
+
lines.append("")
|
|
345
371
|
|
|
346
|
-
|
|
347
|
-
lines.append("
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
lines.append(f" Status: {soul.task_status}")
|
|
351
|
-
else:
|
|
352
|
-
lines.append(" (none recorded)")
|
|
372
|
+
lines.append("-" * 30)
|
|
373
|
+
lines.append(f"TASK: {soul.active_task}")
|
|
374
|
+
lines.append(f"STATUS: {soul.task_status}")
|
|
375
|
+
lines.append("-" * 30)
|
|
353
376
|
lines.append("")
|
|
354
377
|
|
|
355
|
-
# Decisions
|
|
356
378
|
if soul.decisions:
|
|
357
|
-
lines.append("
|
|
379
|
+
lines.append("DECISIONS MADE:")
|
|
358
380
|
for d in soul.decisions:
|
|
359
|
-
lines.append(f"
|
|
381
|
+
lines.append(f" - {d}")
|
|
360
382
|
lines.append("")
|
|
361
383
|
|
|
362
|
-
# Files
|
|
363
384
|
if soul.files_modified or soul.files_created:
|
|
364
|
-
lines.append("
|
|
385
|
+
lines.append("FILES CHANGED:")
|
|
365
386
|
for f in soul.files_modified:
|
|
366
|
-
lines.append(f"
|
|
387
|
+
lines.append(f" M {f}")
|
|
367
388
|
for f in soul.files_created:
|
|
368
|
-
lines.append(f"
|
|
389
|
+
lines.append(f" A {f}")
|
|
369
390
|
lines.append("")
|
|
370
391
|
|
|
371
|
-
# Context
|
|
372
392
|
if soul.key_context:
|
|
373
|
-
lines.append("
|
|
393
|
+
lines.append("KEY CONTEXT:")
|
|
374
394
|
for c in soul.key_context:
|
|
375
|
-
lines.append(f"
|
|
395
|
+
lines.append(f" - {c}")
|
|
376
396
|
lines.append("")
|
|
377
397
|
|
|
378
|
-
# Blockers
|
|
379
398
|
if soul.blockers:
|
|
380
|
-
lines.append("
|
|
399
|
+
lines.append("BLOCKERS:")
|
|
381
400
|
for b in soul.blockers:
|
|
382
|
-
lines.append(f"
|
|
401
|
+
lines.append(f" - {b}")
|
|
383
402
|
lines.append("")
|
|
384
403
|
|
|
385
|
-
# Next steps
|
|
386
404
|
if soul.next_steps:
|
|
387
|
-
lines.append("
|
|
388
|
-
for
|
|
389
|
-
lines.append(f"
|
|
405
|
+
lines.append("NEXT STEPS:")
|
|
406
|
+
for n in soul.next_steps:
|
|
407
|
+
lines.append(f" - {n}")
|
|
390
408
|
lines.append("")
|
|
391
409
|
|
|
392
|
-
|
|
393
|
-
lines.append("
|
|
394
|
-
lines.append(f"
|
|
395
|
-
lines.append(
|
|
396
|
-
lines.append(f" Uncommitted changes: {soul.uncommitted_changes}")
|
|
410
|
+
lines.append("-" * 30)
|
|
411
|
+
lines.append(f"GIT: branch={soul.git_branch}, sha={soul.git_sha}")
|
|
412
|
+
lines.append(f"UNCOMMITTED CHANGES: {soul.uncommitted_changes}")
|
|
413
|
+
lines.append("-" * 30)
|
|
397
414
|
lines.append("")
|
|
415
|
+
lines.append("=== END OF REVIVED CONTEXT ===")
|
|
398
416
|
|
|
399
|
-
# Token stats
|
|
400
|
-
if soul.tokens_used or soul.context_fullness:
|
|
401
|
-
lines.append("--- SESSION STATS ---")
|
|
402
|
-
if soul.tokens_used:
|
|
403
|
-
lines.append(f" Tokens used: ~{soul.tokens_used:,}")
|
|
404
|
-
if soul.context_fullness:
|
|
405
|
-
lines.append(f" Context fullness: {soul.context_fullness:.0%}")
|
|
406
|
-
lines.append("")
|
|
407
|
-
|
|
408
|
-
lines.append("=" * 60)
|
|
409
417
|
return "\n".join(lines)
|
|
410
418
|
|
|
411
419
|
|
|
412
420
|
def revive(project_path: str = "", soul_id: str = "") -> Dict[str, Any]:
|
|
413
|
-
"""
|
|
414
|
-
|
|
415
|
-
Returns a structured dict with both the raw soul data and a
|
|
416
|
-
formatted context string that can be injected into any model.
|
|
417
|
-
"""
|
|
421
|
+
"""Resurrect the session state."""
|
|
418
422
|
project_path = project_path or os.getcwd()
|
|
419
423
|
|
|
424
|
+
soul: Optional[SessionSoul] = None
|
|
425
|
+
|
|
420
426
|
if soul_id:
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
"status": "not_found",
|
|
431
|
-
"message": f"No soul with ID '{soul_id}' found for project {project_path}",
|
|
432
|
-
}
|
|
427
|
+
proj_dir = _project_dir(project_path)
|
|
428
|
+
# Try both direct match and timestamped filename search
|
|
429
|
+
for f in proj_dir.glob("*.json"):
|
|
430
|
+
if soul_id in f.name:
|
|
431
|
+
soul = _load_soul(f)
|
|
432
|
+
break
|
|
433
|
+
else:
|
|
434
|
+
soul = get_latest_soul(project_path)
|
|
433
435
|
|
|
434
|
-
#
|
|
435
|
-
|
|
436
|
-
if
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
# different project. This ADDITIVE path only fires when the
|
|
444
|
-
# resolved project itself is empty AND no explicit soul_id was
|
|
445
|
-
# given, so existing single-project users see no change.
|
|
446
|
-
fallback = find_most_recent_soul_across_projects(
|
|
447
|
-
exclude_project_path=project_path
|
|
448
|
-
)
|
|
449
|
-
if fallback:
|
|
450
|
-
recovered = fallback["soul"]
|
|
451
|
-
return {
|
|
452
|
-
"status": "revived",
|
|
453
|
-
"soul": asdict(recovered),
|
|
454
|
-
"context": _format_revival(recovered),
|
|
455
|
-
"recovered_from_venture": recovered.project_path
|
|
456
|
-
or fallback.get("project_hash", ""),
|
|
457
|
-
"recovered_project_hash": fallback.get("project_hash", ""),
|
|
458
|
-
"note": (
|
|
459
|
-
f"No soul for {project_path}; recovered the most recent "
|
|
460
|
-
f"soul from {recovered.project_path or fallback.get('project_hash', '')}."
|
|
461
|
-
),
|
|
462
|
-
}
|
|
436
|
+
# LED-218 FIX D: cross-venture fallback
|
|
437
|
+
source_project = project_path
|
|
438
|
+
if soul is None:
|
|
439
|
+
cross_soul = find_most_recent_soul_across_projects(exclude_project_path=project_path)
|
|
440
|
+
if cross_soul:
|
|
441
|
+
soul = cross_soul["soul"]
|
|
442
|
+
source_project = cross_soul["project_path"]
|
|
443
|
+
|
|
444
|
+
if soul is None:
|
|
463
445
|
return {
|
|
464
|
-
"status": "
|
|
465
|
-
"
|
|
466
|
-
"hint": "
|
|
446
|
+
"status": "failed",
|
|
447
|
+
"error": f"No captured soul found for project at {project_path}",
|
|
448
|
+
"hint": "Run delimit_soul_capture before leaving a session.",
|
|
467
449
|
}
|
|
468
450
|
|
|
469
451
|
return {
|
|
470
452
|
"status": "revived",
|
|
471
453
|
"soul": asdict(soul),
|
|
472
|
-
"
|
|
454
|
+
"revival_text": _format_revival(soul),
|
|
455
|
+
"source_project": source_project,
|
|
456
|
+
"message": f"Soul {soul.soul_id} resurrected from {source_project}.",
|
|
473
457
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
def should_auto_capture(
|
|
477
|
-
context_fullness: float = 0.0,
|
|
478
|
-
session_age_minutes: int = 0,
|
|
479
|
-
last_capture_minutes_ago: int = -1,
|
|
480
|
-
) -> bool:
|
|
481
|
-
"""Determine if we should auto-capture a soul.
|
|
482
|
-
|
|
483
|
-
Triggers:
|
|
484
|
-
- Context > 70% full
|
|
485
|
-
- Session > 30 minutes old with no capture in the last 15 minutes
|
|
486
|
-
- Explicit session end (handled by caller, not this function)
|
|
487
|
-
"""
|
|
488
|
-
if context_fullness >= 0.7:
|
|
489
|
-
return True
|
|
490
|
-
if session_age_minutes >= 30 and (last_capture_minutes_ago < 0 or last_capture_minutes_ago >= 15):
|
|
491
|
-
return True
|
|
492
|
-
return False
|
|
@@ -159,6 +159,64 @@ def _http_patch(table: str, query: str, data: dict) -> bool:
|
|
|
159
159
|
return False
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
def _http_upload_storage(
|
|
163
|
+
bucket: str,
|
|
164
|
+
object_path: str,
|
|
165
|
+
body: bytes,
|
|
166
|
+
content_type: str = "application/json",
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Upload one object to Supabase Storage using the REST API."""
|
|
169
|
+
import urllib.parse
|
|
170
|
+
import urllib.request
|
|
171
|
+
try:
|
|
172
|
+
safe_bucket = urllib.parse.quote(bucket.strip("/"), safe="")
|
|
173
|
+
safe_object = urllib.parse.quote(object_path.strip("/"), safe="/")
|
|
174
|
+
url = f"{SUPABASE_URL.rstrip('/')}/storage/v1/object/{safe_bucket}/{safe_object}"
|
|
175
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
176
|
+
req.add_header("Content-Type", content_type)
|
|
177
|
+
req.add_header("Cache-Control", "3600")
|
|
178
|
+
req.add_header("apikey", SUPABASE_KEY)
|
|
179
|
+
req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
|
|
180
|
+
req.add_header("x-upsert", "true")
|
|
181
|
+
urllib.request.urlopen(req, timeout=5)
|
|
182
|
+
return True
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.debug(f"Supabase Storage upload failed for {bucket}/{object_path}: {e}")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def sync_attestation_bundle(
|
|
189
|
+
bundle_path: str,
|
|
190
|
+
attestation_id: str = "",
|
|
191
|
+
bucket: str = "",
|
|
192
|
+
) -> bool:
|
|
193
|
+
"""Best-effort mirror of a signed attestation JSON bundle to Supabase Storage.
|
|
194
|
+
|
|
195
|
+
Local files remain the source of truth. This only makes /att/<id> and the
|
|
196
|
+
dashboard index able to discover bundles without committing static JSON.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
client = _get_client()
|
|
200
|
+
if client is None:
|
|
201
|
+
return False
|
|
202
|
+
if not bundle_path:
|
|
203
|
+
return False
|
|
204
|
+
path = Path(bundle_path)
|
|
205
|
+
if not path.exists() or not path.is_file():
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
object_id = attestation_id or path.stem
|
|
209
|
+
object_path = f"{object_id}.json" if not object_id.endswith(".json") else object_id
|
|
210
|
+
storage_bucket = bucket or os.environ.get(
|
|
211
|
+
"DELIMIT_ATTESTATION_BUCKET",
|
|
212
|
+
"attestations",
|
|
213
|
+
)
|
|
214
|
+
return _http_upload_storage(storage_bucket, object_path, path.read_bytes())
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.debug(f"Attestation bundle sync failed: {e}")
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
162
220
|
def sync_event(event: dict):
|
|
163
221
|
"""Sync an event to Supabase (fire-and-forget).
|
|
164
222
|
|
package/gateway/ai/swarm.py
CHANGED
|
@@ -964,6 +964,8 @@ def hot_reload(reason: str = "update") -> Dict[str, Any]:
|
|
|
964
964
|
"ai.reddit_scanner",
|
|
965
965
|
"ai.ledger_manager",
|
|
966
966
|
"ai.backends.repo_bridge",
|
|
967
|
+
"ai.backends.governance_bridge",
|
|
968
|
+
"backends.governance_bridge",
|
|
967
969
|
"ai.backends.tools_infra",
|
|
968
970
|
"backends.repo_bridge", # alias used by server.py lazy imports
|
|
969
971
|
"ai.social_target", # depends on ai.social
|
package/gateway/ai/tui.py
CHANGED
|
@@ -24,6 +24,7 @@ from textual.binding import Binding
|
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
26
|
import subprocess
|
|
27
|
+
import sqlite3
|
|
27
28
|
import time
|
|
28
29
|
from datetime import datetime, timezone
|
|
29
30
|
from pathlib import Path
|
|
@@ -136,6 +137,51 @@ def _load_daemon_state() -> Dict[str, Any]:
|
|
|
136
137
|
return {"status": "unknown"}
|
|
137
138
|
|
|
138
139
|
|
|
140
|
+
|
|
141
|
+
def _load_pending_approvals(limit: int = 20) -> List[Dict]:
|
|
142
|
+
"""Load pending drafts from the SQLite registry (LED-1129)."""
|
|
143
|
+
db_path = DELIMIT_HOME / "drafts.db"
|
|
144
|
+
if not db_path.exists():
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
approvals = []
|
|
148
|
+
try:
|
|
149
|
+
# Connect read-only to avoid locking issues with the daemon
|
|
150
|
+
with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn:
|
|
151
|
+
conn.row_factory = sqlite3.Row
|
|
152
|
+
cursor = conn.execute(
|
|
153
|
+
"SELECT * FROM drafts WHERE status IN ('pending', 'waiting_for_approval') "
|
|
154
|
+
"ORDER BY created_at DESC LIMIT ?",
|
|
155
|
+
(limit,)
|
|
156
|
+
)
|
|
157
|
+
for row in cursor:
|
|
158
|
+
d = dict(row)
|
|
159
|
+
# Parse target_json for a summary
|
|
160
|
+
try:
|
|
161
|
+
target = json.loads(d.get("target_json", "{}"))
|
|
162
|
+
d["target_summary"] = target.get("repo", target.get("venture", "unknown"))
|
|
163
|
+
if "issue" in target:
|
|
164
|
+
d["target_summary"] += f" #{target['issue']}"
|
|
165
|
+
except:
|
|
166
|
+
d["target_summary"] = "unknown"
|
|
167
|
+
|
|
168
|
+
# Calculate age
|
|
169
|
+
created_at = d.get("created_at", 0)
|
|
170
|
+
if created_at:
|
|
171
|
+
diff = int(time.time()) - created_at
|
|
172
|
+
if diff < 60: d["age_str"] = f"{diff}s"
|
|
173
|
+
elif diff < 3600: d["age_str"] = f"{diff//60}m"
|
|
174
|
+
elif diff < 86400: d["age_str"] = f"{diff//3600}h"
|
|
175
|
+
else: d["age_str"] = f"{diff//86400}d"
|
|
176
|
+
else:
|
|
177
|
+
d["age_str"] = "n/a"
|
|
178
|
+
|
|
179
|
+
approvals.append(d)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
return approvals
|
|
183
|
+
|
|
184
|
+
|
|
139
185
|
def _load_process_list() -> List[Dict[str, Any]]:
|
|
140
186
|
"""Build a list of known daemons with status from state files and alerts."""
|
|
141
187
|
processes = []
|
|
@@ -488,6 +534,33 @@ def _channel_color(channel: str) -> str:
|
|
|
488
534
|
return colors.get(channel, "white")
|
|
489
535
|
|
|
490
536
|
|
|
537
|
+
|
|
538
|
+
class ApprovalsPanel(Static):
|
|
539
|
+
"""Pending approvals view -- shows items from drafts.db."""
|
|
540
|
+
|
|
541
|
+
def compose(self) -> ComposeResult:
|
|
542
|
+
yield DataTable(id="approvals-table")
|
|
543
|
+
|
|
544
|
+
def on_mount(self) -> None:
|
|
545
|
+
table = self.query_one("#approvals-table", DataTable)
|
|
546
|
+
table.add_columns("ID", "Kind", "Target", "Status", "Age")
|
|
547
|
+
table.cursor_type = "row"
|
|
548
|
+
self._refresh_data()
|
|
549
|
+
self.set_interval(10, self._refresh_data)
|
|
550
|
+
|
|
551
|
+
def _refresh_data(self) -> None:
|
|
552
|
+
table = self.query_one("#approvals-table", DataTable)
|
|
553
|
+
table.clear()
|
|
554
|
+
for item in _load_pending_approvals(25):
|
|
555
|
+
table.add_row(
|
|
556
|
+
item.get("draft_id", "")[:12],
|
|
557
|
+
item.get("draft_kind", ""),
|
|
558
|
+
item.get("target_summary", "")[:40],
|
|
559
|
+
item.get("status", ""),
|
|
560
|
+
item.get("age_str", ""),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
491
564
|
class FilesystemPanel(Static):
|
|
492
565
|
"""Filesystem browser -- navigate .delimit/ directory tree."""
|
|
493
566
|
|
|
@@ -774,6 +847,7 @@ class DelimitOS(App):
|
|
|
774
847
|
BINDINGS = [
|
|
775
848
|
Binding("q", "quit", "Quit", key_display="Q"),
|
|
776
849
|
Binding("l", "focus_ledger", "Ledger", key_display="L"),
|
|
850
|
+
Binding("a", "focus_approvals", "Approvals", key_display="A"),
|
|
777
851
|
Binding("s", "focus_swarm", "Swarm", key_display="S"),
|
|
778
852
|
Binding("n", "focus_notifications", "Notifications", key_display="N"),
|
|
779
853
|
Binding("f", "focus_files", "Files", key_display="F"),
|
|
@@ -788,6 +862,8 @@ class DelimitOS(App):
|
|
|
788
862
|
def compose(self) -> ComposeResult:
|
|
789
863
|
yield GovernanceBar()
|
|
790
864
|
with TabbedContent():
|
|
865
|
+
with TabPane("Approvals", id="tab-approvals"):
|
|
866
|
+
yield ApprovalsPanel()
|
|
791
867
|
with TabPane("Ledger", id="tab-ledger"):
|
|
792
868
|
yield LedgerPanel()
|
|
793
869
|
with TabPane("Swarm", id="tab-swarm"):
|
|
@@ -806,6 +882,9 @@ class DelimitOS(App):
|
|
|
806
882
|
|
|
807
883
|
# -- Tab focus actions -----------------------------------------------------
|
|
808
884
|
|
|
885
|
+
def action_focus_approvals(self) -> None:
|
|
886
|
+
self.query_one(TabbedContent).active = "tab-approvals"
|
|
887
|
+
|
|
809
888
|
def action_focus_ledger(self) -> None:
|
|
810
889
|
self.query_one(TabbedContent).active = "tab-ledger"
|
|
811
890
|
|
|
@@ -831,6 +910,8 @@ class DelimitOS(App):
|
|
|
831
910
|
|
|
832
911
|
def action_refresh(self) -> None:
|
|
833
912
|
"""Refresh all panels."""
|
|
913
|
+
for panel in self.query(ApprovalsPanel):
|
|
914
|
+
panel._refresh_data()
|
|
834
915
|
for panel in self.query(LedgerPanel):
|
|
835
916
|
panel._refresh_data()
|
|
836
917
|
for panel in self.query(SwarmPanel):
|