delimit-cli 4.7.3 → 4.7.5

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/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,91 @@ 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
+ BINDINGS = [
542
+ Binding("y", "approve", "Approve", key_display="Y"),
543
+ Binding("n", "reject", "Reject", key_display="N"),
544
+ ]
545
+
546
+ def compose(self) -> ComposeResult:
547
+ yield DataTable(id="approvals-table")
548
+
549
+ def on_mount(self) -> None:
550
+ table = self.query_one("#approvals-table", DataTable)
551
+ table.add_columns("ID", "Kind", "Target", "Status", "Age")
552
+ table.cursor_type = "row"
553
+ self._refresh_data()
554
+ self.set_interval(10, self._refresh_data)
555
+
556
+ def _refresh_data(self) -> None:
557
+ table = self.query_one("#approvals-table", DataTable)
558
+ table.clear()
559
+ self.items = _load_pending_approvals(25)
560
+ for item in self.items:
561
+ table.add_row(
562
+ item.get("draft_id", "")[:12],
563
+ item.get("draft_kind", ""),
564
+ item.get("target_summary", "")[:40],
565
+ item.get("status", ""),
566
+ item.get("age_str", ""),
567
+ )
568
+
569
+ def action_approve(self) -> None:
570
+ self._handle_action("approve")
571
+
572
+ def action_reject(self) -> None:
573
+ self._handle_action("reject")
574
+
575
+ def _handle_action(self, action: str) -> None:
576
+ table = self.query_one("#approvals-table", DataTable)
577
+ cursor_row = table.cursor_row
578
+ if cursor_row is None or not hasattr(self, "items") or cursor_row >= len(self.items):
579
+ self.app.notify("No draft selected.", severity="warning")
580
+ return
581
+
582
+ item = self.items[cursor_row]
583
+ draft_id = item.get("draft_id")
584
+ current_status = item.get("status")
585
+
586
+ if not draft_id or not current_status:
587
+ self.app.notify("Invalid draft data selected.", severity="error")
588
+ return
589
+
590
+ from .inbox_drafts import transition
591
+ db_path = DELIMIT_HOME / "drafts.db"
592
+ new_status = "approved" if action == "approve" else "cancelled"
593
+
594
+ try:
595
+ success = transition(
596
+ draft_id,
597
+ expected=current_status,
598
+ new=new_status,
599
+ db_path=db_path
600
+ )
601
+ if success:
602
+ self.app.notify(
603
+ f"Draft {draft_id[:12]} {action}d successfully!",
604
+ title="Action Succeeded",
605
+ severity="information"
606
+ )
607
+ self._refresh_data()
608
+ else:
609
+ self.app.notify(
610
+ f"Failed to {action} draft {draft_id[:12]}: state mismatch.",
611
+ title="Action Failed",
612
+ severity="warning"
613
+ )
614
+ except Exception as e:
615
+ self.app.notify(
616
+ f"Error performing {action}: {e}",
617
+ title="System Error",
618
+ severity="error"
619
+ )
620
+
621
+
491
622
  class FilesystemPanel(Static):
492
623
  """Filesystem browser -- navigate .delimit/ directory tree."""
493
624
 
@@ -774,6 +905,7 @@ class DelimitOS(App):
774
905
  BINDINGS = [
775
906
  Binding("q", "quit", "Quit", key_display="Q"),
776
907
  Binding("l", "focus_ledger", "Ledger", key_display="L"),
908
+ Binding("a", "focus_approvals", "Approvals", key_display="A"),
777
909
  Binding("s", "focus_swarm", "Swarm", key_display="S"),
778
910
  Binding("n", "focus_notifications", "Notifications", key_display="N"),
779
911
  Binding("f", "focus_files", "Files", key_display="F"),
@@ -788,6 +920,8 @@ class DelimitOS(App):
788
920
  def compose(self) -> ComposeResult:
789
921
  yield GovernanceBar()
790
922
  with TabbedContent():
923
+ with TabPane("Approvals", id="tab-approvals"):
924
+ yield ApprovalsPanel()
791
925
  with TabPane("Ledger", id="tab-ledger"):
792
926
  yield LedgerPanel()
793
927
  with TabPane("Swarm", id="tab-swarm"):
@@ -806,6 +940,13 @@ class DelimitOS(App):
806
940
 
807
941
  # -- Tab focus actions -----------------------------------------------------
808
942
 
943
+ def action_focus_approvals(self) -> None:
944
+ self.query_one(TabbedContent).active = "tab-approvals"
945
+ try:
946
+ self.query_one("#approvals-table", DataTable).focus()
947
+ except Exception:
948
+ pass
949
+
809
950
  def action_focus_ledger(self) -> None:
810
951
  self.query_one(TabbedContent).active = "tab-ledger"
811
952
 
@@ -831,6 +972,8 @@ class DelimitOS(App):
831
972
 
832
973
  def action_refresh(self) -> None:
833
974
  """Refresh all panels."""
975
+ for panel in self.query(ApprovalsPanel):
976
+ panel._refresh_data()
834
977
  for panel in self.query(LedgerPanel):
835
978
  panel._refresh_data()
836
979
  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