Skip to content
2 changes: 1 addition & 1 deletion .githooks/validate-k9.sh
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ validate_k9() {
fi

# Also check for signature fields outside pedigree (top-level)
if [[ "$line" =~ ^[[:space:]]*(signature)[[:space:]]*= ]]; then
if [[ "$line" =~ ^[[:space:]]*(signature|signature_required)[[:space:]]*= ]]; then
has_signature_field=true
fi
done < "$file"
Expand Down
57 changes: 53 additions & 4 deletions .machine_readable/bot_directives/gate_triage.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
# Origin: owner ruling 2026-09-03, given on the vacuous-Hypatia-gate options menu.

[metadata]
version = "0.1.0"
last-updated = "2026-09-03"
version = "0.2.0"
last-updated = "2026-09-04"
spec = "https://github.com/hyperpolymath/standards/blob/main/agentic-a2ml/docs/ADR-002-methodology-layer.adoc"
status = "draft" # not yet wired into the fight engine; see issue
status = "active" # wired into `squabble fight`; see crates/squabble-core/src/polarity.rs

# ============================================================================
# THE CORE PRINCIPLE
Expand Down Expand Up @@ -170,8 +170,57 @@ note = "This is a contradiction with the environment declaration, NOT a judgemen

[gate-triage.detection]
source = "actions jobs API step conclusions"

# GROUND TRUTH, not a paraphrase. Census of 33 local `static-analysis-gate.yml`
# copies, 2026-09-04: both names below appear 33/33 with ZERO variants. The
# matcher in `squabble-core::polarity::step_concluded` is an EXACT compare, so
# the abbreviated form this file carried until now ("Create stub findings")
# matched no real job on earth — every green check would have classified
# `Genuine`, forever, and the vacuity classifier would have been its own fake
# green. Do not shorten these for readability.
signature-skipped-steps = ["Run Hypatia scan"]
signature-success-steps = ["Create stub findings"]
signature-success-steps = ["Create stub findings (when Hypatia unavailable)"]
check-conclusion-when-vacuous = "success"
polarity = "green" # NOT red — this is the gap in `fight`
evidence-required = ["run-count", "stub-rate", "upstream-exists", "target-tech-present"]

# Coverage, stated honestly. The same workflow carries two more stub paths of
# identical shape, found by the same census:
# "Run panic-attack assail" / "Create stub findings (when panic-attack unavailable)" 33/33
# "Run Patch Bridge triage" / "Create stub report (when unavailable)" 27/33
# Neither is covered here. The matcher quantifies with `all`, so one signature
# describes one scanner; and each scanner is a separate JOB, hence a separate
# check, so an unmatched scanner is NOT escalated on the signature. That is an
# UNDERCOUNT, never a false alarm — with one stated exception: a job that
# concluded success having recorded steps, every one of them SKIPPED, is
# reported `all-steps-skipped` whether or not any signature matched. That is
# not a false alarm either; a job that ran nothing enforced nothing, and the
# finding stands on its own terms without naming a scanner.
#
# A green whose job records NO steps at all is the opposite case: absence of
# evidence, not evidence of absence. It is reported to the operator on stderr
# and classified `Genuine`, never escalated.
#
# A second signature needs a repeatable table in this file plus a parser
# change, so it is filed rather than bodged in.
signature-covers = ["hypatia"]
signature-not-yet-covered = ["panic-attack", "patch-bridge"]

# Measurability, stated honestly (Doctrine #10, no overclaim).
# `run-count` and `stub-rate` are NOT a measured history. `squabble fight`
# inspects ONE run, so it reports run-count 1 and a rate that is trivially 0.0
# or 1.0; a real rate needs a runs-list probe. `upstream-exists` and
# `target-tech-present` need a repo-tree probe that no gate declares globs for,
# so they render `unmeasured` rather than being guessed at. An unmeasured field
# can never reach the destructive recommendation, and a single-run rate must
# never be read as evidence of a pattern. Removal advice waits for real
# measurement.
evidence-measurable-by-host = ["run-count", "stub-rate"]
evidence-unmeasured-by-host = ["upstream-exists", "target-tech-present"]
evidence-single-run-only = ["run-count", "stub-rate"]

# Applicability (axis 0) is CORE-ONLY. `classify()` takes an `Applicability` and
# a `RepoDeclaration`, but `squabble fight` passes the defaults, so the owner's
# operator-type / channel axis cannot fire in production yet. Declared here so
# the gap is visible instead of being implied to work.
applicability-wired-into-host = false
11 changes: 11 additions & 0 deletions .machine_readable/root-allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,14 @@ MAINTAINERS # Project file that must remain at the repository ro
REQUIRES_INITIALISATION.md # Project documentation.
flake.guix # Project file that must remain at the repository root.
mise.toml # Build/tool manifest — must sit at the root to be found.

# ─── Declared 2026-09-04: AsciiDoc-by-default migration + tool dotfile.
# The .md docs above were converted to .adoc (openssf-compliance and
# docs checks already accept either); the old entries are left above
# for tooling that still references the historical .md names.
ARCHITECTURE.adoc # Project documentation (AsciiDoc-by-default).
CHANGELOG.adoc # Project documentation (AsciiDoc-by-default).
CODE_OF_CONDUCT.adoc # Project documentation (AsciiDoc-by-default).
CONTRIBUTING.adoc # Project documentation (AsciiDoc-by-default).
SECURITY.adoc # Project documentation (AsciiDoc-by-default).
.mise.toml # Repo-specific mise config; must sit at the root to be found.
71 changes: 60 additions & 11 deletions crates/squabble-cli/src/boj.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,19 @@ struct ExpertCall {
/// obligation never reroutes to a different one (a HypatiaFleet escalation
/// with a `scan` obligation is still hypatia's case, possibly *using* a
/// scan). The routing is deliberately host-side data, not core types.
fn route(e: &Escalation, repo_path: &str) -> ExpertCall {
///
/// Returns `None` for a group with **no cartridge**. That is not a gap to be
/// filled with a wildcard arm: routing `GateTriage` to hypatia would reintroduce
/// the hypatia dependency this repo declares as an IS-NOT, by the back door.
fn route(e: &Escalation, repo_path: &str) -> Option<ExpertCall> {
match e.group {
ExpertGroup::Security => ExpertCall {
ExpertGroup::Security => Some(ExpertCall {
cartridge: "panic-attack-mcp",
tool: "panic_attack_scan",
arguments: serde_json::json!({ "path": repo_path }),
meaning: "weak-point scan",
},
ExpertGroup::Proof => ExpertCall {
}),
ExpertGroup::Proof => Some(ExpertCall {
cartridge: "echidna-llm-mcp",
tool: "consult",
arguments: serde_json::json!({
Expand All @@ -154,16 +158,20 @@ fn route(e: &Escalation, repo_path: &str) -> ExpertCall {
)
}),
meaning: "proof consultation",
},
}),
// Hypatia / HypatiaFleet: hypatia assesses; the fleet cartridge only
// *tracks* gate results. Actuation (fix + PR) has no cartridge today
// and stays external — recorded as such.
ExpertGroup::Hypatia | ExpertGroup::HypatiaFleet => ExpertCall {
ExpertGroup::Hypatia | ExpertGroup::HypatiaFleet => Some(ExpertCall {
cartridge: "hypatia-mcp",
tool: "hypatia_scan_repo",
arguments: serde_json::json!({ "path": repo_path }),
meaning: "hypatia assessment (actuation external — no fixer cartridge yet)",
},
}),
// A vacuous-gate finding is the owner's to discharge. No cartridge can
// assess whether a gate is worth keeping — the `gate_triage` directive
// reserves that judgement to the owner.
ExpertGroup::GateTriage => None,
}
}

Expand Down Expand Up @@ -218,7 +226,27 @@ pub fn summon(outcome: &mut Outcome, repo_root: &Path) {
continue;
}
};
let call = route(e, repo_path);
// `no-silent-skip`: a group with no cartridge is recorded as an honest
// non-dispatch, not quietly passed over. The escalation stands.
let Some(call) = route(e, repo_path) else {
e.evidence
.push_str(" | summon: owner-facing group — no cartridge to dispatch to");
report.blockers.push(format!(
"summon `{}`: owner-facing group — no cartridge, escalation stands",
e.check
));
verdicts.push(ExpertVerdict {
check: e.check.clone(),
cartridge: "(none)".to_string(),
tool: "(none)".to_string(),
ok: false,
meaning: "owner-facing; no cartridge".to_string(),
verdict:
"not dispatched — the gate_triage directive reserves this judgement to the owner"
.to_string(),
});
continue;
};
match client.invoke(call.cartridge, call.tool, call.arguments) {
Ok(v) => {
e.evidence.push_str(&format!(
Expand Down Expand Up @@ -281,12 +309,14 @@ mod tests {
let sec = route(
&escalation(ExpertGroup::Security, EscalationKind::Scan),
"/r",
);
)
.expect("Security routes to a cartridge");
assert_eq!(sec.cartridge, "panic-attack-mcp");
let proof = route(
&escalation(ExpertGroup::Proof, EscalationKind::VerifyClaim),
"/r",
);
)
.expect("Proof routes to a cartridge");
assert_eq!(proof.cartridge, "echidna-llm-mcp");
// The obligation must never reroute away from the planner's chosen
// specialist: HypatiaFleet stays hypatia's case even for scan/verify.
Expand All @@ -296,12 +326,31 @@ mod tests {
EscalationKind::DispatchFix,
EscalationKind::AssessConfidence,
] {
let fleet = route(&escalation(ExpertGroup::HypatiaFleet, obligation), "/r");
let fleet = route(&escalation(ExpertGroup::HypatiaFleet, obligation), "/r")
.expect("HypatiaFleet routes to a cartridge");
assert_eq!(fleet.cartridge, "hypatia-mcp");
assert!(fleet.meaning.contains("actuation external"));
}
}

#[test]
fn gate_triage_has_no_cartridge() {
// ANCHOR: `hypatia-dependent` is an IS-NOT, and the gate_triage directive
// sets `fallback-must-be-standalone = true`. A wildcard arm here would
// have routed owner-facing findings to hypatia by the back door.
for obligation in [
EscalationKind::Scan,
EscalationKind::VerifyClaim,
EscalationKind::DispatchFix,
EscalationKind::AssessConfidence,
] {
assert!(
route(&escalation(ExpertGroup::GateTriage, obligation), "/r").is_none(),
"GateTriage must never dispatch to a cartridge"
);
}
}

#[test]
fn body_error_detects_estate_error_shapes() {
// The boj router unwraps cartridge failures into 200 bodies; both
Expand Down
Loading
Loading