Skip to content

Commit 6e8706a

Browse files
committed
Investigated codemarie’s completion gate stack (auditGateReport.ts, gatePolicy.ts, completionAudit.ts, attemptCompletionUtils.ts) and ported the portable enforcement layer into Hermes as a third gate tier alongside JoyZoning convergence and roadmap steering.
Architecture (codemarie → Hermes) Codemarie Hermes equivalent attempt_completion + audit pipeline kanban_complete + pre_tool_call evaluateCompletionGate() lib/agent/audit/completion_gate.py Hardening score / severity tiers hardening.py + severity.py Spider gate at completion spider_gate RPC + spider_runner.py Governance violations governance_hooks → session audit store explain_gate / CI status Roadmap explain_gate + convergence_status What was added lib/agent/audit/ — portable quality gate engine: Score/threshold evaluation with intent adjustments (FIX/TEST/DELETE get stricter thresholds) Critical-only mode, plan regression, advisory escalation, new-violations-only (baseline) support Session-scoped violation tracking (session_store.py) Spider gate runner via new spider_gate Hermes RPC op Hook integration: convergence_gate.py — blocks kanban_complete when quality gate fails (after JoyZoning + roadmap checks) lib/runtime/audit_hooks.py — records broccolidb_violations / joyzoning tool outputs governance_exemptions.py — records governance blocks into audit session explain_gate + convergence_status — now include combined quality_gate + unified kanban_complete_allowed Configuration Enable under Hermes config.yaml: joyzoning: governance: enabled: true completion_gate: enabled: true # defaults on when governance.enabled is true score_threshold: 50 critical_only: false spider_gate_required: true spider_scope: changed-files fail_on_spider_warning: false require_recent_verify: false # set true to require joyzoning verify before complete max_block_count: 10 # circuit breaker Gate evaluation order for kanban_complete JoyZoning convergence (review / converged state) Roadmap steering gates (stale checkpoint, validation pending, etc.) Quality audit gate (Spider structural gate + hardening score + optional verify evidence) Verification 141 tests OK (make verify) BroccoliDB builds with spider_gate RPC Intentionally not ported (VS Code / webview-only) SARIF/JUnit/GitHub Checks artifact writers (.audit/gate-policy.json workspace files) attempt_completion result-length / demo-command preflight Focus-chain alignment checks Live audit UI (AuditGateBlockRow, settings toggles) Those can be added later as Hermes slash commands or CI scripts if you want parity artifacts.
1 parent 31ef74f commit 6e8706a

20 files changed

Lines changed: 1016 additions & 5 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ verify test-roadmap:
2525
python3 scripts/roadmap_smoke.py
2626
python3 scripts/roadmap_audit.py
2727
python3 scripts/roadmap_operator_smoke.py
28-
python3 -m unittest tests.test_roadmap_checkpoint tests.test_roadmap_tools tests.test_roadmap_external_watch tests.test_project_map_tools tests.test_layer_align tests.test_native_mutation tests.test_mem_tools -q
28+
python3 -m unittest tests.test_roadmap_checkpoint tests.test_roadmap_tools tests.test_roadmap_external_watch tests.test_project_map_tools tests.test_layer_align tests.test_native_mutation tests.test_mem_tools tests.test_quality_gate -q
2929

3030
install:
3131
python3 install.py

audit.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"lib/runtime/kanban_hooks.py",
7171
"lib/runtime/jsdp_hooks.py",
7272
"lib/runtime/roadmap_hooks.py",
73+
"lib/runtime/audit_hooks.py",
7374
"lib/tools/roadmap_tools.py",
7475
"lib/agent/roadmap/schema.py",
7576
"lib/agent/roadmap/evidence.py",
@@ -94,6 +95,8 @@
9495
"lib/agent/roadmap/workspace_state.py",
9596
"lib/agent/roadmap/explain_gate.py",
9697
"lib/agent/roadmap/gate.py",
98+
"lib/agent/audit/completion_gate.py",
99+
"lib/agent/audit/quality_gate.py",
97100
"lib/agent/roadmap/skill_install.py",
98101
"optional-skills/dietcode/auto-rolling-roadmap/SKILL.md",
99102
"lib/runtime/mutation_hooks.py",

broccolidb/infrastructure/hermes/agent_invoke.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const AGENT_OPS = [
3333
"simulate_merge_forecast",
3434
"acquire_lock",
3535
"release_lock",
36+
"spider_gate",
3637
"verify_sovereignty",
3738
] as const;
3839

