diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index 888180377f..5daaf8adaa 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -28,42 +28,43 @@ ## Parity matrix -| # | Capability | Script source today | Native owner | Phase | + +| # | Capability | Current commander | Native owner | Native phases | |---|---|---|---|---| -| 1 | Create a problem with owner, order and deadline | `order.py problem`; `problems.json` | `CommsTodoCreateTool` (exists) | 1 | -| 2 | Assign, reassign, drop an order | `order.py send`, `--reassign`, `drop`; `deadlines.json` | `CommsOrderTool`, `CommsTodoTool` assignment and release (exist) | 1 | -| 3 | Complete only on the delivered commit | `order.py done` | `CommsOrderDoneTool` (exists), returning a delivery state: merged to `main`, merged to another branch, not merged | 1, 3 | -| 4 | Close with installed evidence | `order.py close`; `closed.jsonl` | `CommsProblemCloseTool` (exists) | 1 | -| 5 | Board, overdue, silent, spinning | `order.py board`, `overdue`; `fleet-board.md` | `CommsTodoListTool` and the fleet tool, derived (exist) | 1 | -| 6 | Branch hygiene | `branches-*.txt` by hand | `CommsWorkBranchesTool` (exists) | done | -| 7 | Strikes and the strike limit | `order.py strike`; `strikes.jsonl` | `Strike` rows on the order; `StrikeLimit` declared on the commanded author; one `comms_fire` | 3 | -| 8 | Verdicts on a unit's tool calls | `judge.py ok`, `back`; `verdicts.jsonl` | `Verdict` on the order; a send-back revises the order Goal and records the strike in one write | 3 | -| 9 | Plain words in verdicts and strikes | judge and order checks | `PlainText` value type | 3 | -| 10 | Refuse soft or vague orders | `order.py` | `OrderText` value type on every order action | 3 | -| 11 | What a unit did during its order | `tool_watch.py`; `edits.jsonl`; `judge.py show` | `comms_order_work`, read from both transcript families by `tool_call_id` | 4 | -| 12 | What the commander has not judged yet | `judge.py unjudged`; `vibe_watch.py` | `comms_unjudged`: tool calls and unit messages after the last verdict, derived | 4 | -| 13 | Public story and roasts | `fleet-narrative.md`; `judge.py roast` | `comms_order_story`, `comms_fleet_story`; each send-back verdict posts its plain reason to the order's channel | 5 | -| 14 | Heartbeat: next unit every minute | `rotate.py` (60 s loop) | a scheduled commander wake owned by Core | 7 | -| 15 | Standing rules broken right now | `rotate.py broken_rules` | a `StandingRule` family, each member deriving its violations on read | 7 | -| 16 | Rule review | `rules.py`; `rules.jsonl`; a maximum age | the rules as instruction segments in the commander's turn context, with provenance | 7 | -| 17 | Load and forking | `capacity.py`: 6 units per commander, 10-minute beat age; `forks/` | `CommanderLoad` derived from open orders and the unjudged queue; forks are registered threads | 7 | -| 18 | Growth alarm | `growth_watch.py`: merges growing `src` by more than 40 net lines | a growth check derived on read from git per configured repository | 7 | -| 19 | Storage hygiene | `storage.py`: worktree limit per unit, cleanup due under 30 GB free, nothing allocates under 5 GB | `UnitStorage` derived on read from worktree paths and free disk | 7 | -| 20 | Commander reaches units only through orders | `send_guard.py` (PreToolUse hook) | stays a hook, at the harness boundary, checking against Core's order tools | 7 | -| 21 | Paths, repositories, commander identity | `CommanderConfig` from `commander.json` | Core's project and registry configuration | 8 | -| 33 | Several commanders on one machine and bus, one per project | none (one `commander.json`; a second install overwrites the first) | each commander is a registered thread that owns one project. Orders, strikes, load, board, alarms and forks are keyed by that commander, and the commander for a call comes from its `--from` thread. The send guard refuses a raw send from any registered commander. Two commanders for one project are refused when the second registers | 1, 8 | -| 22 | Seeing the fleet: board, strikes, queue, story, alarms | `fleet-board.md`, `order.py board`, the terminal output of `rotate.py` and `judge.py` | a commander page in Toad, rendering Core's answers through `ui_model` | 6 | -| 23 | An order states the user-visible run that closes it | scratch `rts/order.py send` (`Works when:`) | `OrderText` requires it; `CommsProblemCloseTool.works` is checked against it | 3 | -| 24 | Reassigning past two owners needs the commander's trace | scratch `rts/order.py send --reassign --traced` | the transfer operation reads owners from the Todo's assignment history and refuses without a traced source location | 3 | -| 25 | Unit wording gets a correction, not a strike | scratch `vibe_watch.py` | `PlainText` applies to the commander's own verdicts and strikes; unit wording never becomes a strike | 3 | -| 26 | Alerts carry only decisions | none (today every line interrupts) | the commander's wake receives decision events only, each once | 7 | -| 27 | Momentum prediction per unit, compared with the outcome | none | a prediction record on the order, shown on the commander page beside what happened | 6 | -| 28 | Measured load and self-report | `capacity.py` unit count | `CommanderLoad`: look debt, judge debt, inflow, calibration, context, plus a one-word self-report calibrated against them | 7 | -| 29 | No handoff of finished, checked work | none (scratch `order.py send` moved P507 after its fix was pushed and checked) | the transfer operation refuses an order whose current owner has a delivered commit with its check passing; that owner merges it, and the check's recorded run is reused, never rerun by another unit | 3 | -| 30 | A proof is bound to the build it ran on | none (screenshots and logs are bare file paths) | the run record written by the build check (`tools/build_runtime.py`) carries runtime ID and Core/Toad revisions; `CommsProblemCloseTool` refuses when that runtime does not contain the order's delivered commit | 3 | -| 31 | An order states its intent as a deletion target | none (orders name a line, so units fix the line: P520 swapped `is_set()` for `except ValueError` around every caller) | the order declares the decision family being deleted, its owner, and a family-wide count from the existing NRA/refactor-audit scanners; completion runs that count on the delivered commit and refuses unless it is zero with no new site of the same kind | 3 | -| 32 | Where units' time goes, classified | none (`activity.log` has call times but no durations or turn boundaries) | Core's turn records carry each call's start and end and each turn's start reason; `UnitTime` derives per unit and fleet the minutes in model, tool runs (build, check, test, shell), waiting on another owner, waiting on the commander, and idle; the commander marks each large wait necessary or self-inflicted | 6, 7 | -| 33 | Order progress beside its deadline; the unit never sees a due time | `order.py board`, `overdue`, `due`, `send`; `rotate.py` (before: a due time and "Delivered" only, so merged work drew the strike threat) | `OrderProgress` in Core (`todos.py`), derived on read from the unit's checkout and the repository's PRs, never stored: no commit, committed not pushed, pushed with no PR, open PR, merged; `CommsTodoListTool` returns it per held order. Merged work draws no strike; no first commit within 10 minutes, or no new commit, push or PR change for 15 minutes, is a STALL with the progress line. `CommsOrderTool` sends the unit no due time: the deadline stays the commander's fire line | 1, 7 | +| 1 | Create a problem with owner, order and deadline | `order.py problem`; `problems.json` | `CommsTodoCreateTool` | `one_order_owner` | +| 2 | Assign, reassign, drop an order | `order.py send`, `--reassign`, `drop`; `deadlines.json` | `CommsOrderTool`, `CommsTodoTool` assignment and release | `one_order_owner` | +| 3 | Complete only on the delivered commit | `order.py done` | `CommsOrderDoneTool`: merged to main, another branch, or not merged | `one_order_owner`, `record_plane` | +| 4 | Close with installed evidence | `order.py close`; `closed.jsonl` | `CommsProblemCloseTool` | `one_order_owner` | +| 5 | Board, overdue, silent, spinning | `order.py board`, `overdue`; `fleet-board.md` | `CommsTodoListTool` and the fleet tool, derived | `one_order_owner` | +| 6 | Branch hygiene | `branches-*.txt` by hand | `CommsWorkBranchesTool` | `one_order_owner` | +| 7 | Strikes and the strike limit | `order.py strike`; `strikes.jsonl` | `Strike` rows, author-declared `StrikeLimit`, one `comms_fire` | `record_plane` | +| 8 | Verdicts on a unit's tool calls | `judge.py ok`, `back`; `verdicts.jsonl` | Order `Verdict`; send-back revises the Goal and records its strike atomically | `record_plane` | +| 9 | Plain words in verdicts and strikes | judge and order checks | `PlainText` | `record_plane` | +| 10 | Refuse soft or vague orders | `order.py` | `OrderText` on every order action | `record_plane` | +| 11 | What a unit did during its order | `tool_watch.py`; `edits.jsonl`; `judge.py show` | `comms_order_work`: both transcript families by `tool_call_id` | `review_queue` | +| 12 | What the commander has not judged yet | `judge.py unjudged`; `vibe_watch.py` | `comms_unjudged`: calls and unit messages after the last verdict | `review_queue` | +| 13 | Public story and roasts | `fleet-narrative.md`; `judge.py roast` | `comms_order_story`, `comms_fleet_story`; send-back reason posted once | `story` | +| 14 | Heartbeat: next unit every minute | `rotate.py` (60 s loop) | Core-owned scheduled commander wake | `control_plane` | +| 15 | Standing rules broken right now | `rotate.py broken_rules` | `StandingRule` members derive violations on read | `control_plane` | +| 16 | Rule review | `rules.py`; `rules.jsonl`; maximum age | Rule instruction segments in the commander's turn context, with provenance | `control_plane` | +| 17 | Load and forking | `agent-comms-commander capacity`: load and live fork reports; ten-minute beat age | Reporting-only `commander.capacity.Load.of`; no capacity refusal or automatic fork policy | `control_plane` | +| 18 | Growth alarm | `growth_watch.py`: merges growing src by more than 40 net lines | `commander.config.Repo.main_changes` → `commander_measurements.MergeGrowth.read` | `control_plane` | +| 19 | Storage hygiene | `storage.py`: checkout limit, cleanup under 30 GB, allocation floor 5 GB | `UnitStorage`, derived from worktree paths and free disk | `control_plane` | +| 20 | Commander reaches units only through orders | `send_guard.py` (PreToolUse hook) | Harness hook checking Core order tools | `control_plane` | +| 21 | Paths, repositories, commander identity | `CommanderConfig` from `commander.json` | Core project and registry configuration | `cutover` | +| 22 | Seeing the fleet: board, strikes, queue, story, alarms | `fleet-board.md`, `order.py board`, `rotate.py`, `judge.py` | Toad commander page rendering Core through `ui_model` | `commander_page` | +| 23 | An order states the user-visible run that closes it | scratch `rts/order.py send` (`Works when:`) | `OrderText` requires the run; `CommsProblemCloseTool.works` checks it | `record_plane` | +| 24 | Reassigning past two owners needs the commander's trace | scratch `order.py send --reassign --traced` | Transfer reads Todo owner history and requires traced source location | `record_plane` | +| 25 | Unit wording gets a correction, not a strike | scratch `vibe_watch.py` | `PlainText` for commander verdicts/strikes; unit wording gets correction | `record_plane` | +| 26 | Alerts carry only decisions | none; every line interrupts | `messages.AlertKind.require_publishable`, `tools.CommsDecisionTool` | `control_plane` | +| 27 | Momentum prediction per unit, compared with the outcome | none | Prediction record and later outcome on commander page; `CommitMomentum` is a partial candidate | `commander_page` | +| 28 | Measured load and self-report | `capacity.py` unit count | `commander.capacity.Load.of` reports orders, overdue, edits and forks; self-report comparison unverified | `control_plane` | +| 29 | No handoff of finished, checked work | none; scratch transfer moved P507 after its fix was pushed and checked | Transfer refuses delivered checked work; that owner merges; check is reused | `record_plane` | +| 30 | A proof is bound to the build it ran on | none; screenshots and logs are bare paths | Build run records runtime and Core/Toad revisions; close requires delivered commit | `record_plane` | +| 31 | An order states its intent as a deletion target | none; P520 renamed a decision rather than deleting it | Order owns deletion family/site/count; delivery refuses nonzero or renamed decisions | `record_plane` | +| 32 | Where units' time goes, classified | none; activity.log lacks durations and turn boundaries | `UnitTime`: model, build/check/test/shell, dependency/commander waits and idle; classify large waits | `commander_page`, `control_plane` | +| 33 | Order progress beside its deadline; the unit never sees a due time | `order.py board`, `overdue`, `due`, `send`; `rotate.py` | Derived `OrderProgress`; merged work draws no strike; first commit 10 min, later progress 15 min STALL; units see no due time | `one_order_owner`, `control_plane` | +| 34 | Several commanders on one machine and bus, one per project | none; a second commander.json install overwrites the first | Registry commander/project ownership; caller from --from; scoped orders, strikes, load, board, alarms and forks; raw-send guard; duplicate project commander refused | `one_order_owner`, `cutover` | **Dropped deliberately:** old verdicts and `edits.jsonl`. They judged copies of tool calls, and any old call can be judged again from the transcripts. diff --git a/src/agent_comms/commander_parity/__init__.py b/src/agent_comms/commander_parity/__init__.py new file mode 100644 index 0000000000..22073f86a4 --- /dev/null +++ b/src/agent_comms/commander_parity/__init__.py @@ -0,0 +1,92 @@ +"""Parity declarations own matrix generation and the cutover decision.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +from ..declared_family import DeclaredFamily + +if TYPE_CHECKING: + from ..plan_phases import PlanPhase + from ..todos import Todo + + +class ParityComparison(ABC): + @property + @abstractmethod + def passed(self) -> bool: ... + + def describe(self) -> str: + return repr(self) + + +@dataclass(frozen=True) +class UnverifiedParity(ParityComparison): + """Missing behavior has no evidence from which to claim a pass.""" + + reason: str = "The native/current workflow comparison has not run." + passed: ClassVar[bool] = False + + def describe(self) -> str: + return self.reason + + +class ParityRow( + DeclaredFamily, affix="ParityRow", discovery_package="agent_comms.commander_parity" +): + number: ClassVar[int] + capability: ClassVar[str] + current: ClassVar[str] + native: ClassVar[str] + phases: ClassVar[tuple[type[PlanPhase], ...]] + + @classmethod + def compare(cls, todos: Iterable[Todo]) -> ParityComparison: + return UnverifiedParity() + + @classmethod + def verify(cls) -> ParityComparison: + """Run this declared workflow now; a run is not durable cutover authority.""" + return UnverifiedParity() + + @classmethod + def rows(cls) -> tuple[type[ParityRow], ...]: + rows = tuple(sorted(cls.members_with(cls), key=lambda row: row.number)) + numbers = tuple(row.number for row in rows) + if not numbers or min(numbers) < 1 or len(set(numbers)) != len(numbers): + raise ValueError("Parity declarations require unique positive row numbers") + return rows + + @classmethod + def done(cls, todos: Iterable[Todo]) -> bool: + history = tuple(todos) + return all(row.compare(history).passed for row in cls.rows()) + + @classmethod + def matrix(cls) -> str: + """Render declared facts only; no mutable completion column lives here.""" + lines = [ + "", + "| # | Capability | Current commander | Native owner | Native phases |", + "|---|---|---|---|---|", + ] + for row in cls.rows(): + owners = ", ".join(f"`{phase.declared_name}`" for phase in row.phases) + lines.append( + f"| {row.number} | {row.capability} | {row.current} | {row.native} | {owners} |" + ) + return "\n".join(lines) + "\n" + + @classmethod + def render_plan(cls, path: Path) -> str: + """Replace a generated section, never decode prose as executable identity.""" + text = path.read_text() + before, marker, remainder = text.partition("## Parity matrix\n") + _, end, after = remainder.partition("\n**Dropped deliberately:**") + if not marker or not end: + raise ValueError("Plan has no bounded parity matrix section") + return before + marker + "\n" + cls.matrix() + end + after diff --git a/src/agent_comms/commander_parity/__main__.py b/src/agent_comms/commander_parity/__main__.py new file mode 100644 index 0000000000..ee378b8178 --- /dev/null +++ b/src/agent_comms/commander_parity/__main__.py @@ -0,0 +1,42 @@ +"""Render the commander matrix from its owning declarations.""" + +import argparse +import json +from pathlib import Path + +from . import ParityRow + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", type=Path, help="Render the full plan instead of only the table") + parser.add_argument( + "--verify", action="store_true", help="Run live comparisons; do not grant cutover" + ) + args = parser.parse_args() + if args.verify: + comparisons = tuple((row, row.verify()) for row in ParityRow.rows()) + print( + json.dumps( + { + "verified": sum(result.passed for _, result in comparisons), + "total": len(comparisons), + "rows": [ + dict( + number=row.number, + capability=row.capability, + passed=result.passed, + result=result.describe(), + ) + for row, result in comparisons + ], + }, + indent=2, + ) + ) + raise SystemExit(0 if all(result.passed for _, result in comparisons) else 1) + print(ParityRow.render_plan(args.plan) if args.plan else ParityRow.matrix(), end="") + + +if __name__ == "__main__": + main() diff --git a/src/agent_comms/commander_parity/load.py b/src/agent_comms/commander_parity/load.py new file mode 100644 index 0000000000..66ec536cef --- /dev/null +++ b/src/agent_comms/commander_parity/load.py @@ -0,0 +1,105 @@ +"""Observe the native load measurement used by the installed capacity command.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from ..commander.config import CommanderConfig +from ..field_codec import FieldCodec +from ..private_path import FileRevision +from . import ParityComparison, UnverifiedParity + +# Profile the real return, never replace the measurement, command, bus or reply. +# Comparing independent reads of a running fleet confuses new edits with drift. +OBSERVE_CAPACITY = """ +import contextlib, io, json, sys +import agent_comms.commander.capacity as capacity +from agent_comms.commander.cli import main +measured = [] +contexts = [] +def observe(frame, event, value): + if event == 'return' and frame.f_code is capacity.Load.of.__func__.__code__: + measured.append(value) + if event == 'call' and frame.f_code is capacity.main.__code__: + contexts.append(frame.f_locals['ctx']) +output = io.StringIO() +sys.setprofile(observe) +try: + with contextlib.redirect_stdout(output): + main() +finally: + sys.setprofile(None) +if len(measured) != 1 or len(contexts) != 1: + raise ValueError('Capacity must use exactly one native measurement and selected context') +config = contexts[0] +print(json.dumps(dict(current=output.getvalue(), native=str(measured[0]), + source=capacity.__file__, root=str(config.root), + commander=config.commander))) +""" + + +def state_files(directory: Path) -> tuple[tuple[str, FileRevision], ...]: + return tuple( + (str(path.relative_to(directory)), FileRevision.from_stat(path.stat())) + for path in sorted(directory.rglob("*")) + if path.is_file() + ) + + +@dataclass(frozen=True) +class MeasuredOutput: + current: str + native: str + source: str + root: str + commander: str + + +@dataclass(frozen=True) +class LoadComparison(ParityComparison): + observation: MeasuredOutput + state_before: tuple[tuple[str, FileRevision], ...] + state_after: tuple[tuple[str, FileRevision], ...] + + @property + def passed(self) -> bool: + return ( + self.observation.current.strip() == self.observation.native + and self.state_before == self.state_after + ) + + def describe(self) -> str: + return ( + f"current={self.observation.current.strip()!r}; " + f"native={self.observation.native!r}; owner={self.observation.source}; " + f"commander={self.observation.commander}; root={self.observation.root}; " + f"private_state_unchanged={self.state_before == self.state_after}" + ) + + @classmethod + def read(cls) -> ParityComparison: + config = CommanderConfig.read(CommanderConfig.path()) + before = state_files(config.state) + # -I uses installed imports even when this candidate runs with PYTHONPATH=src. + # HOME and the environment stay unchanged; the actual CLI main runs. + current = subprocess.run( + (str(config.cli.parent / "python"), "-I", "-c", OBSERVE_CAPACITY, "capacity"), + capture_output=True, + text=True, + timeout=30, + ) + if current.returncode: + return UnverifiedParity(current.stderr) + observed = FieldCodec.decode(MeasuredOutput, FieldCodec.parse(current.stdout)) + source = Path(observed.source) + if ( + observed.root != str(config.root) + or observed.commander != config.commander + or not any(source.is_relative_to(repo.checkout) for repo in config.repos) + ): + return UnverifiedParity( + "The installed command used a different configuration or source" + ) + return cls(observed, before, state_files(config.state)) diff --git a/src/agent_comms/commander_parity/rows.py b/src/agent_comms/commander_parity/rows.py new file mode 100644 index 0000000000..0c64e8c69a --- /dev/null +++ b/src/agent_comms/commander_parity/rows.py @@ -0,0 +1,306 @@ +"""Each native/current workflow declares its identity once.""" + +from ..plan_phases import ( + CommanderPagePhase, + ControlPlanePhase, + CutoverPhase, + OneOrderOwnerPhase, + RecordPlanePhase, + ReviewQueuePhase, + StoryPhase, +) +from . import ParityComparison, ParityRow +from .load import LoadComparison + + +class CreateProblemParityRow(ParityRow): + number = 1 + capability = "Create a problem with owner, order and deadline" + current = "`order.py problem`; `problems.json`" + native = "`CommsTodoCreateTool`" + phases = (OneOrderOwnerPhase,) + + +class AssignOrderParityRow(ParityRow): + number = 2 + capability = "Assign, reassign, drop an order" + current = "`order.py send`, `--reassign`, `drop`; `deadlines.json`" + native = "`CommsOrderTool`, `CommsTodoTool` assignment and release" + phases = (OneOrderOwnerPhase,) + + +class DeliveredCommitParityRow(ParityRow): + number = 3 + capability = "Complete only on the delivered commit" + current = "`order.py done`" + native = "`CommsOrderDoneTool`: merged to main, another branch, or not merged" + phases = (OneOrderOwnerPhase, RecordPlanePhase) + + +class InstalledEvidenceParityRow(ParityRow): + number = 4 + capability = "Close with installed evidence" + current = "`order.py close`; `closed.jsonl`" + native = "`CommsProblemCloseTool`" + phases = (OneOrderOwnerPhase,) + + +class BoardParityRow(ParityRow): + number = 5 + capability = "Board, overdue, silent, spinning" + current = "`order.py board`, `overdue`; `fleet-board.md`" + native = "`CommsTodoListTool` and the fleet tool, derived" + phases = (OneOrderOwnerPhase,) + + +class BranchHygieneParityRow(ParityRow): + number = 6 + capability = "Branch hygiene" + current = "`branches-*.txt` by hand" + native = "`CommsWorkBranchesTool`" + phases = (OneOrderOwnerPhase,) + + +class StrikeLimitParityRow(ParityRow): + number = 7 + capability = "Strikes and the strike limit" + current = "`order.py strike`; `strikes.jsonl`" + native = "`Strike` rows, author-declared `StrikeLimit`, one `comms_fire`" + phases = (RecordPlanePhase,) + + +class VerdictParityRow(ParityRow): + number = 8 + capability = "Verdicts on a unit's tool calls" + current = "`judge.py ok`, `back`; `verdicts.jsonl`" + native = "Order `Verdict`; send-back revises the Goal and records its strike atomically" + phases = (RecordPlanePhase,) + + +class PlainWordsParityRow(ParityRow): + number = 9 + capability = "Plain words in verdicts and strikes" + current = "judge and order checks" + native = "`PlainText`" + phases = (RecordPlanePhase,) + + +class OrderWordingParityRow(ParityRow): + number = 10 + capability = "Refuse soft or vague orders" + current = "`order.py`" + native = "`OrderText` on every order action" + phases = (RecordPlanePhase,) + + +class OrderWorkParityRow(ParityRow): + number = 11 + capability = "What a unit did during its order" + current = "`tool_watch.py`; `edits.jsonl`; `judge.py show`" + native = "`comms_order_work`: both transcript families by `tool_call_id`" + phases = (ReviewQueuePhase,) + + +class UnjudgedParityRow(ParityRow): + number = 12 + capability = "What the commander has not judged yet" + current = "`judge.py unjudged`; `vibe_watch.py`" + native = "`comms_unjudged`: calls and unit messages after the last verdict" + phases = (ReviewQueuePhase,) + + +class PublicStoryParityRow(ParityRow): + number = 13 + capability = "Public story and roasts" + current = "`fleet-narrative.md`; `judge.py roast`" + native = "`comms_order_story`, `comms_fleet_story`; send-back reason posted once" + phases = (StoryPhase,) + + +class ScheduledWakeParityRow(ParityRow): + number = 14 + capability = "Heartbeat: next unit every minute" + current = "`rotate.py` (60 s loop)" + native = "Core-owned scheduled commander wake" + phases = (ControlPlanePhase,) + + +class StandingRulesParityRow(ParityRow): + number = 15 + capability = "Standing rules broken right now" + current = "`rotate.py broken_rules`" + native = "`StandingRule` members derive violations on read" + phases = (ControlPlanePhase,) + + +class RuleReviewParityRow(ParityRow): + number = 16 + capability = "Rule review" + current = "`rules.py`; `rules.jsonl`; maximum age" + native = "Rule instruction segments in the commander's turn context, with provenance" + phases = (ControlPlanePhase,) + + +class LoadForkingParityRow(ParityRow): + number = 17 + capability = "Load and forking" + current = "`agent-comms-commander capacity`: load and live fork reports; ten-minute beat age" + native = ( + "Reporting-only `commander.capacity.Load.of`; no capacity refusal or automatic fork policy" + ) + phases = (ControlPlanePhase,) + + @classmethod + def verify(cls) -> ParityComparison: + return LoadComparison.read() + + +class GrowthAlarmParityRow(ParityRow): + number = 18 + capability = "Growth alarm" + current = "`growth_watch.py`: merges growing src by more than 40 net lines" + native = "`commander.config.Repo.main_changes` → `commander_measurements.MergeGrowth.read`" + phases = (ControlPlanePhase,) + + +class StorageHygieneParityRow(ParityRow): + number = 19 + capability = "Storage hygiene" + current = "`storage.py`: checkout limit, cleanup under 30 GB, allocation floor 5 GB" + native = "`UnitStorage`, derived from worktree paths and free disk" + phases = (ControlPlanePhase,) + + +class OrderOnlyContactParityRow(ParityRow): + number = 20 + capability = "Commander reaches units only through orders" + current = "`send_guard.py` (PreToolUse hook)" + native = "Harness hook checking Core order tools" + phases = (ControlPlanePhase,) + + +class ConfigurationParityRow(ParityRow): + number = 21 + capability = "Paths, repositories, commander identity" + current = "`CommanderConfig` from `commander.json`" + native = "Core project and registry configuration" + phases = (CutoverPhase,) + + +class CommanderPageParityRow(ParityRow): + number = 22 + capability = "Seeing the fleet: board, strikes, queue, story, alarms" + current = "`fleet-board.md`, `order.py board`, `rotate.py`, `judge.py`" + native = "Toad commander page rendering Core through `ui_model`" + phases = (CommanderPagePhase,) + + +class ApplicationRunParityRow(ParityRow): + number = 23 + capability = "An order states the user-visible run that closes it" + current = "scratch `rts/order.py send` (`Works when:`)" + native = "`OrderText` requires the run; `CommsProblemCloseTool.works` checks it" + phases = (RecordPlanePhase,) + + +class TracedTransferParityRow(ParityRow): + number = 24 + capability = "Reassigning past two owners needs the commander's trace" + current = "scratch `order.py send --reassign --traced`" + native = "Transfer reads Todo owner history and requires traced source location" + phases = (RecordPlanePhase,) + + +class UnitWordingParityRow(ParityRow): + number = 25 + capability = "Unit wording gets a correction, not a strike" + current = "scratch `vibe_watch.py`" + native = "`PlainText` for commander verdicts/strikes; unit wording gets correction" + phases = (RecordPlanePhase,) + + +class DecisionAlertsParityRow(ParityRow): + number = 26 + capability = "Alerts carry only decisions" + current = "none; every line interrupts" + native = "`messages.AlertKind.require_publishable`, `tools.CommsDecisionTool`" + phases = (ControlPlanePhase,) + + +class MomentumParityRow(ParityRow): + number = 27 + capability = "Momentum prediction per unit, compared with the outcome" + current = "none" + native = ( + "Prediction record and later outcome on commander page; " + "`CommitMomentum` is a partial candidate" + ) + phases = (CommanderPagePhase,) + + +class MeasuredLoadParityRow(ParityRow): + number = 28 + capability = "Measured load and self-report" + current = "`capacity.py` unit count" + native = ( + "`commander.capacity.Load.of` reports orders, overdue, edits and forks; " + "self-report comparison unverified" + ) + phases = (ControlPlanePhase,) + + +class FinishedWorkTransferParityRow(ParityRow): + number = 29 + capability = "No handoff of finished, checked work" + current = "none; scratch transfer moved P507 after its fix was pushed and checked" + native = "Transfer refuses delivered checked work; that owner merges; check is reused" + phases = (RecordPlanePhase,) + + +class BuildBoundProofParityRow(ParityRow): + number = 30 + capability = "A proof is bound to the build it ran on" + current = "none; screenshots and logs are bare paths" + native = "Build run records runtime and Core/Toad revisions; close requires delivered commit" + phases = (RecordPlanePhase,) + + +class DeletionTargetParityRow(ParityRow): + number = 31 + capability = "An order states its intent as a deletion target" + current = "none; P520 renamed a decision rather than deleting it" + native = "Order owns deletion family/site/count; delivery refuses nonzero or renamed decisions" + phases = (RecordPlanePhase,) + + +class UnitTimeParityRow(ParityRow): + number = 32 + capability = "Where units' time goes, classified" + current = "none; activity.log lacks durations and turn boundaries" + native = ( + "`UnitTime`: model, build/check/test/shell, dependency/commander waits and idle; " + "classify large waits" + ) + phases = (CommanderPagePhase, ControlPlanePhase) + + +class OrderProgressParityRow(ParityRow): + number = 33 + capability = "Order progress beside its deadline; the unit never sees a due time" + current = "`order.py board`, `overdue`, `due`, `send`; `rotate.py`" + native = ( + "Derived `OrderProgress`; merged work draws no strike; first commit 10 min, " + "later progress 15 min STALL; units see no due time" + ) + phases = (OneOrderOwnerPhase, ControlPlanePhase) + + +class MultipleCommandersParityRow(ParityRow): + number = 34 + capability = "Several commanders on one machine and bus, one per project" + current = "none; a second commander.json install overwrites the first" + native = ( + "Registry commander/project ownership; caller from --from; scoped orders, strikes, " + "load, board, alarms and forks; raw-send guard; duplicate project commander refused" + ) + phases = (OneOrderOwnerPhase, CutoverPhase) diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index c42d40d3c8..04865c669b 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -12,6 +12,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, ClassVar +from .commander_parity import ParityRow from .declared_family import DeclaredFamily from .phase_tasks import PhaseTask, UnspecifiedPhaseTask @@ -89,11 +90,17 @@ class ControlPlanePhase(PlanPhase): after = (RecordPlanePhase,) -class CutoverPhase(PlanPhase): - # The parity gate in the Markdown plan has no executable declaration yet. - # These dependencies do not establish parity or authorize deleting scripts. +class ParityPhase(PlanPhase): after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase) + @classmethod + def done(cls, todos: Iterable[Todo]) -> bool: + return ParityRow.done(todos) + + +class CutoverPhase(PlanPhase): + after = (RecordPlanePhase, ReviewQueuePhase, ControlPlanePhase, ParityPhase) + class StoryPhase(PlanPhase): """A view of the native records, not a prerequisite for their cutover.""" diff --git a/tests/test_commander_parity.py b/tests/test_commander_parity.py new file mode 100644 index 0000000000..a034cd029e --- /dev/null +++ b/tests/test_commander_parity.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from agent_comms.commander_parity import ParityComparison, ParityRow, UnverifiedParity +from agent_comms.plan_phases import ( + CommanderPagePhase, + ControlPlanePhase, + CutoverPhase, + ParityPhase, + PlanPhase, + RecordPlanePhase, + ReviewQueuePhase, + StoryPhase, +) + +PLAN = Path(__file__).resolve().parents[1] / "plans/commander-native-plan.md" + + +class MatchingComparison(ParityComparison): + passed = True + + +def test_generated_matrix_is_a_derived_output(): + assert ParityRow.render_plan(PLAN) == PLAN.read_text() + rows = ParityRow.rows() + assert len(rows) == len({row.number for row in rows}) == 34 + assert all(row.capability and row.current and row.native and row.phases for row in rows) + assert all(not row.compare(()).passed for row in rows) + + +def test_cutover_gate_is_computed_from_every_comparison(monkeypatch): + assert CutoverPhase.after == ( + RecordPlanePhase, + ReviewQueuePhase, + ControlPlanePhase, + ParityPhase, + ) + assert not ParityPhase.done(()) + for row in ParityRow.rows(): + monkeypatch.setattr(row, "compare", classmethod(lambda cls, history: MatchingComparison())) + assert ParityPhase.done(()) + last = ParityRow.rows()[-1] + monkeypatch.setattr(last, "compare", classmethod(lambda cls, history: UnverifiedParity())) + assert not ParityPhase.done(()) + assert not any( + dependency in (StoryPhase, CommanderPagePhase) + for phase in PlanPhase.members_with(PlanPhase) + for dependency in phase.after + ) + + +def test_generated_prose_is_not_input_to_the_gate(tmp_path): + plan = tmp_path / "plan.md" + plan.write_text( + "Before\n## Parity matrix\n\nHand edited, invalid prose.\n" + "**Dropped deliberately:** historical text\nAfter\n" + ) + rendered = ParityRow.render_plan(plan) + assert rendered.startswith("Before\n## Parity matrix\n\n") + assert rendered.endswith("**Dropped deliberately:** historical text\nAfter\n") + assert "Hand edited" not in rendered + assert not ParityPhase.done(()) diff --git a/tests/test_commander_parity_load.py b/tests/test_commander_parity_load.py new file mode 100644 index 0000000000..6e26d798fa --- /dev/null +++ b/tests/test_commander_parity_load.py @@ -0,0 +1,24 @@ +from dataclasses import replace + +from agent_comms.commander.capacity import Load +from agent_comms.commander_parity.load import LoadComparison, MeasuredOutput, state_files + + +def test_load_comparison_requires_every_reported_field_and_no_private_write(tmp_path): + load = Load(37, 8, 1692, ("merger",)) + observed = MeasuredOutput(str(load) + "\n", str(load), "capacity.py", "/bus", "lead") + comparison = LoadComparison(observed, (), ()) + assert comparison.passed + for different in ( + replace(load, owed=38), + replace(load, overdue=9), + replace(load, unjudged=1693), + replace(load, forks=()), + ): + assert not replace(comparison, observation=replace(observed, native=str(different))).passed + path = tmp_path / "old-state" + path.write_text("before") + before = state_files(tmp_path) + path.write_text("after") + after = state_files(tmp_path) + assert not LoadComparison(observed, before, after).passed diff --git a/tests/test_plan_phases.py b/tests/test_plan_phases.py index 669ccccd83..82dfc41aeb 100644 --- a/tests/test_plan_phases.py +++ b/tests/test_plan_phases.py @@ -68,7 +68,8 @@ def test_views_wait_for_native_work_but_never_block_cutover(commander, monkeypat accepted(todo) if todo.order.serves in required else todo for todo in todos ) - assert CutoverPhase.ready(native_done) + # Native source completion alone cannot bypass the independent parity gate. + assert not CutoverPhase.ready(native_done) assert StoryPhase.ready(native_done) assert CommanderPagePhase.ready(native_done) assert not SkillPhase.ready(native_done)