From aeccc68390f87ae2c1993bcb130ed6a98fec0cf3 Mon Sep 17 00:00:00 2001 From: dongyun Date: Sat, 29 Aug 2026 03:34:34 +0000 Subject: [PATCH] fix(humanize1): gen-plan stops while a decision is still PENDING rlcr never blocks on a person -- the quiz is advisory and --yolo answers the rest -- so a plan handed on with `## Pending User Decisions` entries still `PENDING` does not stop the loop, it idles it: every task hanging on a decision is deferred, round after round, and a week of reviews builds nothing. The gate is gen-plan's, at the end of the final consolidation: the finished plan is read for entries whose `Decision Status` still says `PENDING`, and the run stops with their DEC names rather than finish. The plan stays on disk with every position written down; deciding is all that is left to do. The template's own unfilled status line counts, since a status nobody touched is a decision nobody made. Co-Authored-By: Claude Fable 5 --- flows/humanize1/__init__.py | 51 +++++++++++++++++++++++++++--- tests/test_gen_plan.py | 63 +++++++++++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/flows/humanize1/__init__.py b/flows/humanize1/__init__.py index f79f58c..b49b6de 100644 --- a/flows/humanize1/__init__.py +++ b/flows/humanize1/__init__.py @@ -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. @@ -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. @@ -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(): @@ -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}") @@ -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() diff --git a/tests/test_gen_plan.py b/tests/test_gen_plan.py index 13d9d50..8d4bee5 100644 --- a/tests/test_gen_plan.py +++ b/tests/test_gen_plan.py @@ -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] @@ -24,7 +25,6 @@ import os from collections.abc import Callable, Iterator - import pytest from pydantic import BaseModel @@ -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.""" @@ -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(".,:;")) @@ -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) @@ -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 ``\n" + ) + + assert humanize1._undecided(held) == ["DEC-2"] # noqa: SLF001