Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions cartridges/domains/ci-cd/cicd-squabbler-mcp/README.adoc
Original file line number Diff line number Diff line change
@@ -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"}]}}}'
----
70 changes: 70 additions & 0 deletions cartridges/domains/ci-cd/cicd-squabbler-mcp/cartridge.json
Original file line number Diff line number Diff line change
@@ -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) <j.d.a.jewell@open.ac.uk>",
"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"
]
}
}
]
}
57 changes: 57 additions & 0 deletions cartridges/domains/ci-cd/cicd-squabbler-mcp/mod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// 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}` } };
}
}
Loading