Commit ·
b4efd7d
1
Parent(s): 081db53
feat: BLK-GUI-198 spec — NeuroGraph GUI 2.0 Phase 2 Dashboard tab
Browse files- specs/BLK-GUI-198.json +230 -0
specs/BLK-GUI-198.json
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"spec_version": "1.0.0",
|
| 3 |
+
"block": {
|
| 4 |
+
"id": "BLK-GUI-198",
|
| 5 |
+
"name": "NeuroGraph GUI 2.0 — Phase 2: Ecosystem Dashboard Tab",
|
| 6 |
+
"agent": "QB",
|
| 7 |
+
"scope": "NeuroGraph — add Dashboard tab to neurograph_gui.py with 5 auto-refreshing ecosystem panels",
|
| 8 |
+
"acceptance_criteria": [
|
| 9 |
+
"EcosystemPoller class present in neurograph_gui.py",
|
| 10 |
+
"self.ecosystem_poller initialized in NeuroGraphGUI.__init__",
|
| 11 |
+
"_build_dashboard_tab() called in _build_tabs() after _build_modules_tab()",
|
| 12 |
+
"Dashboard tab renders with 5 LabelFrame panels: Syl/NeuroGraph, TID Routing, Bunyan Narrative, Elmer Health, THC Repairs",
|
| 13 |
+
"_refresh_dashboard(), _apply_dashboard_panel(), _format_panel_data(), _start_dashboard_autorefresh() methods present",
|
| 14 |
+
"python3 -m py_compile neurograph_work/neurograph_gui.py passes with exit code 0",
|
| 15 |
+
"Changes committed and pushed to NeuroGraph repo"
|
| 16 |
+
],
|
| 17 |
+
"depends_on": [
|
| 18 |
+
"BLK-GUI-197"
|
| 19 |
+
]
|
| 20 |
+
},
|
| 21 |
+
"snap_interface": {
|
| 22 |
+
"inputs": [
|
| 23 |
+
{
|
| 24 |
+
"name": "neurograph_gui.py",
|
| 25 |
+
"type": "file",
|
| 26 |
+
"source_block": "BLK-GUI-197",
|
| 27 |
+
"path": "neurograph_work/neurograph_gui.py"
|
| 28 |
+
}
|
| 29 |
+
],
|
| 30 |
+
"outputs": [
|
| 31 |
+
{
|
| 32 |
+
"name": "neurograph_gui.py",
|
| 33 |
+
"type": "file",
|
| 34 |
+
"path": "neurograph_work/neurograph_gui.py",
|
| 35 |
+
"contract": "Dashboard tab added; EcosystemPoller class present; 5 panels wired; py_compile passes"
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
"constraints": {
|
| 40 |
+
"never": [
|
| 41 |
+
"Do not modify any vendored file: ng_lite.py, ng_peer_bridge.py, ng_tract_bridge.py, ng_ecosystem.py, openclaw_adapter.py, ng_autonomic.py, ng_embed.py",
|
| 42 |
+
"Do not instantiate NeuroGraphMemory or any module class directly — HTTP endpoints or file reads only (Law 1)",
|
| 43 |
+
"Do not touch data/checkpoints/ or any msgpack file"
|
| 44 |
+
],
|
| 45 |
+
"anti_drift": [
|
| 46 |
+
"All new classes go inside neurograph_gui.py, not in new files",
|
| 47 |
+
"EcosystemPoller uses threading.Thread with daemon=True for each panel fetch",
|
| 48 |
+
"In-flight guard per panel: skip spawn if _in_flight[panel] is True",
|
| 49 |
+
"All panel fetches degrade gracefully with offline:True dict on any error"
|
| 50 |
+
],
|
| 51 |
+
"tool_allowlist": [
|
| 52 |
+
"write_file",
|
| 53 |
+
"edit_file",
|
| 54 |
+
"shell_execute",
|
| 55 |
+
"read_file"
|
| 56 |
+
],
|
| 57 |
+
"shell_allowlist": [
|
| 58 |
+
"rm",
|
| 59 |
+
"echo"
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
"steps": [
|
| 63 |
+
{
|
| 64 |
+
"id": "check_token",
|
| 65 |
+
"type": "action",
|
| 66 |
+
"description": "Verify GITHUB_TOKEN is set",
|
| 67 |
+
"tool": "shell_execute",
|
| 68 |
+
"params": {
|
| 69 |
+
"command": "python3 -c \"import os; t=os.environ.get('GITHUB_TOKEN',''); print('TOKEN_OK' if t else 'TOKEN_MISSING')\""
|
| 70 |
+
},
|
| 71 |
+
"validation": {
|
| 72 |
+
"checks": [
|
| 73 |
+
{
|
| 74 |
+
"operator": "contains",
|
| 75 |
+
"value": "TOKEN_OK"
|
| 76 |
+
}
|
| 77 |
+
]
|
| 78 |
+
},
|
| 79 |
+
"on_failure": "abort"
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"id": "cleanup_workspace",
|
| 83 |
+
"type": "action",
|
| 84 |
+
"description": "Remove stale partial clone from any previous run",
|
| 85 |
+
"tool": "shell_execute",
|
| 86 |
+
"params": {
|
| 87 |
+
"command": "rm -rf neurograph_work && echo CLEANED"
|
| 88 |
+
},
|
| 89 |
+
"validation": {
|
| 90 |
+
"checks": [
|
| 91 |
+
{
|
| 92 |
+
"operator": "contains",
|
| 93 |
+
"value": "CLEANED"
|
| 94 |
+
}
|
| 95 |
+
]
|
| 96 |
+
},
|
| 97 |
+
"on_failure": "abort"
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"id": "clone_repo",
|
| 101 |
+
"type": "action",
|
| 102 |
+
"description": "Clone NeuroGraph repo (includes Phase 1 changes from BLK-GUI-197)",
|
| 103 |
+
"tool": "shell_execute",
|
| 104 |
+
"params": {
|
| 105 |
+
"command": "git clone --depth=1 https://${GITHUB_TOKEN}@github.com/greatnorthernfishguy-hub/NeuroGraph.git neurograph_work && echo CLONE_OK"
|
| 106 |
+
},
|
| 107 |
+
"validation": {
|
| 108 |
+
"checks": [
|
| 109 |
+
{
|
| 110 |
+
"operator": "contains",
|
| 111 |
+
"value": "CLONE_OK"
|
| 112 |
+
}
|
| 113 |
+
]
|
| 114 |
+
},
|
| 115 |
+
"on_failure": "abort"
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"id": "add_ecosystem_poller_class",
|
| 119 |
+
"type": "action",
|
| 120 |
+
"description": "Insert EcosystemPoller class before the IngestionManager section separator",
|
| 121 |
+
"tool": "edit_file",
|
| 122 |
+
"params": {
|
| 123 |
+
"path": "neurograph_work/neurograph_gui.py",
|
| 124 |
+
"old_text": "\n\n# ---------------------------------------------------------------------------\n# 5. IngestionManager\n# ---------------------------------------------------------------------------",
|
| 125 |
+
"new_text": "\n\n# ---------------------------------------------------------------------------\n# 4b. EcosystemPoller\n# ---------------------------------------------------------------------------\n\n\nclass EcosystemPoller:\n \"\"\"Fetches live data for the 5 Dashboard panels.\n\n Each panel runs its fetch on a daemon thread. An in-flight guard\n per panel prevents overlapping fetches when auto-refresh fires\n faster than the fetch completes.\n \"\"\"\n\n PANELS = (\"neurograph\", \"bunyan\", \"tid\", \"elmer\", \"thc\")\n\n def __init__(self, on_panel_ready: Callable[[str, Dict[str, Any]], None]) -> None:\n self._on_panel_ready = on_panel_ready\n self._in_flight: Dict[str, bool] = {p: False for p in self.PANELS}\n\n def fetch_all(self) -> None:\n for panel in self.PANELS:\n self._fetch_panel(panel)\n\n def _fetch_panel(self, panel: str) -> None:\n if self._in_flight.get(panel):\n return\n self._in_flight[panel] = True\n threading.Thread(target=self._worker, args=(panel,), daemon=True).start()\n\n def _worker(self, panel: str) -> None:\n try:\n data = self._fetch(panel)\n except Exception as exc:\n data = {\"offline\": True, \"reason\": str(exc)}\n finally:\n self._in_flight[panel] = False\n self._on_panel_ready(panel, data)\n\n def _fetch(self, panel: str) -> Dict[str, Any]:\n if panel == \"neurograph\":\n return self._fetch_http(\"http://127.0.0.1:8847/stats\")\n if panel == \"tid\":\n return self._fetch_http(\"http://127.0.0.1:7437/stats\")\n if panel == \"bunyan\":\n return self._fetch_bunyan()\n if panel == \"elmer\":\n return self._fetch_json(\n Path.home() / \".et_modules\" / \"elmer\" / \"ng_lite_state.json\"\n )\n if panel == \"thc\":\n return self._fetch_json(\n Path.home() / \".et_modules\" / \"healing_collective\" / \"detection_calibrator.json\"\n )\n return {\"offline\": True, \"reason\": \"unknown panel\"}\n\n def _fetch_http(self, url: str) -> Dict[str, Any]:\n import urllib.request\n try:\n with urllib.request.urlopen(url, timeout=5) as resp:\n return json.loads(resp.read().decode())\n except Exception as exc:\n return {\"offline\": True, \"reason\": str(exc)}\n\n def _fetch_bunyan(self) -> Dict[str, Any]:\n import glob as _glob\n pattern = str(Path.home() / \".et_modules\" / \"shared_learning\" / \"bunyan*.jsonl\")\n files = sorted(_glob.glob(pattern))\n if not files:\n return {\"offline\": True, \"reason\": \"no bunyan files\"}\n lines: List[str] = []\n for fpath in reversed(files):\n try:\n with open(fpath, \"r\", encoding=\"utf-8\") as fh:\n chunk = [ln.strip() for ln in fh.readlines() if ln.strip()]\n lines = chunk + lines\n if len(lines) >= 5:\n break\n except OSError:\n continue\n events: List[Any] = []\n for ln in lines[-5:]:\n try:\n events.append(json.loads(ln))\n except json.JSONDecodeError:\n events.append({\"raw\": ln})\n return {\"events\": events}\n\n def _fetch_json(self, path: Path) -> Dict[str, Any]:\n try:\n with open(path, \"r\", encoding=\"utf-8\") as fh:\n return json.load(fh)\n except FileNotFoundError:\n return {\"offline\": True, \"reason\": \"file not found\"}\n except Exception as exc:\n return {\"offline\": True, \"reason\": str(exc)}\n\n\n\n# ---------------------------------------------------------------------------\n# 5. IngestionManager\n# ---------------------------------------------------------------------------"
|
| 126 |
+
},
|
| 127 |
+
"validation": {
|
| 128 |
+
"checks": [
|
| 129 |
+
{
|
| 130 |
+
"operator": "result_is_string"
|
| 131 |
+
}
|
| 132 |
+
]
|
| 133 |
+
},
|
| 134 |
+
"on_failure": "abort"
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"id": "add_dashboard_tab_call",
|
| 138 |
+
"type": "action",
|
| 139 |
+
"description": "Wire _build_dashboard_tab() into _build_tabs() after _build_modules_tab()",
|
| 140 |
+
"tool": "edit_file",
|
| 141 |
+
"params": {
|
| 142 |
+
"path": "neurograph_work/neurograph_gui.py",
|
| 143 |
+
"old_text": " self._build_modules_tab()\n self._build_logs_tab()",
|
| 144 |
+
"new_text": " self._build_modules_tab()\n self._build_dashboard_tab()\n self._build_logs_tab()"
|
| 145 |
+
},
|
| 146 |
+
"validation": {
|
| 147 |
+
"checks": [
|
| 148 |
+
{
|
| 149 |
+
"operator": "result_is_string"
|
| 150 |
+
}
|
| 151 |
+
]
|
| 152 |
+
},
|
| 153 |
+
"on_failure": "abort"
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"id": "init_ecosystem_poller",
|
| 157 |
+
"type": "action",
|
| 158 |
+
"description": "Initialize self.ecosystem_poller in NeuroGraphGUI.__init__ before file_watcher",
|
| 159 |
+
"tool": "edit_file",
|
| 160 |
+
"params": {
|
| 161 |
+
"path": "neurograph_work/neurograph_gui.py",
|
| 162 |
+
"old_text": " self.file_watcher: Optional[FileWatcher] = None\n self._ingest_history: List[Dict[str, Any]] = []",
|
| 163 |
+
"new_text": " self.ecosystem_poller = EcosystemPoller(\n on_panel_ready=lambda panel, data: self.msg_queue.put(\n self._apply_dashboard_panel, panel, data\n ),\n )\n self.file_watcher: Optional[FileWatcher] = None\n self._ingest_history: List[Dict[str, Any]] = []"
|
| 164 |
+
},
|
| 165 |
+
"validation": {
|
| 166 |
+
"checks": [
|
| 167 |
+
{
|
| 168 |
+
"operator": "result_is_string"
|
| 169 |
+
}
|
| 170 |
+
]
|
| 171 |
+
},
|
| 172 |
+
"on_failure": "abort"
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"id": "add_dashboard_methods",
|
| 176 |
+
"type": "action",
|
| 177 |
+
"description": "Add all Dashboard tab methods to NeuroGraphGUI before Settings Dialog section",
|
| 178 |
+
"tool": "edit_file",
|
| 179 |
+
"params": {
|
| 180 |
+
"path": "neurograph_work/neurograph_gui.py",
|
| 181 |
+
"old_text": " # ---- Settings Dialog ----",
|
| 182 |
+
"new_text": " # ---- Dashboard Tab ----\n\n def _build_dashboard_tab(self) -> None:\n frame = ttk.Frame(self.notebook, padding=10)\n self.notebook.add(frame, text=\" Dashboard \")\n\n hdr = ttk.Frame(frame)\n hdr.pack(fill=tk.X, pady=(0, 8))\n ttk.Label(hdr, text=\"Ecosystem Dashboard\", font=(\"\", 13, \"bold\")).pack(side=tk.LEFT)\n ttk.Button(hdr, text=\"Refresh Now\", command=self._refresh_dashboard).pack(\n side=tk.RIGHT, padx=(4, 0)\n )\n ttk.Label(hdr, text=\"Interval (s):\").pack(side=tk.RIGHT)\n self._dashboard_interval_var = tk.StringVar(\n value=str(self.config.get(\"dashboard_refresh_seconds\", 30))\n )\n ttk.Entry(\n hdr, textvariable=self._dashboard_interval_var, width=5\n ).pack(side=tk.RIGHT, padx=(0, 4))\n ttk.Button(\n hdr, text=\"Apply\", command=self._start_dashboard_autorefresh\n ).pack(side=tk.RIGHT, padx=(0, 2))\n\n grid = ttk.Frame(frame)\n grid.pack(fill=tk.BOTH, expand=True)\n grid.columnconfigure(0, weight=1)\n grid.columnconfigure(1, weight=1)\n\n panel_defs = [\n (\"neurograph\", \"Syl / NeuroGraph\", 0, 0),\n (\"tid\", \"TID Routing\", 0, 1),\n (\"bunyan\", \"Bunyan Narrative\", 1, 0),\n (\"elmer\", \"Elmer Health\", 1, 1),\n (\"thc\", \"THC Repairs\", 2, 0),\n ]\n self._dashboard_texts: Dict[str, tk.Text] = {}\n for panel_id, title, row, col in panel_defs:\n lf = ttk.LabelFrame(grid, text=title, padding=6)\n lf.grid(row=row, column=col, sticky=tk.NSEW, padx=4, pady=4)\n grid.rowconfigure(row, weight=1)\n txt = tk.Text(\n lf, height=6, wrap=tk.WORD, state=tk.DISABLED, font=(\"Courier\", 9)\n )\n txt.pack(fill=tk.BOTH, expand=True)\n self._dashboard_texts[panel_id] = txt\n\n self._dashboard_after_id: Optional[str] = None\n self._refresh_dashboard()\n self._start_dashboard_autorefresh()\n\n def _refresh_dashboard(self) -> None:\n self.ecosystem_poller.fetch_all()\n\n def _apply_dashboard_panel(self, panel: str, data: Dict[str, Any]) -> None:\n txt = self._dashboard_texts.get(panel)\n if txt is None:\n return\n txt.config(state=tk.NORMAL)\n txt.delete(\"1.0\", tk.END)\n if data.get(\"offline\"):\n txt.insert(tk.END, f\"Offline \\u2014 {data.get('reason', 'unavailable')}\")\n else:\n txt.insert(tk.END, self._format_panel_data(panel, data))\n txt.config(state=tk.DISABLED)\n\n def _format_panel_data(self, panel: str, data: Dict[str, Any]) -> str:\n if panel == \"neurograph\":\n lines = []\n for k in (\"total_nodes\", \"total_synapses\", \"firing_rate\",\n \"active_nodes\", \"avg_voltage\"):\n if k in data:\n lines.append(f\"{k}: {data[k]}\")\n return \"\\n\".join(lines) if lines else json.dumps(data, indent=2)[:400]\n if panel == \"tid\":\n lines = []\n models = data.get(\"models\") or data.get(\"routing\") or {}\n if isinstance(models, dict):\n ranked = sorted(\n models.items(),\n key=lambda kv: kv[1].get(\"usage\", 0) if isinstance(kv[1], dict) else 0,\n reverse=True,\n )[:3]\n for name, stats in ranked:\n usage = stats.get(\"usage\", \"?\") if isinstance(stats, dict) else \"?\"\n quality = stats.get(\"quality\", \"?\") if isinstance(stats, dict) else \"?\"\n lines.append(f\"{name}: usage={usage} quality={quality}\")\n return \"\\n\".join(lines) if lines else json.dumps(data, indent=2)[:400]\n if panel == \"bunyan\":\n events = data.get(\"events\", [])\n lines = []\n for ev in events[-5:]:\n if isinstance(ev, dict):\n msg = ev.get(\"message\") or ev.get(\"event\") or ev.get(\"raw\") or str(ev)\n lines.append(str(msg)[:120])\n else:\n lines.append(str(ev)[:120])\n return \"\\n\".join(lines) if lines else \"No events\"\n if panel == \"elmer\":\n lines = []\n for k in (\"health_score\", \"maintenance_target\", \"last_action\",\n \"coherence\", \"total_nodes\"):\n if k in data:\n lines.append(f\"{k}: {data[k]}\")\n return \"\\n\".join(lines) if lines else json.dumps(data, indent=2)[:400]\n if panel == \"thc\":\n repairs = (\n data.get(\"repair_events\")\n or data.get(\"events\")\n or data.get(\"calibration_events\")\n or []\n )\n if isinstance(repairs, list):\n lines = []\n for ev in repairs[-5:]:\n if isinstance(ev, dict):\n lines.append(\n str(ev.get(\"event\") or ev.get(\"action\") or ev)[:120]\n )\n else:\n lines.append(str(ev)[:120])\n return \"\\n\".join(lines) if lines else json.dumps(data, indent=2)[:400]\n return json.dumps(data, indent=2)[:400]\n return json.dumps(data, indent=2)[:400]\n\n def _start_dashboard_autorefresh(self) -> None:\n if self._dashboard_after_id is not None:\n self.root.after_cancel(self._dashboard_after_id)\n try:\n interval_s = max(5, int(self._dashboard_interval_var.get()))\n except (ValueError, AttributeError):\n interval_s = 30\n self.config.set(\"dashboard_refresh_seconds\", interval_s)\n\n def _tick() -> None:\n self._refresh_dashboard()\n self._dashboard_after_id = self.root.after(interval_s * 1000, _tick)\n\n self._dashboard_after_id = self.root.after(interval_s * 1000, _tick)\n\n # ---- Settings Dialog ----"
|
| 183 |
+
},
|
| 184 |
+
"validation": {
|
| 185 |
+
"checks": [
|
| 186 |
+
{
|
| 187 |
+
"operator": "result_is_string"
|
| 188 |
+
}
|
| 189 |
+
]
|
| 190 |
+
},
|
| 191 |
+
"on_failure": "abort"
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"id": "syntax_check",
|
| 195 |
+
"type": "action",
|
| 196 |
+
"description": "Verify neurograph_gui.py has no syntax errors",
|
| 197 |
+
"tool": "shell_execute",
|
| 198 |
+
"params": {
|
| 199 |
+
"command": "python3 -m py_compile neurograph_work/neurograph_gui.py && echo SYNTAX_OK"
|
| 200 |
+
},
|
| 201 |
+
"validation": {
|
| 202 |
+
"checks": [
|
| 203 |
+
{
|
| 204 |
+
"operator": "contains",
|
| 205 |
+
"value": "SYNTAX_OK"
|
| 206 |
+
}
|
| 207 |
+
]
|
| 208 |
+
},
|
| 209 |
+
"on_failure": "abort"
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"id": "commit_and_push",
|
| 213 |
+
"type": "action",
|
| 214 |
+
"description": "Commit Phase 2 changes and push to NeuroGraph main",
|
| 215 |
+
"tool": "shell_execute",
|
| 216 |
+
"params": {
|
| 217 |
+
"command": "git -C neurograph_work config user.email \"codemine@et-systems.ai\" && git -C neurograph_work config user.name \"Codemine\" && git -C neurograph_work add neurograph_gui.py && git -C neurograph_work diff --cached --quiet && echo ALREADY_COMMITTED || (git -C neurograph_work commit -m \"feat: GUI 2.0 Phase 2 — Ecosystem Dashboard tab (BLK-GUI-198)\" && git -C neurograph_work push origin HEAD && echo PUSH_OK)"
|
| 218 |
+
},
|
| 219 |
+
"validation": {
|
| 220 |
+
"checks": [
|
| 221 |
+
{
|
| 222 |
+
"operator": "contains",
|
| 223 |
+
"value": "COMMIT"
|
| 224 |
+
}
|
| 225 |
+
]
|
| 226 |
+
},
|
| 227 |
+
"on_failure": "abort"
|
| 228 |
+
}
|
| 229 |
+
]
|
| 230 |
+
}
|