diff --git a/skills/loop-engineering/SKILL.md b/skills/loop-engineering/SKILL.md index d8cdc78..f92f4c8 100644 --- a/skills/loop-engineering/SKILL.md +++ b/skills/loop-engineering/SKILL.md @@ -50,6 +50,11 @@ manually and label the run as a non-deterministic fallback. The script emits `budget_exhausted` when the declared ceiling is consumed. 8. Run `show`; continue only while `terminal_status` is `running`. +While state is `running` and the next action is authorized, begin the next cycle +immediately in the same invocation. Do not yield an intermediate result or ask +again. Yield only for a terminal outcome, user interruption, or real runtime +boundary. + If later evidence contradicts a recorded fact, use `annotate --evidence ""`. Preserve the audit trail; do not reopen or hand-edit terminal state. @@ -58,6 +63,10 @@ Use `finish` for `blocked`, `needs_human`, `cancelled`, or `continue_scheduled`. Never translate those states or `budget_exhausted` into `complete`. +After intervention clears a resumable terminal condition, use `resume` to +create a bound successor state, replay the blocked check, and continue while +the successor is `running`. Never reopen the predecessor. + ## Preserve durable context When the installed `worklog` protocol is available, hydrate resume context diff --git a/skills/loop-engineering/references/examples.md b/skills/loop-engineering/references/examples.md index 30550b2..00afad8 100644 --- a/skills/loop-engineering/references/examples.md +++ b/skills/loop-engineering/references/examples.md @@ -20,8 +20,26 @@ terminal_status: running ``` Run one discriminating check per hypothesis. Serialize edits and test after each -one. End `complete` only with three passing runs; otherwise end -`budget_exhausted` with failures and the safest next action. +one. Continue immediately between hypotheses while state is `running`; do not +return an intermediate handoff. End `complete` only with three passing runs; +otherwise end `budget_exhausted` with failures and the safest next action. +When the terminal run itself consumes a declared unit, use `finish --consume 1`; +never also `advance` for that same unit. + +If a fresh check later contradicts evidence in a saved terminal run, append the +correction and revalidate the state: + +```bash +python3 /scripts/loop_state.py annotate \ + --state \ + --evidence "Correction: isolated rerun reproduced the timing failure" +python3 /scripts/loop_state.py validate --state +python3 /scripts/loop_state.py show --state +``` + +Confirm the terminal status and consumed budget remain unchanged. The earlier +evidence remains in history. Start a new authorized state file for further work; +do not reopen the terminal run. ## 2. Worklog-backed delegation @@ -56,6 +74,11 @@ primitive, schedule bounded checks and end each run `continue_scheduled` until CI evidence satisfies the goal. If no such primitive is available, end `needs_human` and name the missing capability. +At each verified wakeup, use `resume` to create a successor bound to the prior +`continue_scheduled` state, replay the CI check, and keep cycling. If a +credential or authority blocker requires intervention, checkpoint, ask for that +specific intervention, then resume a successor and replay the blocked check. + Do not claim that a timer or stop hook exists merely because another host supports one. diff --git a/skills/loop-engineering/references/hosts.md b/skills/loop-engineering/references/hosts.md index 0d82fc2..ce9b301 100644 --- a/skills/loop-engineering/references/hosts.md +++ b/skills/loop-engineering/references/hosts.md @@ -9,26 +9,39 @@ tracking. - Use only standard `name` and `description` frontmatter in `SKILL.md`. - Discover capabilities before invoking them. - Preserve the same run contract and terminal statuses on every host. +- During an active invocation, keep cycling while state is `running`; progress + updates are not pause points. +- Across invocations, continue only through a verified host recurrence + primitive. A prompt cannot manufacture background execution. +- After intervention or a scheduled wakeup, the agent creates a successor state + bound to the terminal predecessor and replays the stopping check before + continuing. Treat supplied intervention as pending until that check passes. ## Codex - Discover shared skills from `~/.agents/skills/`. - Use the current task plan and available subagent tools for in-session tracking and bounded delegation. +- Keep issuing tool calls in the current task while state is `running`; do not + yield merely because one cycle ended. - Invoke the installed `worklog` skill for durable context and checkpoints. - For recurrence, use an available Codex automation or `loop-orchestrator`; if - neither is callable, end `needs_human`. + neither is callable, end `needs_human`. Each heartbeat wakes the agent, which + resumes from the prior `continue_scheduled` state instead of reopening it. ## Claude Code - Discover personal skills from `~/.claude/skills/`. - Use the available task tracker and Agent tool for in-session tracking and bounded delegation. +- Continue the current tool/agent sequence while state is `running`; do not end + the response between authorized cycles. - Invoke `/worklog context --for=compact` before cold delegation and pass the returned pack directly. Use `/worklog sync` for the protocol's confirmation/checkpoint boundary. - Use Claude's real `/loop`, scheduled task, or hook capability only when exposed - and authorized. Otherwise end `needs_human`. + and authorized. Each recurrence wakes the agent to resume a bound successor. + Otherwise end `needs_human`. ## Cursor @@ -36,11 +49,14 @@ tracking. `~/.cursor/skills/`; project-local alternatives may use `.agents/skills/` or `.cursor/skills/`. - Use Cursor todos and subagents when exposed. +- Continue the active agent run while state is `running`; a todo update alone + is not a reason to pause. - Invoke the installed worklog skill or its documented helper commands for durable context. If unavailable, use one durable project tracker and label the fallback. - Use a configured Cursor automation or hook only after verifying it exists and - has a bounded stop rule. Otherwise end `needs_human`. + has a bounded stop rule. Each recurrence wakes the agent to resume a bound + successor. Otherwise end `needs_human`. ## Compatibility rule diff --git a/skills/loop-engineering/references/protocol.md b/skills/loop-engineering/references/protocol.md index bf0216e..0a4e14c 100644 --- a/skills/loop-engineering/references/protocol.md +++ b/skills/loop-engineering/references/protocol.md @@ -9,16 +9,47 @@ Use `scripts/loop_state.py`; do not reimplement its state machine or hand-edit the JSON. - `init` creates a `running` state and refuses overwrite unless `--force`. -- `advance` records a failed or nonterminal cycle, consumes positive budget, - and atomically changes the state to `budget_exhausted` at the ceiling. +- `advance` records a failed or nonterminal cycle, consumes one budget unit by + default, and atomically changes the state to `budget_exhausted` at the + ceiling. Use `--consume N` only when one recorded cycle represents `N` + declared units; it cannot exceed the remaining budget. +- `resume --state --new-state ` starts a fresh running + state after `blocked`, `needs_human`, `budget_exhausted`, or + `continue_scheduled`. It inherits the goal and cumulative budget, requires a + next action, and binds the successor to the predecessor path, status, and + SHA-256. Use `--extend-budget N` only with explicit authorization; exhausted + predecessors require an extension. It rejects `running`, `complete`, and + `cancelled`. - `finish` records exactly one terminal outcome. `complete` requires - `--verification` naming tool output or an artifact. + `--verification` naming tool output or an artifact. It leaves budget + unchanged by default; use `--consume N` when the terminal cycle spent `N` + declared units. Every executed cycle must be accounted exactly once by + `advance` or `finish`. - `annotate` appends corrected evidence without reopening terminal state or changing the consumed budget. - `validate` checks the schema and transition invariants. - `show` prints the five-field contract; `--json` returns the full history. Every write is atomic. A failed transition leaves the previous state unchanged. +Malformed CLI usage exits `2` with a `usage:` error. A well-formed command +rejected by the state contract exits `3` with a `loop-state:` error. + +## Continuous execution and intervention + +- While state is `running`, execute the next authorized cycle immediately. Do + not stop at an intermediate progress update or ask for permission already + granted by the effect boundary. +- On `blocked` or `needs_human`, checkpoint durable context and ask for the + smallest specific intervention. Include the predecessor state path and the + exact check that will prove the blocker cleared. +- After intervention, create a successor with `resume`, record the intervention + as supplied but pending verification, replay that check, append verified + clearing evidence, and keep iterating while the successor remains `running`. +- On `continue_scheduled`, a verified scheduler wakes the agent; the agent + creates the successor and replays the stopping check. Active-turn + continuation and background recurrence are distinct. +- A user may authorize a successor after `budget_exhausted`; do not silently + grant more budget. Never resume `complete` or `cancelled`. ## Effect boundary diff --git a/skills/loop-engineering/scripts/loop_state.py b/skills/loop-engineering/scripts/loop_state.py index f6cf8f6..4c8ed73 100755 --- a/skills/loop-engineering/scripts/loop_state.py +++ b/skills/loop-engineering/scripts/loop_state.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import pathlib @@ -22,6 +23,12 @@ "cancelled", "continue_scheduled", } +RESUMABLE_STATUSES = { + "blocked", + "needs_human", + "budget_exhausted", + "continue_scheduled", +} ALL_STATUSES = {"running", *TERMINAL_STATUSES} @@ -154,14 +161,8 @@ def command_init(args: argparse.Namespace) -> dict[str, Any]: def command_advance(args: argparse.Namespace) -> dict[str, Any]: state = read_state(args.state) require_running(state) - remaining = state["budget"]["limit"] - state["budget"]["used"] - if args.consume > remaining: - raise StateError( - f"transition consumes {args.consume} {state['budget']['unit']}; " - f"only {remaining} remain" - ) + consume_budget(state, args.consume) state["progress_evidence"].extend(args.evidence) - state["budget"]["used"] += args.consume state["next_action"] = args.next_action if state["budget"]["used"] == state["budget"]["limit"]: state["terminal_status"] = "budget_exhausted" @@ -170,11 +171,78 @@ def command_advance(args: argparse.Namespace) -> dict[str, Any]: return state +def consume_budget(state: dict[str, Any], amount: int) -> None: + remaining = state["budget"]["limit"] - state["budget"]["used"] + if amount > remaining: + raise StateError( + f"transition consumes {amount} {state['budget']['unit']}; " + f"only {remaining} remain" + ) + state["budget"]["used"] += amount + + +def command_resume(args: argparse.Namespace) -> dict[str, Any]: + if args.new_state.exists(): + raise StateError(f"refusing to overwrite existing state: {args.new_state}") + if args.state.resolve() == args.new_state.resolve(): + raise StateError("resume requires a distinct --new-state path") + predecessor = read_state(args.state) + status = predecessor["terminal_status"] + if status not in RESUMABLE_STATUSES: + raise StateError(f"cannot resume state with status: {status}") + if ( + predecessor["budget"]["used"] == predecessor["budget"]["limit"] + and args.extend_budget == 0 + ): + raise StateError( + "resume requires --extend-budget when predecessor budget is exhausted" + ) + + digest = hashlib.sha256(args.state.read_bytes()).hexdigest() + predecessor_reference = { + "path": str(args.state.resolve()), + "sha256": digest, + "terminal_status": status, + } + resume_evidence = [ + (f"Resumed from {predecessor_reference['path']} ({status}, sha256={digest})"), + *args.evidence, + ] + state: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "goal": predecessor["goal"], + "progress_evidence": resume_evidence, + "budget": { + "unit": predecessor["budget"]["unit"], + "limit": predecessor["budget"]["limit"] + args.extend_budget, + "used": predecessor["budget"]["used"], + }, + "next_action": args.next_action, + "terminal_status": "running", + "allowed_effects": [ + *predecessor.get("allowed_effects", []), + *args.allowed_effect, + ], + "approval_boundary": ( + args.approval_boundary + if args.approval_boundary is not None + else predecessor.get("approval_boundary", "") + ), + "verification": "", + "predecessor": predecessor_reference, + "history": [], + } + append_history(state, f"resumed:{status}", resume_evidence, args.next_action) + write_state(args.new_state, state) + return state + + def command_finish(args: argparse.Namespace) -> dict[str, Any]: state = read_state(args.state) require_running(state) if args.status == "complete" and not args.verification: raise StateError("complete requires --verification from a tool or artifact") + consume_budget(state, args.consume) state["progress_evidence"].extend(args.evidence) state["terminal_status"] = args.status if args.next_action is not None: @@ -240,12 +308,26 @@ def build_parser() -> argparse.ArgumentParser: advance.add_argument("--consume", type=int, default=1) advance.set_defaults(handler=command_advance) + resume = subparsers.add_parser( + "resume", + help="start a running successor from a resumable terminal state", + ) + add_state_argument(resume) + resume.add_argument("--new-state", type=pathlib.Path, required=True) + resume.add_argument("--evidence", action="append", required=True) + resume.add_argument("--extend-budget", type=int, default=0) + resume.add_argument("--next-action", required=True) + resume.add_argument("--allowed-effect", action="append", default=[]) + resume.add_argument("--approval-boundary") + resume.set_defaults(handler=command_resume) + finish = subparsers.add_parser("finish", help="record a terminal outcome") add_state_argument(finish) finish.add_argument("--status", choices=sorted(TERMINAL_STATUSES), required=True) finish.add_argument("--evidence", action="append", required=True) finish.add_argument("--verification") finish.add_argument("--next-action") + finish.add_argument("--consume", type=int, default=0) finish.set_defaults(handler=command_finish) annotate = subparsers.add_parser( @@ -271,13 +353,17 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: parser = build_parser() args = parser.parse_args() - if getattr(args, "consume", 1) < 1: + if args.command == "advance" and args.consume < 1: parser.error("--consume must be a positive integer") + if args.command == "finish" and args.consume < 0: + parser.error("--consume must be zero or a positive integer") + if args.command == "resume" and args.extend_budget < 0: + parser.error("--extend-budget must be zero or a positive integer") try: state = args.handler(args) except StateError as exc: print(f"loop-state: {exc}", file=sys.stderr) - return 2 + return 3 if args.command == "show" and not args.json: print(summary(state)) else: diff --git a/skills/loop-engineering/tests/test_loop_state.py b/skills/loop-engineering/tests/test_loop_state.py index 4e81817..0b9b337 100755 --- a/skills/loop-engineering/tests/test_loop_state.py +++ b/skills/loop-engineering/tests/test_loop_state.py @@ -3,6 +3,7 @@ from __future__ import annotations +import hashlib import json import pathlib import subprocess @@ -19,6 +20,7 @@ def setUp(self) -> None: self.temporary_directory = tempfile.TemporaryDirectory() self.addCleanup(self.temporary_directory.cleanup) self.state = pathlib.Path(self.temporary_directory.name) / "loop.json" + self.successor = pathlib.Path(self.temporary_directory.name) / "successor.json" def run_cli( self, @@ -80,7 +82,7 @@ def test_init_refuses_to_overwrite_existing_state(self) -> None: "1", "--next-action", "stop", - expected_returncode=2, + expected_returncode=3, ) self.assertIn("refusing to overwrite", result.stderr) @@ -99,6 +101,149 @@ def test_advance_exhausts_budget_without_claiming_complete(self) -> None: self.assertEqual(state["terminal_status"], "budget_exhausted") self.assertNotEqual(state["terminal_status"], "complete") + def test_resume_creates_bound_successor_without_reopening_blocker(self) -> None: + self.initialize() + self.run_cli( + "finish", + "--state", + str(self.state), + "--status", + "needs_human", + "--evidence", + "deployment credential requires intervention", + "--next-action", + "ask operator to refresh the credential", + "--consume", + "1", + ) + predecessor_text = self.state.read_text() + result = self.run_cli( + "resume", + "--state", + str(self.state), + "--new-state", + str(self.successor), + "--evidence", + "operator refreshed the credential", + "--next-action", + "replay the deployment verification", + "--allowed-effect", + "run deployment verification", + "--approval-boundary", + "use only the refreshed credential", + ) + successor = json.loads(result.stdout) + + self.assertEqual(self.state.read_text(), predecessor_text) + self.assertEqual(successor["terminal_status"], "running") + self.assertEqual(successor["goal"], "targeted test passes") + self.assertEqual( + successor["budget"], + {"unit": "hypotheses", "limit": 2, "used": 1}, + ) + self.assertEqual( + successor["predecessor"]["terminal_status"], + "needs_human", + ) + self.assertEqual( + successor["predecessor"]["sha256"], + hashlib.sha256(predecessor_text.encode()).hexdigest(), + ) + self.assertEqual(successor["history"][-1]["event"], "resumed:needs_human") + self.assertIn("edit worktree", successor["allowed_effects"]) + self.assertIn("run deployment verification", successor["allowed_effects"]) + + def test_resume_rejects_running_and_complete_predecessors(self) -> None: + self.initialize() + running_result = self.run_cli( + "resume", + "--state", + str(self.state), + "--new-state", + str(self.successor), + "--evidence", + "no intervention occurred", + "--next-action", + "continue current run", + expected_returncode=3, + ) + self.assertIn("cannot resume state with status: running", running_result.stderr) + self.assertFalse(self.successor.exists()) + + self.run_cli( + "finish", + "--state", + str(self.state), + "--status", + "complete", + "--evidence", + "targeted test passed", + "--verification", + "test log", + ) + complete_result = self.run_cli( + "resume", + "--state", + str(self.state), + "--new-state", + str(self.successor), + "--evidence", + "request to reopen completed work", + "--next-action", + "do not reopen", + expected_returncode=3, + ) + self.assertIn( + "cannot resume state with status: complete", complete_result.stderr + ) + self.assertFalse(self.successor.exists()) + + def test_exhausted_resume_requires_explicit_budget_extension(self) -> None: + self.initialize(limit=1) + self.run_cli( + "advance", + "--state", + str(self.state), + "--evidence", + "initial hypothesis falsified", + "--next-action", + "request authority for another hypothesis", + ) + rejected = self.run_cli( + "resume", + "--state", + str(self.state), + "--new-state", + str(self.successor), + "--evidence", + "no additional budget authorized", + "--next-action", + "remain exhausted", + expected_returncode=3, + ) + self.assertIn("requires --extend-budget", rejected.stderr) + self.assertFalse(self.successor.exists()) + + resumed = json.loads( + self.run_cli( + "resume", + "--state", + str(self.state), + "--new-state", + str(self.successor), + "--evidence", + "operator authorized two more hypotheses", + "--extend-budget", + "2", + "--next-action", + "test the next hypothesis", + ).stdout + ) + self.assertEqual( + resumed["budget"], + {"unit": "hypotheses", "limit": 3, "used": 1}, + ) + def test_complete_requires_verification_and_failed_transition_is_atomic( self, ) -> None: @@ -112,7 +257,7 @@ def test_complete_requires_verification_and_failed_transition_is_atomic( "complete", "--evidence", "agent says fixed", - expected_returncode=2, + expected_returncode=3, ) self.assertIn("requires --verification", result.stderr) self.assertEqual(self.state.read_text(), before) @@ -131,10 +276,47 @@ def test_complete_records_external_verification(self) -> None: "pytest log artifact: /tmp/targeted-test.log", "--next-action", "checkpoint worklog", + "--consume", + "1", ) state = json.loads(result.stdout) self.assertEqual(state["terminal_status"], "complete") self.assertIn("pytest log", state["verification"]) + self.assertEqual(state["budget"]["used"], 1) + + def test_runtime_rejection_and_cli_misuse_are_distinct_and_atomic(self) -> None: + self.initialize(limit=1) + before = self.state.read_text() + result = self.run_cli( + "finish", + "--state", + str(self.state), + "--status", + "blocked", + "--evidence", + "two retries attempted", + "--consume", + "2", + expected_returncode=3, + ) + self.assertIn("only 1 remain", result.stderr) + self.assertEqual(self.state.read_text(), before) + + misuse = self.run_cli( + "advance", + "--state", + str(self.state), + "--evidence", + "invalid zero-unit transition", + "--next-action", + "fix invocation", + "--consume", + "0", + expected_returncode=2, + ) + self.assertIn("usage:", misuse.stderr) + self.assertNotIn("loop-state:", misuse.stderr) + self.assertEqual(self.state.read_text(), before) def test_annotate_corrects_terminal_evidence_without_reopening(self) -> None: self.initialize(limit=1) diff --git a/tests/run.sh b/tests/run.sh index addf8bb..ffe4d47 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -153,11 +153,40 @@ checks = { "worklog creation gate": "slugless `sync`" in protocol, "worklog checkpoint route": "persist arbitrary evidence or task-body changes" in protocol, "terminal evidence rule": "model's prose claim is not evidence" in root, + "continuous active run": ( + "While state is `running`" in root + and "Do not yield an intermediate result" in root + ), "complete verification guard": "complete requires" in state_script and "--verification" in state_script, "evidence-gate completion route": "$evidence-gate" in root and "evidence-gate verification value" in root, "append-only correction": "def command_annotate" in state_script and "annotated" in state_script, + "bound successor resume": ( + "def command_resume" in state_script + and '"predecessor": predecessor_reference' in state_script + and "resume --state --new-state " in protocol + ), + "live contradiction fixture": all( + text in examples + for text in ( + "loop_state.py annotate", + "loop_state.py validate", + "terminal status and consumed budget remain unchanged", + ) + ), + "terminal cycle accounting": "finish --consume" in examples, + "distinct rejection exit": ( + "return 3" in state_script + and "Malformed CLI usage exits `2`" in protocol + and "state contract exits `3`" in protocol + ), "atomic state write": "os.replace" in state_script, "host differences deferred": references == {"examples.md", "hosts.md", "protocol.md"}, + "cross-host continuation": ( + hosts.count("while state is `running`") >= 3 + and "A prompt cannot manufacture background execution" in hosts + and hosts.count("wakes the agent") >= 3 + and "Treat supplied intervention as pending" in hosts + ), "no host-only injection": "!`" not in root and "allowed-tools:" not in root, "three contrastive fixtures": examples.count("\n## ") == 4, "Codex shared install": "~/.agents/skills/" in hosts,