delimit-cli 4.7.3 → 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.
@@ -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):
@@ -100,89 +100,117 @@ class CIFormatter:
100
100
  decision = result.get("decision", "unknown")
101
101
  violations = result.get("violations", [])
102
102
  summary = result.get("summary", {})
103
- semver = result.get("semver") # optional dict from semver_classifier
103
+ semver = result.get("semver")
104
+ all_changes = result.get("all_changes", [])
105
+ migration = result.get("migration")
104
106
 
105
- # Header include semver badge when available
106
- bump_badge = ""
107
- if semver:
108
- bump = semver.get("bump", "unknown")
109
- bump_badge = {"major": " `MAJOR`", "minor": " `MINOR`", "patch": " `PATCH`", "none": ""}.get(bump, "")
107
+ bc = summary.get("breaking_changes", 0)
108
+ total = summary.get("total_changes", 0)
109
+ additive = total - bc
110
110
 
111
- if decision == "fail":
112
- lines.append(f"## 🚨 Delimit: Breaking Changes{bump_badge}\n")
113
- elif decision == "warn":
114
- lines.append(f"## ⚠️ Delimit: Potential Issues{bump_badge}\n")
111
+ errors = [v for v in violations if v.get("severity") == "error"]
112
+ warnings = [v for v in violations if v.get("severity") == "warning"]
113
+
114
+ if bc == 0:
115
+ # ── GREEN PATH ──
116
+ bump_label = "NONE"
117
+ if semver:
118
+ bump_label = semver.get("bump", "none").upper()
119
+ lines.append("\U0001f6e1\ufe0f **Governance Passed**\n")
120
+ if total > 0:
121
+ lines.append(
122
+ f"> **No breaking API changes detected.** "
123
+ f"{additive} additive change{'s' if additive != 1 else ''} "
124
+ f"found \u2014 Semver: **{bump_label}**\n"
125
+ )
126
+ else:
127
+ lines.append("> **No breaking API changes detected.**\n")
128
+
129
+ # Additive changes
130
+ safe_changes = [c for c in all_changes if not c.get("is_breaking")]
131
+ if safe_changes and len(safe_changes) <= 15:
132
+ lines.append("<details>")
133
+ lines.append(f"<summary>\u2705 New additions ({len(safe_changes)})</summary>\n")
134
+ for c in safe_changes:
135
+ lines.append(f"- `{c.get('path', '')}` \u2014 {c.get('message', '')}")
136
+ lines.append("</details>\n")
115
137
  else:
116
- lines.append(f"## API Changes Look Good{bump_badge}\n")
138
+ # ── RED PATH ──
139
+ lines.append("\U0001f6e1\ufe0f **Breaking API Changes Detected**\n")
117
140
 
118
- # Semver + summary table
119
- lines.append("| Metric | Value |")
120
- lines.append("|--------|-------|")
121
- if semver:
122
- lines.append(f"| Semver bump | `{semver.get('bump', 'unknown')}` |")
123
- if semver.get("next_version"):
124
- lines.append(f"| Next version | `{semver['next_version']}` |")
125
- lines.append(f"| Total changes | {summary.get('total_changes', 0)} |")
126
- lines.append(f"| Breaking | {summary.get('breaking_changes', 0)} |")
127
- if summary.get("violations", 0) > 0:
128
- lines.append(f"| Policy violations | {summary['violations']} |")
129
- lines.append("")
141
+ # Summary card
142
+ parts = [f"\U0001f534 **{bc} breaking change{'s' if bc != 1 else ''}**"]
143
+ parts.append("Semver: **MAJOR**")
144
+ if semver and semver.get("next_version"):
145
+ parts.append(f"Next: `{semver['next_version']}`")
146
+ separator = " \u00b7 "
147
+ lines.append(f"> {separator.join(parts)}\n")
130
148
 
131
- # Violations table
132
- if violations:
133
- errors = [v for v in violations if v.get("severity") == "error"]
134
- warnings = [v for v in violations if v.get("severity") == "warning"]
149
+ # Stats table
150
+ lines.append("| | Count |")
151
+ lines.append("|---|---|")
152
+ lines.append(f"| Total changes | {total} |")
153
+ lines.append(f"| Breaking | {bc} |")
154
+ lines.append(f"| Additive | {additive} |")
155
+ if len(warnings) > 0:
156
+ lines.append(f"| Warnings | {len(warnings)} |")
157
+ if summary.get("violations", 0) > 0:
158
+ lines.append(f"| Policy violations | {summary['violations']} |")
159
+ lines.append("")
135
160
 
