016b348117
Phase 0 (foundation):
- Cause enum (controller_events.cause) for action attribution
- Schema: grooming_decisions audit table; workflows gains
grooming_evaluated_at + deferred_reason + deferred_at +
deferred_target_workflow_id; pulls gains touched_files
- audit_comments: CLOSE / DEFER templates + render_comment_template
- forgejo_writes: close_issue + defer_issue 5-step crash-safe protocol
(fingerprint dedup, error matrix, dry-run)
- patch_pr_state callback in forgejo_http
- grooming_config: 22-env-var frozen-dataclass config + log_effective
- pulls.touched_files cache extension (_pipeline_cache.py schema v8)
- reaper.reap_grooming_decisions audit-retention sweep
- reconciliation RESUME guard (deferred_reason)
Phase 1 (worker-queue shape, 2026-05-25):
- New state: GROOMING. New events: grooming_started, groom_verdict_
{proceed,defer,close}. 5 new transitions; all invariants still clean
- GroomingInputV1 + GroomingOutputV1 Pydantic contracts
- outcomes._map_grooming_outcome routes verdicts to state-machine events
- prefetch.build_grooming_stage_b_input + list_open_prs callback
- scheduler GROOMING -> grooming_stage_b role
- promote: cfg-gated DISCOVERED -> GROOMING when CONTROLLER_GROOMING_
ENABLED=true; issues skip grooming
- forgejo_writes decomposed: close_act/defer_act (Forgejo writes only;
state-machine already transitioned) + close_decide_and_act/
defer_decide_and_act (Phase 0 callers); _apply_workflow_transition
is underscore-private
- grooming.py library: tokenization, suspicion scoring (Jaccard +
weighted overlap), deterministic checks, action -> verdict mapping
- mcp/grooming_builder.py: 14-tool FastMCP server emits GroomingOutputV1
- .opencode/agents/grooming-stage-b.md: duplicate-detection agent
prompt (claude-haiku-4-5)
- grooming_side_effects.run_grooming_side_effects_tick: per-state tick
performs Forgejo writes after groom_verdict_{defer,close} fires.
Filters on event_type='transition' + payload.event (centralizes the
convention pending Phase 2's latest_transition_event helper)
- GroomingCallbacks frozen dataclass; loop.py + __main__.py wired
Worker role registry (single source of truth):
- worker/roles.py: WORKER_ROLES + WorkerRoleSpec + default_roles_csv
+ output_filename_for. agent_runner.ROLE_TO_MCP_MODULE / ROLE_TO_
OUTPUT_MODEL derive from it; opencode_session.agent_name_for reads
it for flat cases; all 6 prompt builders use output_filename_for;
worker --roles default = default_roles_csv(); launcher script
derives --roles via shell substitution. Cross-site invariant test
enforces alignment across 5 sites + opencode.json MCP registry.
Phase 0 silent-bug fix:
- reconciliation.py RESUME guard SELECT now includes deferred_reason
(was missing since Phase 0; guard was a silent no-op). Tightened
from getattr to attribute access to fail fast on future omissions.
Tests (1456 total, +91 grooming-specific):
- test_grooming_phase0.py: 34 tests (orchestrator matrix, crash
recovery, idempotency, dry-run)
- test_grooming_phase1.py: 60 tests (library, contracts, state
machine, outcomes, scheduler, promote, prefetch, act-variants
with signature parity, side-effect tick incl. natural-idempotency
+ executed-flag-skip + verdict-mismatch + reconciliation RESUME)
- test_mcp_builders.py TestGroomingBuilder: 29 tests (happy paths
+ 22 validation rules + Pydantic round-trip + master-tick-read-
path companion)
- test_worker_agent_runner.py TestRoleMaps: cross-role wiring
alignment + agent-prompt-vs-worker-fallback filename contract +
inspect.signature equality (close_act/defer_act vs
close_issue/defer_issue)
- test_state_machine.py: transition count 51 -> 56 +
events_from_grooming
Live-validated end-to-end on 4 staged sentinel PRs (#55-#58) in
dry_run: agent emits verdicts via MCP, state-machine transitions
fire, side-effect tick writes audit row, deferred_reason gates
reconciliation RESUME correctly.
Deferred refinements + Phase 2 prerequisite (latest_transition_event
helper) tracked in .drew/regressions-plan.md "Phase 1 follow-up
backlog".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
6.4 KiB
Python
167 lines
6.4 KiB
Python
"""Audit-comment templates and the post-commit substitution helper.
|
|
|
|
Phase 0 deliverable (grooming plan): the ``close_issue`` and
|
|
``defer_issue`` callbacks post a Markdown audit comment to Forgejo
|
|
that ties the action back to a row in the ``grooming_decisions``
|
|
audit table by its ``decision_id``.
|
|
|
|
The ``decision_id`` is only known **after** the in-txn audit-row
|
|
insert commits, so the templates carry a literal sentinel
|
|
``<substituted-from-step-1>`` that ``render_comment_template``
|
|
replaces with the actual id. All OTHER placeholders in the
|
|
templates use ``str.format``-style ``{name}`` markers and are
|
|
filled by the caller BEFORE the template reaches the render helper.
|
|
|
|
The two kinds of placeholders coexist because they live in
|
|
different lifecycle stages:
|
|
|
|
- ``{name}`` markers: caller-supplied values (gate, reason
|
|
category, canonical PR number, LLM confidence/reasoning, etc.)
|
|
— known at the moment the caller assembles the comment.
|
|
- ``<substituted-from-step-1>``: the audit row's primary key —
|
|
known only after the DB transaction commits.
|
|
|
|
See ``.drew/regressions-plan.md`` Phase 0, "Audit ID
|
|
substitution" and decision #25 for the design rationale.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
_PLACEHOLDER_RE = re.compile(r"<substituted-from-step-\d+>")
|
|
_KNOWN_PLACEHOLDERS: frozenset[str] = frozenset({"<substituted-from-step-1>"})
|
|
|
|
|
|
def render_comment_template(template: str, *, decision_id: int) -> str:
|
|
"""Substitute the post-commit ``decision_id`` into a comment template.
|
|
|
|
Replaces every occurrence of ``<substituted-from-step-1>`` with the
|
|
string form of ``decision_id``. Raises ``ValueError`` if the
|
|
template contains any ``<substituted-from-step-N>`` marker for
|
|
an unknown ``N`` (whitelist check — guards against silently
|
|
leaving an unsubstituted placeholder in the posted comment).
|
|
|
|
Does NOT scan for generic ``<...>`` patterns in the template,
|
|
because the comment body legitimately contains user-controlled
|
|
angle brackets (e.g. an LLM's preserved-value summary referencing
|
|
a Java type like ``Map<String, Object>``).
|
|
|
|
The helper is also defensive about the substitution itself:
|
|
after the ``replace`` it re-checks that no
|
|
``<substituted-from-step-1>`` text remains in the output. The
|
|
only way that could fail is a future caller-bug where the
|
|
sentinel appears inside data that was substituted in by an
|
|
earlier ``str.format`` call, which is worth catching loudly.
|
|
"""
|
|
found = set(_PLACEHOLDER_RE.findall(template))
|
|
unknown = found - _KNOWN_PLACEHOLDERS
|
|
if unknown:
|
|
raise ValueError(
|
|
"render_comment_template: template contains unknown placeholders "
|
|
f"{sorted(unknown)}; only {sorted(_KNOWN_PLACEHOLDERS)} are "
|
|
"supported. Either add the placeholder to "
|
|
"_KNOWN_PLACEHOLDERS + a substitution branch, or remove it "
|
|
"from the template."
|
|
)
|
|
rendered = template.replace("<substituted-from-step-1>", str(decision_id))
|
|
if "<substituted-from-step-1>" in rendered:
|
|
raise RuntimeError(
|
|
"render_comment_template: substitution failed — "
|
|
"'<substituted-from-step-1>' still present after replace. "
|
|
"This indicates an upstream bug (the sentinel appeared in a "
|
|
"caller-supplied value)."
|
|
)
|
|
return rendered
|
|
|
|
|
|
# ─── close-comment template ─────────────────────────────────────────────
|
|
#
|
|
# Caller-supplied {name} placeholders (filled via str.format):
|
|
# gate — "Gate 1" / "Gate 2" / "Gate 3"
|
|
# reason_category — one of the abandon-reason categories
|
|
# (see .drew/regressions-plan.md "Abandon-reason
|
|
# categories — by gate")
|
|
# explanation — one-paragraph human-readable rationale
|
|
# canonical_pr_line — "Canonical (if duplicate): #42" OR "" (no line)
|
|
# confidence_line — "LLM confidence (when applicable): high" OR ""
|
|
# reasoning_line — "LLM reasoning (when applicable): ..." OR ""
|
|
#
|
|
# Render-helper-supplied placeholder (filled by render_comment_template):
|
|
# <substituted-from-step-1> — the grooming_decisions.decision_id
|
|
#
|
|
# The optional ``*_line`` placeholders are pre-formatted by the caller
|
|
# so the template stays linear. Empty-string substitution leaves a
|
|
# blank line; callers should pass either a fully-formed line or an
|
|
# empty string.
|
|
|
|
CLOSE_COMMENT_TEMPLATE = """\
|
|
[CONTROLLER-CLOSE:{gate}:{reason_category}]
|
|
|
|
{explanation}
|
|
|
|
Decision:
|
|
- Gate: {gate}
|
|
- Reason category: {reason_category}
|
|
{canonical_pr_line}
|
|
{confidence_line}
|
|
{reasoning_line}
|
|
|
|
Audit ID: <substituted-from-step-1>
|
|
|
|
---
|
|
Automated by the CleverAgents controller pipeline.
|
|
Identity: HAL9000 (pipeline action)
|
|
"""
|
|
|
|
|
|
# ─── defer-comment template ─────────────────────────────────────────────
|
|
#
|
|
# Caller-supplied {name} placeholders:
|
|
# gate, reason_category, canonical_pr_number, confidence, reasoning,
|
|
# preserved_value_line — "Preserved value (when applicable): ..." OR ""
|
|
# workflow_id — the deferred workflow's id (used in clear-SQL)
|
|
#
|
|
# Render-helper-supplied: <substituted-from-step-1>
|
|
|
|
DEFER_COMMENT_TEMPLATE = """\
|
|
[CONTROLLER-DEFER:{gate}:{reason_category}]
|
|
|
|
This PR has been deferred for re-evaluation. The controller has stepped back
|
|
from processing it. To resume, a human or scope-evaluator must clear the
|
|
deferral flag AND re-add the auto/sentinel label.
|
|
|
|
Decision:
|
|
- Gate: {gate}
|
|
- Reason category: {reason_category}
|
|
- Canonical: #{canonical_pr_number}
|
|
- LLM confidence: {confidence}
|
|
- LLM reasoning: {reasoning}
|
|
{preserved_value_line}
|
|
|
|
To clear the deferral (SQL):
|
|
UPDATE workflows SET deferred_reason=NULL,
|
|
deferred_at=NULL,
|
|
deferred_target_workflow_id=NULL
|
|
WHERE workflow_id = {workflow_id};
|
|
|
|
INSERT INTO controller_events
|
|
(workflow_id, ts, event_type, payload, cause, forgejo_write_pending, replay_attempts)
|
|
VALUES ({workflow_id}, datetime('now'), 'deferral_cleared',
|
|
json_object('cleared_by', 'operator', 'reason', '<your reason>'),
|
|
'operator', 0, 0);
|
|
|
|
Audit ID: <substituted-from-step-1>
|
|
|
|
---
|
|
Automated by the CleverAgents controller pipeline.
|
|
Identity: HAL9000 (pipeline action)
|
|
"""
|
|
|
|
|
|
__all__ = [
|
|
"CLOSE_COMMENT_TEMPLATE",
|
|
"DEFER_COMMENT_TEMPLATE",
|
|
"render_comment_template",
|
|
]
|