diff --git a/ORCHESTRATION.md b/ORCHESTRATION.md index faa3f11..b07854b 100644 --- a/ORCHESTRATION.md +++ b/ORCHESTRATION.md @@ -40,7 +40,11 @@ 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 `scripts/nq-orchestrate` in -`~/.local/bin/`. The old `nestquest-controller.service` remains static and +`~/.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. Inside an already-open OpenCode TUI, `/start-development` (or the global diff --git a/orchestration_tests/test_cycle_prompt.py b/orchestration_tests/test_cycle_prompt.py index 83c4d84..2e26afc 100644 --- a/orchestration_tests/test_cycle_prompt.py +++ b/orchestration_tests/test_cycle_prompt.py @@ -6,7 +6,6 @@ import unittest ROOT = Path(__file__).resolve().parents[1] RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"] -SELECT_RUNTIME = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["select_runtime"] TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text() PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text()) @@ -23,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) @@ -45,9 +43,8 @@ class CyclePromptTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "PROJECT_NAME"): RENDER(TEMPLATE, other) - def test_terminal_runtime_replaces_service_controller(self): - selected = SELECT_RUNTIME(TEMPLATE, "terminal") - output = RENDER(selected, PROFILE) + 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) diff --git a/orchestration_tests/test_nq_orchestrate.py b/orchestration_tests/test_nq_orchestrate.py index 9403811..830b8be 100644 --- a/orchestration_tests/test_nq_orchestrate.py +++ b/orchestration_tests/test_nq_orchestrate.py @@ -24,7 +24,13 @@ class ForegroundLauncherTests(unittest.TestCase): renderer = scripts / "render-cycle-prompt" renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'") controller = scripts / "nq-controller" - controller.write_text("#!/bin/sh\nprintf 'no interactive NestQuest session\\n'") + 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") @@ -57,6 +63,16 @@ class ForegroundLauncherTests(unittest.TestCase): 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__": 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 index c80a03b..979e0fd 100755 --- a/scripts/nq-orchestrate +++ b/scripts/nq-orchestrate @@ -18,7 +18,7 @@ for required in "$renderer" "$profile"; do [[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; } done -prompt=$("$renderer" "$profile" --runtime terminal) +prompt=$("$renderer" "$profile") if [[ ${1:-} == "--print-prompt" ]]; then printf '%s\n' "$prompt" exit 0 @@ -41,7 +41,7 @@ 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) +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 diff --git a/scripts/render-cycle-prompt b/scripts/render-cycle-prompt index 6085892..bb08229 100755 --- a/scripts/render-cycle-prompt +++ b/scripts/render-cycle-prompt @@ -11,7 +11,6 @@ import sys TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md" -RUNTIME_DIR = TEMPLATE.parent / "runtime" PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") @@ -29,27 +28,15 @@ def render(template: str, profile: dict[str, str]) -> str: return result -def select_runtime(template: str, runtime: str) -> str: - if runtime == "service": - return template - marker = "# 27. HOST CONTROLLER RUNTIME" - if template.count(marker) != 1: - raise ValueError("controller runtime section is missing or duplicated") - prefix = template.split(marker, 1)[0].rstrip() - overlay = (RUNTIME_DIR / f"{runtime}.md").read_text().strip() - return prefix + "\n\n" + overlay + "\n" - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("profile", type=Path) - parser.add_argument("--runtime", choices=("service", "terminal"), default="service") 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(select_runtime(TEMPLATE.read_text(), args.runtime), profile) + content = render(TEMPLATE.read_text(), profile) if args.output: args.output.write_text(content) print(args.output) 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 616f4cf..4ef8a0f 100644 --- a/templates/README.md +++ b/templates/README.md @@ -7,7 +7,6 @@ example. Render a concrete prompt with: ```bash scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md -scripts/render-cycle-prompt templates/nestquest.profile.json --runtime terminal --output /tmp/opencode/nestquest-terminal-prompt.md ``` For another project, copy the profile and set its project name, tracker, @@ -17,17 +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. The default `service` runtime reproduces the CTXD prompt. -The `terminal` runtime replaces Section 27 with a single-session foreground -loop suitable for `opencode --agent ... --prompt ...`. +systems differ. Section 27 is a single-session foreground loop suitable for +`opencode --agent ... --prompt ...`; the reusable prompt has no service +runtime. 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 **service-rendered** prompt into that project's context -store with its version check when CTXD should retain both runtime choices. +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 7c670a5..1c11810 100644 --- a/templates/nestquest.profile.json +++ b/templates/nestquest.profile.json @@ -25,9 +25,6 @@ "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", diff --git a/templates/runtime/terminal.md b/templates/runtime/terminal.md deleted file mode 100644 index 89a7bb2..0000000 --- a/templates/runtime/terminal.md +++ /dev/null @@ -1,34 +0,0 @@ -# 27. FOREGROUND TERMINAL RUNTIME - -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 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. - -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 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. - -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.