161
+ # Violations table
136
162
  if errors or warnings:
137
- lines.append("### Violations\n")
138
- lines.append("| Severity | Rule | Description | Location |")
139
- lines.append("|----------|------|-------------|----------|")
163
+ lines.append("### Breaking Changes\n")
164
+ lines.append("| Severity | Change | Location |")
165
+ lines.append("|----------|--------|----------|")
140
166
 
141
167
  for v in errors:
142
- rule = v.get("name", v.get("rule", "Unknown"))
143
168
  desc = v.get("message", "Unknown violation")
144
169
  location = v.get("path", "-")
145
- lines.append(f"| 🔴 **Error** | {rule} | {desc} | `{location}` |")
170
+ lines.append(f"| \U0001f534 Critical | {desc} | `{location}` |")
146
171
 
147
172
  for v in warnings:
148
- rule = v.get("name", v.get("rule", "Unknown"))
149
173
  desc = v.get("message", "Unknown warning")
150
174
  location = v.get("path", "-")
151
- lines.append(f"| 🟡 Warning | {rule} | {desc} | `{location}` |")
175
+ lines.append(f"| \U0001f7e1 Warning | {desc} | `{location}` |")
152
176
 
153
177
  lines.append("")
154
178
 
155
- # Detailed changes
156
- all_changes = result.get("all_changes", [])
157
- if all_changes and len(all_changes) <= 10:
158
- lines.append("<details>")
159
- lines.append("<summary>All changes</summary>\n")
160
- lines.append("```")
161
- for change in all_changes:
162
- breaking = "BREAKING" if change.get("is_breaking") else "safe"
163
- lines.append(f"[{breaking}] {change.get('message', 'Unknown change')}")
164
- lines.append("```")
165
- lines.append("</details>\n")
179
+ # Migration guidance
180
+ if migration and decision == "fail":
181
+ lines.append("<details>")
182
+ lines.append("<summary>\U0001f4cb Migration guide</summary>\n")
183
+ lines.append(migration)
184
+ lines.append("\n</details>\n")
185
+ elif errors and decision == "fail":
186
+ lines.append("<details>")
187
+ lines.append("<summary>\U0001f4cb Migration guide</summary>\n")
188
+ lines.append("1. **Restore removed endpoints** \u2014 deprecate before removing")
189
+ lines.append("2. **Make parameters optional** \u2014 don't add required params")
190
+ lines.append("3. **Use versioning** \u2014 create `/v2/` for breaking changes")
191
+ lines.append("4. **Gradual migration** \u2014 provide guides and time")
192
+ lines.append("\n</details>\n")
166
193
 
167
- # Migration guidance (from explainer) when available
168
- migration = result.get("migration")
169
- if migration and decision == "fail":
170
- lines.append("<details>")
171
- lines.append("<summary>Migration guide</summary>\n")
172
- lines.append(migration)
173
- lines.append("\n</details>\n")
194
+ # Additive changes
195
+ safe_changes = [c for c in all_changes if not c.get("is_breaking")]
196
+ if safe_changes and len(safe_changes) <= 15:
197
+ lines.append("<details>")
198
+ lines.append(f"<summary>\u2705 New additions ({len(safe_changes)})</summary>\n")
199
+ for c in safe_changes:
200
+ lines.append(f"- `{c.get('path', '')}` \u2014 {c.get('message', '')}")
201
+ lines.append("</details>\n")
174
202
 
175
- # Remediation
176
- if violations and decision == "fail" and not migration:
177
- lines.append("### 💡 How to Fix\n")
178
- lines.append("1. **Restore removed endpoints** — deprecate before removing")
179
- lines.append("2. **Make parameters optional** — don't add required params")
180
- lines.append("3. **Use versioning** — create `/v2/` for breaking changes")
181
- lines.append("4. **Gradual migration** — provide guides and time")
182
- lines.append("")
203
+ lines.append("> **Fix locally:** `npx delimit-cli lint`\n")
183
204
 
184
205
  lines.append("---")
185
- lines.append("*Generated by [Delimit](https://github.com/delimit-ai/delimit) — ESLint for API contracts*")
206
+ lines.append(
207
+ "Powered by [Delimit](https://delimit.ai) \u00b7 "
208
+ "[Docs](https://delimit.ai/docs) \u00b7 "
209
+ "[Install](https://github.com/marketplace/actions/delimit-api-governance)"
210
+ )
211
+
212
+ if bc == 0:
213
+ lines.append("\nKeep Building.")
186
214
 
187
215
  return "\n".join(lines)
188
216