From 68e2f63e9a07191d1ca243521f957a84facdf6bc Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Tue, 22 Sep 2026 10:24:12 -0400 Subject: [PATCH] Add durable NestQuest orchestration roles and controller --- .opencode/agents/approver.md | 31 +++ .opencode/agents/developer.md | 21 ++ .opencode/agents/orchestrator.md | 58 +++++ ORCHESTRATION.md | 43 ++++ deploy/nestquest-controller.service | 22 ++ orchestration_tests/test_nq_approve_merge.py | 27 ++ orchestration_tests/test_nq_controller.py | 59 +++++ orchestration_tests/test_nq_review.py | 43 ++++ scripts/nq-agent | 20 ++ scripts/nq-approve-merge | 111 ++++++++ scripts/nq-controller | 255 +++++++++++++++++++ scripts/nq-review | 34 +++ 12 files changed, 724 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..a52ade2 --- /dev/null +++ b/.opencode/agents/approver.md @@ -0,0 +1,31 @@ +--- +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 + "git status*": allow + "git diff*": allow + "git log*": allow + "git show*": allow + "git rev-parse*": allow + "/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow + external_directory: + "*": ask + "/tmp/opencode/*": 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. 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..5fa772b --- /dev/null +++ b/.opencode/agents/orchestrator.md @@ -0,0 +1,58 @@ +--- +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" in that task worktree, +against the feature branch and the exact current head. 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..630ed15 --- /dev/null +++ b/ORCHESTRATION.md @@ -0,0 +1,43 @@ +# NestQuest controller + +OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their +models match the current NestQuest CTXD prompt (version 6): 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 read-only Git commands and only the +installed `nq-approve-merge` broker for Gitea. That broker requires exact-head +Claude approval evidence, the `dev` target, a mergeable PR, and passing +configured checks before it requests a squash merge. `scripts/nq-review` launches the independent +Claude review in the task worktree and checks the exact head before review. +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 `/etc/systemd/system/`, then enable it. 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. + +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..e2d9929 --- /dev/null +++ b/deploy/nestquest-controller.service @@ -0,0 +1,22 @@ +[Unit] +Description=NestQuest OpenCode controller +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=600 +StartLimitBurst=5 + +[Service] +Type=simple +User=overseer +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=75 77 +TimeoutStartSec=0 +KillMode=control-group +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.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..987e1da --- /dev/null +++ b/orchestration_tests/test_nq_approve_merge.py @@ -0,0 +1,27 @@ +from pathlib import Path +import runpy +import tempfile +import unittest + + +APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge")) +HEAD = "a" * 40 + + +class MergeGateTests(unittest.TestCase): + 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) + + +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..050bc90 --- /dev/null +++ b/orchestration_tests/test_nq_controller.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path +import runpy +import sqlite3 +import sys +import tempfile +import unittest + + +CONTROL = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-controller")) + + +class ControllerProtocolTests(unittest.TestCase): + 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")) + + 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\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)) + + +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..c1bbec4 --- /dev/null +++ b/orchestration_tests/test_nq_review.py @@ -0,0 +1,43 @@ +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/nq-review" + + +class ReviewLauncherTests(unittest.TestCase): + def test_pins_head_and_model_in_worktree(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repo = root / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.invalid"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True) + (repo / "README").write_text("initial\n") + subprocess.run(["git", "-C", str(repo), "add", "README"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "initial"], check=True) + head = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip() + prompt = root / "review.txt" + prompt.write_text("Review this change.") + stub = root / "claude" + stub.write_text("#!/bin/sh\nprintf '%s\\n' \"$PWD\" \"$@\"\n") + stub.chmod(0o700) + env = os.environ.copy() + env["CLAUDE_BIN"] = str(stub) + result = subprocess.run([str(SCRIPT), str(repo), head, head, str(prompt)], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(str(repo), result.stdout) + self.assertIn("--model\nclaude-sonnet-5", result.stdout) + self.assertIn("--effort\nhigh", result.stdout) + self.assertIn(head, result.stdout) + + result = subprocess.run([str(SCRIPT), str(repo), head, "HEAD~1", str(prompt)], env=env, text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + + +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..7c2e6b9 --- /dev/null +++ b/scripts/nq-approve-merge @@ -0,0 +1,111 @@ +#!/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: + 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 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 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. + request(f"/pulls/{args.pr}/merge", token, {"Do": "squash"}) + after = inspect(args.pr, token) + if after["merged"] is not True: + raise RuntimeError("merge request returned, but the PR is not merged") + 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..678475d --- /dev/null +++ b/scripts/nq-controller @@ -0,0 +1,255 @@ +#!/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"] + endpoint = mcp["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: + return False + 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 any(a in ("run", "debug", "serve", "web") for a in args[1:]): + 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 (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 + 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 77 + print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}") + return 0 + if args.check_interactive: + print("interactive NestQuest session open" if interactive_nestquest_session(project) 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: + if interactive_nestquest_session(project): + 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..a98a0f8 --- /dev/null +++ b/scripts/nq-review @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: nq-review WORKTREE BASE_REF HEAD_REF REVIEW_PROMPT_FILE" >&2 + exit 64 +fi + +worktree=$1 +base_ref=$2 +head_ref=$3 +prompt_file=$4 +[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; } + +cd "$worktree" +base_commit=$(git rev-parse --verify "$base_ref^{commit}") +head_commit=$(git rev-parse --verify "$head_ref^{commit}") +[[ "$(git rev-parse HEAD)" == "$head_commit" ]] || { echo "worktree HEAD differs from reviewed head" >&2; exit 65; } +git merge-base --is-ancestor "$base_commit" "$head_commit" || { echo "base is not an ancestor of head" >&2; exit 65; } + +review_prompt=$(cat "$prompt_file") +review_prompt+=$'\n\nReview only the delta from the specified base to this exact head. Base: ' +review_prompt+="$base_commit" +review_prompt+=$'. Head: ' +review_prompt+="$head_commit" +review_prompt+=$'. Verify the 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.' + +claude_bin=${CLAUDE_BIN:-/home/overseer/.local/bin/claude} +exec "$claude_bin" -p "$review_prompt" \ + --model claude-sonnet-5 \ + --effort high \ + --permission-mode plan \ + --allowedTools Read Grep Glob \ + 'Bash(git diff:*)' 'Bash(git log:*)' 'Bash(git show:*)' 'Bash(git status:*)'