Make the reusable prompt terminal-only

This commit is contained in:
2026-09-22 21:22:02 -04:00
parent 2e763cfa77
commit f2e354ac39
10 changed files with 63 additions and 148 deletions
+5 -1
View File
@@ -40,7 +40,11 @@ that copy, run `scripts/nq-controller --check`, and verify the OpenCode
agents (terminal-orchestrator, developer, and approver) agents (terminal-orchestrator, developer, and approver)
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
installed `.opencode` directory. Install `scripts/nq-orchestrate` in 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. inactive only for migration safety; foreground launch stops it.
Inside an already-open OpenCode TUI, `/start-development` (or the global Inside an already-open OpenCode TUI, `/start-development` (or the global
+4 -7
View File
@@ -6,7 +6,6 @@ import unittest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"] 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() TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text()
PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text()) PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text())
@@ -23,9 +22,8 @@ class CyclePromptTests(unittest.TestCase):
"RELEASE_BRANCH": "production", "RELEASE_BRANCH": "production",
"CONTROL_HOME": "/opt/examplequest/controller", "CONTROL_HOME": "/opt/examplequest/controller",
"SERVICE_NAME": "examplequest-controller.service", "SERVICE_NAME": "examplequest-controller.service",
"START_COMMAND": "/start-examplequest-development", "PROJECT_ROOT": "/srv/examplequest",
"STOP_COMMAND": "/stop-examplequest-development", "TERMINAL_LAUNCHER": "eq-orchestrate",
"STATUS_COMMAND": "/examplequest-development-status",
"FEATURE_BRANCH_EXAMPLE": "feature/EQ-17", "FEATURE_BRANCH_EXAMPLE": "feature/EQ-17",
}) })
output = RENDER(TEMPLATE, other) output = RENDER(TEMPLATE, other)
@@ -45,9 +43,8 @@ class CyclePromptTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "PROJECT_NAME"): with self.assertRaisesRegex(ValueError, "PROJECT_NAME"):
RENDER(TEMPLATE, other) RENDER(TEMPLATE, other)
def test_terminal_runtime_replaces_service_controller(self): def test_template_contains_only_terminal_runtime(self):
selected = SELECT_RUNTIME(TEMPLATE, "terminal") output = RENDER(TEMPLATE, PROFILE)
output = RENDER(selected, PROFILE)
self.assertIn("# 27. FOREGROUND TERMINAL RUNTIME", output) self.assertIn("# 27. FOREGROUND TERMINAL RUNTIME", output)
self.assertIn("nq-orchestrate", output) self.assertIn("nq-orchestrate", output)
self.assertNotIn("# 27. HOST CONTROLLER RUNTIME", output) self.assertNotIn("# 27. HOST CONTROLLER RUNTIME", output)
+17 -1
View File
@@ -24,7 +24,13 @@ class ForegroundLauncherTests(unittest.TestCase):
renderer = scripts / "render-cycle-prompt" renderer = scripts / "render-cycle-prompt"
renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'") renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'")
controller = scripts / "nq-controller" 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" opencode = root / "opencode"
output = root / "args" output = root / "args"
opencode.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {output}\n") 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.assertIn("Complete feature EQ-17.", args)
self.assertEqual(stopped.read_text().splitlines(), self.assertEqual(stopped.read_text().splitlines(),
["--user", "stop", "nestquest-controller.service"]) ["--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__": if __name__ == "__main__":
-44
View File
@@ -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
+2 -2
View File
@@ -18,7 +18,7 @@ for required in "$renderer" "$profile"; do
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; } [[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
done done
prompt=$("$renderer" "$profile" --runtime terminal) prompt=$("$renderer" "$profile")
if [[ ${1:-} == "--print-prompt" ]]; then if [[ ${1:-} == "--print-prompt" ]]; then
printf '%s\n' "$prompt" printf '%s\n' "$prompt"
exit 0 exit 0
@@ -41,7 +41,7 @@ fi
# Foreground mode owns orchestration. A dormant service must never race it. # Foreground mode owns orchestration. A dormant service must never race it.
"$systemctl_bin" --user stop nestquest-controller.service "$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 if [[ "$session_status" == "interactive NestQuest session open" ]]; then
echo "Another interactive NestQuest OpenCode session is open." >&2 echo "Another interactive NestQuest OpenCode session is open." >&2
echo "Close it, then run nq-orchestrate again." >&2 echo "Close it, then run nq-orchestrate again." >&2
+1 -14
View File
@@ -11,7 +11,6 @@ import sys
TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md" 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_]*)\}\}") PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
@@ -29,27 +28,15 @@ def render(template: str, profile: dict[str, str]) -> str:
return result 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: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("profile", type=Path) parser.add_argument("profile", type=Path)
parser.add_argument("--runtime", choices=("service", "terminal"), default="service")
parser.add_argument("--output", type=Path) parser.add_argument("--output", type=Path)
args = parser.parse_args() args = parser.parse_args()
profile = json.loads(args.profile.read_text()) profile = json.loads(args.profile.read_text())
if not isinstance(profile, dict): if not isinstance(profile, dict):
parser.error("profile must be a JSON object") 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: if args.output:
args.output.write_text(content) args.output.write_text(content)
print(args.output) print(args.output)
+29 -36
View File
@@ -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 This section is the execution contract for a foreground OpenCode
precedence over Section 21 when deciding whether a single model invocation may orchestrator. It replaces the service runtime section in the rendered prompt
end. The persistent user service runs one fresh, headless orchestrator turn per and takes precedence over any service-specific runtime instructions later
controller tick. Section 21 governs mission completion, not the lifetime of read from {{CONTEXT_STORE}}.
one OpenCode turn.
The user starts this project's cycle explicitly from OpenCode with The user explicitly started this run with `{{TERMINAL_LAUNCHER}}`. Begin
`{{START_COMMAND}}`. `{{STOP_COMMAND}}` stops it, and work immediately in `{{PROJECT_ROOT}}`; do not start, wait for, or delegate
`{{STATUS_COMMAND}}` reports its state. The `{{SERVICE_NAME}}` user unit is control to `{{SERVICE_NAME}}`. This single OpenCode session owns the
not enabled at login. Reading this prompt in an ordinary interactive session orchestration loop until a global terminal condition, an explicit user stop,
does not authorize starting the background service. The service waits for the or a host-forced yield.
interactive project session to close before its first tick.
At the end of EVERY orchestrator invocation, emit exactly one final line: Execute `CONTROLLER_TICK` repeatedly inside this foreground session. Do not
`ORCHESTRATOR_STATE: CONTINUE`, `ORCHESTRATOR_STATE: QUIESCENT`, or end a turn merely to emit `ORCHESTRATOR_STATE: CONTINUE`, and do not treat
`ORCHESTRATOR_STATE: BLOCKED`. Use CONTINUE whenever eligible or in-flight one task, review, pull request, or feature merge as completion. After every
work remains and state the next concrete action immediately above that line. child result or external state change, reconcile {{CONTEXT_STORE}},
The service starts a fresh invocation after reconciling {{CONTEXT_STORE}}, {{WORK_TRACKER}}, Git, {{WORK_TRACKER}}, Git, pull requests, and checks, then perform the next
pull requests, and checks. Use QUIESCENT only after verifying that no eligible eligible action.
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 The installed role launchers live at `{{CONTROL_HOME}}/scripts/`. Use
`{{CONTROL_HOME}}/scripts/`. Use `{{ROLE_LAUNCHER}}` `{{ROLE_LAUNCHER}}` for developer and approver sessions. Use
to start developer or approver OpenCode sessions. Use `{{REVIEW_LAUNCHER}}` with `{{REVIEW_LAUNCHER}}` with WORKTREE, BASE_REF, HEAD_REF,
WORKTREE, BASE_REF, HEAD_REF, REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE to REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE for a fresh read-only
run a fresh read-only {{REVIEWER_DISPLAY_NAME}} review at high effort. Its output file {{REVIEWER_DISPLAY_NAME}} review. Review output belongs under
must be under `{{SCRATCH_DIR}}`; it posts a {{CODE_HOST}} review as `{{REVIEW_ACCOUNT}}` `{{SCRATCH_DIR}}`, and exact-head approval must be posted by
only after approving the exact current head. The feature-to-{{INTEGRATION_BRANCH}} approver uses `{{REVIEW_ACCOUNT}}`. The feature-to-{{INTEGRATION_BRANCH}} approver must
`{{MERGE_BROKER}}` and must verify that same exact-head {{CODE_HOST}} approval. use `{{MERGE_BROKER}}`; the orchestrator and developer never perform that
No controller or developer session performs the feature-to-{{INTEGRATION_BRANCH}} merge. merge.
The service holds one controller lock and waits while an interactive If the host forces a yield before a global terminal condition, return the
{{PROJECT_NAME}} OpenCode session is open. Each model turn has a {{TICK_TIMEOUT_LABEL}} host machine-readable checkpoint from Section 22. The user can resume the same
timeout. After an error or timeout, reconcile external state before retrying; OpenCode session from the terminal. A yield is a checkpoint, not a claim that
three consecutive failed turns stop the service for inspection. A successful the project queue is complete.
feature merge is a transition back into scheduling, not a terminal condition.
+5 -6
View File
@@ -7,7 +7,6 @@ example. Render a concrete prompt with:
```bash ```bash
scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md 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, 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 review gates, and the host runtime section. The template assumes an OpenCode
controller, a feature/task tracker, a versioned context store, a code host, 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 and a separate reviewer and approver. Adapt those sections when the project's
systems differ. The default `service` runtime reproduces the CTXD prompt. systems differ. Section 27 is a single-session foreground loop suitable for
The `terminal` runtime replaces Section 27 with a single-session foreground `opencode --agent ... --prompt ...`; the reusable prompt has no service
loop suitable for `opencode --agent ... --prompt ...`. runtime.
For NestQuest, `nq-orchestrate --print-prompt` emits the fully rendered For NestQuest, `nq-orchestrate --print-prompt` emits the fully rendered
foreground prompt without starting OpenCode. `nq-orchestrate [mission.md]` foreground prompt without starting OpenCode. `nq-orchestrate [mission.md]`
renders that same prompt, appends the optional mission, and supplies it to the renders that same prompt, appends the optional mission, and supplies it to the
foreground orchestrator agent. foreground orchestrator agent.
Publish the reviewed **service-rendered** prompt into that project's context Publish the reviewed rendered prompt into that project's context store with
store with its version check when CTXD should retain both runtime choices. its version check.
Give the project its own agent definitions, broker, profile, and terminal Give the project its own agent definitions, broker, profile, and terminal
launcher. The template itself does not start an orchestrator or register launcher. The template itself does not start an orchestrator or register
another project. another project.
-3
View File
@@ -25,9 +25,6 @@
"PROJECT_ROOT": "/mnt/ai-storage/projects/NestQuest", "PROJECT_ROOT": "/mnt/ai-storage/projects/NestQuest",
"CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller", "CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller",
"SERVICE_NAME": "nestquest-controller.service", "SERVICE_NAME": "nestquest-controller.service",
"START_COMMAND": "/start-nestquest-development",
"STOP_COMMAND": "/stop-nestquest-development",
"STATUS_COMMAND": "/nestquest-development-status",
"TERMINAL_LAUNCHER": "nq-orchestrate", "TERMINAL_LAUNCHER": "nq-orchestrate",
"ROLE_LAUNCHER": "nq-agent", "ROLE_LAUNCHER": "nq-agent",
"REVIEW_LAUNCHER": "nq-review", "REVIEW_LAUNCHER": "nq-review",
-34
View File
@@ -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.