Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions flows/humanize1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
-a claude/claude-opus-4-8:max -a codex/gpt-5.6-sol:max "build it"

which are the plugin's three commands: `gen-idea` opens a loose idea into a repo-grounded
draft, `gen-plan` turns that draft into a plan both sides have converged on, and `rlcr` builds
the plan under review until nothing is left to say. Everything each of them can be told is on
draft, `gen-plan` turns that draft into a plan both sides have converged on -- and stops,
plan on disk, rather than finish with a decision still `PENDING`, since the loop never waits
for a person -- and `rlcr` builds the plan under review until nothing is left to say. Everything each of them can be told is on
`/config` -- one field per flag the plugin takes, under the name the plugin gives it. Add
`-c setup.yaml` to run one set up rather than as it comes, and `hmz -f official/humanize1:rlcr
-c setup.yaml` opens the interface on the same setup.
Expand Down Expand Up @@ -576,6 +577,29 @@ def _section(held: str, *headings: str) -> str:
return ""


def _undecided(held: str) -> list[str]:
"""The decisions a plan still leaves to the person, by their `DEC-N` names.

Args:
held: The plan.

Returns:
Every entry under `## Pending User Decisions` whose `Decision Status` still says
`PENDING`, in the order the plan lists them. The template's own unfilled status line
counts, since a status nobody touched is a decision nobody made.
"""
found: list[str] = []
named = ""
for line in _section(held, "pending user decisions").splitlines():
said = line.strip()
if match := re.match(r"-\s*(DEC-\d+)", said):
named = match.group(1)
elif named and said.startswith("- Decision Status:") and "PENDING" in said:
found.append(named)
named = ""
return found


def _asked(human: Person, question: str, options: list[str]) -> str:
"""Puts one multiple-choice question to whoever is at the prompt.

Expand Down Expand Up @@ -866,7 +890,10 @@ def _plan(

Raises:
ValueError: If the draft is not there, is empty, does not belong to this repository, or
the plan cannot be written where it was asked for.
the plan cannot be written where it was asked for -- and if the finished plan still
says `PENDING` on a decision only the person may make, which is the one gate between
planning and building: the loop never blocks on a person, so what is undecided here
would idle it, not stop it.
"""
began = time.monotonic()
if not draft.is_file():
Expand Down Expand Up @@ -1136,6 +1163,20 @@ def _plan(
staged.write_text(finished, encoding="utf-8")
_promote(staged, where)

# The one gate between planning and building. `rlcr` never blocks on a person -- the
# quiz is advisory, `--yolo` answers the rest -- so a decision still `PENDING` would
# not stop the loop, it would idle it: every task hanging on the decision is deferred,
# round after round, and a week of reviews builds nothing. The plan is durable by now,
# every position written down; deciding is all that is left to do.
if undecided := _undecided(finished):
raise ValueError(
f"{where}: `PENDING` still stands on {', '.join(undecided)} under "
"`## Pending User Decisions`, and a loop handed a plan nobody finished "
"deciding builds none of it. The plan is written, every position with it -- "
"answer each `Decision Status` in the file, or run gen-plan again with "
"somebody at the prompt."
)

language, code = _language(config.alternative_plan_language)
if language:
variant = where.with_name(f"{where.stem}_{code}{where.suffix}")
Expand Down Expand Up @@ -1811,7 +1852,9 @@ def gen_plan(agents: Planning, task: str, config: Plan | None = None) -> None:

Raises:
ValueError: If there is no draft to plan from, or it is not this repository's, or the
plan cannot be written where it was asked for.
plan cannot be written where it was asked for -- and if the finished plan leaves a
`Pending User Decisions` entry `PENDING`: a plan is handed on decided, since the
loop that builds it never waits for a person.
"""
setting = config or Plan()
root = Path.cwd()
Expand Down
63 changes: 57 additions & 6 deletions tests/test_gen_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar

import pytest
from hmz.agents import AgentBase, AgentConfig, Event, Failed, SessionBase

ROOT = Path(__file__).parents[1]
Expand All @@ -24,7 +25,6 @@
import os
from collections.abc import Callable, Iterator

import pytest
from pydantic import BaseModel


Expand Down Expand Up @@ -91,6 +91,12 @@
Keep role names independent of backend names.
"""

DECISION = """- DEC-1: Storage backend
- Planner Position: keep it in sqlite
- Reviewer Position: flat files are enough
- Tradeoff Summary: durability against simplicity
- Decision Status: {status}"""


class Scripted(AgentBase):
"""An agent whose role behavior is supplied by the test."""
Expand Down Expand Up @@ -132,7 +138,9 @@ def _shut(self) -> None:
self.released.set()


def _planner(plan: Path, *, revise_materially: bool = False) -> Scripted:
def _planner(
plan: Path, *, revise_materially: bool = False, decisions: str = ""
) -> Scripted:
def target(prompt: str) -> Path:
for named in re.findall(r"/[^\s`]+", prompt):
path = Path(named.rstrip(".,:;"))
Expand All @@ -159,11 +167,15 @@ def turn(prompt: str, _session: ScriptedSession) -> str:
if "set to `partially_converged`" in prompt
else "converged"
)
output.write_text(
output.read_text().replace(
"`converged` or `partially_converged`", f"`{status}`"
)
held = output.read_text().replace(
"`converged` or `partially_converged`", f"`{status}`"
)
if decisions:
held = held.replace(
"## Pending User Decisions\n- None.",
"## Pending User Decisions\n" + decisions,
)
output.write_text(held)
return str(output)

return Scripted("planner", turn)
Expand Down Expand Up @@ -379,3 +391,42 @@ def test_default_planning_budgets_are_finite() -> None:
assert config.turn_timeout == 3600
assert config.total_timeout == 14400
assert config.turn_retries == 1


def test_a_decision_left_pending_stops_the_run_with_the_plan_kept(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
output = tmp_path / "plan.md"
planner = _planner(output, decisions=DECISION.format(status="`PENDING`"))

with pytest.raises(ValueError, match="PENDING.*DEC-1"):
_run(tmp_path, planner, _analyst())

held = output.read_text()
assert "Planner Position: keep it in sqlite" in held
assert "Final Status: `converged`" in held


def test_a_decision_answered_lets_the_plan_finish(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
output = tmp_path / "plan.md"
planner = _planner(
output, decisions=DECISION.format(status="sqlite, as the planner had it")
)

plan = _run(tmp_path, planner, _analyst())

assert "Decision Status: sqlite, as the planner had it" in plan.read_text()


def test_the_templates_own_unfilled_status_line_counts_as_undecided() -> None:
held = (
"## Pending User Decisions\n\n"
"- DEC-2: Cache eviction\n"
" - Decision Status: `PENDING` or `<User's final decision>`\n"
)

assert humanize1._undecided(held) == ["DEC-2"] # noqa: SLF001