mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(release): CI wait polls the prod rung; escape the header tooltip apostrophe get_latest_ci_conclusion defaults to the ladder's head rung, so wait_for_ci searched slave for a release commit that lives on master and timed out after 40 minutes with the run already green. The wait now passes the prod branch explicitly. Also fixes the react/no-unescaped-entities error that turned master's Panel CI red. * fix(panel,video): dead dialog triggers behind tooltips; dotted composition ids render HelpTip nested inside a Dialog/AlertDialog trigger puts the trigger's click handler on the Tooltip root, which renders no DOM — the agents Spawn item and the KB Reindex-All / Delete-index confirms were dead. Tooltips now wrap the triggers. The video renderer accepts interior single dots in composition ids (release-0.25.0) with '..' still unrepresentable, and propose_video refuses an unrenderable id at authoring time. * fix(dispatch): restart-safe PM review turns A leaf task in awaiting_pm_review had no periodic pickup: the closure dispatcher bailed on childless tasks and skipped PR-bearing review tasks as already-promoted, assuming the submit-time PM session was still alive — an assumption every restart breaks. Proven live on the docs-sync leaf after the 0.25.0 redeploy, which also dependency-blocked its sibling dev task. Childless awaiting_pm_review tasks now flow to the PM's review turn, and the merge turn respawns its PM when none is active. * feat(video): verify the rendered artifact, not the source The 14s release-0.25.0 cut shipped with only one of four scenes visibly registering: the dev authored DOM, the smoke asserted DOM, QA read code — nobody consumed the rendered MP4 before the CEO did. Close that loop, and the reject loop behind it: - sidecar frames mode: POST /render with frames=1..32 renders the cut, ffprobes the REAL duration, extracts midpoint-sampled keyframe PNGs (timestamps in filenames), streams a tar.gz back with X-Video-Duration - request_render do-verb (developer/QA, request_sandbox's shape): renders the caller's ACTUAL composition — dev's own worktree (head_sha/dirty provenance), QA a read-only git-archive export of the assembled branch — extracts frames to the container-shared .previews/ path, stamps the render_preview marker, returns the paths as envelope evidence - gate: i_am_done on a source=video task refuses without a stamped render_preview (Requirement.RENDER_VERIFIED; canonical source string moved to foundation as markers.VIDEO_TASK_SOURCE; mirrored in the possibilities-matrix fast path so it cannot bypass the check) - QA claim_review evidence carries video_context (composition id, the dev's preview, a re-render instruction) so review checks output - dev spawn prompt block + a 4th authoring AC order Read-every-frame verification before submitting - reject -> re-author: a CEO reject with a reason opens a fresh authoring task carrying the verbatim feedback + a revise-in-place pointer at the existing composition (best-effort, never fails the reject) — rejection feedback no longer dies on the cancelled draft E2E: rendered the committed release-0.25.0 composition through the new frames mode locally — the returned keyframes show exactly the reported failure (blank frame at 5.8s, only 'Env ladder' by 12.8s), the check the fleet was missing. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
473 lines
19 KiB
Python
473 lines
19 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
|
|
|
|
from roboco.foundation.policy.content import markers
|
|
|
|
|
|
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"
|
|
# Revision-findings ledger resolution gate (i_am_done): every OPEN
|
|
# finding on the task (from a prior qa_fail/pr_fail/request_changes/
|
|
# ceo_reject) must be addressed — via i_am_done's `resolved_findings` —
|
|
# before the developer can resubmit. Trivially satisfied by an empty
|
|
# ledger (no findings ever filed).
|
|
FINDINGS_ADDRESSED = "findings_addressed"
|
|
# A video-authoring task must carry a request_render preview before
|
|
# i_am_done — proof the author looked at the rendered artifact, not just
|
|
# the source. No-op for a non-video task.
|
|
RENDER_VERIFIED = "render_verified"
|
|
|
|
|
|
@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
|
|
# 8-char ledger ids (str(finding.id)[:8]) still OPEN on the task, computed
|
|
# by the caller (the choreographer, which has DB access this pure module
|
|
# does not) AFTER applying any `resolved_findings` from this same call.
|
|
open_finding_ids: tuple[str, ...] = ()
|
|
|
|
|
|
@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_findings_addressed(_task: Any, ctx: GateContext) -> list[str]:
|
|
"""One ``finding:<id8>`` miss per still-open ledger row.
|
|
|
|
Mirrors ``_check_acceptance_criteria``'s ``acceptance_criterion:<name>``
|
|
shape — the caller (``_build_tracing_gap``) batches these into one
|
|
multi-finding hint instead of a bare per-finding token.
|
|
"""
|
|
return [f"finding:{fid}" for fid in ctx.open_finding_ids]
|
|
|
|
|
|
def _check_render_verified(task: Any, _ctx: GateContext) -> list[str]:
|
|
"""The gate that makes a video dev look at the rendered artifact."""
|
|
if getattr(task, "source", None) != markers.VIDEO_TASK_SOURCE:
|
|
return []
|
|
return [] if markers.get_render_preview(task) else ["render_preview"]
|
|
|
|
|
|
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,
|
|
Requirement.FINDINGS_ADDRESSED: _check_findings_addressed,
|
|
Requirement.RENDER_VERIFIED: _check_render_verified,
|
|
}
|
|
|
|
|
|
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,
|
|
# Every OPEN revision-ledger finding must be addressed (via
|
|
# `resolved_findings`) before resubmitting. Trivially satisfied
|
|
# when the ledger has no rows for this task.
|
|
Requirement.FINDINGS_ADDRESSED,
|
|
# A video-authoring task must have a request_render preview on
|
|
# file. No-op for every other task source.
|
|
Requirement.RENDER_VERIFIED,
|
|
}
|
|
),
|
|
# 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).
|
|
# FINDINGS_ADDRESSED closes the pr_gate/pm/ceo-origin resolution gap: a
|
|
# bounced coordination root had no equivalent of i_am_done's resolution
|
|
# gate, so a re-submit could sail past open findings unaddressed.
|
|
"submit_up": frozenset(
|
|
{
|
|
Requirement.SUBTASKS_TERMINAL,
|
|
Requirement.JOURNAL_DECISION,
|
|
Requirement.JOURNAL_REFLECT,
|
|
Requirement.NOTES_MIN_CHARS,
|
|
Requirement.FINDINGS_ADDRESSED,
|
|
}
|
|
),
|
|
# 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,
|
|
Requirement.FINDINGS_ADDRESSED,
|
|
}
|
|
),
|
|
# PM complete — adds JOURNAL_REFLECT (parity with submit_up).
|
|
"complete": frozenset(
|
|
{
|
|
Requirement.JOURNAL_DECISION,
|
|
Requirement.JOURNAL_REFLECT,
|
|
Requirement.NOTES_MIN_CHARS,
|
|
}
|
|
),
|
|
# PM merge-review reject — a decision like complete; issues are enforced
|
|
# verb-side (non-empty + soup check), so no notes requirement here.
|
|
"request_changes": frozenset({Requirement.JOURNAL_DECISION}),
|
|
# 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
|
|
# declare_coverage is a mechanical AC-ref stamp + its own audit row
|
|
# (task.coverage_declared) — no journal rationale required.
|
|
"declare_coverage",
|
|
"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",
|
|
# waive_finding is an auditor curation move: the required note is the
|
|
# rationale, captured on the ledger row + a task.finding_waived audit
|
|
# event — no task status change, no journal:decision needed.
|
|
"waive_finding",
|
|
}
|
|
)
|
|
|
|
|
|
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)})"
|
|
)
|