@@ -304,6 +305,25 @@ export async function runAgentInvoke(
304305
}
305306
return { success: true, released: true, resource };
306307
}
308+
case "spider_gate": {
309+
const scope = String(args.scope ?? "changed-files") === "all" ? "all" : "changed-files";
310+
const gate = await ctx.graph.spider.gate({ scope, includeTypes: false });
311+
const findings = (gate.report?.findings ?? []).slice(0, 20).map((f) => ({
312+
diagnosticId: f.diagnosticId,
313+
message: f.message,
314+
severity: f.severity,
315+
filePath: f.filePath,
316+
}));
317+
return {
318+
success: true,
319+
blocked: gate.blocked,
320+
exitCode: gate.exitCode,
321+
conclusion: gate.conclusion,
322+
findingCount: gate.report?.findings?.length ?? 0,
323+
reportId: gate.report?.reportId,
324+
findings,
325+
};
326+
}
307327
case "verify_sovereignty": {
308328
const nodeId = String(args.kb_id ?? args.kbId ?? "");
309329
const result = await ctx.reasoning.verifySovereignty({ nodeId });

hooks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,11 @@ def _ensure_handlers() -> None:
6767
_on_post_tool_call as kanban_post,
6868
_on_session_start as kanban_start,
6969
)
70+
from plugins.dietcode.lib.runtime.audit_hooks import _post_tool_call as audit_post
7071

