mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
sync_branch (B1, 250be5c2) is a git-only rebase+force-push verb (composes=(),
no DB transition, side_effects=()) but was never registered in the tracing
parity tables, so test_every_intent_verb_has_a_tracing_decision failed.
Mirrors open_pr: a mechanical git op with inline preconditions (ownership),
no journal/plan rationale required.
416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""Tracing-gate policy — verb→required-set table + check_requirements.
|
|
|
|
Replaces:
|
|
- services/gateway/tracing_gate.py (the entire module)
|
|
- 6 inline `journal:decision` checks scattered in choreographer/_impl.py
|
|
- inline gates in choreographer/qa.py (QA pass/fail)
|
|
- inline gates in choreographer/doc.py (i_documented)
|
|
|
|
Adds (per spec §11 P1-P4 pre-gateway parity restorations):
|
|
- JOURNAL_NOTE_AT_CLAIM — required by i_will_work_on
|
|
- JOURNAL_DECISION_AT_CLAIM — required by i_will_plan
|
|
- JOURNAL_DURING_WORK_AT_LEAST_ONE — required by i_am_done
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
|
|
class Requirement(StrEnum):
|
|
PLAN = "plan"
|
|
COMMITS_AT_LEAST_ONE = "commits>=1"
|
|
PR_OPEN = "pr_open"
|
|
PROGRESS_AT_LEAST_ONE = "progress>=1"
|
|
JOURNAL_REFLECT = "journal:reflect"
|
|
JOURNAL_DECISION = "journal:decision"
|
|
JOURNAL_LEARNING = "journal:learning"
|
|
JOURNAL_STRUGGLE = "journal:struggle"
|
|
JOURNAL_NOTE_AT_CLAIM = "journal:note_at_claim"
|
|
JOURNAL_DECISION_AT_CLAIM = "journal:decision_at_claim"
|
|
JOURNAL_DURING_WORK_AT_LEAST_ONE = "journal:during_work>=1"
|
|
ACCEPTANCE_CRITERIA_ADDRESSED = "acceptance_criteria_addressed"
|
|
QA_NOTES_MIN_CHARS = "qa_notes>=min"
|
|
QA_EVIDENCE_INSPECTED = "qa_evidence_inspected"
|
|
DOCS_NOTES_MIN_CHARS = "docs_notes>=min"
|
|
DOCS_FILES_NON_EMPTY = "docs_files_non_empty"
|
|
SELF_VERIFIED = "self_verified"
|
|
NOTES_MIN_CHARS = "notes>=min"
|
|
SUBTASKS_TERMINAL = "subtasks_terminal"
|
|
# Role note-section obligations (parity with the journal requirements): a
|
|
# role with a dedicated note section must populate it via
|
|
# note(scope='handoff') the same way journals are obligated. The developer's
|
|
# dev_notes, the PR reviewer's pr_reviewer_notes, and the PM's quick_context
|
|
# had no agent write-path before — these obligate the section now that one
|
|
# exists.
|
|
DEV_NOTES_MIN_CHARS = "dev_notes>=min"
|
|
PR_REVIEWER_NOTES_MIN_CHARS = "pr_reviewer_notes>=min"
|
|
QUICK_CONTEXT_MIN_CHARS = "quick_context>=min"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GateContext:
|
|
"""Ambient inputs the checker needs that don't live on the Task model."""
|
|
|
|
journal_reflect_present: bool = False
|
|
journal_decision_present: bool = False
|
|
journal_learning_present: bool = False
|
|
journal_struggle_present: bool = False
|
|
journal_note_at_claim_present: bool = False
|
|
journal_during_work_count: int = 0
|
|
qa_notes_min_chars: int = 80
|
|
docs_notes_min_chars: int = 20
|
|
notes_min_chars: int = 20
|
|
dev_notes_min_chars: int = 40
|
|
pr_reviewer_notes_min_chars: int = 40
|
|
quick_context_min_chars: int = 30
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GateResult:
|
|
passed: bool
|
|
missing: list[str] = field(default_factory=list)
|
|
|
|
|
|
Checker = Callable[[Any, GateContext], list[str]]
|
|
|
|
|
|
def _check_plan(task: Any, _ctx: GateContext) -> list[str]:
|
|
return [] if getattr(task, "plan", None) else ["plan"]
|
|
|
|
|
|
def _check_commits(task: Any, _ctx: GateContext) -> list[str]:
|
|
commits = getattr(task, "commits", None) or []
|
|
return [] if len(commits) >= 1 else ["commits>=1"]
|
|
|
|
|
|
def _check_pr_open(task: Any, _ctx: GateContext) -> list[str]:
|
|
return [] if getattr(task, "pr_number", None) else ["pr_open"]
|
|
|
|
|
|
def _check_progress(task: Any, _ctx: GateContext) -> list[str]:
|
|
progress = getattr(task, "progress_updates", None) or []
|
|
return [] if len(progress) >= 1 else ["progress>=1"]
|
|
|
|
|
|
def _check_journal_reflect(_task: Any, ctx: GateContext) -> list[str]:
|
|
return [] if ctx.journal_reflect_present else ["journal:reflect"]
|
|
|
|
|
|
def _check_journal_decision(_task: Any, ctx: GateContext) -> list[str]:
|
|
return [] if ctx.journal_decision_present else ["journal:decision"]
|
|
|
|
|
|
def _check_journal_learning(_task: Any, ctx: GateContext) -> list[str]:
|
|
return [] if ctx.journal_learning_present else ["journal:learning"]
|
|
|
|
|
|
def _check_journal_struggle(_task: Any, ctx: GateContext) -> list[str]:
|
|
return [] if ctx.journal_struggle_present else ["journal:struggle"]
|
|
|
|
|
|
def _check_journal_note_at_claim(_task: Any, ctx: GateContext) -> list[str]:
|
|
return [] if ctx.journal_note_at_claim_present else ["journal:note_at_claim"]
|
|
|
|
|
|
def _check_journal_decision_at_claim(_task: Any, ctx: GateContext) -> list[str]:
|
|
# Reuse JOURNAL_DECISION presence flag; "_at_claim" timing is the
|
|
# caller's responsibility (i_will_plan only requires a decision entry
|
|
# exists for this task by this agent — its position in the timeline
|
|
# is enforced by the verb's call order, not the gate).
|
|
return [] if ctx.journal_decision_present else ["journal:decision_at_claim"]
|
|
|
|
|
|
def _check_journal_during_work(_task: Any, ctx: GateContext) -> list[str]:
|
|
return [] if ctx.journal_during_work_count >= 1 else ["journal:during_work>=1"]
|
|
|
|
|
|
def _unaddressed_criteria(task: Any) -> list[str]:
|
|
criteria = list(getattr(task, "acceptance_criteria", []) or [])
|
|
status_rows = list(getattr(task, "acceptance_criteria_status", []) or [])
|
|
addressed = {
|
|
s["criterion"]
|
|
for s in status_rows
|
|
if isinstance(s, dict) and s.get("referencing_artifact_id")
|
|
}
|
|
return [c for c in criteria if c not in addressed]
|
|
|
|
|
|
def _check_acceptance_criteria(task: Any, ctx: GateContext) -> list[str]:
|
|
"""Reflect-note serves as the addressing artifact when explicit
|
|
per-criterion citation is absent. See spec §9 item 1."""
|
|
if ctx.journal_reflect_present:
|
|
return []
|
|
return [f"acceptance_criterion:{c}" for c in _unaddressed_criteria(task)]
|
|
|
|
|
|
def _check_qa_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
|
|
notes = getattr(task, "qa_notes", "") or ""
|
|
return [] if len(notes) >= ctx.qa_notes_min_chars else ["qa_notes>=min"]
|
|
|
|
|
|
def _check_qa_evidence_inspected(task: Any, _ctx: GateContext) -> list[str]:
|
|
return (
|
|
[]
|
|
if getattr(task, "qa_evidence_inspected", False)
|
|
else ["qa_evidence_inspected"]
|
|
)
|
|
|
|
|
|
def _check_docs_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
|
|
notes = getattr(task, "doc_notes", "") or ""
|
|
return [] if len(notes) >= ctx.docs_notes_min_chars else ["docs_notes>=min"]
|
|
|
|
|
|
def _check_dev_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
|
|
notes = getattr(task, "dev_notes", "") or ""
|
|
return [] if len(notes) >= ctx.dev_notes_min_chars else ["dev_notes>=min"]
|
|
|
|
|
|
def _check_pr_reviewer_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
|
|
notes = getattr(task, "pr_reviewer_notes", "") or ""
|
|
return (
|
|
[]
|
|
if len(notes) >= ctx.pr_reviewer_notes_min_chars
|
|
else ["pr_reviewer_notes>=min"]
|
|
)
|
|
|
|
|
|
def _check_quick_context_min_chars(task: Any, ctx: GateContext) -> list[str]:
|
|
notes = getattr(task, "quick_context", "") or ""
|
|
return [] if len(notes) >= ctx.quick_context_min_chars else ["quick_context>=min"]
|
|
|
|
|
|
def _check_docs_files_non_empty(task: Any, _ctx: GateContext) -> list[str]:
|
|
docs = getattr(task, "documents", None) or []
|
|
return [] if len(docs) >= 1 else ["docs_files_non_empty"]
|
|
|
|
|
|
def _check_self_verified(task: Any, _ctx: GateContext) -> list[str]:
|
|
return [] if getattr(task, "self_verified", False) else ["self_verified"]
|
|
|
|
|
|
def _check_notes_min_chars(task: Any, ctx: GateContext) -> list[str]:
|
|
notes = getattr(task, "notes", "") or ""
|
|
return [] if len(notes) >= ctx.notes_min_chars else ["notes>=min"]
|
|
|
|
|
|
def _check_subtasks_terminal(task: Any, _ctx: GateContext) -> list[str]:
|
|
"""Caller passes a task whose `_subtasks_all_terminal` boolean is set
|
|
by the choreographer based on a DB query. Validator just reads it."""
|
|
return (
|
|
[] if getattr(task, "_subtasks_all_terminal", False) else ["subtasks_terminal"]
|
|
)
|
|
|
|
|
|
_CHECKERS: dict[Requirement, Checker] = {
|
|
Requirement.PLAN: _check_plan,
|
|
Requirement.COMMITS_AT_LEAST_ONE: _check_commits,
|
|
Requirement.PR_OPEN: _check_pr_open,
|
|
Requirement.PROGRESS_AT_LEAST_ONE: _check_progress,
|
|
Requirement.JOURNAL_REFLECT: _check_journal_reflect,
|
|
Requirement.JOURNAL_DECISION: _check_journal_decision,
|
|
Requirement.JOURNAL_LEARNING: _check_journal_learning,
|
|
Requirement.JOURNAL_STRUGGLE: _check_journal_struggle,
|
|
Requirement.JOURNAL_NOTE_AT_CLAIM: _check_journal_note_at_claim,
|
|
Requirement.JOURNAL_DECISION_AT_CLAIM: _check_journal_decision_at_claim,
|
|
Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE: _check_journal_during_work,
|
|
Requirement.ACCEPTANCE_CRITERIA_ADDRESSED: _check_acceptance_criteria,
|
|
Requirement.QA_NOTES_MIN_CHARS: _check_qa_notes_min_chars,
|
|
Requirement.QA_EVIDENCE_INSPECTED: _check_qa_evidence_inspected,
|
|
Requirement.DOCS_NOTES_MIN_CHARS: _check_docs_notes_min_chars,
|
|
Requirement.DOCS_FILES_NON_EMPTY: _check_docs_files_non_empty,
|
|
Requirement.SELF_VERIFIED: _check_self_verified,
|
|
Requirement.NOTES_MIN_CHARS: _check_notes_min_chars,
|
|
Requirement.SUBTASKS_TERMINAL: _check_subtasks_terminal,
|
|
Requirement.DEV_NOTES_MIN_CHARS: _check_dev_notes_min_chars,
|
|
Requirement.PR_REVIEWER_NOTES_MIN_CHARS: _check_pr_reviewer_notes_min_chars,
|
|
Requirement.QUICK_CONTEXT_MIN_CHARS: _check_quick_context_min_chars,
|
|
}
|
|
|
|
|
|
def check_requirements(
|
|
*,
|
|
task: Any,
|
|
requirements: list[Requirement],
|
|
ctx: GateContext | None = None,
|
|
) -> GateResult:
|
|
"""Run every requirement in `requirements` against `task` + `ctx`.
|
|
|
|
Returns GateResult(passed, missing) — `missing` is empty on pass.
|
|
"""
|
|
context = ctx or GateContext()
|
|
missing: list[str] = []
|
|
for req in requirements:
|
|
missing.extend(_CHECKERS[req](task, context))
|
|
return GateResult(passed=len(missing) == 0, missing=missing)
|
|
|
|
|
|
# Verb name → required Requirements (single source of truth).
|
|
VERB_REQUIREMENTS: dict[str, frozenset[Requirement]] = {
|
|
# Developer claim — pre-gateway DEVELOPER.md required a work_log entry on claim.
|
|
# PLAN mirrors spec.PRECONDITION_PLAN at the tracing layer (single source of truth).
|
|
"i_will_work_on": frozenset(
|
|
{
|
|
Requirement.PLAN,
|
|
Requirement.JOURNAL_NOTE_AT_CLAIM,
|
|
}
|
|
),
|
|
# PM claim — pre-gateway PM.md required a journal:decision on plan.
|
|
"i_will_plan": frozenset(
|
|
{
|
|
Requirement.PLAN,
|
|
Requirement.JOURNAL_DECISION_AT_CLAIM,
|
|
}
|
|
),
|
|
# PM delegate — pre-gateway PM.md required journal:decision before each
|
|
# delegate. QUICK_CONTEXT_MIN_CHARS obligates the PM's resumption section
|
|
# (quick_context) on the parent: satisfiable because the PM pre-writes it via
|
|
# note(scope='handoff', section={done, next, ...}) before delegate.
|
|
"delegate": frozenset(
|
|
{Requirement.JOURNAL_DECISION, Requirement.QUICK_CONTEXT_MIN_CHARS}
|
|
),
|
|
# Developer submit — adds JOURNAL_DURING_WORK_AT_LEAST_ONE for mid-flight cadence.
|
|
# SELF_VERIFIED is set by the auto-run in_progress→verifying transition; it
|
|
# stays in the required-set as a defense-in-depth backstop.
|
|
"i_am_done": frozenset(
|
|
{
|
|
Requirement.COMMITS_AT_LEAST_ONE,
|
|
Requirement.PR_OPEN,
|
|
Requirement.PROGRESS_AT_LEAST_ONE,
|
|
Requirement.SELF_VERIFIED,
|
|
Requirement.JOURNAL_REFLECT,
|
|
Requirement.JOURNAL_DURING_WORK_AT_LEAST_ONE,
|
|
Requirement.ACCEPTANCE_CRITERIA_ADDRESSED,
|
|
# The developer's dedicated section: obligated like the journal.
|
|
# Satisfiable because the dev pre-writes dev_notes via
|
|
# note(scope='handoff') before i_am_done (write-then-gate).
|
|
Requirement.DEV_NOTES_MIN_CHARS,
|
|
}
|
|
),
|
|
# QA pass/fail.
|
|
"pass_review": frozenset(
|
|
{
|
|
Requirement.QA_NOTES_MIN_CHARS,
|
|
Requirement.QA_EVIDENCE_INSPECTED,
|
|
Requirement.JOURNAL_LEARNING,
|
|
}
|
|
),
|
|
"fail_review": frozenset(
|
|
{
|
|
Requirement.QA_NOTES_MIN_CHARS,
|
|
Requirement.QA_EVIDENCE_INSPECTED,
|
|
Requirement.JOURNAL_LEARNING,
|
|
}
|
|
),
|
|
# PR reviewer posts its change-request — must record a learning entry first,
|
|
# and fill its dedicated pr_reviewer_notes section. The section note is the
|
|
# verb's own argument (review body), so it is checked via a SimpleNamespace
|
|
# shim at the call site, not the persisted task (write-then-gate: the arg
|
|
# isn't on the task yet — same pattern as qa_notes).
|
|
"post_pr_review": frozenset(
|
|
{Requirement.JOURNAL_LEARNING, Requirement.PR_REVIEWER_NOTES_MIN_CHARS}
|
|
),
|
|
# In-path PR-review gate: the reviewer records a learning entry before
|
|
# passing or failing the assembled PR (parity with post_pr_review), and
|
|
# fills pr_reviewer_notes (the verb's notes/issues argument, shimmed).
|
|
"pr_pass": frozenset(
|
|
{Requirement.JOURNAL_LEARNING, Requirement.PR_REVIEWER_NOTES_MIN_CHARS}
|
|
),
|
|
"pr_fail": frozenset(
|
|
{Requirement.JOURNAL_LEARNING, Requirement.PR_REVIEWER_NOTES_MIN_CHARS}
|
|
),
|
|
# Doc submit.
|
|
"i_documented": frozenset(
|
|
{
|
|
Requirement.DOCS_FILES_NON_EMPTY,
|
|
Requirement.DOCS_NOTES_MIN_CHARS,
|
|
Requirement.JOURNAL_REFLECT,
|
|
}
|
|
),
|
|
# PM submit-up — adds JOURNAL_REFLECT (pre-gateway required decision AND reflect).
|
|
"submit_up": frozenset(
|
|
{
|
|
Requirement.SUBTASKS_TERMINAL,
|
|
Requirement.JOURNAL_DECISION,
|
|
Requirement.JOURNAL_REFLECT,
|
|
Requirement.NOTES_MIN_CHARS,
|
|
}
|
|
),
|
|
# Main PM submit-root — root analogue of submit_up (opens the root→master
|
|
# PR + enters the gate). Same accountability set.
|
|
"submit_root": frozenset(
|
|
{
|
|
Requirement.SUBTASKS_TERMINAL,
|
|
Requirement.JOURNAL_DECISION,
|
|
Requirement.JOURNAL_REFLECT,
|
|
Requirement.NOTES_MIN_CHARS,
|
|
}
|
|
),
|
|
# PM complete — adds JOURNAL_REFLECT (parity with submit_up).
|
|
"complete": frozenset(
|
|
{
|
|
Requirement.JOURNAL_DECISION,
|
|
Requirement.JOURNAL_REFLECT,
|
|
Requirement.NOTES_MIN_CHARS,
|
|
}
|
|
),
|
|
# PM unblock — was inline at _impl.py:2192-2200; now declared.
|
|
"unblock": frozenset({Requirement.JOURNAL_DECISION}),
|
|
# PM escalate up — was inline.
|
|
"escalate_up": frozenset({Requirement.JOURNAL_DECISION}),
|
|
# Board/MainPM escalate to CEO.
|
|
"escalate_to_ceo": frozenset({Requirement.JOURNAL_DECISION}),
|
|
# Developer block — pre-gateway required journal:struggle.
|
|
"i_am_blocked": frozenset({Requirement.JOURNAL_STRUGGLE}),
|
|
}
|
|
|
|
|
|
# Verbs that intentionally have no tracing requirement (read-only / discovery /
|
|
# pure state moves). Each entry is a deliberate decision, not an oversight.
|
|
VERBS_WITHOUT_TRACING: frozenset[str] = frozenset(
|
|
{
|
|
"give_me_work", # discovery — no state change
|
|
"triage", # read-only listing
|
|
"triage_all", # read-only listing
|
|
"evidence", # read-only evidence dump
|
|
"i_am_idle", # signal only
|
|
"unclaim", # voluntary release; no rationale required
|
|
"reassign", # mechanical intra-cell hand-off; branch/WIP preserved
|
|
"resume", # pure state move paused→in_progress
|
|
# claim_review's tracing applies on pass_review / fail_review.
|
|
"claim_review",
|
|
# claim_pr_review's tracing applies on post_pr_review.
|
|
"claim_pr_review",
|
|
# claim_gate_review's tracing applies on pr_pass / pr_fail.
|
|
"claim_gate_review",
|
|
# claim_doc_task's tracing applies on i_documented.
|
|
"claim_doc_task",
|
|
# open_pr is a mechanical push+open; preconditions are inline.
|
|
"open_pr",
|
|
# sync_branch is a git-only rebase+force-push through the gate; no DB
|
|
# state change, no journal/plan rationale (mirrors open_pr).
|
|
"sync_branch",
|
|
}
|
|
)
|
|
|
|
|
|
def requirements_for(verb: str) -> frozenset[Requirement]:
|
|
"""Lookup the required-set for a verb.
|
|
|
|
Raises KeyError when the verb is neither in VERB_REQUIREMENTS nor in
|
|
VERBS_WITHOUT_TRACING — caller should never reach a verb name unknown to
|
|
foundation.
|
|
"""
|
|
if verb in VERB_REQUIREMENTS:
|
|
return VERB_REQUIREMENTS[verb]
|
|
if verb in VERBS_WITHOUT_TRACING:
|
|
return frozenset()
|
|
raise KeyError(
|
|
f"unknown verb in tracing table: {verb!r} "
|
|
f"(known: {sorted(set(VERB_REQUIREMENTS) | VERBS_WITHOUT_TRACING)})"
|
|
)
|