Skip to content

Commit fece5c1

Browse files
feat(fight): enact the path-filter self-win under squabble fight --apply (#22)
## Summary `squabble fight` could *plan* a fight but never *enact* one. This PR lands the first appliable self-win move, so the squabbler starts paying rent instead of only diagnosing. It is the repo's own STATE `#1` next-action. `squabble fight --apply` 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`. It writes the workflow file and **stops**: no commit, no push, no CI re-run. **Why this is not a bypass (squabble ≠ 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.** **Honesty about scope (no overclaim):** applying a config edit does not make the gate green — only a CI re-run can — so the `Outcome` stays `Red`, now carrying a new `applied` evidence section recording exactly what changed on disk. Only `InjectPathFilterPassThrough` is appliable in v0.1 (a pure local edit). 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. ## Changes - **squabble-core**: add `AppliedChange` + `Report.applied` (`#[serde(default)]`, back-compatible with older reports). - **squabble-fight**: new `apply` module — `remove_on_paths` (tolerant line-based YAML strip, trigger-block-scoped, block + inline forms, `paths-ignore` left alone) and `apply_moves` (fail-closed, idempotent, per-move). Classify a **Missing** + path-filtered required check as the appliable `InjectPathFilterPassThrough` (a `Failed` check *did* trigger, so the filter isn't its cause → stays an escalation). Dedupe self-won path-filter traps out of the structural owner-assignment scan. - **squabble-cli**: `squabble fight --apply` (propose remains the default); an "applied to the working tree (no commit/push)" section in the human report. - **squabble-app**: unchanged — the HTTP backend stays propose-only (no server-side mutation). - **docs**: README status line + `.machine_readable/descriptiles/STATE.a2ml` updated. ## RSR Quality Checklist ### Required - [x] Tests pass (`cargo test --workspace` + `cargo test -p squabble-cli --features boj`) - [x] Code is formatted (`cargo fmt --all --check` clean) - [x] Linter is clean (`cargo clippy` clean, default + `boj` feature) - [x] No banned language patterns (Rust only; no new deps — `Cargo.lock` unchanged) - [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 (`apply.rs` carries `MPL-2.0`) - [x] No secrets, credentials, or `.env` files included ### As Applicable - [x] `.machine_readable/descriptiles/STATE.a2ml` updated (apply milestone + next-actions) - [ ] `.machine_readable/descriptiles/ECOSYSTEM.a2ml` updated (no integration change) - [ ] `.machine_readable/descriptiles/META.a2ml` updated (no new ADR — this is inside ANCHOR's "downstream-may add legitimate moves") - [x] Documentation updated for user-facing changes (README `--apply`) - [ ] `TOPOLOGY.md` updated (no architecture change) - [ ] `CHANGELOG` or release notes updated - [x] New dependencies reviewed (none added — `apply` uses only `std`) - [ ] ABI/FFI changes validated (none) ## Testing - New unit tests in `apply.rs`: block-form strip keeps siblings; inline-form strip; bare-trigger left valid when `paths:` was the only sub-key; `paths:` inside `jobs:` untouched; idempotency; no-filter no-op; temp-file end-to-end write + recorded no-op on second apply; **fail-closed** on a missing file; non-appliable moves ignored. - `workflows.rs`: a `Missing` + path-filtered required check classifies as `InjectPathFilterPassThrough`; a `Failed` one stays `GroundTruthCheckNames`. - `lib.rs`: the same trap is a self-win **and not** also owner-assigned. - `fight.rs`: `--apply` flag parsing (default off). - gnatprove runs in CI, not this environment; the `spark/` files are untouched by this change, so the proved invariant is unaffected. ## Screenshots Live `--apply` on a fixture (`Missing` check emitted by a path-filtered workflow): ``` self-win moves (squabbler's lane): - inject path-filter pass-through for `wellknown` in `wellknown.yml` applied to the working tree (no commit/push — operator's step): - .github/workflows/wellknown.yml: removed the `on.*.paths` trigger filter so the required check is always created (it now runs on every PR — a strict coverage increase, never a bypass); layering an always-run `changes` detector … is a follow-up ``` Before → after (the `paths:` block is stripped; `branches:`, `pull_request:`, `permissions:`, `jobs:`, and the SPDX header all survive): ```diff on: pull_request: - paths: - - '.well-known/**' branches: [main] ``` --- Note: commits on this branch are **unsigned** — this environment has only the public half of the signing key, so doctrine #9 (`status:G`) cannot be satisfied here. CI/owner must sign (or re-sign on squash-merge) before merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SUURnQUnzTXYftzWFs5pfZ --- _Generated by [Claude Code](https://claude.ai/code/session_01SUURnQUnzTXYftzWFs5pfZ)_
2 parents 06a8096 + 2249fb2 commit fece5c1

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)