Skip to content

Commit 2249fb2

Browse files
committed
feat(fight): enact the path-filter self-win under squabble fight --apply
`squabble fight` could plan but not enact. This lands the first appliable self-win move so the squabbler starts paying rent, not just diagnosing. What `--apply` does (and only this, in v0.1): - Strips an `on.*.paths` trigger filter that strands a required check. The documented estate fix (boj-server "CI / Required Status Checks") for a path-filtered required workflow that reports "Expected" forever and leaves a PR `mergeable_state: blocked`. - Classification is Missing-only: the trap manifests as a *Missing* check (the workflow never triggered off-path). A check that ran and *Failed* did trigger, so the filter is not its cause — that stays an escalation, not a strip. - Writes the workflow file and stops. No commit, no push, no CI re-run (doctrine #11, stop-first; only CI can turn a check green). The `Outcome` stays `Red` with a new `applied` evidence section recording exactly what changed on disk (no overclaim). - Fail-closed + idempotent per move: an unreadable/unwritable file or a nothing-to-strip no-op is recorded in the report, never silently skipped. Why this is not a bypass: removing an inclusion filter makes the required check run on *more* PRs, never fewer — a strict coverage increase. It cannot turn a `Failed` run into `Passed` and cannot drop a required context, so `Gate::evaluate` is untouched and the SPARK invariant (Green iff every required check ran and passed) needs no re-proof. The `spark/` tree is unchanged. Details: - squabble-core: add `AppliedChange` + `Report.applied` (#[serde(default)], back-compatible with older reports). - squabble-fight: new `apply` module (`remove_on_paths` line-based YAML strip, `apply_moves` fail-closed driver); classify a Missing + path-filtered required check as the appliable `InjectPathFilterPassThrough`; dedupe self-won path-filter traps out of the structural owner-assignment scan. - squabble-cli: `squabble fight --apply` (propose remains the default), applied section in the human report. - Only `InjectPathFilterPassThrough` is appliable (pure local edit, no network). Required-context reconcile and reusable re-pin need the GitHub ruleset API / a target SHA and stay propose-only; the webhook leg remains honestly absent. New tests: apply unit + temp-file end-to-end + idempotency + fail-closed; Missing-path-filter classification; --apply flag parsing. Whole workspace green (default + `boj` feature), fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SUURnQUnzTXYftzWFs5pfZ
1 parent 06a8096 commit 2249fb2

8 files changed

Lines changed: 632 additions & 31 deletions

File tree

.machine_readable/descriptiles/STATE.a2ml

Lines changed: 5 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-03"
9+
last-updated = "2026-07-17"
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 = 92
15+
completion-percentage = 93
1616

1717
[position]
1818
phase = "implementation" # design | implementation | testing | maintenance | archived
@@ -32,6 +32,7 @@ milestones = [
3232
{ name = "squabble-app loopback HTTP backend (axum, 127.0.0.1:7741): /health, /api/v1/diagnose, /api/v1/fight — the process the cicd-squabbler-mcp cartridge proxies to. The GitHub App *webhook* leg (check_run.requested_action + octocrab) remains honestly absent (no webhook route registered).", completion = 100 },
3333
{ name = "boj-server expert client (feature `boj`, ureq; default build gains zero deps): `squabble fight --summon` maps each EscalateToExpert to its cartridge (Security→panic-attack-mcp, Proof→echidna-llm-mcp, Hypatia*→hypatia-mcp; dispatch-fix recorded as assessed+tracked, actuation external — no fixer cartridge exists). Fail-closed on unreachable experts; loud refusal when built without the feature.", completion = 100 },
3434
{ name = "cicd-squabbler-mcp cartridge (boj-server-cartridges cartridges/domains/ci-cd/): squabble_fight + squabble_diagnose tools, mod.js proxy to SQUABBLE_BACKEND_URL, no ffi block (JS dispatch path)", completion = 100 },
35+
{ name = "`squabble fight --apply` — enacts the first appliable self-win: strips an on.*.paths trigger filter that strands a required check (Missing-only classification), writing the workflow file. Fail-closed + idempotent + no commit/push/re-run; Outcome stays Red with an `applied` evidence section (no overclaim). Provably gate-strengthening (more coverage, never fewer) so the SPARK invariant is untouched. Other self-wins (context reconcile, reusable re-pin) stay propose-only pending the GitHub ruleset API.", completion = 100 },
3536
{ name = "RSR scaffolding customisation (identity, root-allow, A2ML manifests)", completion = 80 },
3637
]
3738

@@ -41,7 +42,8 @@ milestones = [
4142

4243
[critical-next-actions]
4344
actions = [
44-
"Implement `squabble fight --apply` (and the squabble-app GitHub App webhook leg: check_run.requested_action + octocrab) so self-win moves are enacted + re-run, not only planned.",
45+
"Enact the remaining self-wins under --apply: required-context reconcile and reusable-workflow re-pin both need the GitHub ruleset API / a target SHA (network), so they stay propose-only until that host capability exists. (`--apply` already enacts the path-filter strip, which is a pure local file edit.)",
46+
"Add the squabble-app GitHub App webhook leg (check_run.requested_action + octocrab — the \"Squabble!\" button) so a fight can be triggered from a PR, not only from the CLI; still honestly absent (no webhook route registered).",
4547
"Live-fire --summon against a running boj-server with the expert backends up (hypatia :7701, panic-attack, echidna) — today's verification proves the wiring + fail-closed degradation; the experts themselves are not yet deployed.",
4648
"Extend the SPARK invariant to cover the licence-policy gate class (v0.2; v0.1 moves are Rust-only for that class).",
4749
"Register the CLADE (uuid 38bec2ed-bb12-44ee-9fb4-08872370c27b) into hyperpolymath/gv-clade-index (owner action).",

README.adoc

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@ A standalone, per-repo CI/CD fighter that drives a stuck pipeline to a
1515
*legitimate* green — or improves red→yellow, or fails and hands the human a
1616
structured report. It *wins* the gate; it never cheats past it.
1717

18-
NOTE: Status — *v0.1 in active development (updated 2026-07-03).* The engine and
18+
NOTE: Status — *v0.1 in active development (updated 2026-07-17).* The engine and
1919
its formal invariant are real and verified (see below); live gate fetch via `gh`
2020
landed in #6; `squabble fight` (classify each red as self-win | escalate |
2121
assign-owner), the `squabble-app` loopback HTTP backend, and the boj-server
22-
expert summoning (`--summon`, feature `boj`) landed in #13. Still open: the
23-
`squabble apply` plumbing (moves are proposed, not yet executed) and the GitHub
24-
App *webhook* leg (pending the owner's hosting decision). See
22+
expert summoning (`--summon`, feature `boj`) landed in #13. `squabble fight
23+
--apply` now *enacts* the first appliable self-win — stripping an `on.*.paths`
24+
trigger filter that strands a required check — by writing the workflow file
25+
(propose remains the default; `--apply` never commits, pushes, or re-runs CI).
26+
Still open: applying the other self-wins (required-context reconcile,
27+
reusable-workflow re-pin — these need the GitHub ruleset API / a target SHA) and
28+
the GitHub App *webhook* leg (pending the owner's hosting decision). See
2529
`docs/CHARTER.adoc` for the design and open owner decisions.
2630

2731
== Core principle: squabble ≠ bypass
@@ -42,7 +46,7 @@ check away, or any override are provably *not* paths to green.
4246
|===
4347
| `crates/squabble-core` | The fight engine. Pure, detachable, no hypatia/estate dependency — `diagnose → propose move → apply → verify-satisfied`. The valuable reusable part.
4448
| `crates/squabble-fight`| The shared host brain: a2ml ecosystem-context reader + workflow ground-truth + the fight planner. One planner for the CLI and the App, so they cannot drift.
45-
| `crates/squabble-cli` | `squabble` — runs locally or as a CI step. `fetch`, `diagnose`, and `fight` (with `--summon` under the `boj` feature to put expert verdicts on the report); apply plumbing next.
49+
| `crates/squabble-cli` | `squabble` — runs locally or as a CI step. `fetch`, `diagnose`, and `fight` (with `--summon` under the `boj` feature to put expert verdicts on the report, and `--apply` to enact the path-filter self-win by editing the workflow — no commit/push).
4650
| `crates/squabble-app` | The loopback HTTP backend (axum, `127.0.0.1:7741`): serves the shared planner to the boj-server `cicd-squabbler-mcp` cartridge. The GitHub App *webhook* leg (trigger button + octocrab) is still to come — no webhook route is registered.
4751
| `spark/` | The formal heart. `Gate_Machine` + the proved theorem `green ⇔ (non-empty ∧ all passed)`.
4852
|===

crates/squabble-cli/src/boj.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ mod tests {
325325
escalations: vec![escalation(ExpertGroup::Security, EscalationKind::Scan)],
326326
owner_assignments: vec![],
327327
expert_verdicts: vec![],
328+
applied: vec![],
328329
blockers: vec![],
329330
},
330331
};

crates/squabble-cli/src/fight.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ struct FightArgs {
2323
gate_file: Option<String>,
2424
json: bool,
2525
summon: bool,
26+
apply: bool,
2627
}
2728

2829
// Shared with the top-level help in main.rs so the two cannot drift.
29-
pub(crate) const USAGE: &str = "usage: squabble fight <owner>/<repo> <pr> [--repo-root <path>] [--gate <file>] [--json] [--summon]";
30+
pub(crate) const USAGE: &str = "usage: squabble fight <owner>/<repo> <pr> [--repo-root <path>] [--gate <file>] [--json] [--summon] [--apply]";
3031

3132
/// Entry point for `squabble fight`; `rest` is the args after the subcommand.
3233
pub fn run(rest: &[String]) -> ExitCode {
@@ -46,10 +47,30 @@ pub fn run(rest: &[String]) -> ExitCode {
4647
}
4748
};
4849

49-
// `mut` is only exercised by the boj build (summon appends evidence).
50-
#[cfg_attr(not(feature = "boj"), allow(unused_mut))]
5150
let (context, mut outcome) = squabble_fight::plan_at_root(&gate, &args.slug, &args.repo_root);
5251

52+
// `--apply` enacts the appliable self-win moves (v0.1: path-filter strips)
53+
// by writing the workflow files — and nothing more. It never commits or
54+
// pushes, and it never re-runs the checks, so the gate stays honestly red
55+
// (only CI can turn a check green); the report gains an `applied` section
56+
// recording exactly what was written. Default (propose) leaves the tree
57+
// untouched.
58+
if args.apply {
59+
if let Outcome::Red { report } = &mut outcome {
60+
let result =
61+
squabble_fight::apply::apply_moves(&args.repo_root, &report.moves_attempted);
62+
let applied_n = result.applied.len();
63+
report.applied = result.applied;
64+
report.blockers.extend(result.skipped);
65+
if applied_n > 0 {
66+
report.blockers.push(format!(
67+
"applied {applied_n} self-win move(s) to the working tree — re-run CI to \
68+
confirm the gate; nothing was committed or pushed (that is the operator's step)"
69+
));
70+
}
71+
}
72+
}
73+
5374
if args.summon {
5475
// Without the `boj` feature there is no client compiled in. Refusing
5576
// loudly honours `no-silent-skip`: pretending to summon would be
@@ -102,6 +123,7 @@ fn parse_args(rest: &[String]) -> Result<FightArgs, String> {
102123
let mut gate_file = None;
103124
let mut json = false;
104125
let mut summon = false;
126+
let mut apply = false;
105127

106128
let mut i = 0;
107129
while i < rest.len() {
@@ -128,6 +150,10 @@ fn parse_args(rest: &[String]) -> Result<FightArgs, String> {
128150
summon = true;
129151
i += 1;
130152
}
153+
"--apply" => {
154+
apply = true;
155+
i += 1;
156+
}
131157
s if s.starts_with("--") => return Err(format!("unknown flag `{s}`\n{USAGE}")),
132158
s => {
133159
if slug.is_none() {
@@ -153,6 +179,7 @@ fn parse_args(rest: &[String]) -> Result<FightArgs, String> {
153179
gate_file,
154180
json,
155181
summon,
182+
apply,
156183
})
157184
}
158185

@@ -184,6 +211,12 @@ fn print_human(slug: &str, context: &RepoContext, outcome: &Outcome) {
184211
println!(" - {}", m.describe());
185212
}
186213
}
214+
if !report.applied.is_empty() {
215+
println!("\n applied to the working tree (no commit/push — operator's step):");
216+
for a in &report.applied {
217+
println!(" - {}: {}", a.file, a.detail);
218+
}
219+
}
187220
if !report.escalations.is_empty() {
188221
println!("\n escalations (call in the big guns):");
189222
for e in &report.escalations {
@@ -226,6 +259,15 @@ mod tests {
226259
assert_eq!(args.slug, "hyperpolymath/ipv6-only");
227260
}
228261

262+
#[test]
263+
fn parse_args_accepts_apply_flag_default_off() {
264+
let off = parse_args(&["o/r".to_string(), "1".to_string()]).expect("parse");
265+
assert!(!off.apply, "apply must default to propose-only");
266+
let on = parse_args(&["o/r".to_string(), "1".to_string(), "--apply".to_string()])
267+
.expect("parse");
268+
assert!(on.apply);
269+
}
270+
229271
#[test]
230272
fn parse_args_rejects_unknown_flags() {
231273
assert!(parse_args(&["o/r".to_string(), "--bogus".to_string()]).is_err());

crates/squabble-core/src/outcome.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,33 @@ pub struct Report {
6262
/// the escalation's `evidence` string carries only the human narration.
6363
#[serde(default)]
6464
pub expert_verdicts: Vec<ExpertVerdict>,
65+
/// Concrete edits the squabbler wrote to the working tree while executing
66+
/// self-win moves under `--apply` (propose-mode never writes, so this is
67+
/// empty then). Applying a config edit does **not** make the gate green —
68+
/// only a CI re-run can — so the [`Outcome`] stays [`Outcome::Red`] and this
69+
/// section is the honest record of what changed on disk (`evidence-per-step`,
70+
/// `no-overclaim`).
71+
#[serde(default)]
72+
pub applied: Vec<AppliedChange>,
6573
/// Why each remaining requirement could not be satisfied legitimately.
6674
pub blockers: Vec<String>,
6775
}
6876

77+
/// One concrete edit the squabbler made to the working tree while executing a
78+
/// self-win move (only under `--apply`). It records *what* was written, never a
79+
/// claim that the gate improved: the gate's colour is a property of the checks'
80+
/// runs, and nothing here re-ran them. Nothing is committed or pushed — that
81+
/// outward-facing step is deliberately the operator's (doctrine #11, stop-first).
82+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83+
pub struct AppliedChange {
84+
/// The move kind this realises, e.g. `"inject-path-filter-pass-through"`.
85+
pub move_kind: String,
86+
/// Repo-relative path of the file edited.
87+
pub file: String,
88+
/// Human description of the concrete change made.
89+
pub detail: String,
90+
}
91+
6992
/// One expert call made while summoning, recorded verbatim. `ok` follows the
7093
/// fail-closed rule: it is `true` only when the expert both responded AND its
7194
/// body carries no error signal — transport success alone is not a verdict.
@@ -217,6 +240,11 @@ mod tests {
217240
meaning: "weak-point scan".into(),
218241
verdict: r#"{"findings":[]}"#.into(),
219242
}],
243+
applied: vec![AppliedChange {
244+
move_kind: "inject-path-filter-pass-through".into(),
245+
file: ".github/workflows/wellknown-enforcement.yml".into(),
246+
detail: "removed on.*.paths filter so the required check always runs".into(),
247+
}],
220248
blockers: vec![],
221249
};
222250
let json = serde_json::to_string(&report).expect("serialise");
@@ -226,12 +254,13 @@ mod tests {
226254

227255
#[test]
228256
fn report_deserialises_without_new_sections_for_back_compat() {
229-
// Older reports had no escalations/owner_assignments/expert_verdicts;
230-
// #[serde(default)] must keep them parseable.
257+
// Older reports had no escalations/owner_assignments/expert_verdicts/
258+
// applied; #[serde(default)] must keep them parseable.
231259
let legacy = r#"{"summary":"x","unsatisfied":[],"moves_attempted":[],"blockers":[]}"#;
232260
let report: Report = serde_json::from_str(legacy).expect("legacy parse");
233261
assert!(report.escalations.is_empty());
234262
assert!(report.owner_assignments.is_empty());
235263
assert!(report.expert_verdicts.is_empty());
264+
assert!(report.applied.is_empty());
236265
}
237266
}

0 commit comments

Comments
 (0)