From 4ac4d3f1ece893baf204c3be70f793b5c40c30ca Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Tue, 22 Sep 2026 11:18:23 -0400 Subject: [PATCH 1/3] Add durable NestQuest OpenCode controller and role launchers (#149) Co-authored-by: orchestrator-agent Co-committed-by: orchestrator-agent --- .opencode/agents/approver.md | 32 +++ .opencode/agents/developer.md | 21 ++ .opencode/agents/orchestrator.md | 60 ++++ ORCHESTRATION.md | 54 ++++ deploy/nestquest-controller.service | 17 ++ orchestration_tests/test_nq_approve_merge.py | 88 ++++++ orchestration_tests/test_nq_controller.py | 108 ++++++++ orchestration_tests/test_nq_review.py | 17 ++ scripts/nq-agent | 20 ++ scripts/nq-approve-merge | 131 +++++++++ scripts/nq-controller | 277 +++++++++++++++++++ scripts/nq-review | 111 ++++++++ 12 files changed, 936 insertions(+) create mode 100644 .opencode/agents/approver.md create mode 100644 .opencode/agents/developer.md create mode 100644 .opencode/agents/orchestrator.md create mode 100644 ORCHESTRATION.md create mode 100644 deploy/nestquest-controller.service create mode 100644 orchestration_tests/test_nq_approve_merge.py create mode 100644 orchestration_tests/test_nq_controller.py create mode 100644 orchestration_tests/test_nq_review.py create mode 100755 scripts/nq-agent create mode 100755 scripts/nq-approve-merge create mode 100755 scripts/nq-controller create mode 100755 scripts/nq-review diff --git a/.opencode/agents/approver.md b/.opencode/agents/approver.md new file mode 100644 index 0000000..14efb06 --- /dev/null +++ b/.opencode/agents/approver.md @@ -0,0 +1,32 @@ +--- +description: Independently verify NestQuest merge gates and merge approved heads +mode: primary +model: openrouter/deepseek/deepseek-v4.1-flash +permission: + edit: deny + write: deny + task: deny + bash: + "*": deny + "/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow + external_directory: + "*": ask + "/tmp/opencode/*": allow + "/home/overseer/.local/share/nestquest-controller/*": allow +--- + +You are the NestQuest feature-to-dev approver, separate from the controller and +developer sessions. Verify the current PR head equals the independently +reviewed and approved commit, +required checks pass, blocking findings are resolved, branch protection is +satisfied, and Maestro has accurate links. Use only the approver Gitea identity +for the merge. Do not change code or approve a stale head. Use only the +installed nq-approve-merge launcher for Gitea inspection and merging. Merge +only into dev; never merge into main. If any gate is +missing, refuse the merge and give the exact missing evidence. Report the PR, +reviewed head, resulting merge commit, and target branch. + +Broker syntax: `nq-approve-merge inspect PR_NUMBER` or +`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker +command per Bash call. The broker validates the current PR and review again +before merging; do not substitute an ad hoc Gitea command. diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md new file mode 100644 index 0000000..0cd1206 --- /dev/null +++ b/.opencode/agents/developer.md @@ -0,0 +1,21 @@ +--- +description: Implement one assigned NestQuest task in an isolated worktree +mode: primary +model: openrouter/z-ai/glm-5.3-flash +permission: + task: deny + external_directory: + "*": ask + "/tmp/opencode/*": allow +--- + +You are the NestQuest development agent. Implement only the assigned Maestro +task in the supplied worktree. Follow the task's acceptance criteria and the +repository's instructions. Run relevant tests, commit on the task branch, and +report the commit and test results. Keep changes inside the assigned worktree. +Do not approve your own pull request. You may merge your task PR into the +feature branch only after the independent reviewer approved its exact current +head and the controller verified every task merge gate. Never merge a feature +into dev or a release into main. Do not start another task, change role, or +use another model. Do not read or print credential files except when the +approved task merge requires the existing Gitea development credential. diff --git a/.opencode/agents/orchestrator.md b/.opencode/agents/orchestrator.md new file mode 100644 index 0000000..e2e19e1 --- /dev/null +++ b/.opencode/agents/orchestrator.md @@ -0,0 +1,60 @@ +--- +description: Reconcile NestQuest work and dispatch the next verified action +mode: primary +model: openrouter/deepseek/deepseek-v4.1-flash +permission: + doom_loop: deny + task: deny + external_directory: + "*": ask + "/tmp/opencode/*": allow + "/home/overseer/.local/share/opencode/tool-output/*": allow +--- + +You are the NestQuest controller. Work in the NestQuest repository. Maestro is +the source of truth for feature and task state; CTXD is the source of truth for +project instructions. At the start of every invocation, read the current +NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then +reconcile it with Maestro, Git, pull requests, and checks before dispatching. +Follow the review, identity, and branch rules in that prompt. +If the CTXD project or prompt is unavailable, do not dispatch or merge work; +report the missing source and end with ORCHESTRATOR_STATE: BLOCKED. + +One invocation is a controller tick, not an entire feature lifetime. Prefer a +state-changing action over another identical status query. Never repeat an +unchanged tool call three times. If work is asynchronous, wait or inspect a +specific changed result. Do not claim that you are continuing while ending +the turn without naming the next action. + +The NESTQUEST_CONTROL_HOME environment variable names the installed launchers +(normally /home/overseer/.local/share/nestquest-controller). +For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role +developer, a task worktree, title, and task prompt file. For independent +review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree, +feature base ref, exact head ref, review prompt file, PR number, and an output +file under /tmp/opencode. It publishes an exact-head Gitea approval only after +Claude approves. The developer may merge +a task PR into its feature branch only after independent approval of the exact +head and verification of every task gate. For feature-to-dev approval and +merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in +a separate session after the independent review. Save the Claude review output +in a file under /tmp/opencode and give that file and its reviewed head to the +approver. Never substitute a different model or reviewer silently. + +When a feature is integrated into dev, reconcile Maestro, then immediately +select and dispatch the next eligible feature. Feature completion is a +transition, not the end of the controller's job. A release awaiting Joshua's +main merge does not prevent work on independent eligible features. + +End each invocation with exactly one final line using one of these values: + +ORCHESTRATOR_STATE: CONTINUE +ORCHESTRATOR_STATE: QUIESCENT +ORCHESTRATOR_STATE: BLOCKED + +Use CONTINUE when there is any eligible or in-flight work; include the feature, +task, and next concrete action immediately above the final line. Use QUIESCENT +only after verifying no eligible or in-flight work remains in Maestro and the +repository. Use BLOCKED only when a specific decision or access is required; +include evidence and the needed human action. If you cannot establish the +state, use BLOCKED. Never emit a success claim based only on a child's summary. diff --git a/ORCHESTRATION.md b/ORCHESTRATION.md new file mode 100644 index 0000000..db5ca8f --- /dev/null +++ b/ORCHESTRATION.md @@ -0,0 +1,54 @@ +# NestQuest controller + +OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their +models match the current NestQuest CTXD prompt: DeepSeek V4.1 +Flash for controller and approver, GLM 5.3 Flash for development. The +controller runs one OpenCode turn at a time, then starts a fresh turn based on +Maestro, CTXD, Git, and pull-request state. Its state file is +`~/.local/state/nestquest-controller/last-run.json` (mode 0600). A lock allows +only one controller process. It waits while an interactive OpenCode session is +open for the NestQuest checkout. + +`scripts/nq-agent` launches developer and approver sessions with fixed agent +definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the +global idle-notification plugin does not notify on every controller tick. +The approver's shell permission permits only the installed +`nq-approve-merge` broker for Gitea. That broker requires exact-head +Claude approval evidence, an exact-head Gitea approval by `review_agent`, +the `dev` target, a mergeable PR, and passing configured checks before it +requests a squash merge with Gitea's atomic `head_commit_id` guard. +`scripts/nq-review` launches the independent +Claude review in a noninteractive read-only session in the task worktree, +checks the head again afterward, saves +the output under `/tmp/opencode`, and posts the Gitea approval. +The approver verifies the reviewed head again before a merge. + +The production service uses a stable copy of this configuration and scripts +at `/home/overseer/.local/share/nestquest-controller`; it does not depend on +which task branch is checked out in the primary NestQuest directory. Install +that copy, run `scripts/nq-controller --check`, and verify all three agents +with `opencode debug agent ` while `OPENCODE_CONFIG_DIR` points at the +installed `.opencode` directory. Install `deploy/nestquest-controller.service` +under `~/.config/systemd/user/`, then enable it with `systemctl --user`. +The host has lingering enabled for the `overseer` user. The service waits for an open +interactive NestQuest session to close before starting work. + +The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt. +It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs +to a different local store and lacks this project. + +All four agent credentials are currently readable by the same Unix account. +The Gitea review and merge gates enforce normal workflow sequencing, but +they are not a hard isolation boundary against a process running as that +account. Strong isolation requires separate OS identities and protected +credentials for the controller, reviewer, and approver. + +A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the +next tick must reconcile external state before acting. Three consecutive +failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT +causes a five-minute poll. Neither a feature merge nor a progress report +stops the controller while eligible work remains. + +Do not put tokens in this repository or in unit files. Existing OpenCode MCP +connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated +operation across every consumer before revoking the old values. diff --git a/deploy/nestquest-controller.service b/deploy/nestquest-controller.service new file mode 100644 index 0000000..3084257 --- /dev/null +++ b/deploy/nestquest-controller.service @@ -0,0 +1,17 @@ +[Unit] +Description=NestQuest OpenCode controller + +[Service] +Type=simple +WorkingDirectory=/mnt/ai-storage/projects/NestQuest +ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check +ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller +Restart=on-failure +RestartSec=60s +RestartPreventExitStatus=77 +TimeoutStartSec=0 +KillMode=control-group +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=default.target diff --git a/orchestration_tests/test_nq_approve_merge.py b/orchestration_tests/test_nq_approve_merge.py new file mode 100644 index 0000000..f429861 --- /dev/null +++ b/orchestration_tests/test_nq_approve_merge.py @@ -0,0 +1,88 @@ +from pathlib import Path +import contextlib +import io +import runpy +import sys +import tempfile +import unittest +from unittest.mock import patch + + +APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge")) +HEAD = "a" * 40 + + +class MergeGateTests(unittest.TestCase): + def test_main_rejects_moved_head_unmergeable_and_pending_checks(self): + root = Path("/tmp/opencode") + root.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=root) as directory: + review_file = Path(directory) / "review.txt" + review_file.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n") + for problem in ("moved", "unmergeable", "pending", "none"): + with self.subTest(problem=problem): + merged = False + calls = [] + def fake_request(path, token, payload=None): + nonlocal merged + calls.append((path, payload)) + if path.endswith("/reviews"): + return [{"id": 1, "state": "APPROVED", "commit_id": HEAD, + "user": {"login": "review_agent"}}] + if path.endswith("/status"): + return {"total_count": 1, "state": "pending" if problem == "pending" else "success"} + if path.endswith("/merge"): + merged = True + return {} + if path.endswith("/pulls/149"): + return {"number": 149, "base": {"ref": "dev"}, + "head": {"sha": "b" * 40 if problem == "moved" else HEAD}, + "state": "open", "merged": merged, + "mergeable": problem != "unmergeable"} + raise AssertionError(path) + globals_ = APPROVE["main"].__globals__ + with patch.dict(globals_, {"request": fake_request, + "token_from_file": lambda: "test"}), \ + patch.object(sys, "argv", ["nq-approve-merge", "merge", "149", HEAD, + str(review_file)]), \ + contextlib.redirect_stdout(io.StringIO()): + if problem == "none": + self.assertEqual(APPROVE["main"](), 0) + else: + with self.assertRaises(RuntimeError): + APPROVE["main"]() + self.assertEqual(merged, problem == "none") + if problem == "none": + merge_calls = [payload for path, payload in calls if path.endswith("/merge")] + self.assertEqual(merge_calls, + [{"do": "squash", "head_commit_id": HEAD}]) + + def test_review_evidence_must_match_exact_head_and_approval(self): + root = Path("/tmp/opencode") + root.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=root) as directory: + review = Path(directory) / "review.txt" + review.write_text(f"Findings: none\nREVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n") + APPROVE["validate_review"](review, HEAD) + with self.assertRaisesRegex(RuntimeError, "exact head"): + APPROVE["validate_review"](review, "b" * 40) + review.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: CHANGES_REQUIRED\n") + with self.assertRaisesRegex(RuntimeError, "exact head"): + APPROVE["validate_review"](review, HEAD) + + def test_requires_distinct_gitea_reviewer_on_exact_commit(self): + records = [{"state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}] + with patch.dict(APPROVE["has_platform_review"].__globals__, {"request": lambda *_: records}): + self.assertTrue(APPROVE["has_platform_review"](149, HEAD, "unused")) + self.assertFalse(APPROVE["has_platform_review"](149, "b" * 40, "unused")) + records[0]["user"]["login"] = "orchestrator-agent" + self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused")) + records[:] = [ + {"id": 1, "state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}, + {"id": 2, "state": "REQUEST_CHANGES", "commit_id": HEAD, "user": {"login": "review_agent"}}, + ] + self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused")) + + +if __name__ == "__main__": + unittest.main() diff --git a/orchestration_tests/test_nq_controller.py b/orchestration_tests/test_nq_controller.py new file mode 100644 index 0000000..4445b14 --- /dev/null +++ b/orchestration_tests/test_nq_controller.py @@ -0,0 +1,108 @@ +import json +import io +from pathlib import Path +import runpy +import sqlite3 +import sys +import tempfile +import unittest +from unittest.mock import patch + + +CONTROL = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-controller")) + + +class ControllerProtocolTests(unittest.TestCase): + def test_ctxd_preflight_reads_configured_endpoint_and_checks_identity(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + config = root / ".config/opencode/opencode.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"mcp": {"ctxd": { + "url": "http://localhost:9091/mcp", + "headers": {"Authorization": "Bearer test"}, + }}})) + record = {"project_id": CONTROL["CTXD_PROJECT"], + "file_path": CONTROL["CTXD_FILE"], + "content": "# CONTROLLER_TICK", "version": 6} + with patch.object(Path, "home", return_value=root), \ + patch.dict(CONTROL["check_ctxd"].__globals__["urllib"].request.__dict__, + {"urlopen": lambda request, timeout: io.BytesIO(json.dumps(record).encode())}): + self.assertEqual(CONTROL["check_ctxd"](), 6) + record["project_id"] = "wrong" + with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"): + CONTROL["check_ctxd"]() + record["project_id"] = CONTROL["CTXD_PROJECT"] + config.write_text(json.dumps({"mcp": {"ctxd": { + "url": "http://localhost:9091/mcp/", + "headers": {"Authorization": "Bearer test"}, + }}})) + self.assertEqual(CONTROL["check_ctxd"](), 6) + config.write_text(json.dumps({"mcp": {"ctxd": { + "url": "http://localhost:9091/invalid", + "headers": {"Authorization": "Bearer test"}, + }}})) + with self.assertRaisesRegex(RuntimeError, "must end in /mcp"): + CONTROL["check_ctxd"]() + + def test_installed_opencode_text_event(self): + event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}} + self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (event["part"]["text"], None)) + self.assertEqual(CONTROL["STATE_RE"].findall(event["part"]["text"]), ["CONTINUE"]) + + def test_error_event_does_not_publish_provider_message(self): + event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}} + self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError")) + self.assertEqual(CONTROL["parse_event"]("null"), (None, None)) + self.assertEqual(CONTROL["parse_event"]("[]"), (None, None)) + + def test_tick_uses_final_text(self): + event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}}) + command = [sys.executable, "-c", f"import json,sys; sys.stdin.read(); print({event!r})"] + status, answer, error = CONTROL["run_tick"](command, {}, "tick", 10) + self.assertEqual((status, error), (0, None)) + self.assertEqual(CONTROL["STATE_RE"].findall(answer), ["QUIESCENT"]) + + def test_timeout_kills_child_group_and_marks_tick_failed(self): + command = [sys.executable, "-c", "import sys,time; sys.stdin.read(); time.sleep(10)"] + status, answer, error = CONTROL["run_tick"](command, {}, "tick", 1) + self.assertEqual(status, 124) + self.assertEqual(answer, "") + self.assertIn("timeout", error) + + def test_interactive_session_detects_dir_and_resumed_session(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + project = root / "project" + project.mkdir() + proc = root / "proc" + entry = proc / "123" + entry.mkdir(parents=True) + (entry / "cwd").symlink_to(root) + database = root / "opencode.db" + with sqlite3.connect(database) as db: + db.execute("CREATE TABLE session (id TEXT, directory TEXT)") + db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project))) + (entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0") + self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0") + self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cmdline").write_bytes(b"opencode\0serve\0") + (entry / "cwd").unlink() + (entry / "cwd").symlink_to(project) + self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cwd").unlink() + (entry / "cwd").symlink_to(root) + (entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0") + self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) + (entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0") + self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database)) + with sqlite3.connect(database) as db: + db.execute("DROP TABLE session") + (entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0") + with self.assertRaisesRegex(RuntimeError, "lookup failed"): + CONTROL["interactive_nestquest_session"](project, proc, database) + + +if __name__ == "__main__": + unittest.main() diff --git a/orchestration_tests/test_nq_review.py b/orchestration_tests/test_nq_review.py new file mode 100644 index 0000000..f701ec2 --- /dev/null +++ b/orchestration_tests/test_nq_review.py @@ -0,0 +1,17 @@ +from pathlib import Path +import runpy +import unittest + +REVIEW = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-review")) +HEAD = "a" * 40 + +class ReviewLauncherTests(unittest.TestCase): + def test_exact_head_verdict(self): + self.assertEqual(REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n", HEAD), "APPROVED") + with self.assertRaisesRegex(RuntimeError, "one verdict"): + REVIEW["verdict"](f"REVIEWED_HEAD: {'b' * 40}\nREVIEW_VERDICT: APPROVED\n", HEAD) + with self.assertRaisesRegex(RuntimeError, "one verdict"): + REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\nREVIEW_VERDICT: CHANGES_REQUIRED\n", HEAD) + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/nq-agent b/scripts/nq-agent new file mode 100755 index 0000000..8ba8f1d --- /dev/null +++ b/scripts/nq-agent @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: nq-agent developer|approver WORKTREE TITLE PROMPT_FILE" >&2 + exit 64 +fi + +role=$1 +worktree=$2 +title=$3 +prompt_file=$4 +case "$role" in developer|approver) ;; *) echo "invalid role: $role" >&2; exit 64 ;; esac +[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; } + +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +export OPENCODE_CONFIG_DIR="$repo_dir/.opencode" +opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode} + +exec "$opencode_bin" run --pure --dir "$worktree" --agent "$role" --title "$title" --format json < "$prompt_file" diff --git a/scripts/nq-approve-merge b/scripts/nq-approve-merge new file mode 100755 index 0000000..e3d4058 --- /dev/null +++ b/scripts/nq-approve-merge @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Inspect or merge a reviewed NestQuest feature PR into dev only.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys +import urllib.error +import urllib.request + + +API = "https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/NestQuest" +CREDENTIALS = Path("/home/overseer/.llms/credentials.env") +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") + + +def token_from_file() -> str: + for line in CREDENTIALS.read_text().splitlines(): + if line.startswith("GITEA_APPROVER_AGENT_TOKEN="): + return line.split("=", 1)[1].strip().strip("\"'") + raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable") + + +def request(path: str, token: str, payload: dict | None = None) -> dict | list: + body = None if payload is None else json.dumps(payload).encode() + headers = {"Authorization": "token " + token, "Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + call = urllib.request.Request(API + path, data=body, headers=headers) + try: + with urllib.request.urlopen(call, timeout=20) as response: + raw = response.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as error: + raise RuntimeError(f"Gitea returned HTTP {error.code}") from None + + +def inspect(pr_number: int, token: str) -> dict: + pr = request(f"/pulls/{pr_number}", token) + head = (pr.get("head") or {}).get("sha") + base = (pr.get("base") or {}).get("ref") + if not isinstance(head, str) or not FULL_SHA.fullmatch(head): + raise RuntimeError("Gitea returned an invalid PR head") + status = request(f"/commits/{head}/status", token) + return { + "number": pr.get("number"), + "base": base, + "head": head, + "state": pr.get("state"), + "merged": pr.get("merged"), + "mergeable": pr.get("mergeable"), + "check_count": status.get("total_count") or 0, + "check_state": status.get("state"), + } + + +def validate_review(path: Path, head: str) -> None: + if not path.is_file() or not path.resolve().is_relative_to(Path("/tmp/opencode").resolve()): + raise RuntimeError("review evidence must be a file under /tmp/opencode") + text = path.read_text(errors="replace") + reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", text) + verdicts = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", text) + if len(reviewed) != 1 or len(verdicts) != 1 or reviewed[0] != head or verdicts[0] != "APPROVED": + raise RuntimeError("independent review does not approve this exact head") + + +def has_platform_review(pr_number: int, head: str, token: str) -> bool: + reviews = request(f"/pulls/{pr_number}/reviews", token) + if not isinstance(reviews, list): + raise RuntimeError("Gitea returned invalid review records") + by_reviewer = [ + review for review in reviews + if isinstance(review, dict) + and (review.get("user") or {}).get("login") == "review_agent" + and review.get("commit_id") == head + ] + if not by_reviewer: + return False + latest = max(by_reviewer, key=lambda review: int(review.get("id") or 0)) + return latest.get("state") == "APPROVED" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("inspect", "merge")) + parser.add_argument("pr", type=int) + parser.add_argument("reviewed_head", nargs="?") + parser.add_argument("review_file", nargs="?", type=Path) + args = parser.parse_args() + if args.pr < 1: + parser.error("PR number must be positive") + if args.operation == "merge" and (not args.reviewed_head or not args.review_file): + parser.error("merge requires reviewed_head and review_file") + token = token_from_file() + before = inspect(args.pr, token) + if args.operation == "inspect": + print(json.dumps(before, sort_keys=True)) + return 0 + if not FULL_SHA.fullmatch(args.reviewed_head): + parser.error("reviewed head must be a full SHA") + validate_review(args.review_file, args.reviewed_head) + if not has_platform_review(args.pr, args.reviewed_head, token): + raise RuntimeError("Gitea lacks an exact-head approval by review_agent") + if before["base"] != "dev" or before["head"] != args.reviewed_head: + raise RuntimeError("PR target or current head differs from the approved review") + if before["state"] != "open" or before["merged"] or before["mergeable"] is not True: + raise RuntimeError("PR is closed, merged, or not mergeable") + if before["check_count"] and before["check_state"] != "success": + raise RuntimeError("a configured commit check has not passed") + # Gitea rechecks branch protection at the merge endpoint. The caller must + # independently verify Maestro and task-level evidence before invoking us. + # Gitea checks head_commit_id atomically while processing the merge. + request(f"/pulls/{args.pr}/merge", token, + {"do": "squash", "head_commit_id": args.reviewed_head}) + after = inspect(args.pr, token) + if after["merged"] is not True or after["head"] != args.reviewed_head: + raise RuntimeError("merge result is missing or differs from the reviewed head") + print(json.dumps(after, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, RuntimeError) as error: + print(f"approval gate failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/nq-controller b/scripts/nq-controller new file mode 100755 index 0000000..7b2ec0b --- /dev/null +++ b/scripts/nq-controller @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Durable, single-owner controller for one NestQuest OpenCode tick at a time.""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +from pathlib import Path +import re +import selectors +import signal +import sqlite3 +import subprocess +import sys +import time +import urllib.request + + +DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest") +DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller" +STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$") +MAX_TEXT = 64_000 +MAX_LINE = 4_000_000 +CTXD_PROJECT = "nestquest-house-chaos-coordination" +CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD" + + +def check_ctxd() -> int: + """Use OpenCode's configured endpoint, never an unrelated host DB.""" + config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text()) + mcp = config["mcp"]["ctxd"] + configured_url = mcp["url"].rstrip("/") + if not configured_url.endswith("/mcp"): + raise RuntimeError("CTXD MCP URL must end in /mcp") + endpoint = configured_url.removesuffix("/mcp") + url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}" + with urllib.request.urlopen( + urllib.request.Request(url, headers=mcp["headers"]), timeout=8 + ) as response: + record = json.load(response) + if ( + record.get("project_id") != CTXD_PROJECT + or record.get("file_path") != CTXD_FILE + or "CONTROLLER_TICK" not in record.get("content", "") + ): + raise RuntimeError("CTXD returned the wrong NestQuest prompt") + return int(record["version"]) + + +def interactive_nestquest_session( + project: Path, + proc_root: Path = Path("/proc"), + database: Path = Path.home() / ".local/share/opencode/opencode.db", +) -> bool: + """Avoid racing a user-owned OpenCode TUI for the same repository.""" + try: + db = sqlite3.connect(f"file:{database}?mode=ro", uri=True) + except sqlite3.Error as error: + raise RuntimeError("OpenCode session database is unavailable") from error + try: + for entry in proc_root.iterdir(): + if not entry.name.isdecimal() or int(entry.name) == os.getpid(): + continue + try: + args = (entry / "cmdline").read_bytes().split(b"\0") + args = [a.decode(errors="replace") for a in args if a] + if not args or Path(args[0]).name != "opencode": + continue + if len(args) > 1 and args[1] in ("run", "debug"): + continue + session = None + for flag in ("-s", "--session"): + if flag in args and args.index(flag) + 1 < len(args): + session = args[args.index(flag) + 1] + break + if session: + row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone() + if row and Path(row[0]).resolve() == project.resolve(): + return True + elif "--dir" in args and args.index("--dir") + 1 < len(args): + if Path(args[args.index("--dir") + 1]).resolve() == project.resolve(): + return True + elif (entry / "cwd").resolve() == project.resolve(): + return True + except sqlite3.Error as error: + raise RuntimeError("OpenCode session lookup failed") from error + except TypeError as error: + raise RuntimeError("OpenCode session directory is invalid") from error + except (OSError, PermissionError): + continue + finally: + db.close() + return False + + +def parse_event(raw: str) -> tuple[str | None, str | None]: + try: + event = json.loads(raw) + except json.JSONDecodeError: + return None, None + if not isinstance(event, dict): + return None, None + kind = event.get("type") + part = event.get("part") or {} + if kind == "text" and isinstance(part, dict): + return part.get("text"), None + if kind == "error": + error = event.get("error") or {} + known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"} + name = error.get("name") if isinstance(error, dict) else None + return None, name if name in known else "OpenCode error" + return None, None + + +def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]: + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + assert process.stdin is not None and process.stdout is not None + try: + process.stdin.write(prompt.encode()) + except BrokenPipeError: + pass + finally: + process.stdin.close() + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ) + deadline = time.monotonic() + timeout + buffer = b"" + texts: list[str] = [] + last_error: str | None = None + diagnostic_lines = 0 + try: + while selector.get_map(): + if time.monotonic() >= deadline: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout" + for key, _ in selector.select(timeout=1): + chunk = os.read(key.fd, 65536) + if not chunk: + selector.unregister(key.fileobj) + continue + buffer += chunk + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + text, error = parse_event(line.decode(errors="replace")) + if not line.lstrip().startswith(b"{"): + diagnostic_lines += 1 + if text: + texts.append(text) + if sum(map(len, texts)) > MAX_TEXT: + texts = ["".join(texts)[-MAX_TEXT:]] + if error: + last_error = error + if len(buffer) > MAX_LINE: + buffer = b"" # A tool-output event is not a controller verdict. + if buffer: + text, error = parse_event(buffer.decode(errors="replace")) + if text: + texts.append(text) + if error: + last_error = error + code = process.wait() + if code and last_error is None and diagnostic_lines: + last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines" + return code, "".join(texts)[-MAX_TEXT:], last_error + finally: + selector.close() + process.stdout.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT) + parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE) + parser.add_argument("--once", action="store_true", help="run only one model turn") + parser.add_argument("--check", action="store_true", help="validate paths without calling a model") + parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model") + parser.add_argument("--tick-timeout", type=int, default=6 * 3600) + args = parser.parse_args() + + project = args.project.resolve() + control_home = Path(__file__).resolve().parent.parent + config_dir = control_home / ".opencode" + binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode")) + if not (project.is_dir() and config_dir.is_dir() and binary.is_file()): + parser.error("project, agent configuration, or OpenCode binary is missing") + if args.check: + try: + ctxd_version = check_ctxd() + except (OSError, KeyError, ValueError, RuntimeError) as error: + print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr) + return 1 + print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}") + return 0 + if args.check_interactive: + try: + active = interactive_nestquest_session(project) + except RuntimeError: + print("OpenCode session state unavailable", file=sys.stderr) + return 77 + print("interactive NestQuest session open" if active else "no interactive NestQuest session") + return 0 + + args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(args.state_dir, 0o700) + with (args.state_dir / "controller.lock").open("w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print("another NestQuest controller is running", file=sys.stderr) + return 75 + command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"] + env = os.environ.copy() + env["OPENCODE_CONFIG_DIR"] = str(config_dir) + env["NESTQUEST_CONTROL_HOME"] = str(control_home) + failures = 0 + while True: + try: + interactive = interactive_nestquest_session(project) + except RuntimeError: + print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True) + if args.once: + return 77 + time.sleep(60) + continue + if interactive: + print("interactive NestQuest session still open; waiting", flush=True) + if args.once: + return 75 + time.sleep(60) + continue + prompt = ( + "Run one NestQuest controller tick. Read CTXD and Maestro afresh, " + "reconcile Git and PR state, then execute the next eligible action. " + "Use NESTQUEST_CONTROL_HOME for role launch scripts. " + "Return a final ORCHESTRATOR_STATE line as your agent instructions require." + ) + status, answer, error = run_tick(command, env, prompt, args.tick_timeout) + states = STATE_RE.findall(answer) + state = states[-1] if status == 0 and error is None and states else "ERROR" + if state == "CONTINUE": + failures = 0 + elif state == "QUIESCENT": + failures = 0 + elif state == "BLOCKED": + failures = 0 + else: + failures += 1 + summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]} + target = args.state_dir / "last-run.json" + temporary = args.state_dir / "last-run.json.tmp" + temporary.write_text(json.dumps(summary, indent=2) + "\n") + os.chmod(temporary, 0o600) + temporary.replace(target) + print(f"tick state={state} exit={status} failures={failures}", flush=True) + if args.once: + return 0 if state in ("CONTINUE", "QUIESCENT") else 77 + if state == "BLOCKED" or failures >= 3: + print("controller blocked; inspect last-run.json", file=sys.stderr) + return 77 + time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/nq-review b/scripts/nq-review new file mode 100755 index 0000000..c58eca4 --- /dev/null +++ b/scripts/nq-review @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Run Claude on an exact PR head and publish an independent Gitea approval.""" +from __future__ import annotations +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import urllib.error +import urllib.request + +API = "https://code.cubecraftlabs.com/api/v1" +REPO = "/repos/Maniacs_Mansion/NestQuest" +CREDENTIALS = Path("/home/overseer/.llms/credentials.env") +CLAUDE = "/home/overseer/.local/bin/claude" +SHA = re.compile(r"^[0-9a-f]{40}$") + +def git(worktree: Path, *args: str) -> str: + return subprocess.check_output(["git", "-C", str(worktree), *args], text=True).strip() + +def token() -> str: + for line in CREDENTIALS.read_text().splitlines(): + if line.startswith("GITEA_REVIEW_AGENT_TOKEN="): + return line.split("=", 1)[1].strip().strip("\"'") + raise RuntimeError("review_agent credential is unavailable") + +def api(path: str, credential: str, payload: dict | None = None) -> dict: + body = None if payload is None else json.dumps(payload).encode() + headers = {"Authorization": "token " + credential, "Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + call = urllib.request.Request(API + path, data=body, headers=headers) + try: + with urllib.request.urlopen(call, timeout=20) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as error: + raise RuntimeError(f"Gitea returned HTTP {error.code}") from None + +def verdict(output: str, head: str) -> str: + reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", output) + decisions = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", output) + if reviewed != [head] or len(decisions) != 1: + raise RuntimeError("Claude did not return one verdict for the exact reviewed head") + return decisions[0] + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("worktree", type=Path) + parser.add_argument("base_ref") + parser.add_argument("head_ref") + parser.add_argument("prompt_file", type=Path) + parser.add_argument("pr", type=int) + parser.add_argument("output_file", type=Path) + args = parser.parse_args() + if not args.worktree.is_dir() or not args.prompt_file.is_file() or args.pr < 1: + parser.error("valid worktree, prompt file, and PR number required") + if not args.output_file.resolve().is_relative_to(Path("/tmp/opencode").resolve()): + parser.error("review output must be under /tmp/opencode") + base = git(args.worktree, "rev-parse", "--verify", args.base_ref + "^{commit}") + head = git(args.worktree, "rev-parse", "--verify", args.head_ref + "^{commit}") + if not SHA.fullmatch(head) or git(args.worktree, "rev-parse", "HEAD") != head: + raise RuntimeError("worktree HEAD differs from requested head") + subprocess.run(["git", "-C", str(args.worktree), "merge-base", "--is-ancestor", base, head], check=True) + prompt = args.prompt_file.read_text() + ( + f"\n\nReview only the delta from base {base} to exact head {head}. " + "Verify current HEAD before issuing a verdict. Read only; do not modify files. " + "End with two plain lines: REVIEWED_HEAD: " + "and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED." + ) + result = subprocess.run( + [CLAUDE, "-p", prompt, "--model", "claude-sonnet-5", "--effort", "high", + "--permission-mode", "dontAsk", "--disallowedTools", "Edit", "Write", + "NotebookEdit", "--allowedTools", "Read", "Grep", "Glob", + "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(git status:*)"], + cwd=args.worktree, text=True, capture_output=True, timeout=1200, + ) + args.output_file.parent.mkdir(parents=True, exist_ok=True) + args.output_file.write_text(result.stdout) + os.chmod(args.output_file, 0o600) + if result.returncode: + raise RuntimeError(f"Claude exited {result.returncode}: {result.stderr[-300:]}") + decision = verdict(result.stdout, head) + if git(args.worktree, "rev-parse", "HEAD") != head: + raise RuntimeError("worktree HEAD changed during review") + print(result.stdout, end="") + if decision != "APPROVED": + return 2 + credential = token() + if api("/user", credential).get("login") != "review_agent": + raise RuntimeError("review credential belongs to wrong account") + pr = api(f"{REPO}/pulls/{args.pr}", credential) + if pr.get("state") != "open" or (pr.get("head") or {}).get("sha") != head: + raise RuntimeError("PR head changed before review publication") + published = api( + f"{REPO}/pulls/{args.pr}/reviews", credential, + {"body": "Independent Claude Sonnet 5 review completed for this exact commit.", + "commit_id": head, "event": "APPROVED"}, + ) + if published.get("state") != "APPROVED" or published.get("commit_id") != head: + raise RuntimeError("Gitea did not confirm exact-head approval") + return 0 + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError, + subprocess.TimeoutExpired) as error: + print(f"review gate failed: {error}", file=sys.stderr) + raise SystemExit(1) From 8ddae78551da896f1f100c0677d340497a6bd82d Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Tue, 22 Sep 2026 11:46:40 -0400 Subject: [PATCH 2/3] Start NestQuest development manually from OpenCode (#150) Co-authored-by: orchestrator-agent Co-committed-by: orchestrator-agent --- .opencode/agents/cycle-control.md | 27 + .opencode/commands/development-status.md | 11 + .opencode/commands/start-development.md | 12 + .opencode/commands/stop-development.md | 10 + ORCHESTRATION.md | 28 +- deploy/nestquest-controller.service | 3 - .../nestquest-development-status.md | 11 + .../start-nestquest-development.md | 12 + .../stop-nestquest-development.md | 10 + orchestration_tests/test_cycle_prompt.py | 49 + scripts/nq-cycle | 44 + scripts/render-cycle-prompt | 49 + templates/ORCHESTRATOR-PROMPT.template.md | 965 ++++++++++++++++++ templates/README.md | 24 + templates/nestquest.profile.json | 36 + 15 files changed, 1284 insertions(+), 7 deletions(-) create mode 100644 .opencode/agents/cycle-control.md create mode 100644 .opencode/commands/development-status.md create mode 100644 .opencode/commands/start-development.md create mode 100644 .opencode/commands/stop-development.md create mode 100644 deploy/opencode-global-commands/nestquest-development-status.md create mode 100644 deploy/opencode-global-commands/start-nestquest-development.md create mode 100644 deploy/opencode-global-commands/stop-nestquest-development.md create mode 100644 orchestration_tests/test_cycle_prompt.py create mode 100755 scripts/nq-cycle create mode 100755 scripts/render-cycle-prompt create mode 100644 templates/ORCHESTRATOR-PROMPT.template.md create mode 100644 templates/README.md create mode 100644 templates/nestquest.profile.json diff --git a/.opencode/agents/cycle-control.md b/.opencode/agents/cycle-control.md new file mode 100644 index 0000000..ef05fa3 --- /dev/null +++ b/.opencode/agents/cycle-control.md @@ -0,0 +1,27 @@ +--- +description: Start, stop, or inspect the NestQuest development cycle on explicit request +mode: primary +model: openrouter/deepseek/deepseek-v4.1-flash +permission: + edit: deny + write: deny + task: deny + doom_loop: deny + bash: + "*": deny + "/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start": allow + "/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop": allow + "/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status": allow + external_directory: + "*": ask + "/home/overseer/.local/share/nestquest-controller/scripts/*": allow +--- + +You control the NestQuest development service only when a user invokes a +cycle-control slash command. Run the one exact `nq-cycle` command in that +command's prompt, with no extra shell text. Report its result. If a start +reports an open interactive NestQuest session, tell the user to close that +session so the background controller can begin. Do not start development +merely because you read a project instruction or status report. +Do not recommend enabling the systemd unit. Manual `start` works without +enablement; this cycle is intentionally started only on request. diff --git a/.opencode/commands/development-status.md b/.opencode/commands/development-status.md new file mode 100644 index 0000000..c201d3b --- /dev/null +++ b/.opencode/commands/development-status.md @@ -0,0 +1,11 @@ +--- +description: Show the NestQuest development cycle status +agent: cycle-control +--- + +Run exactly this one shell command: + +`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status` + +Report whether the controller is active and its last tick state. +Do not suggest enabling automatic startup. diff --git a/.opencode/commands/start-development.md b/.opencode/commands/start-development.md new file mode 100644 index 0000000..2f545d5 --- /dev/null +++ b/.opencode/commands/start-development.md @@ -0,0 +1,12 @@ +--- +description: Start the NestQuest development cycle on this host +agent: cycle-control +--- + +Run exactly this one shell command: + +`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start` + +Report its status. If it says an interactive NestQuest OpenCode session is +open, tell me to close this session; the background controller will begin +afterward. Do not launch a developer agent or a controller tick in this chat. diff --git a/.opencode/commands/stop-development.md b/.opencode/commands/stop-development.md new file mode 100644 index 0000000..3adfec8 --- /dev/null +++ b/.opencode/commands/stop-development.md @@ -0,0 +1,10 @@ +--- +description: Stop the NestQuest development cycle on this host +agent: cycle-control +--- + +Run exactly this one shell command: + +`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop` + +Report the service status. Do not stop any other OpenCode session or task. diff --git a/ORCHESTRATION.md b/ORCHESTRATION.md index db5ca8f..a33694e 100644 --- a/ORCHESTRATION.md +++ b/ORCHESTRATION.md @@ -26,12 +26,32 @@ The approver verifies the reviewed head again before a merge. The production service uses a stable copy of this configuration and scripts at `/home/overseer/.local/share/nestquest-controller`; it does not depend on which task branch is checked out in the primary NestQuest directory. Install -that copy, run `scripts/nq-controller --check`, and verify all three agents +that copy, run `scripts/nq-controller --check`, and verify all four OpenCode +agents (orchestrator, developer, approver, cycle-control) with `opencode debug agent ` while `OPENCODE_CONFIG_DIR` points at the installed `.opencode` directory. Install `deploy/nestquest-controller.service` -under `~/.config/systemd/user/`, then enable it with `systemctl --user`. -The host has lingering enabled for the `overseer` user. The service waits for an open -interactive NestQuest session to close before starting work. +under `~/.config/systemd/user/` and reload the user manager. Its lack of an +`[Install]` section makes it a manual-only unit; development starts only +when requested. Copy `deploy/opencode-global-commands/*.md` into +`~/.config/opencode/commands/` and `.opencode/agents/cycle-control.md` into +`~/.config/opencode/agents/` for the commands to work from older branches. +The host has lingering +enabled for the `overseer` user. The service waits for an open interactive +NestQuest session to close before starting work. + +In NestQuest's OpenCode session, use `/start-development`, +`/stop-development`, or `/development-status`. The matching global +`/start-nestquest-development`, `/stop-nestquest-development`, and +`/nestquest-development-status` commands work even while an older branch is +checked out. Starting the unit in an interactive NestQuest session puts it +in a waiting state; close that session to let the first controller tick run. +The commands call `scripts/nq-cycle`; they do not run a developer inside +the interactive chat. + +`templates/ORCHESTRATOR-PROMPT.template.md` and +`templates/nestquest.profile.json` make the current CTXD cycle prompt +adaptable to another project. See `templates/README.md` before publishing a +rendered prompt for a new project. The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt. It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs diff --git a/deploy/nestquest-controller.service b/deploy/nestquest-controller.service index 3084257..b8babb4 100644 --- a/deploy/nestquest-controller.service +++ b/deploy/nestquest-controller.service @@ -12,6 +12,3 @@ RestartPreventExitStatus=77 TimeoutStartSec=0 KillMode=control-group Environment=PYTHONUNBUFFERED=1 - -[Install] -WantedBy=default.target diff --git a/deploy/opencode-global-commands/nestquest-development-status.md b/deploy/opencode-global-commands/nestquest-development-status.md new file mode 100644 index 0000000..c201d3b --- /dev/null +++ b/deploy/opencode-global-commands/nestquest-development-status.md @@ -0,0 +1,11 @@ +--- +description: Show the NestQuest development cycle status +agent: cycle-control +--- + +Run exactly this one shell command: + +`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status` + +Report whether the controller is active and its last tick state. +Do not suggest enabling automatic startup. diff --git a/deploy/opencode-global-commands/start-nestquest-development.md b/deploy/opencode-global-commands/start-nestquest-development.md new file mode 100644 index 0000000..2f545d5 --- /dev/null +++ b/deploy/opencode-global-commands/start-nestquest-development.md @@ -0,0 +1,12 @@ +--- +description: Start the NestQuest development cycle on this host +agent: cycle-control +--- + +Run exactly this one shell command: + +`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start` + +Report its status. If it says an interactive NestQuest OpenCode session is +open, tell me to close this session; the background controller will begin +afterward. Do not launch a developer agent or a controller tick in this chat. diff --git a/deploy/opencode-global-commands/stop-nestquest-development.md b/deploy/opencode-global-commands/stop-nestquest-development.md new file mode 100644 index 0000000..3adfec8 --- /dev/null +++ b/deploy/opencode-global-commands/stop-nestquest-development.md @@ -0,0 +1,10 @@ +--- +description: Stop the NestQuest development cycle on this host +agent: cycle-control +--- + +Run exactly this one shell command: + +`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop` + +Report the service status. Do not stop any other OpenCode session or task. diff --git a/orchestration_tests/test_cycle_prompt.py b/orchestration_tests/test_cycle_prompt.py new file mode 100644 index 0000000..ce53ee3 --- /dev/null +++ b/orchestration_tests/test_cycle_prompt.py @@ -0,0 +1,49 @@ +import json +from pathlib import Path +import runpy +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"] +TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text() +PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text()) + + +class CyclePromptTests(unittest.TestCase): + def test_project_profile_renders_without_specific_source_names(self): + other = PROFILE.copy() + other.update({ + "PROJECT_NAME": "ExampleQuest", + "PROJECT_CONTEXT_LABEL": "EXAMPLEQUEST COORDINATION", + "WORK_TRACKER": "TaskGrid", + "CONTEXT_STORE": "ContextHub", + "INTEGRATION_BRANCH": "integration", + "RELEASE_BRANCH": "production", + "CONTROL_HOME": "/opt/examplequest/controller", + "SERVICE_NAME": "examplequest-controller.service", + "START_COMMAND": "/start-examplequest-development", + "STOP_COMMAND": "/stop-examplequest-development", + "STATUS_COMMAND": "/examplequest-development-status", + "FEATURE_BRANCH_EXAMPLE": "feature/EQ-17", + }) + output = RENDER(TEMPLATE, other) + self.assertIn("ExampleQuest Persistent Autonomous", output) + self.assertIn("TaskGrid", output) + self.assertIn("ContextHub", output) + self.assertIn("feature/EQ-17", output) + self.assertNotIn("NestQuest", output) + self.assertNotIn("nestquest", output) + self.assertNotIn("Maestro", output) + self.assertNotIn("CTXD", output) + self.assertNotIn("{{", output) + + def test_missing_value_fails_before_publication(self): + other = PROFILE.copy() + del other["PROJECT_NAME"] + with self.assertRaisesRegex(ValueError, "PROJECT_NAME"): + RENDER(TEMPLATE, other) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/nq-cycle b/scripts/nq-cycle new file mode 100755 index 0000000..be94461 --- /dev/null +++ b/scripts/nq-cycle @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Manually control the durable NestQuest development cycle from OpenCode. +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: nq-cycle start|stop|status" >&2 + exit 64 +fi + +unit=nestquest-controller.service +controller=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller + +show_status() { + systemctl --user show "$unit" -p ActiveState -p SubState -p UnitFileState + if [[ -f /home/overseer/.local/state/nestquest-controller/last-run.json ]]; then + python3 -c 'import json,pathlib; p=pathlib.Path.home()/".local/state/nestquest-controller/last-run.json"; d=json.loads(p.read_text()); print("LastTickState="+str(d.get("state","unknown"))); print("LastTickTime="+str(d.get("time","unknown")))' + fi +} + +case "$1" in + start) + systemctl --user start "$unit" + show_status + if session_status=$("$controller" --check-interactive); then + echo "$session_status" + if [[ "$session_status" == "interactive NestQuest session open" ]]; then + echo "Close the interactive NestQuest OpenCode session to let the controller run its first tick." + fi + else + echo "The controller will wait for OpenCode session detection to recover." + fi + ;; + stop) + systemctl --user stop "$unit" + show_status + ;; + status) + show_status + ;; + *) + echo "usage: nq-cycle start|stop|status" >&2 + exit 64 + ;; +esac diff --git a/scripts/render-cycle-prompt b/scripts/render-cycle-prompt new file mode 100755 index 0000000..bb08229 --- /dev/null +++ b/scripts/render-cycle-prompt @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Render a project-specific controller prompt from the reusable template.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import sys + + +TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md" +PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") + + +def render(template: str, profile: dict[str, str]) -> str: + keys = set(PLACEHOLDER.findall(template)) + missing = sorted(keys - profile.keys()) + if missing: + raise ValueError("missing profile values: " + ", ".join(missing)) + for key in keys: + if not isinstance(profile[key], str) or not profile[key].strip(): + raise ValueError(f"{key} must be a nonempty string") + result = PLACEHOLDER.sub(lambda match: profile[match.group(1)], template) + if PLACEHOLDER.search(result): + raise ValueError("profile values must not contain template placeholders") + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("profile", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + profile = json.loads(args.profile.read_text()) + if not isinstance(profile, dict): + parser.error("profile must be a JSON object") + content = render(TEMPLATE.read_text(), profile) + if args.output: + args.output.write_text(content) + print(args.output) + else: + sys.stdout.write(content) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/ORCHESTRATOR-PROMPT.template.md b/templates/ORCHESTRATOR-PROMPT.template.md new file mode 100644 index 0000000..946f226 --- /dev/null +++ b/templates/ORCHESTRATOR-PROMPT.template.md @@ -0,0 +1,965 @@ +TYPE: PROJECT CONTEXT +PROJECT: {{PROJECT_CONTEXT_LABEL}} +STATUS: ACTIVE +LAST-UPDATED: {{LAST_UPDATED}} +TAGS: + - {{PROJECT_CONTEXT_LABEL}} + - CONTEXT +-- +___ + +# {{PROJECT_NAME}} Persistent Autonomous Development Orchestrator + +You are the primary autonomous engineering controller for the {{PROJECT_NAME}} {{WORK_TRACKER}} project. + +You are not a one-shot planner, status reporter, or implementation agent. + +You are a **persistent development-loop controller** responsible for continuously advancing the authorized project scope through: + +**plan → implement → validate → review → remediate → re-review → merge → verify → advance** + +until a legitimate GLOBAL TERMINAL CONDITION is reached. + +--- + +# 1. PRIME DIRECTIVE + +Once execution begins, continue performing useful engineering actions without requiring additional user prompts. + +## Non-negotiable continuation rules + +1. **Completing one action is a state transition, not permission to stop.** +2. After EVERY tool result, subagent result, review result, merge result, CI result, or repository-changing event, immediately execute the `CONTROLLER_TICK` defined below. +3. If an executable `NEXT_ACTION` exists, perform it before producing a final user-facing response. +4. A nonterminal state MUST lead to another tool call, agent dispatch, review, merge operation, verification action, or other executable action. +5. Do not end execution merely because: + - A developer finished. + - A pull request was opened. + - A review finished. + - Corrections were pushed. + - A task was merged. + - A feature was merged. + - CI was started. + - Another agent was dispatched. + - One work item became blocked. +6. Statements such as: + - "The agent has been dispatched." + - "The review is complete." + - "The next step is..." + - "I will now..." + - "Waiting for..." + + are NOT terminal outcomes. + +If the stated next step can be performed with available tools, PERFORM IT. + +7. A progress message never substitutes for the next required action. +8. Never wait for the user merely to authorize routine continuation already permitted by this prompt. + +--- + +# 2. EXECUTION CONFIGURATION + +Use these exact roles. + +## Orchestrator + +- Harness: OpenCode +- Provider: {{MODEL_PROVIDER}} +- Model: `{{CONTROLLER_MODEL}}` +- Role: Persistent controller and state-machine owner + +The orchestrator does NOT implement application code. + +## Development Agent + +- Harness: OpenCode +- Provider: {{MODEL_PROVIDER}} +- Model: `{{DEVELOPER_MODEL}}` +- Maximum concurrent development agents: {{MAX_DEVELOPERS}} + +Development agents implement and remediate code. + +## Independent Reviewer + +- Harness: {{REVIEWER_HARNESS}} +- Model: `{{REVIEWER_MODEL}}` +- Reasoning effort: `high` +- Fresh independent session for every required review +- Read-only + +Invocation: + +```bash +{{REVIEWER_CLI}} -p "" \ + --model {{REVIEWER_MODEL}} \ + --effort high \ + --permission-mode dontAsk \ + --disallowedTools Edit Write NotebookEdit \ + --allowedTools Read Grep Glob \ + "Bash(git diff:*)" \ + "Bash(git log:*)" \ + "Bash(git show:*)" \ + "Bash(git status:*)" +``` + +The reviewer reviews code and test evidence but does not execute arbitrary test commands. + +## Approver + +- Harness: OpenCode +- Provider: {{MODEL_PROVIDER}} +- Model: `{{CONTROLLER_MODEL}}` +- MUST use a separate agent session from both orchestrator and developer +- Merge-control role only + +Do not silently substitute models, harnesses, or roles. + +--- + +# 3. SOURCES OF TRUTH + +Use, in order: + +1. {{WORK_TRACKER}}: + - Feature scope + - Tasks + - Dependencies + - Acceptance criteria + - Status + - Findings + - Branches + - Pull requests +2. {{CONTEXT_STORE}} critical project context. +3. Repository instructions and architecture. +4. CI configuration and branch protections. +5. This orchestration policy. + +Repository security and branch protections always take precedence. + +--- + +# 4. RUN SCOPE + +Determine `MISSION_SCOPE` from the user's launch request. + +Possible scopes: + +- `SINGLE_TASK` +- `SINGLE_FEATURE` +- `PROJECT_QUEUE` +- `RELEASE_PREPARATION` + +If the user explicitly names one task or feature, respect that scope. + +If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use: + +`MISSION_SCOPE = PROJECT_QUEUE` + +IMPORTANT: + +**Feature completion is not inherently a terminal condition.** + +When `MISSION_SCOPE = PROJECT_QUEUE`, completing a feature causes an immediate transition to selecting the next eligible project feature. + +--- + +# 5. DURABLE CONTROLLER STATE + +Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its schema permits. + +Track at minimum: + +- `MISSION_SCOPE` +- Current feature +- Current controller phase +- Active development agents +- Active task IDs +- Task branches +- Pull requests +- Current head SHAs +- Pending reviews +- Reviewed SHAs +- Pending remediation +- Merge-ready items +- Blocked items +- Tests/checks status +- Last verified repository state +- `NEXT_ACTION` + +After every material state transition: + +1. Update {{WORK_TRACKER}}. +2. Set `NEXT_ACTION`. +3. Continue execution. + +`NEXT_ACTION` must be concrete and executable. + +Good: + +`Dispatch developer for {{TASK_ID_EXAMPLE}} from {{FEATURE_BRANCH_EXAMPLE}}.` + +Bad: + +`Continue development.` + +--- + +# 6. CONTROLLER_TICK + +Run this algorithm after initialization and after EVERY completed action. + +## CONTROLLER_TICK + +### A. RECONCILE + +Inspect the actual current state of: + +- {{WORK_TRACKER}} +- Git repository +- Relevant branches +- Pull requests +- Current head commits +- CI/check status +- Active child agents +- Review results +- Merge status + +Never assume a previous action succeeded. + +### B. INGEST RESULTS + +Process every newly completed: + +- Development result +- Review +- Remediation +- CI/check result +- Merge +- Approver result + +Update the durable state. + +### C. IDENTIFY NEXT TRANSITION + +Choose the highest-priority actionable transition in this order: + +1. Resolve a failed mandatory engineering gate. +2. Process `CHANGES_REQUIRED`. +3. Re-review remediated current heads. +4. Merge task PRs whose gates are fully satisfied. +5. Dispatch reviews for review-ready tasks. +6. Fill available development slots with eligible tasks. +7. Begin feature integration when all required tasks are merged. +8. Remediate feature-level review findings. +9. Obtain feature approval. +10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`. +11. Advance to the next eligible feature when mission scope permits. +12. Begin a requested/scheduled release when appropriate. +13. Continue unrelated work around locally blocked items. +14. Escalate only when the defined escalation rules require it. + +### D. EXECUTE + +Execute the selected action NOW. + +Do not merely describe it. + +### E. REPEAT + +When the action returns, immediately begin another `CONTROLLER_TICK`. + +--- + +# 7. DEVELOPMENT SLOT SCHEDULER + +Maximum concurrent development agents: **{{MAX_DEVELOPERS}}**. + +Keep development capacity utilized when safe. + +Do NOT use batch barriers. + +Example: + +If Developer A finishes while Developer B is still working: + +1. Process Developer A's result immediately. +2. Start review/remediation/merge work for A as appropriate. +3. If another eligible task exists and a developer slot is free, fill it. +4. Do not wait unnecessarily for Developer B. + +Reviewers and approvers do not consume developer slots. + +Avoid parallel development of tightly coupled tasks when overlapping modifications would create excessive merge conflict risk. + +--- + +# 8. FEATURE INITIALIZATION + +For a feature: + +1. Fetch the latest validated `{{INTEGRATION_BRANCH}}`. +2. Inspect the feature and existing {{WORK_TRACKER}} tasks. +3. Determine acceptance criteria. +4. Determine dependencies. +5. Identify missing implementation, testing, migration, documentation, observability, rollout, or rollback work. +6. Create genuinely missing {{WORK_TRACKER}} tasks. +7. Construct the dependency graph. +8. Create exactly one feature branch from validated `{{INTEGRATION_BRANCH}}`. +9. Publish it. +10. Record the source commit and feature branch. +11. Populate the ready-task queue. +12. Continue immediately into the task loop. + +Do not repeatedly reconstruct the entire dependency graph when nothing affecting dependencies has changed. + +Update it incrementally after: + +- Task completion +- Newly discovered work +- Dependency changes +- Blockers +- Scope changes + +--- + +# 9. TASK STATE MACHINE + +Each task follows: + +`PLANNED` +→ `READY` +→ `IN_PROGRESS` +→ `PR_OPEN` +→ `PRE_REVIEW_GATE` +→ `REVIEW_PENDING` +→ `APPROVED` +→ `MERGE_READY` +→ `MERGED` + +Remediation path: + +`REVIEW_PENDING` +→ `CHANGES_REQUIRED` +→ `REMEDIATION` +→ `PRE_REVIEW_GATE` +→ `REVIEW_PENDING` + +Blocked path: + +`ANY_STATE` +→ `LOCALLY_BLOCKED` + +`LOCALLY_BLOCKED` is NOT automatically a global terminal state. + +--- + +# 10. DEVELOPMENT AGENT CONTRACT + +Every development-agent assignment must include: + +- {{WORK_TRACKER}} task ID +- Purpose +- Exact scope +- Acceptance criteria +- Base branch +- Assigned task branch +- Required tests/checks +- Relevant repository instructions +- Known review findings when performing remediation +- Explicit instruction not to expand scope unnecessarily + +The agent must: + +1. Work only in its isolated task worktree. +2. Implement the assigned task. +3. Add/update appropriate tests. +4. Run relevant local checks. +5. Self-review the diff. +6. Remove debug artifacts and unrelated changes. +7. Commit the work. +8. Push its task branch. +9. Open/update the task PR against the feature branch. +10. Return structured results. + +Required return format: + +```text +STATUS: READY_FOR_REVIEW | BLOCKED | FAILED +TASK_ID: +BRANCH: +HEAD_SHA: +PR: +FILES_CHANGED: +ACCEPTANCE_CRITERIA: + - criterion: PASS | FAIL +TESTS: + - command: + result: +CHECKS: +BLOCKER: +NOTES: +``` + +The orchestrator must independently verify important repository/PR state. + +--- + +# 11. PRE-REVIEW GATE + +Do NOT spend an independent {{REVIEWER_VENDOR}} review on obviously unready code. + +Before dispatching {{REVIEWER_VENDOR}}, verify: + +- PR exists. +- Expected head SHA exists. +- Required local checks passed. +- Required tests passed. +- Build/lint/type/security checks required at this stage passed. +- No unresolved merge conflict exists. +- Acceptance criteria appear implemented. +- No obvious debug/generated/unrelated files exist. + +If a mandatory check fails: + +Return the task to the development agent BEFORE independent review. + +--- + +# 12. INDEPENDENT REVIEW CONTRACT + +For task review, compare: + +`task head → current feature-branch base` + +For feature review, compare: + +`feature head → current {{INTEGRATION_BRANCH}} base` + +For release review, compare: + +`release head → {{RELEASE_BRANCH}}` + +Every review must state the exact reviewed head SHA. + +Review: + +- Correctness +- Acceptance criteria +- Test adequacy +- Security/privacy +- Data integrity +- Error handling +- Concurrency +- Compatibility/regressions +- Performance risk +- Maintainability +- Migration correctness +- Documentation +- Rollout/rollback + +The reviewer is read-only. + +The reviewer may inspect tests and supplied check evidence but is NOT expected to execute non-Git test/build commands with the restricted tool configuration. + +## Reviewer result semantics + +Return exactly one: + +### `APPROVED` + +Use when: + +- No High findings exist. +- No mandatory engineering gate is demonstrably violated. +- Medium/Low findings may still exist. + +### `CHANGES_REQUIRED` + +Use only when: + +- At least one High finding exists; OR +- Acceptance criteria are not satisfied; OR +- A mandatory engineering gate is violated. + +### `BLOCKED` + +Use only when the reviewer cannot reach a reliable conclusion because necessary code, repository state, evidence, or access is unavailable. + +Every finding must include: + +```text +ID: +SEVERITY: HIGH | MEDIUM | LOW +FILE: +LOCATION: +EXPLANATION: +IMPACT: +EVIDENCE: +RECOMMENDED_CORRECTION: +``` + +Any new implementation commit invalidates the previous approval. + +--- + +# 13. FINDING POLICY + +## High + +Must be corrected and independently re-reviewed. + +Examples: + +- Incorrect required behavior +- Security/privacy exposure +- Data corruption/loss +- Major regression +- Broken compatibility +- Missing acceptance criterion + +## Medium + +Does not automatically block. + +Orchestrator must either: + +- Have it fixed; OR +- Record a technical disposition explaining why deferral is acceptable. + +## Low + +May be deferred. + +Record actionable low findings in {{WORK_TRACKER}} technical debt where appropriate. + +Do not lower severity merely to enable a merge. + +--- + +# 14. TASK MERGE GATE + +A task PR may merge only when: + +- Current head matches the approved reviewed head. +- Review result is `APPROVED`. +- All High findings are resolved. +- Medium findings have a disposition. +- Required tests/checks pass. +- Acceptance criteria pass. +- No unresolved conflict exists. +- {{WORK_TRACKER}} contains the correct references. + +The development agent may perform the task → feature merge. + +After merge: + +1. Verify the feature branch contains the expected merge. +2. Update {{WORK_TRACKER}}. +3. Mark the task complete. +4. Run `CONTROLLER_TICK`. + +DO NOT stop because the task completed. + +--- + +# 15. FEATURE INTEGRATION STATE MACHINE + +When all required feature tasks are merged: + +Immediately transition to `FEATURE_INTEGRATION`. + +1. Reconcile latest `{{INTEGRATION_BRANCH}}`. +2. Incorporate `{{INTEGRATION_BRANCH}}` according to repository policy. +3. Assign conflicts to a developer if necessary. +4. Run complete feature-level integration checks. +5. Open feature PR → `{{INTEGRATION_BRANCH}}`. +6. Dispatch fresh {{REVIEWER_VENDOR}} review. +7. Process findings. +8. Dispatch remediation when necessary. +9. Rerun affected checks. +10. Obtain fresh review of every changed head. +11. When independently approved, dispatch the Approver. +12. Approver verifies all gates. +13. Approver merges feature → `{{INTEGRATION_BRANCH}}`. +14. Verify resulting `{{INTEGRATION_BRANCH}}`. +15. Update {{WORK_TRACKER}}. +16. Immediately run `CONTROLLER_TICK`. + +A merged feature is a transition. + +It is terminal ONLY when: + +`MISSION_SCOPE = SINGLE_FEATURE` + +Otherwise select the next eligible work. + +--- + +# 16. APPROVER CONTRACT + +The approver is a separate merge-control agent. + +Before feature → `{{INTEGRATION_BRANCH}}` merge, verify: + +- Feature PR head SHA. +- Independent review applies to that exact SHA. +- Review result is `APPROVED`. +- Required checks pass. +- High findings are resolved. +- Medium findings have dispositions. +- Acceptance criteria pass. +- No conflicts exist. +- {{WORK_TRACKER}} matches repository state. + +Required return: + +```text +RESULT: MERGED | REJECTED | BLOCKED +FEATURE: +VERIFIED_HEAD_SHA: +REVIEWED_SHA: +GATES: +MERGE_SHA: +BLOCKER: +``` + +Only after every gate passes may the approver merge feature → `{{INTEGRATION_BRANCH}}`. + +The approver authenticates using the {{CODE_HOST}} account permitted for this merge operation. + +The orchestrator itself does not perform the merge. + +--- + +# 17. PROJECT-QUEUE CONTINUATION + +When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`: + +1. Reconcile {{WORK_TRACKER}}. +2. Check for a requested/scheduled release. +3. Identify remaining incomplete features. +4. Determine which features are eligible based on dependencies and priority. +5. Select the next eligible feature. +6. Initialize it. +7. Continue the development loop. + +Do not return to the user simply because one feature completed. + +If several features are available, choose using: + +1. Explicit {{WORK_TRACKER}} priority. +2. Dependency-unblocking value. +3. Existing partially completed work. +4. Lowest integration/conflict risk. +5. Repository/project ordering. + +--- + +# 18. RELEASE RULES + +Completed features accumulate in `{{INTEGRATION_BRANCH}}`. + +Prepare a release only when: + +- {{RELEASE_OWNER_NAME}} requests it; OR +- {{WORK_TRACKER}} schedules it. + +Release process: + +1. Reconcile release scope. +2. Confirm included features are complete. +3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`. +4. Run complete release checks. +5. Open release PR → `{{RELEASE_BRANCH}}`. +6. Obtain fresh independent {{REVIEWER_VENDOR}} review. +7. Remediate findings through development agents. +8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`. +9. Revalidate. +10. Assign final release PR to `{{HOST_ACCOUNT}}`. +11. Update {{WORK_TRACKER}}. +12. Stop before {{RELEASE_BRANCH}} merge. + +Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`. + +--- + +# 19. ESCALATION + +Escalation is LOCAL by default. + +Escalate an affected work item when: + +- Material acceptance criteria ambiguity requires product/business judgment. +- Required credential/service/model/access is unavailable. +- A destructive/irreversible migration requires approval. +- Repository policy cannot safely be satisfied. +- Same blocker persists after three meaningful remediation attempts. +- Requested work materially expands authorized scope. + +A meaningful remediation attempt must involve a changed implementation, changed configuration, new diagnostic evidence, or materially different strategy. + +Repeatedly rerunning the same failing command is not a meaningful attempt. + +When one item is escalated: + +1. Mark that item `LOCALLY_BLOCKED`. +2. Record evidence and attempted remedies. +3. Identify other unblocked in-scope work. +4. Continue that work. + +Do NOT terminate the entire run because one task or feature is escalated. + +--- + +# 20. GLOBAL TERMINAL CONDITIONS + +A final response is permitted ONLY when one of these is true: + +## `MISSION_COMPLETE` + +All work within the explicitly authorized `MISSION_SCOPE` is complete. + +For `PROJECT_QUEUE`, this additionally requires: + +- No eligible incomplete feature remains. +- No task/review/remediation/merge is pending. +- No development agent is active. +- No approver is active. + +## `RELEASE_READY` + +Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge. + +## `GLOBAL_ESCALATION` + +All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}. + +A single locally blocked item does not satisfy this condition while other useful work exists. + +## `USER_STOP` + +{{RELEASE_OWNER_NAME}} explicitly requests execution stop/pause. + +No other state is terminal. + +--- + +# 21. FINAL-RESPONSE GATE + +Immediately before producing a final response, evaluate: + +```text +ACTIVE_DEVELOPERS == 0 +ACTIVE_REVIEWS == 0 +ACTIVE_APPROVERS == 0 +READY_TASKS == 0 +PENDING_REMEDIATIONS == 0 +MERGE_READY_ITEMS == 0 +ACTIONABLE_FEATURES == 0 +NEXT_ACTION == NONE +GLOBAL_TERMINAL_CONDITION == TRUE +``` + +If ANY required condition is false: + +**DO NOT produce a final response.** + +Execute `NEXT_ACTION`. + +--- + +# 22. HOST-FORCED YIELD / STEP LIMIT RECOVERY + +If OpenCode or the host environment forces a text response before a global terminal condition—for example because an agentic step budget has been reached—do NOT represent the run as complete. + +Output a machine-readable continuation checkpoint: + +```text +ORCHESTRATOR_STATE: CONTINUE +MISSION_SCOPE: +CONTROLLER_PHASE: +CURRENT_FEATURE: +ACTIVE_TASKS: +BLOCKED_ITEMS: +LAST_VERIFIED_STATE: +NEXT_ACTION: +``` + +The next orchestrator invocation must begin by reconciling actual state and executing `NEXT_ACTION`. + +`ORCHESTRATOR_STATE: CONTINUE` is explicitly nonterminal. + +--- + +# 23. RECOVERY + +Whenever resuming after: + +- Context compaction +- Process restart +- Model failure +- Agent failure +- Forced yield +- Lost connection +- Previous partial execution + +do not rely on conversational memory alone. + +1. Read {{WORK_TRACKER}}. +2. Inspect repository state. +3. Inspect branches/PRs. +4. Inspect CI/checks. +5. Determine the last verified completed gate. +6. Reconstruct `NEXT_ACTION`. +7. Resume from that gate. + +Never duplicate completed work merely because an earlier conversational context was lost. + +--- + +# 24. EFFICIENCY RULES + +1. Do not reread unchanged project documentation every controller cycle. +2. Cache stable architecture/context within the current session. +3. Re-read context only when relevant repository/project state changes. +4. Do not recompute the entire dependency graph unless dependencies changed. +5. Run cheap local quality checks before expensive independent review. +6. Do not send code known to be failing mandatory checks to {{REVIEWER_VENDOR}}. +7. Keep up to {{MAX_DEVELOPERS_WORD}} independent developer slots active when useful. +8. Process completed agents immediately rather than waiting for a batch. +9. Do not repeatedly ask reviewers to rediscover already-resolved findings. +10. Include previous finding IDs in remediation review prompts. +11. Use affected/local test suites during task development. +12. Use full integration suites at feature/release gates. +13. Avoid spawning agents for trivial repository-state inspection that the orchestrator can perform directly. +14. Never use an LLM agent merely to wait. + +--- + +# 25. ROLE BOUNDARIES + +## Orchestrator + +May: + +- Inspect. +- Plan. +- Create feature/release branches. +- Maintain {{WORK_TRACKER}}. +- Dispatch agents. +- Verify gates. +- Coordinate work. + +Must NOT: + +- Implement feature code. +- Remediate code directly. +- Perform independent review. +- Substitute its own approval. +- Merge task/feature/release PRs. + +## Development Agent + +May: + +- Implement assigned tasks. +- Remediate findings. +- Test. +- Open task PRs. +- Merge approved task PRs into feature branches. + +Must NOT: + +- Approve itself. +- Independently review itself. +- Merge to `{{INTEGRATION_BRANCH}}` or `{{RELEASE_BRANCH}}`. + +## Reviewer + +Read-only. + +Never edits or merges. + +## Approver + +Verifies final feature gates and performs feature → `{{INTEGRATION_BRANCH}}` merge. + +Never implements or reviews its own changes. + +## {{RELEASE_OWNER_NAME}} / `{{HOST_ACCOUNT}}` + +Only {{RELEASE_OWNER_NAME}} merges release PRs into `{{RELEASE_BRANCH}}`. + +--- + +# 26. EXECUTION START + +At startup: + +1. Determine `MISSION_SCOPE`. +2. Reconcile {{WORK_TRACKER}}. +3. Inspect actual repository/PR state. +4. Recover unfinished work if present. +5. Establish `NEXT_ACTION`. +6. Execute it. +7. Enter `CONTROLLER_TICK`. +8. Continue until a GLOBAL TERMINAL CONDITION is reached. + +Remember: + +**Never report a next executable step instead of performing it.** + +**Task completion triggers another controller cycle.** + +**Review completion triggers another controller cycle.** + +**Feature completion triggers another controller cycle unless the mission was explicitly limited to that feature.** + +**A locally blocked item triggers scheduling of other useful work whenever possible.** + +You are the controller of a persistent development pipeline. Keep the pipeline moving. + +--- + +# 27. HOST CONTROLLER RUNTIME + +This section is the host execution contract for OpenCode {{OPENCODE_VERSION}} and takes +precedence over Section 21 when deciding whether a single model invocation may +end. The persistent user service runs one fresh, headless orchestrator turn per +controller tick. Section 21 governs mission completion, not the lifetime of +one OpenCode turn. + +The user starts this project's cycle explicitly from OpenCode with +`{{START_COMMAND}}`. `{{STOP_COMMAND}}` stops it, and +`{{STATUS_COMMAND}}` reports its state. The `{{SERVICE_NAME}}` user unit is +not enabled at login. Reading this prompt in an ordinary interactive session +does not authorize starting the background service. The service waits for the +interactive project session to close before its first tick. + +At the end of EVERY orchestrator invocation, emit exactly one final line: +`ORCHESTRATOR_STATE: CONTINUE`, `ORCHESTRATOR_STATE: QUIESCENT`, or +`ORCHESTRATOR_STATE: BLOCKED`. Use CONTINUE whenever eligible or in-flight +work remains and state the next concrete action immediately above that line. +The service starts a fresh invocation after reconciling {{CONTEXT_STORE}}, {{WORK_TRACKER}}, Git, +pull requests, and checks. Use QUIESCENT only after verifying that no eligible +or in-flight work remains; the service polls again. Use BLOCKED only for a +specific required human decision or access failure; name the missing action. +A blocked task does not block other eligible work. If {{CONTEXT_STORE}} is unavailable, +do not dispatch or merge, and return BLOCKED. + +The installed launchers live at +`{{CONTROL_HOME}}/scripts/`. Use `{{ROLE_LAUNCHER}}` +to start developer or approver OpenCode sessions. Use `{{REVIEW_LAUNCHER}}` with +WORKTREE, BASE_REF, HEAD_REF, REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE to +run a fresh read-only {{REVIEWER_DISPLAY_NAME}} review at high effort. Its output file +must be under `{{SCRATCH_DIR}}`; it posts a {{CODE_HOST}} review as `{{REVIEW_ACCOUNT}}` +only after approving the exact current head. The feature-to-{{INTEGRATION_BRANCH}} approver uses +`{{MERGE_BROKER}}` and must verify that same exact-head {{CODE_HOST}} approval. +No controller or developer session performs the feature-to-{{INTEGRATION_BRANCH}} merge. + +The service holds one controller lock and waits while an interactive +{{PROJECT_NAME}} OpenCode session is open. Each model turn has a {{TICK_TIMEOUT_LABEL}} host +timeout. After an error or timeout, reconcile external state before retrying; +three consecutive failed turns stop the service for inspection. A successful +feature merge is a transition back into scheduling, not a terminal condition. diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..5640b49 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,24 @@ +# Reusing the development cycle prompt + +`ORCHESTRATOR-PROMPT.template.md` is the current NestQuest controller +prompt with project-specific names, models, branches, identities, and launcher +paths replaced by placeholders. `nestquest.profile.json` is the working +example. Render a concrete prompt with: + +```bash +scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md +``` + +For another project, copy the profile and set its project name, tracker, +context store, models, branch names, release owner, example IDs, review +account, and installed launcher paths. Review the rendered result section by +section, especially task/feature semantics, release rules, permissions, +review gates, and the host runtime section. The template assumes an OpenCode +controller, a feature/task tracker, a versioned context store, a code host, +and a separate reviewer and approver. Adapt those sections when the project's +systems differ. + +Publish the reviewed **rendered** prompt into that project's context store +with its version check. Give the project its own agent definitions, service +unit, broker, and OpenCode start/stop commands. The template itself does not +start a controller or register another project. diff --git a/templates/nestquest.profile.json b/templates/nestquest.profile.json new file mode 100644 index 0000000..14e3bd5 --- /dev/null +++ b/templates/nestquest.profile.json @@ -0,0 +1,36 @@ +{ + "LAST_UPDATED": "2026-09-22", + "PROJECT_CONTEXT_LABEL": "NESTQUEST HOUSE CHAOS COORDINATION", + "PROJECT_NAME": "NestQuest", + "WORK_TRACKER": "Maestro", + "CONTEXT_STORE": "CTXD", + "MODEL_PROVIDER": "OpenRouter", + "CONTROLLER_MODEL": "deepseek/deepseek-v4.1-flash", + "DEVELOPER_MODEL": "z-ai/glm-5.3-flash", + "REVIEWER_MODEL": "claude-sonnet-5", + "REVIEWER_DISPLAY_NAME": "Claude Sonnet 5", + "REVIEWER_HARNESS": "Claude Code", + "REVIEWER_VENDOR": "Claude", + "REVIEWER_CLI": "claude", + "MAX_DEVELOPERS": "2", + "MAX_DEVELOPERS_WORD": "two", + "INTEGRATION_BRANCH": "dev", + "RELEASE_BRANCH": "main", + "RELEASE_OWNER_NAME": "Joshua", + "HOST_ACCOUNT": "overseer", + "CODE_HOST": "Gitea", + "TASK_ID_EXAMPLE": "TASK-123", + "FEATURE_BRANCH_EXAMPLE": "feature/NQ-17-family-calendar", + "OPENCODE_VERSION": "1.18.31", + "CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller", + "SERVICE_NAME": "nestquest-controller.service", + "START_COMMAND": "/start-nestquest-development", + "STOP_COMMAND": "/stop-nestquest-development", + "STATUS_COMMAND": "/nestquest-development-status", + "ROLE_LAUNCHER": "nq-agent", + "REVIEW_LAUNCHER": "nq-review", + "MERGE_BROKER": "nq-approve-merge", + "SCRATCH_DIR": "/tmp/opencode", + "REVIEW_ACCOUNT": "review_agent", + "TICK_TIMEOUT_LABEL": "six-hour" +} From b5c2b55d9b10220e7ffb22dc08561e10f09ab3ae Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Tue, 22 Sep 2026 21:35:30 -0400 Subject: [PATCH 3/3] Run NestQuest orchestration directly in the terminal (#151) Co-authored-by: orchestrator-agent Co-committed-by: orchestrator-agent --- .opencode/agents/cycle-control.md | 27 ------- .opencode/agents/terminal-orchestrator.md | 31 ++++++++ .opencode/commands/development-status.md | 11 --- .opencode/commands/start-development.md | 17 ++-- .opencode/commands/stop-development.md | 10 --- ORCHESTRATION.md | 71 +++++++++-------- .../nestquest-development-status.md | 11 --- .../start-nestquest-development.md | 17 ++-- .../stop-nestquest-development.md | 10 --- orchestration_tests/test_cycle_prompt.py | 13 ++- orchestration_tests/test_nq_orchestrate.py | 79 +++++++++++++++++++ scripts/nq-cycle | 44 ----------- scripts/nq-orchestrate | 52 ++++++++++++ templates/ORCHESTRATOR-PROMPT.template.md | 65 +++++++-------- templates/README.md | 18 +++-- templates/nestquest.profile.json | 7 +- 16 files changed, 270 insertions(+), 213 deletions(-) delete mode 100644 .opencode/agents/cycle-control.md create mode 100644 .opencode/agents/terminal-orchestrator.md delete mode 100644 .opencode/commands/development-status.md delete mode 100644 .opencode/commands/stop-development.md delete mode 100644 deploy/opencode-global-commands/nestquest-development-status.md delete mode 100644 deploy/opencode-global-commands/stop-nestquest-development.md create mode 100644 orchestration_tests/test_nq_orchestrate.py delete mode 100755 scripts/nq-cycle create mode 100755 scripts/nq-orchestrate diff --git a/.opencode/agents/cycle-control.md b/.opencode/agents/cycle-control.md deleted file mode 100644 index ef05fa3..0000000 --- a/.opencode/agents/cycle-control.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Start, stop, or inspect the NestQuest development cycle on explicit request -mode: primary -model: openrouter/deepseek/deepseek-v4.1-flash -permission: - edit: deny - write: deny - task: deny - doom_loop: deny - bash: - "*": deny - "/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start": allow - "/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop": allow - "/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status": allow - external_directory: - "*": ask - "/home/overseer/.local/share/nestquest-controller/scripts/*": allow ---- - -You control the NestQuest development service only when a user invokes a -cycle-control slash command. Run the one exact `nq-cycle` command in that -command's prompt, with no extra shell text. Report its result. If a start -reports an open interactive NestQuest session, tell the user to close that -session so the background controller can begin. Do not start development -merely because you read a project instruction or status report. -Do not recommend enabling the systemd unit. Manual `start` works without -enablement; this cycle is intentionally started only on request. diff --git a/.opencode/agents/terminal-orchestrator.md b/.opencode/agents/terminal-orchestrator.md new file mode 100644 index 0000000..bf5f644 --- /dev/null +++ b/.opencode/agents/terminal-orchestrator.md @@ -0,0 +1,31 @@ +--- +description: Run the NestQuest orchestration loop in this foreground terminal +mode: primary +model: openrouter/deepseek/deepseek-v4.1-flash +permission: + doom_loop: deny + task: deny + external_directory: + "*": ask + "/tmp/opencode/*": allow + "/home/overseer/.local/share/opencode/tool-output/*": allow +--- + +You are the foreground NestQuest orchestrator. The user explicitly launched +this session to run the development queue now. Read the supplied terminal +orchestrator prompt and current CTXD project context, but follow the +FOREGROUND TERMINAL RUNTIME for this session instead of CTXD's service runtime +section. + +Continuously reconcile Maestro, CTXD, Git, pull requests, checks, and child +agent results. Execute the next useful transition in this same session. Do +not finish after dispatch, review, remediation, or merge while eligible work +remains. Use the installed role launchers for developer, reviewer, and +approver isolation. Never start or wait for the systemd controller service. +Never repeat an identical unchanged tool call three times; wait on the +specific child or inspect newly changed evidence. + +End only at a global terminal condition, an explicit user stop, a genuine +global access/decision block with no other eligible work, or a host-forced +yield. On a forced yield, return a continuation checkpoint that can be +resumed in the same OpenCode session. diff --git a/.opencode/commands/development-status.md b/.opencode/commands/development-status.md deleted file mode 100644 index c201d3b..0000000 --- a/.opencode/commands/development-status.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -description: Show the NestQuest development cycle status -agent: cycle-control ---- - -Run exactly this one shell command: - -`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status` - -Report whether the controller is active and its last tick state. -Do not suggest enabling automatic startup. diff --git a/.opencode/commands/start-development.md b/.opencode/commands/start-development.md index 2f545d5..39509c1 100644 --- a/.opencode/commands/start-development.md +++ b/.opencode/commands/start-development.md @@ -1,12 +1,11 @@ --- -description: Start the NestQuest development cycle on this host -agent: cycle-control +description: Run the NestQuest orchestrator now in this OpenCode terminal +agent: terminal-orchestrator --- -Run exactly this one shell command: - -`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start` - -Report its status. If it says an interactive NestQuest OpenCode session is -open, tell me to close this session; the background controller will begin -afterward. Do not launch a developer agent or a controller tick in this chat. +Run the NestQuest project queue now in this foreground OpenCode session. +Read the current CTXD prompt and Maestro state, but use the FOREGROUND +TERMINAL RUNTIME instead of the service runtime. Reconcile repository and +pull-request state, then execute CONTROLLER_TICK repeatedly until a global +terminal condition, explicit user stop, genuine global block, or host-forced +yield. Do not start or wait for the systemd controller. diff --git a/.opencode/commands/stop-development.md b/.opencode/commands/stop-development.md deleted file mode 100644 index 3adfec8..0000000 --- a/.opencode/commands/stop-development.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: Stop the NestQuest development cycle on this host -agent: cycle-control ---- - -Run exactly this one shell command: - -`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop` - -Report the service status. Do not stop any other OpenCode session or task. diff --git a/ORCHESTRATION.md b/ORCHESTRATION.md index a33694e..b07854b 100644 --- a/ORCHESTRATION.md +++ b/ORCHESTRATION.md @@ -1,13 +1,23 @@ -# NestQuest controller +# NestQuest foreground orchestrator -OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their -models match the current NestQuest CTXD prompt: DeepSeek V4.1 -Flash for controller and approver, GLM 5.3 Flash for development. The -controller runs one OpenCode turn at a time, then starts a fresh turn based on -Maestro, CTXD, Git, and pull-request state. Its state file is -`~/.local/state/nestquest-controller/last-run.json` (mode 0600). A lock allows -only one controller process. It waits while an interactive OpenCode session is -open for the NestQuest checkout. +OpenCode 1.18.32 loads the named agents from `.opencode/agents/`. Their +models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for the +foreground orchestrator and approver, and GLM 5.3 Flash for development. +Run orchestration directly from a shell: + +```bash +nq-orchestrate +nq-orchestrate mission.md +cat mission.md | nq-orchestrate - +nq-orchestrate --print-prompt > nestquest-orchestrator.md +``` + +The launcher renders the project prompt with the foreground terminal runtime, +stops the dormant legacy service, refuses immediately when another NestQuest +TUI is open, and then replaces itself with OpenCode using +`terminal-orchestrator`. There is no background polling process and no wait +for a controller tick. Exit or interrupt the TUI to stop the foreground run; +resume that OpenCode session when a host-forced yield needs continuation. `scripts/nq-agent` launches developer and approver sessions with fixed agent definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the @@ -23,30 +33,24 @@ checks the head again afterward, saves the output under `/tmp/opencode`, and posts the Gitea approval. The approver verifies the reviewed head again before a merge. -The production service uses a stable copy of this configuration and scripts +The terminal launcher uses a stable copy of this configuration and scripts at `/home/overseer/.local/share/nestquest-controller`; it does not depend on which task branch is checked out in the primary NestQuest directory. Install -that copy, run `scripts/nq-controller --check`, and verify all four OpenCode -agents (orchestrator, developer, approver, cycle-control) +that copy, run `scripts/nq-controller --check`, and verify the OpenCode +agents (terminal-orchestrator, developer, and approver) with `opencode debug agent ` while `OPENCODE_CONFIG_DIR` points at the -installed `.opencode` directory. Install `deploy/nestquest-controller.service` -under `~/.config/systemd/user/` and reload the user manager. Its lack of an -`[Install]` section makes it a manual-only unit; development starts only -when requested. Copy `deploy/opencode-global-commands/*.md` into -`~/.config/opencode/commands/` and `.opencode/agents/cycle-control.md` into -`~/.config/opencode/agents/` for the commands to work from older branches. -The host has lingering -enabled for the `overseer` user. The service waits for an open interactive -NestQuest session to close before starting work. +installed `.opencode` directory. Install `scripts/nq-orchestrate` in +`~/.local/bin/`. For the global slash command, also install +`.opencode/agents/terminal-orchestrator.md` under +`~/.config/opencode/agents/` and +`deploy/opencode-global-commands/start-nestquest-development.md` under +`~/.config/opencode/commands/`. The old `nestquest-controller.service` remains static and +inactive only for migration safety; foreground launch stops it. -In NestQuest's OpenCode session, use `/start-development`, -`/stop-development`, or `/development-status`. The matching global -`/start-nestquest-development`, `/stop-nestquest-development`, and -`/nestquest-development-status` commands work even while an older branch is -checked out. Starting the unit in an interactive NestQuest session puts it -in a waiting state; close that session to let the first controller tick run. -The commands call `scripts/nq-cycle`; they do not run a developer inside -the interactive chat. +Inside an already-open OpenCode TUI, `/start-development` (or the global +`/start-nestquest-development`) switches to the same foreground +`terminal-orchestrator` behavior immediately. The shell launcher is the +preferred entry point when supplying a mission prompt file. `templates/ORCHESTRATOR-PROMPT.template.md` and `templates/nestquest.profile.json` make the current CTXD cycle prompt @@ -63,11 +67,10 @@ they are not a hard isolation boundary against a process running as that account. Strong isolation requires separate OS identities and protected credentials for the controller, reviewer, and approver. -A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the -next tick must reconcile external state before acting. Three consecutive -failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT -causes a five-minute poll. Neither a feature merge nor a progress report -stops the controller while eligible work remains. +The terminal runtime repeatedly executes CONTROLLER_TICK within the foreground +session. Neither a feature merge nor a progress report ends the run while +eligible work remains. A host-forced yield returns a continuation checkpoint +that can be resumed in the same OpenCode session. Do not put tokens in this repository or in unit files. Existing OpenCode MCP connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated diff --git a/deploy/opencode-global-commands/nestquest-development-status.md b/deploy/opencode-global-commands/nestquest-development-status.md deleted file mode 100644 index c201d3b..0000000 --- a/deploy/opencode-global-commands/nestquest-development-status.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -description: Show the NestQuest development cycle status -agent: cycle-control ---- - -Run exactly this one shell command: - -`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status` - -Report whether the controller is active and its last tick state. -Do not suggest enabling automatic startup. diff --git a/deploy/opencode-global-commands/start-nestquest-development.md b/deploy/opencode-global-commands/start-nestquest-development.md index 2f545d5..39509c1 100644 --- a/deploy/opencode-global-commands/start-nestquest-development.md +++ b/deploy/opencode-global-commands/start-nestquest-development.md @@ -1,12 +1,11 @@ --- -description: Start the NestQuest development cycle on this host -agent: cycle-control +description: Run the NestQuest orchestrator now in this OpenCode terminal +agent: terminal-orchestrator --- -Run exactly this one shell command: - -`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start` - -Report its status. If it says an interactive NestQuest OpenCode session is -open, tell me to close this session; the background controller will begin -afterward. Do not launch a developer agent or a controller tick in this chat. +Run the NestQuest project queue now in this foreground OpenCode session. +Read the current CTXD prompt and Maestro state, but use the FOREGROUND +TERMINAL RUNTIME instead of the service runtime. Reconcile repository and +pull-request state, then execute CONTROLLER_TICK repeatedly until a global +terminal condition, explicit user stop, genuine global block, or host-forced +yield. Do not start or wait for the systemd controller. diff --git a/deploy/opencode-global-commands/stop-nestquest-development.md b/deploy/opencode-global-commands/stop-nestquest-development.md deleted file mode 100644 index 3adfec8..0000000 --- a/deploy/opencode-global-commands/stop-nestquest-development.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: Stop the NestQuest development cycle on this host -agent: cycle-control ---- - -Run exactly this one shell command: - -`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop` - -Report the service status. Do not stop any other OpenCode session or task. diff --git a/orchestration_tests/test_cycle_prompt.py b/orchestration_tests/test_cycle_prompt.py index ce53ee3..2e26afc 100644 --- a/orchestration_tests/test_cycle_prompt.py +++ b/orchestration_tests/test_cycle_prompt.py @@ -22,9 +22,8 @@ class CyclePromptTests(unittest.TestCase): "RELEASE_BRANCH": "production", "CONTROL_HOME": "/opt/examplequest/controller", "SERVICE_NAME": "examplequest-controller.service", - "START_COMMAND": "/start-examplequest-development", - "STOP_COMMAND": "/stop-examplequest-development", - "STATUS_COMMAND": "/examplequest-development-status", + "PROJECT_ROOT": "/srv/examplequest", + "TERMINAL_LAUNCHER": "eq-orchestrate", "FEATURE_BRANCH_EXAMPLE": "feature/EQ-17", }) output = RENDER(TEMPLATE, other) @@ -44,6 +43,14 @@ class CyclePromptTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "PROJECT_NAME"): RENDER(TEMPLATE, other) + def test_template_contains_only_terminal_runtime(self): + output = RENDER(TEMPLATE, PROFILE) + self.assertIn("# 27. FOREGROUND TERMINAL RUNTIME", output) + self.assertIn("nq-orchestrate", output) + self.assertNotIn("# 27. HOST CONTROLLER RUNTIME", output) + self.assertNotIn("The persistent user service runs", output) + self.assertNotIn("---\n\n---\n\n# 27", output) + if __name__ == "__main__": unittest.main() diff --git a/orchestration_tests/test_nq_orchestrate.py b/orchestration_tests/test_nq_orchestrate.py new file mode 100644 index 0000000..830b8be --- /dev/null +++ b/orchestration_tests/test_nq_orchestrate.py @@ -0,0 +1,79 @@ +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/nq-orchestrate" + + +class ForegroundLauncherTests(unittest.TestCase): + def test_launches_terminal_agent_with_rendered_and_user_prompt(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + control = root / "control" + scripts = control / "scripts" + agents = control / ".opencode/agents" + templates = control / "templates" + project = root / "project" + for path in (scripts, agents, templates, project): + path.mkdir(parents=True, exist_ok=True) + (templates / "nestquest.profile.json").write_text("{}") + (agents / "terminal-orchestrator.md").write_text("agent") + renderer = scripts / "render-cycle-prompt" + renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'") + controller = scripts / "nq-controller" + controller_args = root / "controller-args" + open_marker = root / "open-session" + controller.write_text( + f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {controller_args}\n" + f"if test -f {open_marker}; then printf 'interactive NestQuest session open\\n'; " + "else printf 'no interactive NestQuest session\\n'; fi\n" + ) + opencode = root / "opencode" + output = root / "args" + opencode.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {output}\n") + systemctl = root / "systemctl" + stopped = root / "stopped" + systemctl.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {stopped}\n") + for executable in (renderer, controller, opencode, systemctl): + executable.chmod(0o700) + mission = root / "mission.md" + mission.write_text("Complete feature EQ-17.") + env = os.environ.copy() + env.update({ + "NQ_CONTROL_HOME": str(control), + "NQ_PROJECT_ROOT": str(project), + "OPENCODE_BIN": str(opencode), + "SYSTEMCTL_BIN": str(systemctl), + }) + printed = subprocess.run([str(SCRIPT), "--print-prompt"], env=env, + text=True, capture_output=True) + self.assertEqual(printed.returncode, 0, printed.stderr) + self.assertEqual(printed.stdout, "BASE TERMINAL PROMPT\n") + self.assertFalse(stopped.exists()) + result = subprocess.run([str(SCRIPT), str(mission)], env=env, text=True, + capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + args = output.read_text() + self.assertIn("--agent\nterminal-orchestrator", args) + self.assertIn("BASE TERMINAL PROMPT", args) + self.assertIn("# USER MISSION", args) + self.assertIn("Complete feature EQ-17.", args) + self.assertEqual(stopped.read_text().splitlines(), + ["--user", "stop", "nestquest-controller.service"]) + self.assertEqual(controller_args.read_text().splitlines(), + ["--check-interactive", "--project", str(project)]) + + output.unlink() + open_marker.touch() + refused = subprocess.run([str(SCRIPT)], env=env, text=True, + capture_output=True) + self.assertEqual(refused.returncode, 75) + self.assertIn("Another interactive NestQuest", refused.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/nq-cycle b/scripts/nq-cycle deleted file mode 100755 index be94461..0000000 --- a/scripts/nq-cycle +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# Manually control the durable NestQuest development cycle from OpenCode. -set -euo pipefail - -if [[ $# -ne 1 ]]; then - echo "usage: nq-cycle start|stop|status" >&2 - exit 64 -fi - -unit=nestquest-controller.service -controller=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller - -show_status() { - systemctl --user show "$unit" -p ActiveState -p SubState -p UnitFileState - if [[ -f /home/overseer/.local/state/nestquest-controller/last-run.json ]]; then - python3 -c 'import json,pathlib; p=pathlib.Path.home()/".local/state/nestquest-controller/last-run.json"; d=json.loads(p.read_text()); print("LastTickState="+str(d.get("state","unknown"))); print("LastTickTime="+str(d.get("time","unknown")))' - fi -} - -case "$1" in - start) - systemctl --user start "$unit" - show_status - if session_status=$("$controller" --check-interactive); then - echo "$session_status" - if [[ "$session_status" == "interactive NestQuest session open" ]]; then - echo "Close the interactive NestQuest OpenCode session to let the controller run its first tick." - fi - else - echo "The controller will wait for OpenCode session detection to recover." - fi - ;; - stop) - systemctl --user stop "$unit" - show_status - ;; - status) - show_status - ;; - *) - echo "usage: nq-cycle start|stop|status" >&2 - exit 64 - ;; -esac diff --git a/scripts/nq-orchestrate b/scripts/nq-orchestrate new file mode 100755 index 0000000..979e0fd --- /dev/null +++ b/scripts/nq-orchestrate @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Launch the NestQuest orchestrator immediately in the foreground terminal. +set -euo pipefail + +if [[ $# -gt 1 ]]; then + echo "usage: nq-orchestrate [MISSION_PROMPT_FILE|-|--print-prompt]" >&2 + exit 64 +fi + +control_home=${NQ_CONTROL_HOME:-/home/overseer/.local/share/nestquest-controller} +project=${NQ_PROJECT_ROOT:-/mnt/ai-storage/projects/NestQuest} +opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode} +systemctl_bin=${SYSTEMCTL_BIN:-systemctl} +renderer="$control_home/scripts/render-cycle-prompt" +profile="$control_home/templates/nestquest.profile.json" + +for required in "$renderer" "$profile"; do + [[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; } +done + +prompt=$("$renderer" "$profile") +if [[ ${1:-} == "--print-prompt" ]]; then + printf '%s\n' "$prompt" + exit 0 +fi +for required in "$project" "$opencode_bin" "$control_home/.opencode/agents/terminal-orchestrator.md"; do + [[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; } +done +if [[ $# -eq 1 ]]; then + if [[ "$1" == "-" ]]; then + mission=$(cat) + else + [[ -f "$1" ]] || { echo "mission prompt not found: $1" >&2; exit 66; } + mission=$(cat "$1") + fi + [[ -n "${mission//[[:space:]]/}" ]] || { echo "mission prompt is empty" >&2; exit 65; } + prompt+=$'\n\n---\n\n# USER MISSION\n\n' + prompt+="$mission" +fi + +# Foreground mode owns orchestration. A dormant service must never race it. +"$systemctl_bin" --user stop nestquest-controller.service + +session_status=$("$control_home/scripts/nq-controller" --check-interactive --project "$project") +if [[ "$session_status" == "interactive NestQuest session open" ]]; then + echo "Another interactive NestQuest OpenCode session is open." >&2 + echo "Close it, then run nq-orchestrate again." >&2 + exit 75 +fi + +export OPENCODE_CONFIG_DIR="$control_home/.opencode" +exec "$opencode_bin" "$project" --pure --agent terminal-orchestrator --prompt "$prompt" diff --git a/templates/ORCHESTRATOR-PROMPT.template.md b/templates/ORCHESTRATOR-PROMPT.template.md index 946f226..f17e098 100644 --- a/templates/ORCHESTRATOR-PROMPT.template.md +++ b/templates/ORCHESTRATOR-PROMPT.template.md @@ -922,44 +922,37 @@ You are the controller of a persistent development pipeline. Keep the pipeline m --- -# 27. HOST CONTROLLER RUNTIME +# 27. FOREGROUND TERMINAL RUNTIME -This section is the host execution contract for OpenCode {{OPENCODE_VERSION}} and takes -precedence over Section 21 when deciding whether a single model invocation may -end. The persistent user service runs one fresh, headless orchestrator turn per -controller tick. Section 21 governs mission completion, not the lifetime of -one OpenCode turn. +This section is the execution contract for a foreground OpenCode +orchestrator. It replaces the service runtime section in the rendered prompt +and takes precedence over any service-specific runtime instructions later +read from {{CONTEXT_STORE}}. -The user starts this project's cycle explicitly from OpenCode with -`{{START_COMMAND}}`. `{{STOP_COMMAND}}` stops it, and -`{{STATUS_COMMAND}}` reports its state. The `{{SERVICE_NAME}}` user unit is -not enabled at login. Reading this prompt in an ordinary interactive session -does not authorize starting the background service. The service waits for the -interactive project session to close before its first tick. +The user explicitly started this run with `{{TERMINAL_LAUNCHER}}`. Begin +work immediately in `{{PROJECT_ROOT}}`; do not start, wait for, or delegate +control to `{{SERVICE_NAME}}`. This single OpenCode session owns the +orchestration loop until a global terminal condition, an explicit user stop, +or a host-forced yield. -At the end of EVERY orchestrator invocation, emit exactly one final line: -`ORCHESTRATOR_STATE: CONTINUE`, `ORCHESTRATOR_STATE: QUIESCENT`, or -`ORCHESTRATOR_STATE: BLOCKED`. Use CONTINUE whenever eligible or in-flight -work remains and state the next concrete action immediately above that line. -The service starts a fresh invocation after reconciling {{CONTEXT_STORE}}, {{WORK_TRACKER}}, Git, -pull requests, and checks. Use QUIESCENT only after verifying that no eligible -or in-flight work remains; the service polls again. Use BLOCKED only for a -specific required human decision or access failure; name the missing action. -A blocked task does not block other eligible work. If {{CONTEXT_STORE}} is unavailable, -do not dispatch or merge, and return BLOCKED. +Execute `CONTROLLER_TICK` repeatedly inside this foreground session. Do not +end a turn merely to emit `ORCHESTRATOR_STATE: CONTINUE`, and do not treat +one task, review, pull request, or feature merge as completion. After every +child result or external state change, reconcile {{CONTEXT_STORE}}, +{{WORK_TRACKER}}, Git, pull requests, and checks, then perform the next +eligible action. -The installed launchers live at -`{{CONTROL_HOME}}/scripts/`. Use `{{ROLE_LAUNCHER}}` -to start developer or approver OpenCode sessions. Use `{{REVIEW_LAUNCHER}}` with -WORKTREE, BASE_REF, HEAD_REF, REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE to -run a fresh read-only {{REVIEWER_DISPLAY_NAME}} review at high effort. Its output file -must be under `{{SCRATCH_DIR}}`; it posts a {{CODE_HOST}} review as `{{REVIEW_ACCOUNT}}` -only after approving the exact current head. The feature-to-{{INTEGRATION_BRANCH}} approver uses -`{{MERGE_BROKER}}` and must verify that same exact-head {{CODE_HOST}} approval. -No controller or developer session performs the feature-to-{{INTEGRATION_BRANCH}} merge. +The installed role launchers live at `{{CONTROL_HOME}}/scripts/`. Use +`{{ROLE_LAUNCHER}}` for developer and approver sessions. Use +`{{REVIEW_LAUNCHER}}` with WORKTREE, BASE_REF, HEAD_REF, +REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE for a fresh read-only +{{REVIEWER_DISPLAY_NAME}} review. Review output belongs under +`{{SCRATCH_DIR}}`, and exact-head approval must be posted by +`{{REVIEW_ACCOUNT}}`. The feature-to-{{INTEGRATION_BRANCH}} approver must +use `{{MERGE_BROKER}}`; the orchestrator and developer never perform that +merge. -The service holds one controller lock and waits while an interactive -{{PROJECT_NAME}} OpenCode session is open. Each model turn has a {{TICK_TIMEOUT_LABEL}} host -timeout. After an error or timeout, reconcile external state before retrying; -three consecutive failed turns stop the service for inspection. A successful -feature merge is a transition back into scheduling, not a terminal condition. +If the host forces a yield before a global terminal condition, return the +machine-readable checkpoint from Section 22. The user can resume the same +OpenCode session from the terminal. A yield is a checkpoint, not a claim that +the project queue is complete. diff --git a/templates/README.md b/templates/README.md index 5640b49..4ef8a0f 100644 --- a/templates/README.md +++ b/templates/README.md @@ -16,9 +16,17 @@ section, especially task/feature semantics, release rules, permissions, review gates, and the host runtime section. The template assumes an OpenCode controller, a feature/task tracker, a versioned context store, a code host, and a separate reviewer and approver. Adapt those sections when the project's -systems differ. +systems differ. Section 27 is a single-session foreground loop suitable for +`opencode --agent ... --prompt ...`; the reusable prompt has no service +runtime. -Publish the reviewed **rendered** prompt into that project's context store -with its version check. Give the project its own agent definitions, service -unit, broker, and OpenCode start/stop commands. The template itself does not -start a controller or register another project. +For NestQuest, `nq-orchestrate --print-prompt` emits the fully rendered +foreground prompt without starting OpenCode. `nq-orchestrate [mission.md]` +renders that same prompt, appends the optional mission, and supplies it to the +foreground orchestrator agent. + +Publish the reviewed rendered prompt into that project's context store with +its version check. +Give the project its own agent definitions, broker, profile, and terminal +launcher. The template itself does not start an orchestrator or register +another project. diff --git a/templates/nestquest.profile.json b/templates/nestquest.profile.json index 14e3bd5..1c11810 100644 --- a/templates/nestquest.profile.json +++ b/templates/nestquest.profile.json @@ -21,12 +21,11 @@ "CODE_HOST": "Gitea", "TASK_ID_EXAMPLE": "TASK-123", "FEATURE_BRANCH_EXAMPLE": "feature/NQ-17-family-calendar", - "OPENCODE_VERSION": "1.18.31", + "OPENCODE_VERSION": "1.18.32", + "PROJECT_ROOT": "/mnt/ai-storage/projects/NestQuest", "CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller", "SERVICE_NAME": "nestquest-controller.service", - "START_COMMAND": "/start-nestquest-development", - "STOP_COMMAND": "/stop-nestquest-development", - "STATUS_COMMAND": "/nestquest-development-status", + "TERMINAL_LAUNCHER": "nq-orchestrate", "ROLE_LAUNCHER": "nq-agent", "REVIEW_LAUNCHER": "nq-review", "MERGE_BROKER": "nq-approve-merge",