7172
_ON_SESSION_START = (kanban_start, jz_start, jsdp_start, roadmap_start)
7273
_ON_SESSION_END = (jz_end, roadmap_end)
73-
_POST_TOOL_CALL = (jz_post, mutation_post, kanban_post, roadmap_post)
74+
_POST_TOOL_CALL = (jz_post, mutation_post, kanban_post, roadmap_post, audit_post)
7475
_PRE_TOOL_CALL = (jz_pre, roadmap_pre)
7576
_TRANSFORM_TOOL_RESULT = (
7677
on_mutation_journal_transform,

lib/agent/audit/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Quality audit gate — SonarQube-style completion enforcement for Hermes."""
2+
3+
__all__ = (
4+
"CompletionGateDecision",
5+
"build_gate_block_message",
6+
"evaluate_completion_gate",
7+
"explain_quality_gate",
8+
"get_completion_gate_config",
9+
"kanban_complete_allowed",
10+
"record_governance_block",
11+
"record_tool_quality_result",
12+
)
13+
14+
15+
def __getattr__(name: str):
16+
if name == "CompletionGateDecision":
17+
from plugins.dietcode.lib.agent.audit.completion_gate import CompletionGateDecision
18+
19+
return CompletionGateDecision
20+
if name in ("build_gate_block_message", "evaluate_completion_gate"):
21+
from plugins.dietcode.lib.agent.audit import completion_gate as mod
22+
23+
return getattr(mod, name)
24+
if name == "get_completion_gate_config":
25+
from plugins.dietcode.lib.agent.audit.config import get_completion_gate_config
26+
27+
return get_completion_gate_config
28+
if name in (
29+
"explain_quality_gate",
30+
"kanban_complete_allowed",
31+
"record_governance_block",
32+
"record_tool_quality_result",
33+
):
34+
from plugins.dietcode.lib.agent.audit import quality_gate as mod
35+
36+
return getattr(mod, name)
37+
raise AttributeError(name)

lib/agent/audit/completion_gate.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Unified completion gate evaluator — port of auditGateReport.ts."""
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass
5+
from typing import Any, Optional
6+
7+
from plugins.dietcode.lib.agent.audit.config import CompletionGateConfig, get_completion_gate_config
8+
from plugins.dietcode.lib.agent.audit.gate_policy import resolve_effective_gate_threshold
9+
from plugins.dietcode.lib.agent.audit.hardening import compute_hardening_assessment
10+
from plugins.dietcode.lib.agent.audit.severity import has_critical_violations, partition_violations
11+
12+
13+
@dataclass(frozen=True)
14+
class GateReason:
15+
code: str
16+
message: str
17+
18+
19+
@dataclass(frozen=True)
20+
class CompletionGateDecision:
21+
blocked: bool
22+
score: int
23+
effective_threshold: int
24+
grade: str
25+
reasons: tuple[GateReason, ...]
26+
27+
28+
def evaluate_completion_gate(
29+
metadata: dict[str, Any],
30+
*,
31+
config: Optional[CompletionGateConfig] = None,
32+
baseline_metadata: Optional[dict[str, Any]] = None,
33+
advisory_metadata: Optional[dict[str, Any]] = None,
34+
) -> CompletionGateDecision:
35+
cfg = config or get_completion_gate_config()
36+
assessment = compute_hardening_assessment(metadata)
37+
score = int(metadata.get("hardening_score") or assessment["score"])
38+
grade = str(metadata.get("hardening_grade") or assessment["grade"])
39+
intent = str(metadata.get("intent_classification") or "GENERAL")
40+
effective_threshold = resolve_effective_gate_threshold(
41+
cfg.score_threshold,
42+
intent,
43+
intent_adjustments_enabled=cfg.intent_adjusted_threshold,
44+
)
45+
46+
violations = list(metadata.get("violations") or [])
47+
if cfg.new_violations_only and baseline_metadata:
48+
baseline_set = set(baseline_metadata.get("violations") or [])
49+
violations = [v for v in violations if v not in baseline_set]
50+
51+
reasons: list[GateReason] = []
52+
53+
if not cfg.enabled:
54+
return CompletionGateDecision(
55+
blocked=False,
56+
score=score,
57+
effective_threshold=effective_threshold,
58+
grade=grade,
59+
reasons=(GateReason("gate_disabled", "Completion gate disabled"),),
60+
)
61+
62+
if cfg.advisory_escalation_enabled and advisory_metadata:
63+
adv_critical = has_critical_violations(advisory_metadata.get("violations"))
64+
if adv_critical and has_critical_violations(metadata.get("violations")):
65+
reasons.append(
66+
GateReason(
67+
"advisory_escalation",
68+
"Critical act-mode advisory findings remain unresolved",
69+
)
70+
)
71+
72+
if cfg.plan_regression_gate_enabled and baseline_metadata:
73+
base_score = int(
74+
baseline_metadata.get("hardening_score")
75+
or compute_hardening_assessment(baseline_metadata)["score"]
76+
)
77+
if score < base_score:
78+
reasons.append(
79+
GateReason(
80+
"plan_regression",
81+
"Hardening score regressed from plan audit baseline",
82+
)
83+
)
84+
85+
spider = metadata.get("spider_gate") or {}
86+
if cfg.spider_gate_required:
87+
if spider.get("blocked"):
88+
reasons.append(
89+
GateReason(
90+
"spider_gate_blocked",
91+
f"Spider structural gate blocked (exit {spider.get('exitCode', 1)})",
92+
)
93+
)
94+
elif cfg.fail_on_spider_warning and str(spider.get("qualityGate", "")).upper() == "WARNING":
95+
reasons.append(
96+
GateReason("spider_warning", "Spider gate reported WARNING-level structural findings")
97+
)
98+
99+
if cfg.require_recent_verify and not metadata.get("recent_verify_passed"):
100+
reasons.append(
101+
GateReason(
102+
"missing_validation_evidence",
103+
"No passing joyzoning verify recorded for active mutation scope",
104+
)
105+
)
106+
107+
if cfg.new_violations_only:
108+
if cfg.critical_only:
109+
if has_critical_violations(violations):
110+
reasons.append(
111+
GateReason(
112+
"critical_violations",
113+
f"{len(violations)} new critical violation(s) since baseline",
114+
)
115+
)
116+
elif violations:
117+
reasons.append(
118+
GateReason(
119+
"policy_violations",
120+
f"{len(violations)} new violation(s) since baseline",
121+
)
122+
)
123+
elif cfg.critical_only and has_critical_violations(metadata.get("violations")):
124+
reasons.append(
125+
GateReason(
126+
"critical_violations",
127+
f"{len(metadata.get('violations') or [])} critical violation(s) present",
128+
)
129+
)
130+
elif score < effective_threshold:
131+
if assessment["critical_count"] > 0 or violations:
132+
reasons.append(
133+
GateReason(
134+
"score_below_threshold",
135+
f"Score {score} below threshold {effective_threshold}",
136+
)
137+
)
138+
139+
blocked = any(r.code != "gate_disabled" for r in reasons)
140+
return CompletionGateDecision(
141+
blocked=blocked,
142+
score=score,
143+
effective_threshold=effective_threshold,
144+
grade=grade,
145+
reasons=tuple(reasons),
146+
)
147+
148+
149+
def build_gate_block_message(decision: CompletionGateDecision, metadata: dict[str, Any]) -> str:
150+
if not decision.blocked:
151+
return (
152+
f"Gate ready: Grade {decision.grade} "
153+
f"({decision.score}/100, threshold {decision.effective_threshold})"
154+
)
155+
parts = partition_violations(metadata.get("violations"))
156+
lines = [
157+
"COMPLETION BLOCKED — quality audit gate failed.",
158+
f"Grade: {decision.grade} ({decision.score}/100, threshold {decision.effective_threshold}).",
159+
"",
160+
"Resolve before kanban_complete:",
161+
]
162+
for reason in decision.reasons:
163+
if reason.code != "gate_disabled":
164+
lines.append(f"- {reason.message}")
165+
critical = parts["critical"][:5]
166+
warning = parts["warning"][:3]
167+
if critical:
168+
lines.append(f"Critical: {', '.join(critical)}")
169+
if warning:
170+
lines.append(f"Warnings: {', '.join(warning)}")
171+
lines.append("")
172+
lines.append("Run broccolidb_violations / joyzoning(action='verify') then retry.")
173+
return "\n".join(lines)

lib/agent/audit/config.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Completion gate configuration — mirrors codemarie auditCompletionGate* settings."""
2+
from __future__ import annotations
3+
4+
import time
5+
from dataclasses import dataclass
6+
from typing import Optional
7+
8+
_config_cache: Optional["CompletionGateConfig"] = None
9+
_config_cache_at: float = 0.0
10+
_CONFIG_TTL = 30.0
11+
12+
13+
@dataclass(frozen=True)
14+
class CompletionGateConfig:
15+
enabled: bool = False
16+
score_threshold: int = 50
17+
critical_only: bool = False
18+
spider_gate_required: bool = True
19+
spider_scope: str = "changed-files"
20+
fail_on_spider_warning: bool = False
21+
require_recent_verify: bool = False
22+
intent_adjusted_threshold: bool = True
23+
plan_regression_gate_enabled: bool = True
24+
advisory_escalation_enabled: bool = True
25+
new_violations_only: bool = False
26+
max_block_count: int = 10
27+
28+
@classmethod
29+
def load(cls) -> "CompletionGateConfig":
30+
try:
31+
from hermes_cli.config import load_config
32+
33+
raw = load_config() or {}
34+
jz = raw.get("joyzoning") if isinstance(raw, dict) else {}
35+
gov = jz.get("governance") if isinstance(jz, dict) else {}
36+
if not isinstance(gov, dict):
37+
gov = {}
38+
cg = gov.get("completion_gate") if isinstance(gov.get("completion_gate"), dict) else {}
39+
gov_enabled = bool(gov.get("enabled", False))
40+
enabled = cg.get("enabled")
41+
if enabled is None:
42+
enabled = gov_enabled
43+
return cls(
44+
enabled=bool(enabled),
45+
score_threshold=int(cg.get("score_threshold", 50)),
46+
critical_only=bool(cg.get("critical_only", False)),
47+
spider_gate_required=bool(cg.get("spider_gate_required", True)),
48+
spider_scope=str(cg.get("spider_scope") or "changed-files"),
49+
fail_on_spider_warning=bool(cg.get("fail_on_spider_warning", False)),
50+
require_recent_verify=bool(cg.get("require_recent_verify", False)),
51+
intent_adjusted_threshold=bool(cg.get("intent_adjusted_threshold", True)),
52+
plan_regression_gate_enabled=bool(cg.get("plan_regression_gate_enabled", True)),
53+
advisory_escalation_enabled=bool(cg.get("advisory_escalation_enabled", True)),
54+
new_violations_only=bool(cg.get("new_violations_only", False)),
55+
max_block_count=int(cg.get("max_block_count", 10)),
56+
)
57+
except Exception:
58+
return cls()
59+
60+
61+
def get_completion_gate_config() -> CompletionGateConfig:
62+
global _config_cache, _config_cache_at
63+
now = time.monotonic()
64+
if _config_cache is None or (now - _config_cache_at) > _CONFIG_TTL:
65+
_config_cache = CompletionGateConfig.load()
66+
_config_cache_at = now
67+
return _config_cache

lib/agent/audit/gate_policy.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Gate policy constants — port of src/shared/audit/gatePolicy.ts."""
2+
from __future__ import annotations
3+
4+
from typing import Optional
5+
6+
COMPLETION_GATE_SCORE_THRESHOLD = 50
7+
MAX_COMPLETION_GATE_BLOCK_COUNT = 10
8+
COMPLETION_GATE_WARN_THRESHOLD = 5
9+
COMPLETION_RESULT_MIN_LENGTH = 40
10+
11+
DEFAULT_INTENT_THRESHOLD_ADJUSTMENTS: dict[str, int] = {
12+
"FIX": 10,
13+
"TEST": 10,
14+
"DELETE": 5,
15+
"INVESTIGATE": 5,
16+
}
17+
18+
19+
def resolve_effective_gate_threshold(
20+
base_threshold: int,
21+
intent: Optional[str] = None,
22+
*,
23+
intent_adjustments_enabled: bool = True,
24+
) -> int:
25+
if not intent_adjustments_enabled:
26+
return base_threshold
27+
adjustment = DEFAULT_INTENT_THRESHOLD_ADJUSTMENTS.get((intent or "").upper(), 0)
28+
return max(0, min(100, base_threshold + adjustment))

0 commit comments

Comments
 (0)