Skip to content

Commit 86d2ec5

Browse files
feat(squabble): call-in-the-big-guns moves + ecosystem context + squabble fight (#9)
## Summary Gives the fighter the two capabilities it lacked to be a *benefit rather than a drain*, without becoming heavyweight: the judgement to recognise when a red is **out of its lane**, and a way to **argue the wider case** — while keeping `squabble ≠ bypass` structurally intact. The heavy experts are **not** embedded; they are reached (in a later push) through boj-server, which already brokers them. This push is the foundation + the training-ground proof. ## Changes **`squabble-core` — pure, no new deps, gate invariant untouched** - `Move::EscalateToExpert { check, group, obligation, evidence }` — hand a red outside the CI-config lane (code/build fix, proof, scan) to a named `ExpertGroup` (`Hypatia`, `HypatiaFleet`, `Proof`, `Security`). A **hand-off, never a win**: constructs no `Passed` run, drops no required context. - `Move::AssignGateOwner { check, owner, disposition, rationale }` — surface a check that is owned upstream, structurally misconfigured, or should be in CI, and name who must own it (`OwnershipDisposition`). **Pure annotation, never a path to green.** - `Report` gains typed `escalations` + `owner_assignments` sections (`#[serde(default)]` for back-compat) so it is a complete evidence manifest (`AGENTIC`: evidence-per-step, no-silent-skip). **`squabble-cli` — host-only; core stays detachable** - `context.rs` — tolerant, fail-safe a2ml reader → `RepoContext` (is / is-not, coordination owner, golden path, banned langs, relationships). Never errors on a missing/unknown shape. - `workflows.rs` — line-based ground-truth of `.github/workflows/`: emitting workflow per check, reusable-workflow owner, path-filter traps, job kind (`Hygiene | CodeBuild | Security | Proof`) → per-check classification. - `fight.rs` — `squabble fight <owner/repo> <pr> [--repo-root] [--gate] [--json]`: classify every red as **self-win | escalate | assign-owner**, plus a read-only structural scan (missing well-known files, path-filter traps, banned AGPL licence label), emitted as an `Outcome`/`Report`. **First binary to emit `Outcome`.** - `examples/ipv6-only-pr43.json` — the training case, as a committed gate fixture. **Invariant note:** both new moves are non-satisfying by construction, so the SPARK postcondition (`Green ⇔ non-empty ∧ all-passed`) is unchanged and needs no re-proof. No gate-weakening path is representable as a `Move`. **Deferred (next push, by design):** the boj-server client + `cicd-squabbler-mcp` cartridge + live specialist groups. `EscalateToExpert` is the seam they plug into. ## RSR Quality Checklist ### Required - [x] Tests pass (`cargo test --workspace` — 39 tests green) - [x] Code is formatted (`cargo fmt --check` clean) - [x] Linter is clean (`cargo clippy --workspace --all-targets` — no warnings) - [x] No banned language patterns (Rust only) - [x] No `unsafe` blocks without `// SAFETY:` comments (none added) - [x] No banned functions (`believe_me`, `unsafeCoerce`, `Obj.magic`, `Admitted`, `sorry`) - [x] SPDX license headers present on all new/modified source files (MPL-2.0) - [x] No secrets, credentials, or `.env` files included ### As Applicable - [x] `.machine_readable/descriptiles/STATE.a2ml` updated (new moves/command; boj wiring + apply marked open) - [x] New dependencies reviewed — none added (core stays dependency-light and detachable) ## Testing `cargo test --workspace` → 39 tests pass (18 core, 21 cli), including: the two new moves are legitimate and round-trip; `Report` back-compat parse; a2ml extraction (coordination owner, multi-line arrays); workflow classification (reusable → owned-upstream, code-build → escalation, hygiene → in-lane); and an end-to-end test that classifies all five of ipv6-only#43's reds. End-to-end against the real ipv6-only workflow files: ``` $ squabble fight hyperpolymath/ipv6-only 43 --gate examples/ipv6-only-pr43.json --repo-root <ipv6-only> gate Red: 5 unsatisfied — 1 self-win, 2 escalated, 5 owner-assigned self-win: lint-workflows → ground-truth workflow-linter.yml (CI-config lane) escalate: lint-shell → hypatia+fleet [dispatch-fix] (ci.yml builds/lints code) container-build → hypatia+fleet [dispatch-fix] owner: governance/Well-Known → hyperpolymath/standards [owned-upstream] governance/Wf-sec-lint→ hyperpolymath/standards [owned-upstream] + path-filter traps (wellknown-enforcement, workflow-linter) [misconfigured-gate] + Containerfile AGPL label [misconfigured-gate, owner-only per doctrine #6] ``` **Note:** the SPARK gate proof (`gnatprove`) is checked in CI and is unaffected by these additions; `gnatprove` was not available in the authoring environment. Commits in this environment are unsigned (no signing key provisioned) — signature/verification is a follow-up for CI/owner. ## Screenshots n/a (terminal output above). --- _Generated by [Claude Code](https://claude.ai/code/session_01SUURnQUnzTXYftzWFs5pfZ)_
2 parents e77787c + a8471a4 commit 86d2ec5

8 files changed

Lines changed: 1669 additions & 6 deletions

File tree

.machine_readable/descriptiles/STATE.a2ml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
[metadata]
77
project = "cicd-squabbler"
88
version = "0.1.0"
9-
last-updated = "2026-07-01"
9+
last-updated = "2026-07-03"
1010
status = "active" # active | paused | archived
1111

1212
[project-context]
1313
name = "cicd-squabbler"
1414
purpose = "CI/CD gate fighter: drives a stuck branch-protection gate to a legitimately-satisfied green by satisfying its required checks — never by admin override, enforce_admins toggle, or removing/renaming a required context. Rust fight engine + SPARK-proved gate invariant. Detachable; no hypatia/estate dependency."
15-
completion-percentage = 85
15+
completion-percentage = 88
1616

1717
[position]
1818
phase = "implementation" # design | implementation | testing | maintenance | archived
@@ -24,7 +24,12 @@ milestones = [
2424
{ name = "v0.1 fight playbook — 5 gate-deadlock moves (reconcile-context, path-filter pass-through, repin-reusable, resolve-and-rerun, ground-truth-names)", completion = 100 },
2525
{ name = "squabble-cli (`squabble diagnose <gate.json>`)", completion = 100 },
2626
{ name = "Licence-policy squabbling — Move 6 (LicencePolicyDrift; author-allowed vs owner-manual encoded in the type system)", completion = 100 },
27+
{ name = "Non-functional-gate recognition — Move 7 (FlagNonFunctionalGate; escalate an unwinnable-from-PR check)", completion = 100 },
28+
{ name = "Call-in-the-big-guns — Moves 8-9 (EscalateToExpert → hypatia/fleet/echidna/security groups; AssignGateOwner → owned-upstream / misconfigured-gate / should-be-added-to-ci). Both non-satisfying by construction; SPARK invariant untouched.", completion = 100 },
29+
{ name = "Ecosystem-context reader (host-only a2ml/k9 → RepoContext; fail-safe, never a core dependency)", completion = 100 },
30+
{ name = "`squabble fight` orchestration — classifies each red as self-win | escalate | assign-owner via workflow ground-truth; first binary to emit an Outcome/Report evidence manifest. Proven on ipv6-only#43 (5 reds classified).", completion = 100 },
2731
{ name = "squabble-app GitHub App webhook server (currently an honest stub — exits EX_UNAVAILABLE rather than fake OK)", completion = 20 },
32+
{ name = "boj-server client + cicd-squabbler-mcp cartridge + specialist expert groups (escalations become live hypatia/fleet/echidna calls) — designed, deferred to next push; EscalateToExpert is the seam", completion = 0 },
2833
{ name = "RSR scaffolding customisation (identity, root-allow, A2ML manifests)", completion = 80 },
2934
]
3035

@@ -34,7 +39,8 @@ milestones = [
3439

3540
[critical-next-actions]
3641
actions = [
37-
"Implement the squabble-app webhook server (axum + octocrab) so the fighter runs as a GitHub App, not only a CLI.",
42+
"Wire the escalation seam to boj-server: a feature-gated MCP/REST client + a cicd-squabbler-mcp cartridge, so EscalateToExpert calls hypatia-mcp/fleet-mcp/echidna live (specialist groups: Hypatia, Hypatia+Fleet, Proof, Security). Fail-closed if unreachable.",
43+
"Implement `squabble fight --apply` (and the squabble-app webhook) so self-win moves are enacted + re-run, not only planned.",
3844
"Extend the SPARK invariant to cover the licence-policy gate class (v0.2; v0.1 moves are Rust-only for that class).",
3945
"Register the CLADE (uuid 38bec2ed-bb12-44ee-9fb4-08872370c27b) into hyperpolymath/gv-clade-index (owner action).",
4046
]

crates/squabble-cli/src/context.rs

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//! Ecosystem-context reader — lets the squabbler "argue its case systemically".
4+
//!
5+
//! A fight is not just "which checks are red"; it is "what is this repo, what
6+
//! does it coordinate with, and therefore who owns the reds it cannot win".
7+
//! This module reads the target repo's `.machine_readable/` descriptiles
8+
//! (a2ml) and distils them into a [`RepoContext`] the fight orchestration uses
9+
//! to attribute owners and narrate the case.
10+
//!
11+
//! Two deliberate design choices keep this honest and lightweight:
12+
//!
13+
//! * **Host-only.** This lives in `squabble-cli`, never in `squabble-core` —
14+
//! the pure engine stays free of any estate/format dependency (detachability).
15+
//! * **Fail-safe, not fail-open.** a2ml appears in more than one dialect across
16+
//! the estate, and a target repo may have no `.machine_readable/` at all. So
17+
//! this reader never errors: a missing file or unrecognised shape yields an
18+
//! empty field, and the fight falls back to ground-truthing the workflow
19+
//! source (which is the authoritative owner signal anyway). Context enriches
20+
//! the argument; it is never the sole basis for a move.
21+
22+
use std::path::Path;
23+
24+
/// The distilled ecosystem context of a repo under fight.
25+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
26+
pub struct RepoContext {
27+
/// What the repo *is* (semantic authority statement, if declared).
28+
pub is: Vec<String>,
29+
/// What the repo *is not* (the `what-this-is-not` boundary list).
30+
pub is_not: Vec<String>,
31+
/// The repo this one coordinates with, normalised to `owner/repo`
32+
/// (e.g. `standards` → `hyperpolymath/standards`). Drives upstream owner
33+
/// attribution for reds produced by shared/reusable gates.
34+
pub coordination_owner: Option<String>,
35+
/// The declared golden-path command (e.g. `just ci`).
36+
pub golden_path: Option<String>,
37+
/// Languages/licences the repo bans (best-effort; e.g. `AGPL`, `Python`).
38+
pub banned_langs: Vec<String>,
39+
/// Declared estate/external relationships.
40+
pub relationships: Vec<String>,
41+
/// Whether any descriptile was actually found and read. `false` means the
42+
/// fight is proceeding on workflow ground-truth alone.
43+
pub found: bool,
44+
}
45+
46+
impl RepoContext {
47+
/// Load context from a repo checkout. Never fails: absent files or
48+
/// unrecognised shapes leave fields empty and `found == false`.
49+
pub fn load(repo_root: &Path) -> Self {
50+
let desc = repo_root.join(".machine_readable/descriptiles");
51+
let read = |name: &str| std::fs::read_to_string(desc.join(name)).unwrap_or_default();
52+
let ecosystem = read("ECOSYSTEM.a2ml");
53+
let clade = read("CLADE.a2ml");
54+
let anchor = read("ANCHOR.a2ml");
55+
let agentic = read("AGENTIC.a2ml");
56+
let found = [&ecosystem, &clade, &anchor, &agentic]
57+
.iter()
58+
.any(|s| !s.is_empty());
59+
let mut ctx = Self::from_sources(&ecosystem, &clade, &anchor, &agentic);
60+
ctx.found = found;
61+
ctx
62+
}
63+
64+
/// Build context from the raw a2ml texts. Pure and the unit of test
65+
/// coverage — [`load`](Self::load) only supplies these four strings.
66+
pub fn from_sources(ecosystem: &str, _clade: &str, anchor: &str, agentic: &str) -> Self {
67+
RepoContext {
68+
is: extract_scalar(anchor, "defines").into_iter().collect(),
69+
is_not: extract_array(ecosystem, "not"),
70+
coordination_owner: extract_scalar(ecosystem, "coordination").map(normalise_owner),
71+
golden_path: extract_scalar(anchor, "command"),
72+
banned_langs: extract_banned(agentic),
73+
relationships: extract_array(ecosystem, "related"),
74+
found: false,
75+
}
76+
}
77+
}
78+
79+
/// Normalise a bare coordination name to `owner/repo`. Estate convention is that
80+
/// a bare `standards` means `hyperpolymath/standards`; an already-qualified
81+
/// `owner/repo` is left untouched.
82+
fn normalise_owner(raw: String) -> String {
83+
if raw.contains('/') {
84+
raw
85+
} else {
86+
format!("hyperpolymath/{raw}")
87+
}
88+
}
89+
90+
/// The candidate banned tokens the estate policy uses. Best-effort: a repo's
91+
/// AGENTIC records these in prose/comments, so we scan for their presence on any
92+
/// line that also signals a ban. This is narrative enrichment, never the basis
93+
/// for a move.
94+
const BANNED_CANDIDATES: &[&str] = &[
95+
"AGPL",
96+
"TypeScript",
97+
"Python",
98+
"Go",
99+
"Nix",
100+
"Node",
101+
"npm",
102+
"Ruby",
103+
"Perl",
104+
"Kotlin",
105+
"Swift",
106+
];
107+
108+
fn extract_banned(text: &str) -> Vec<String> {
109+
let mut out = Vec::new();
110+
for line in text.lines() {
111+
let l = line;
112+
let ll = l.to_ascii_lowercase();
113+
let signals_ban = ll.contains("banned")
114+
|| ll.contains("never use")
115+
|| ll.contains("deny")
116+
|| ll.contains("agpl");
117+
if !signals_ban {
118+
continue;
119+
}
120+
for cand in BANNED_CANDIDATES {
121+
if l.contains(cand) && !out.iter().any(|e: &String| e == cand) {
122+
out.push((*cand).to_string());
123+
}
124+
}
125+
}
126+
out
127+
}
128+
129+
/// Extract the first double-quoted value on the first line whose trimmed start
130+
/// is `key` immediately followed by `=` or `:`. Tolerant of TOML-like and
131+
/// S-expr-ish a2ml alike.
132+
fn extract_scalar(text: &str, key: &str) -> Option<String> {
133+
for line in text.lines() {
134+
let t = line.trim();
135+
if let Some(rest) = t.strip_prefix(key) {
136+
let rest = rest.trim_start();
137+
if rest.starts_with('=') || rest.starts_with(':') {
138+
let mut vals = Vec::new();
139+
push_quoted(line, &mut vals);
140+
if let Some(first) = vals.into_iter().next() {
141+
return Some(first);
142+
}
143+
}
144+
}
145+
}
146+
None
147+
}
148+
149+
/// Extract every double-quoted string in an array-valued key, whether the array
150+
/// is written on one line or spread across several (`key = [ ... ]`).
151+
fn extract_array(text: &str, key: &str) -> Vec<String> {
152+
let mut out = Vec::new();
153+
let mut collecting = false;
154+
for line in text.lines() {
155+
let t = line.trim();
156+
if !collecting {
157+
if let Some(rest) = t.strip_prefix(key) {
158+
let rest = rest.trim_start();
159+
if (rest.starts_with('=') || rest.starts_with(':')) && line.contains('[') {
160+
collecting = true;
161+
push_quoted(line, &mut out);
162+
if line.contains(']') {
163+
break;
164+
}
165+
}
166+
}
167+
} else {
168+
push_quoted(line, &mut out);
169+
if t.contains(']') {
170+
break;
171+
}
172+
}
173+
}
174+
out
175+
}
176+
177+
/// Push every double-quoted substring of `line` onto `out`.
178+
fn push_quoted(line: &str, out: &mut Vec<String>) {
179+
let mut rest = line;
180+
while let Some(open) = rest.find('"') {
181+
let after = &rest[open + 1..];
182+
if let Some(close) = after.find('"') {
183+
out.push(after[..close].to_string());
184+
rest = &after[close + 1..];
185+
} else {
186+
break;
187+
}
188+
}
189+
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
const ECOSYSTEM: &str = r#"
196+
[position]
197+
pipeline = "tooling / CI-CD"
198+
coordination = "standards"
199+
200+
[relations]
201+
related = ["hyperpolymath/hypatia (the big system)", "hyperpolymath/gitbot-fleet"]
202+
203+
[what-this-is-not]
204+
not = [
205+
"A bypass. Never enforce_admins=off, never drop a required context.",
206+
"hypatia-dependent. Detachability is the genesis.",
207+
"A red→green code-fixer (deferred past v0.1)."
208+
]
209+
"#;
210+
211+
const ANCHOR: &str = r#"
212+
[authority]
213+
defines = "What it means to reach a CI/CD gate's green LEGITIMATELY (squabble != bypass)."
214+
215+
[golden-path]
216+
command = "just ci"
217+
"#;
218+
219+
const AGENTIC: &str = r#"
220+
[agent-constraints]
221+
# - Never use banned languages (TypeScript, Python, Go, etc.)
222+
# - Never use AGPL license (use MPL-2.0)
223+
"#;
224+
225+
#[test]
226+
fn extracts_coordination_owner_normalised() {
227+
let ctx = RepoContext::from_sources(ECOSYSTEM, "", ANCHOR, AGENTIC);
228+
assert_eq!(
229+
ctx.coordination_owner.as_deref(),
230+
Some("hyperpolymath/standards")
231+
);
232+
}
233+
234+
#[test]
235+
fn extracts_multiline_is_not_array() {
236+
let ctx = RepoContext::from_sources(ECOSYSTEM, "", ANCHOR, AGENTIC);
237+
assert_eq!(ctx.is_not.len(), 3);
238+
assert!(ctx.is_not[1].contains("hypatia-dependent"));
239+
}
240+
241+
#[test]
242+
fn extracts_relationships_single_line_array() {
243+
let ctx = RepoContext::from_sources(ECOSYSTEM, "", ANCHOR, AGENTIC);
244+
assert_eq!(ctx.relationships.len(), 2);
245+
assert!(ctx.relationships[0].contains("hypatia"));
246+
}
247+
248+
#[test]
249+
fn extracts_golden_path_and_defines() {
250+
let ctx = RepoContext::from_sources(ECOSYSTEM, "", ANCHOR, AGENTIC);
251+
assert_eq!(ctx.golden_path.as_deref(), Some("just ci"));
252+
assert_eq!(ctx.is.len(), 1);
253+
assert!(ctx.is[0].contains("LEGITIMATELY"));
254+
}
255+
256+
#[test]
257+
fn best_effort_banned_langs_include_agpl() {
258+
let ctx = RepoContext::from_sources(ECOSYSTEM, "", ANCHOR, AGENTIC);
259+
assert!(ctx.banned_langs.iter().any(|b| b == "AGPL"));
260+
assert!(ctx.banned_langs.iter().any(|b| b == "TypeScript"));
261+
}
262+
263+
#[test]
264+
fn empty_sources_yield_empty_context_not_an_error() {
265+
let ctx = RepoContext::from_sources("", "", "", "");
266+
assert!(ctx.coordination_owner.is_none());
267+
assert!(ctx.is_not.is_empty());
268+
assert!(!ctx.found);
269+
}
270+
271+
#[test]
272+
fn already_qualified_owner_is_untouched() {
273+
let eco = "coordination = \"hyperpolymath/custom-standards\"\n";
274+
let ctx = RepoContext::from_sources(eco, "", "", "");
275+
assert_eq!(
276+
ctx.coordination_owner.as_deref(),
277+
Some("hyperpolymath/custom-standards")
278+
);
279+
}
280+
}

0 commit comments

Comments
 (0)