diff --git a/cartridges/domains/ci-cd/cicd-squabbler-mcp/README.adoc b/cartridges/domains/ci-cd/cicd-squabbler-mcp/README.adoc new file mode 100644 index 0000000..a8b0fd4 --- /dev/null +++ b/cartridges/domains/ci-cd/cicd-squabbler-mcp/README.adoc @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += cicd-squabbler-mcp + +CI/CD gate-fighter cartridge — makes +https://github.com/hyperpolymath/cicd-squabbler[cicd-squabbler] summonable +through boj-server. Operationalises estate Doctrine #5: *squabble, don't +bypass* — a gate is won only by legitimately satisfying its required checks, +never by admin-override or dropping a context (SPARK-proved invariant in the +upstream repo). + +== Tools + +|=== +|Tool |Purpose + +|`squabble_fight` +|Plan a fight for a stuck gate: classify every unsatisfied required check as +*self-win* (CI-config lane) / *escalate-to-expert* (hypatia / fleet / proof / +security specialist groups) / *assign-owner* (upstream reusable or +misconfigured gate), plus a structural scan (path-filter traps, missing +well-known files, banned licence labels). Returns the `Outcome`/`Report` +evidence manifest — fail-closed, no silent skip. + +|`squabble_diagnose` +|Pure-engine diagnosis: gate state (`blocked`/`red`/`green`) plus one +conservative legitimate move per unsatisfied check. No filesystem context, +no mutation. +|=== + +== Backend + +Proxies to the `squabble-app` loopback HTTP backend +(`SQUABBLE_BACKEND_URL`, default `http://127.0.0.1:7741`) — the same shared +planner the `squabble` CLI uses. Start it with: + +[source,shell] +---- +cargo run -p squabble-app # in hyperpolymath/cicd-squabbler +---- + +No `ffi` block on purpose: this cartridge takes the `mod.js` (Deno gateway) +dispatch path. The gateway only proxies; the sandbox has no subprocess +capability, and the gate invariant lives in `squabble-core` upstream. + +== Example + +[source,shell] +---- +curl -s -X POST http://localhost:7700/cartridge/cicd-squabbler-mcp/invoke \ + -H 'Content-Type: application/json' \ + -d '{"tool":"squabble_fight","arguments":{"slug":"hyperpolymath/ipv6-only","gate":{"checks":[{"required_context":"lint-shell","run":"failed"}]}}}' +---- diff --git a/cartridges/domains/ci-cd/cicd-squabbler-mcp/cartridge.json b/cartridges/domains/ci-cd/cicd-squabbler-mcp/cartridge.json new file mode 100644 index 0000000..551a800 --- /dev/null +++ b/cartridges/domains/ci-cd/cicd-squabbler-mcp/cartridge.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://hyperpolymath.dev/standards/cartridges/cartridge-v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "cicd-squabbler-mcp", + "version": "0.1.0", + "description": "cicd-squabbler — the CI/CD gate fighter (squabble != bypass). Diagnoses a stuck branch-protection gate and plans legitimate moves: self-win CI-config fixes, escalations to specialist experts, and owner assignments. Proxies to the squabble-app loopback backend.", + "domain": "ci", + "category": "domain", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://cicd-squabbler-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "squabble_fight", + "description": "Plan a fight for a stuck gate: classify every unsatisfied required check as self-win (CI-config lane) | escalate-to-expert (hypatia/fleet/proof/security) | assign-owner (upstream or misconfigured), plus a structural scan (path-filter traps, missing well-known files, banned licence labels). Returns the Outcome/Report evidence manifest. Never bypasses: no admin-override, no dropped contexts.", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Repository as owner/repo (used for upstream-owner attribution)" + }, + "gate": { + "type": "object", + "description": "The gate: {checks:[{required_context, run: missing|pending|failed|passed}]} — from `squabble fetch` or the caller's own check-run data" + }, + "repo_root": { + "type": "string", + "description": "Optional path to a local checkout; enables the a2ml ecosystem-context reader and workflow ground-truthing. Fail-safe when absent." + } + }, + "required": [ + "slug", + "gate" + ] + } + }, + { + "name": "squabble_diagnose", + "description": "Pure-engine diagnosis of a gate: current state (blocked|red|green) plus one conservative legitimate move per unsatisfied required check. No filesystem context; no mutation.", + "inputSchema": { + "type": "object", + "properties": { + "checks": { + "type": "array", + "description": "Required checks with realised runs: [{required_context: string, run: missing|pending|failed|passed}]", + "items": { + "type": "object" + } + } + }, + "required": [ + "checks" + ] + } + } + ] +} diff --git a/cartridges/domains/ci-cd/cicd-squabbler-mcp/mod.js b/cartridges/domains/ci-cd/cicd-squabbler-mcp/mod.js new file mode 100644 index 0000000..c959e79 --- /dev/null +++ b/cartridges/domains/ci-cd/cicd-squabbler-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cicd-squabbler-mcp/mod.js — CI/CD gate fighter gateway (squabble != bypass) +// +// Delegates to the squabble-app loopback backend at http://127.0.0.1:7741 +// (override with SQUABBLE_BACKEND_URL). The backend is the same shared +// planner the `squabble` CLI uses; this gateway only proxies — the cartridge +// sandbox has no subprocess capability, and the squabbler's gate invariant +// (green only by satisfying required checks, never by weakening them) lives +// in squabble-core, not here. + +const BASE_URL = Deno.env.get("SQUABBLE_BACKEND_URL") ?? "http://127.0.0.1:7741"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "cicd-squabbler-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `cicd-squabbler-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "squabble_fight": { + const { slug, gate } = args ?? {}; + if (!slug || !gate) { + return { status: 400, data: { error: "slug and gate are required" } }; + } + // Pass through exactly what the backend's FightRequest expects. + const payload = { slug, gate }; + if (args.repo_root) payload.repo_root = args.repo_root; + return post("/api/v1/fight", payload); + } + case "squabble_diagnose": { + const { checks } = args ?? {}; + if (!Array.isArray(checks)) { + return { status: 400, data: { error: "checks (array) is required" } }; + } + // The backend takes the Gate object directly. + return post("/api/v1/diagnose", { checks }); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +}