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)
with `opencode debug agent <name>` 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
+4 -7
View File
@@ -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)
+17 -1
View File
@@ -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__":
-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; }
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
+1 -14
View File
@@ -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)
+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
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.
+5 -6
View File
@@ -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.
-3
View File
@@ -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",
-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.