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.
@@ -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. Prefer the soul's own
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 under SOULS_BASE_DIR and
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
- lines.append("")
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
- # Current task
347
- lines.append("--- ACTIVE TASK ---")
348
- if soul.active_task:
349
- lines.append(f" {soul.active_task}")
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("--- KEY DECISIONS ---")
379
+ lines.append("DECISIONS MADE:")
358
380
  for d in soul.decisions:
359
- lines.append(f" - {d}")
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("--- FILES CHANGED ---")
385
+ lines.append("FILES CHANGED:")
365
386
  for f in soul.files_modified:
366
- lines.append(f" M {f}")
387
+ lines.append(f" M {f}")
367
388
  for f in soul.files_created:
368
- lines.append(f" + {f}")
389
+ lines.append(f" A {f}")
369
390
  lines.append("")
370
391
 
371
- # Context
372
392
  if soul.key_context:
373
- lines.append("--- KEY CONTEXT ---")
393
+ lines.append("KEY CONTEXT:")
374
394
  for c in soul.key_context:
375
- lines.append(f" - {c}")
395
+ lines.append(f" - {c}")
376
396
  lines.append("")
377
397
 
378
- # Blockers
379
398
  if soul.blockers:
380
- lines.append("--- BLOCKERS ---")
399
+ lines.append("BLOCKERS:")
381
400
  for b in soul.blockers:
382
- lines.append(f" ! {b}")
401
+ lines.append(f" - {b}")
383
402
  lines.append("")
384
403
 
385
- # Next steps
386
404
  if soul.next_steps:
387
- lines.append("--- NEXT STEPS ---")
388
- for i, s in enumerate(soul.next_steps, 1):
389
- lines.append(f" {i}. {s}")
405
+ lines.append("NEXT STEPS:")
406
+ for n in soul.next_steps:
407
+ lines.append(f" - {n}")
390
408
  lines.append("")
391
409
 
392
- # Git state
393
- lines.append("--- GIT STATE ---")
394
- lines.append(f" Branch: {soul.git_branch or '(unknown)'}")
395
- lines.append(f" SHA: {soul.git_sha or '(unknown)'}")
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
- """Revive the latest session soul for this project.
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
- # Search for a specific soul by ID
422
- for soul in list_souls(project_path):
423
- if soul.soul_id == soul_id:
424
- return {
425
- "status": "revived",
426
- "soul": asdict(soul),
427
- "context": _format_revival(soul),
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
- # Get latest
435
- soul = get_latest_soul(project_path)
436
- if not soul:
437
- # FIX D — cross-venture fallback. The current working directory
438
- # resolved to a project with no soul (common when reviving from a
439
- # neutral dir like /root). Rather than dead-ending at "no_souls",
440
- # surface the globally-most-recent soul from any other venture /
441
- # project so the operator still gets continuity. Clearly labeled
442
- # via `recovered_from_venture` so the caller knows it came from a
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": "no_souls",
465
- "message": f"No session souls found for {project_path}. Nothing to revive.",
466
- "hint": "Use delimit_soul_capture to save session state before ending.",
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
- "context": _format_revival(soul),
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
 
@@ -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):