diff --git a/scripts/codex-fleet/down.sh b/scripts/codex-fleet/down.sh index 46abd57..7bc599f 100755 --- a/scripts/codex-fleet/down.sh +++ b/scripts/codex-fleet/down.sh @@ -45,6 +45,48 @@ else echo "[codex-fleet] no tmux session named $SESSION (already down)" fi +# [SI-15] Clean up plan-into-writable_root symlinks created at bringup time. +# +# Reads the active-plan slug + the priority plan's metadata.writable_roots, +# then unlinks every $W/openspec/plans/$slug symlink that exists. Idempotent: +# missing files / missing slugs / missing repo are all non-fatal — the +# whole block is wrapped in a tolerant `if`. +REPO_ROOT_FOR_PLAN="${CODEX_FLEET_REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}" +ACTIVE_PLAN_FILE="$REPO_ROOT_FOR_PLAN/.codex-fleet/active-plan" +if [[ -f "$ACTIVE_PLAN_FILE" ]]; then + PLAN_SLUG_FOR_DOWN="$(tr -d '[:space:]' < "$ACTIVE_PLAN_FILE" 2>/dev/null || true)" + PLAN_JSON_FOR_DOWN="$REPO_ROOT_FOR_PLAN/openspec/plans/$PLAN_SLUG_FOR_DOWN/plan.json" + if [[ -n "$PLAN_SLUG_FOR_DOWN" && -f "$PLAN_JSON_FOR_DOWN" ]]; then + WRITABLE_ROOTS_LIST="$(PLAN_FILE="$PLAN_JSON_FOR_DOWN" python3 - <<'PY' 2>/dev/null || true +import json, os +p = os.environ.get("PLAN_FILE", "") +try: + with open(p) as f: + data = json.load(f) +except Exception: + data = {} +roots = (data.get("metadata") or {}).get("writable_roots") or [] +for r in roots: + print(r) +PY + )" + while IFS= read -r writable_root; do + [[ -z "$writable_root" ]] && continue + # Skip roots that ARE the repo (staging skipped them too). + case "$writable_root" in + "$REPO_ROOT_FOR_PLAN"|"$REPO_ROOT_FOR_PLAN"/*) + continue + ;; + esac + link_path="$writable_root/openspec/plans/$PLAN_SLUG_FOR_DOWN" + if [[ -L "$link_path" ]]; then + rm -f "$link_path" + echo "[codex-fleet] unlinked plan symlink: $link_path" + fi + done <<< "$WRITABLE_ROOTS_LIST" + fi +fi + if [[ "$PURGE" -eq 1 ]]; then if [[ "$WORK_ROOT" == "/" || "$WORK_ROOT" == "$HOME" ]]; then echo "fatal: refusing to purge work-root $WORK_ROOT (looks like a sensitive path)" >&2 diff --git a/scripts/codex-fleet/full-bringup.sh b/scripts/codex-fleet/full-bringup.sh index 32cad45..972c881 100755 --- a/scripts/codex-fleet/full-bringup.sh +++ b/scripts/codex-fleet/full-bringup.sh @@ -160,6 +160,12 @@ fi [ -f "openspec/plans/$PLAN_SLUG/plan.json" ] || die "plan workspace missing: openspec/plans/$PLAN_SLUG/plan.json" log "priority plan: $PLAN_SLUG" +# Persist the priority slug so daemons (pr-babysitter, auto-reviewer, +# plan-watcher, wake-prompt-templater) + down.sh's symlink cleanup can read +# it without re-running the newest-plan picker. Single-line file, no newline. +mkdir -p "$REPO/.codex-fleet" +printf '%s' "$PLAN_SLUG" > "$REPO/.codex-fleet/active-plan" + # 2b. Build --add-dir flags from plan metadata.writable_roots (schema: # scripts/codex-fleet/lib/plan-meta.md). Falls back to the recodee + # codex-fleet pair when the plan declares nothing. @@ -236,6 +242,43 @@ for path in $(printf '%s\n' "$ADD_DIR_FLAGS" | awk '{for(i=1;i<=NF;i++) if($i==" done log "writable roots ok: $add_count root(s)" +# 2e. [SI-15] Stage the priority plan into every writable_root. +# +# When a worker pane cd's into a writable_root W (e.g. polymarket-cli), +# `colony plan status ` resolves the plan workspace relative to W's +# cwd — i.e. `W/openspec/plans//plan.json`. Without staging, that +# file does not exist outside the codex-fleet repo and the call errors +# with ENOENT (observed 2026-05-18 during the pt2 trading-edge run). +# +# Fix: for each W in metadata.writable_roots that is OUTSIDE this repo, +# `ln -sfn $REPO/openspec/plans/ $W/openspec/plans/` so the +# plan workspace resolves from any cwd. Symlinks are idempotent (-sfn +# replaces an existing link without erroring) and torn down by down.sh. +# +# Roots inside REPO are skipped silently — the plan already lives there. +stage_plan_symlink() { + local writable_root="$1" slug="$2" repo="$3" + # Skip when the writable_root is the repo itself or any subpath thereof — + # the plan workspace already resolves directly at $repo/openspec/plans/. + case "$writable_root" in + "$repo"|"$repo"/*) + return 0 + ;; + esac + local target="$repo/openspec/plans/$slug" + local link_parent="$writable_root/openspec/plans" + local link_path="$link_parent/$slug" + mkdir -p "$link_parent" 2>/dev/null || return 1 + ln -sfn "$target" "$link_path" || return 1 + log "symlinked plan into writable root: $link_path" + return 0 +} + +for path in $(printf '%s\n' "$ADD_DIR_FLAGS" | awk '{for(i=1;i<=NF;i++) if($i=="--add-dir"){print $(i+1)}}'); do + stage_plan_symlink "$path" "$PLAN_SLUG" "$REPO" || \ + warn "failed to stage plan symlink at $path/openspec/plans/$PLAN_SLUG" +done + # 3. Pre-spawn git cleanup (prevents 'incorrect old value provided' inside agent-branch-start.sh) log "pruning stale remote refs" git -C "$REPO" remote prune origin 2>&1 | sed 's/^/ /' || true @@ -900,6 +943,14 @@ ticker_window claim-release "CR_SUP_SESSION=$SESSION CR_SUP_WINDOW=overview bash # Cooldown prevents re-prompting the same worker on the same plan. ticker_window plan-watcher "CODEX_FLEET_SESSION=$SESSION CODEX_FLEET_REPO_ROOT=$REPO bash $SCRIPT_DIR/plan-watcher.sh --loop --interval=30" +# wake-prompt: every 30s, refreshes /tmp/codex-fleet-wake-prompt.md with +# the LIVE next-available subtask from the priority plan. Without this, +# workers re-read a stale snapshot of the wake prompt that bringup captured +# at fleet start — referencing tasks that have long since been merged +# (observed 2026-05-18: every worker still pointed at TE-2 hours after +# TE-2 had landed). See scripts/codex-fleet/wake-prompt-templater.sh. +ticker_window wake-prompt "bash $SCRIPT_DIR/wake-prompt-templater.sh" + # auto-reviewer: every 5m, reviews merged PRs attached to completed plan # sub-tasks. Slow cadence keeps Claude call cost bounded while still catching # new completions within the hour. diff --git a/scripts/codex-fleet/test/run-plan-symlink.sh b/scripts/codex-fleet/test/run-plan-symlink.sh new file mode 100755 index 0000000..ded83b4 --- /dev/null +++ b/scripts/codex-fleet/test/run-plan-symlink.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# run-plan-symlink.sh — SI-15 smoke test for the plan-into-writable_root +# symlink staging in scripts/codex-fleet/full-bringup.sh. +# +# Extracts the stage_plan_symlink() helper from full-bringup.sh and asserts: +# (a) for a writable_root OUTSIDE the repo, the symlink is created and +# resolves to the canonical plan workspace +# (b) for a writable_root INSIDE the repo, the helper skips (no symlink +# is created — the plan already lives there) +# (c) re-running the helper is idempotent (ln -sfn replaces the link +# without error) + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FLEET_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BRINGUP="$FLEET_DIR/full-bringup.sh" + +[ -f "$BRINGUP" ] || { echo "FAIL: $BRINGUP not found" >&2; exit 1; } + +tmpdir="$(mktemp -d -t plan-symlink-test.XXXXXX)" +# shellcheck disable=SC2064 +trap "rm -rf '$tmpdir'" EXIT + +# Fake repo with one plan workspace. +repo="$tmpdir/repo" +mkdir -p "$repo/openspec/plans/test-plan-slug" +echo '{"plan_slug":"test-plan-slug"}' > "$repo/openspec/plans/test-plan-slug/plan.json" + +# Foreign writable_root (outside the repo). +foreign="$tmpdir/foreign" +mkdir -p "$foreign" + +# Extract stage_plan_symlink + log/warn helpers from full-bringup.sh. +helper="$tmpdir/symlink-helper.sh" +{ + echo '#!/usr/bin/env bash' + echo 'set -uo pipefail' + echo 'log() { printf "[full-bringup] %s\n" "$*"; }' + echo 'warn() { printf "[full-bringup] %s\n" "$*" >&2; }' + awk ' + /^stage_plan_symlink\(\) \{/ { capture=1 } + capture { print } + capture && /^\}$/ { capture=0; exit } + ' "$BRINGUP" +} > "$helper" + +# shellcheck disable=SC1090 +. "$helper" + +fail=0 + +# Case (a): foreign writable_root → symlink created. +stage_plan_symlink "$foreign" "test-plan-slug" "$repo" >/dev/null +link="$foreign/openspec/plans/test-plan-slug" +if [ -L "$link" ] && [ -e "$link" ]; then + resolved="$(readlink "$link")" + if [ "$resolved" = "$repo/openspec/plans/test-plan-slug" ]; then + echo "OK: foreign writable_root → symlink resolves to canonical plan dir" + else + echo "FAIL: foreign writable_root → symlink points at '$resolved'" >&2 + fail=$((fail + 1)) + fi +else + echo "FAIL: foreign writable_root → symlink not created at $link" >&2 + fail=$((fail + 1)) +fi + +# Case (b): writable_root inside the repo → skipped. +inside="$repo/inside-root" +mkdir -p "$inside" +stage_plan_symlink "$inside" "test-plan-slug" "$repo" >/dev/null +inside_link="$inside/openspec/plans/test-plan-slug" +if [ -L "$inside_link" ] || [ -e "$inside_link" ]; then + echo "FAIL: in-repo writable_root → unexpected symlink/dir at $inside_link" >&2 + fail=$((fail + 1)) +else + echo "OK: in-repo writable_root → skipped (no symlink staged)" +fi + +# Case (c): re-running on the foreign root is idempotent. +if stage_plan_symlink "$foreign" "test-plan-slug" "$repo" >/dev/null; then + if [ -L "$link" ]; then + echo "OK: re-running stage_plan_symlink is idempotent" + else + echo "FAIL: re-run lost the symlink" >&2 + fail=$((fail + 1)) + fi +else + echo "FAIL: re-run returned non-zero" >&2 + fail=$((fail + 1)) +fi + +[ "$fail" -eq 0 ] || exit 1 +echo "summary: plan-symlink ok" diff --git a/scripts/codex-fleet/test/run-wake-templater.sh b/scripts/codex-fleet/test/run-wake-templater.sh new file mode 100755 index 0000000..24c407b --- /dev/null +++ b/scripts/codex-fleet/test/run-wake-templater.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# run-wake-templater.sh — SI-19 smoke test for wake-prompt-templater.sh. +# +# Sets up a fixture repo with .codex-fleet/active-plan + a fake plan, then +# injects a mock `colony` CLI via WAKE_COLONY_BIN that returns canned JSON +# for `task ready --json`. Runs the templater with WAKE_ONCE=1 and asserts +# the rendered file contains the substituted slug + title. +# +# Also verifies the exhausted-variant render when the mock returns empty. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FLEET_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TEMPLATER="$FLEET_DIR/wake-prompt-templater.sh" +TEMPLATE="$FLEET_DIR/wake-prompt.template.md" + +[ -x "$TEMPLATER" ] || { echo "FAIL: $TEMPLATER not executable" >&2; exit 1; } +[ -r "$TEMPLATE" ] || { echo "FAIL: $TEMPLATE missing" >&2; exit 1; } + +tmpdir="$(mktemp -d -t wake-templater-test.XXXXXX)" +# shellcheck disable=SC2064 +trap "rm -rf '$tmpdir'" EXIT + +# Fixture: fake repo with active-plan + a plan workspace. +fake_repo="$tmpdir/repo" +mkdir -p "$fake_repo/.codex-fleet" "$fake_repo/openspec/plans/fixture-plan-2026-05-18" +printf '%s' 'fixture-plan-2026-05-18' > "$fake_repo/.codex-fleet/active-plan" + +# Mock colony — emits a single ready item matching the fixture plan slug. +mkdir -p "$tmpdir/bin" +cat > "$tmpdir/bin/colony" <<'MOCK' +#!/usr/bin/env bash +# Mock colony CLI: when asked for `task ready --json`, emit a canned payload. +# Honor WAKE_TEST_MODE=exhausted to emit an empty payload (no ready items). +if [ "${1:-}" = "task" ] && [ "${2:-}" = "ready" ]; then + shift 2 + for arg in "$@"; do + case "$arg" in + --json) ;; + esac + done + if [ "${WAKE_TEST_MODE:-live}" = "exhausted" ]; then + cat <<'JSON' +{"ready":[]} +JSON + else + cat <<'JSON' +{"ready":[{"plan_slug":"fixture-plan-2026-05-18","subtask_index":7,"title":"[FIX-7] live templater smoke test subject","description":"Make sure the wake-prompt-templater renders the live next-subtask into /tmp/codex-fleet-wake-prompt.md."}]} +JSON + fi + exit 0 +fi +exit 0 +MOCK +chmod +x "$tmpdir/bin/colony" + +fail=0 + +# ---- Live variant: live next subtask is rendered ---- +output_path="$tmpdir/wake-live.md" +WAKE_ONCE=1 \ + WAKE_TEMPLATE_PATH="$TEMPLATE" \ + WAKE_OUTPUT_PATH="$output_path" \ + WAKE_COLONY_BIN="$tmpdir/bin/colony" \ + CODEX_FLEET_REPO_ROOT="$fake_repo" \ + WAKE_TEST_MODE=live \ + bash "$TEMPLATER" >/dev/null 2>&1 + +if [ ! -f "$output_path" ]; then + echo "FAIL: live variant — output file not written: $output_path" >&2 + fail=$((fail + 1)) +else + if grep -q 'fixture-plan-2026-05-18' "$output_path" \ + && grep -q '\[FIX-7\] live templater smoke test subject' "$output_path" \ + && grep -q '7' "$output_path"; then + echo "OK: live variant — slug + title + index substituted" + else + echo "FAIL: live variant — output missing slug/title/index" >&2 + sed 's/^/ /' "$output_path" >&2 + fail=$((fail + 1)) + fi + # The live variant must NOT render the plan-exhausted notice. + if grep -q 'plan-exhausted' "$output_path"; then + echo "FAIL: live variant — exhausted notice should be empty" >&2 + fail=$((fail + 1)) + else + echo "OK: live variant — no exhausted notice" + fi +fi + +# ---- Exhausted variant: no ready items → exhausted notice rendered ---- +output_path2="$tmpdir/wake-exhausted.md" +WAKE_ONCE=1 \ + WAKE_TEMPLATE_PATH="$TEMPLATE" \ + WAKE_OUTPUT_PATH="$output_path2" \ + WAKE_COLONY_BIN="$tmpdir/bin/colony" \ + CODEX_FLEET_REPO_ROOT="$fake_repo" \ + WAKE_TEST_MODE=exhausted \ + bash "$TEMPLATER" >/dev/null 2>&1 + +if [ ! -f "$output_path2" ]; then + echo "FAIL: exhausted variant — output file not written" >&2 + fail=$((fail + 1)) +else + if grep -q 'plan-exhausted' "$output_path2" \ + && grep -q 'fixture-plan-2026-05-18' "$output_path2"; then + echo "OK: exhausted variant — plan-exhausted notice + slug rendered" + else + echo "FAIL: exhausted variant — missing exhausted notice or slug" >&2 + sed 's/^/ /' "$output_path2" >&2 + fail=$((fail + 1)) + fi +fi + +[ "$fail" -eq 0 ] || exit 1 +echo "summary: wake-templater ok" diff --git a/scripts/codex-fleet/wake-prompt-templater.sh b/scripts/codex-fleet/wake-prompt-templater.sh new file mode 100755 index 0000000..88325f2 --- /dev/null +++ b/scripts/codex-fleet/wake-prompt-templater.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# wake-prompt-templater — SI-19 daemon +# +# Every 30 seconds: +# 1. Read .codex-fleet/active-plan to resolve the priority plan slug. +# 2. Call `colony task ready --json` with --null-agent / no-agent to +# preview the next claimable subtask without claiming it. +# 3. Substitute {{PLAN_SLUG}}, {{SUBTASK_INDEX}}, {{NEXT_TITLE}}, +# {{NEXT_DESCRIPTION}}, {{EXHAUSTED_NOTICE}} into +# scripts/codex-fleet/wake-prompt.template.md. +# 4. Atomically rename the rendered file to /tmp/codex-fleet-wake-prompt.md. +# +# Why: bringup captures a single snapshot of the wake prompt at fleet +# start and panes re-read that snapshot every loop iteration. When the +# referenced subtask has already merged, the prompt keeps pointing +# workers at a long-gone task (observed 2026-05-18: all 8 panes still +# named TE-2 hours after TE-2 landed). This daemon makes the wake +# prompt live. +# +# Env knobs: +# WAKE_TEMPLATE_PATH override template path (default: sibling .template.md) +# WAKE_OUTPUT_PATH override output path (default: /tmp/codex-fleet-wake-prompt.md) +# WAKE_INTERVAL_SEC loop interval seconds (default: 30) +# WAKE_ONCE if set to 1, run a single iteration and exit +# WAKE_COLONY_BIN override colony CLI path (test-mode mock injection) +# CODEX_FLEET_REPO_ROOT +# repo to read .codex-fleet/active-plan from +# +# Exits cleanly on SIGTERM / SIGINT. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${CODEX_FLEET_REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}" + +TEMPLATE_PATH="${WAKE_TEMPLATE_PATH:-$SCRIPT_DIR/wake-prompt.template.md}" +OUTPUT_PATH="${WAKE_OUTPUT_PATH:-/tmp/codex-fleet-wake-prompt.md}" +INTERVAL_SEC="${WAKE_INTERVAL_SEC:-30}" +COLONY_BIN="${WAKE_COLONY_BIN:-colony}" + +log() { printf '[wake-prompt-templater] %s\n' "$*"; } +warn() { printf '[wake-prompt-templater] WARN: %s\n' "$*" >&2; } + +stop_requested=0 +on_signal() { + stop_requested=1 +} +trap on_signal TERM INT + +# active_plan_slug — strip whitespace from .codex-fleet/active-plan. +# Echoes empty when the pointer is missing/blank. +active_plan_slug() { + local f="$REPO_ROOT/.codex-fleet/active-plan" + [ -f "$f" ] || { printf ''; return 0; } + tr -d '[:space:]' < "$f" 2>/dev/null || true +} + +# fetch_next_subtask +# +# Calls `colony task ready --json --limit 1` and emits a TSV row of +# `idxtitledescription` for the first ready subtask whose +# plan_slug matches the argument. Echoes empty on any error / no match +# / no colony binary so the caller renders the EXHAUSTED variant. +fetch_next_subtask() { + local slug="$1" + command -v "$COLONY_BIN" >/dev/null 2>&1 || { printf ''; return 0; } + + local raw + raw="$("$COLONY_BIN" task ready --json --limit 5 --agent claude 2>/dev/null || true)" + [ -n "$raw" ] || { printf ''; return 0; } + + # Pass the JSON payload via env var, not stdin: the heredoc to python's + # `python3 -` already occupies stdin and would clobber a piped payload. + RAW="$raw" SLUG="$slug" python3 - <<'PY' 2>/dev/null || true +import json, os +slug = os.environ.get("SLUG", "") +raw = os.environ.get("RAW", "") +try: + data = json.loads(raw) +except Exception: + raise SystemExit(0) +ready = data.get("ready") or [] +if isinstance(data.get("task_ready"), list) and not ready: + ready = data["task_ready"] +for item in ready: + if not isinstance(item, dict): + continue + item_slug = item.get("plan_slug") or (item.get("plan") or {}).get("slug") or "" + if slug and item_slug and item_slug != slug: + continue + idx = item.get("subtask_index") + if idx is None: + idx = item.get("sub_idx") + if idx is None: + continue + title = (item.get("title") or "").replace("\t", " ").replace("\n", " ").strip() + desc = (item.get("description") or "").replace("\t", " ").replace("\n", " ").strip() + # cap description to keep the wake prompt readable + if len(desc) > 600: + desc = desc[:600].rstrip() + "..." + print(f"{idx}\t{title}\t{desc}") + break +PY +} + +# render_template <desc> <exhausted_notice> +# +# Atomically writes the substituted template to OUTPUT_PATH. Uses +# `mv` from a tmp file in the same directory so a concurrent reader +# never observes a partial write. +render_template() { + local slug="$1" idx="$2" title="$3" desc="$4" notice="$5" + + [ -r "$TEMPLATE_PATH" ] || { warn "template not readable: $TEMPLATE_PATH"; return 1; } + + local out_dir + out_dir="$(dirname "$OUTPUT_PATH")" + mkdir -p "$out_dir" 2>/dev/null || true + local tmp_path + tmp_path="$(mktemp "${OUTPUT_PATH}.tmp.XXXXXX")" || return 1 + + # Substitute via python so multi-line {{NEXT_DESCRIPTION}} values and + # any sed-special chars (& / [ ]) survive intact. Values are passed + # via env vars to avoid argv-length or quoting hazards. + if ! TEMPLATE="$TEMPLATE_PATH" \ + OUT="$tmp_path" \ + SLUG="$slug" \ + IDX="$idx" \ + TITLE="$title" \ + DESC="$desc" \ + NOTICE="$notice" \ + python3 - <<'PY' +import os +with open(os.environ["TEMPLATE"]) as f: + body = f.read() +body = body.replace("{{PLAN_SLUG}}", os.environ.get("SLUG", "")) +body = body.replace("{{SUBTASK_INDEX}}", os.environ.get("IDX", "")) +body = body.replace("{{NEXT_TITLE}}", os.environ.get("TITLE", "")) +body = body.replace("{{NEXT_DESCRIPTION}}",os.environ.get("DESC", "")) +body = body.replace("{{EXHAUSTED_NOTICE}}",os.environ.get("NOTICE", "")) +with open(os.environ["OUT"], "w") as f: + f.write(body) +PY + then + rm -f "$tmp_path" + return 1 + fi + + mv "$tmp_path" "$OUTPUT_PATH" || { rm -f "$tmp_path"; return 1; } + return 0 +} + +# render_once — one templater tick. Returns 0 always so the daemon loop +# survives transient failures (missing colony, missing active-plan). +render_once() { + local slug idx title desc notice row + slug="$(active_plan_slug)" + + if [ -z "$slug" ]; then + notice="> NOTE: no active plan (.codex-fleet/active-plan missing or" + notice="$notice empty). Waiting for the operator to pin a priority plan." + render_template "" "" "(no active plan)" "(no description available)" "$notice" \ + || warn "render failed (no active plan)" + return 0 + fi + + row="$(fetch_next_subtask "$slug")" + if [ -z "$row" ]; then + notice="> NOTE: plan-exhausted — Colony reports no claimable subtasks" + notice="$notice for \`$slug\`. Workers should standby; await operator." + render_template "$slug" "" "(no available subtask)" "(no description available)" "$notice" \ + || warn "render failed (exhausted variant)" + return 0 + fi + + IFS=$'\t' read -r idx title desc <<< "$row" + [ -n "$title" ] || title="(untitled)" + [ -n "$desc" ] || desc="(no description)" + render_template "$slug" "$idx" "$title" "$desc" "" \ + || warn "render failed (live variant)" + return 0 +} + +main() { + if [ "${WAKE_ONCE:-0}" = "1" ]; then + render_once + return 0 + fi + + log "starting (interval=${INTERVAL_SEC}s template=$TEMPLATE_PATH output=$OUTPUT_PATH)" + while [ "$stop_requested" -eq 0 ]; do + render_once + # Sleep in 1s slices so SIGTERM is observed within ~1s instead of waiting + # for the full 30s interval. + local waited=0 + while [ "$waited" -lt "$INTERVAL_SEC" ] && [ "$stop_requested" -eq 0 ]; do + sleep 1 + waited=$((waited + 1)) + done + done + log "stop requested; exiting" + return 0 +} + +main "$@" diff --git a/scripts/codex-fleet/wake-prompt.template.md b/scripts/codex-fleet/wake-prompt.template.md new file mode 100644 index 0000000..8fca7c6 --- /dev/null +++ b/scripts/codex-fleet/wake-prompt.template.md @@ -0,0 +1,40 @@ +# codex-fleet worker wake prompt (live) + +You are a codex-fleet worker pane. The orchestrator is the host Claude +session plus the `force-claim` + `claim-release-supervisor` daemons. +Your job: pull → preflight → execute → report. Do not propose tasks. +Do not chat. + +> This file is regenerated every 30 seconds by +> `scripts/codex-fleet/wake-prompt-templater.sh` (SI-19) from the LIVE +> next-available subtask in Colony's matchmaker. The placeholders below +> are substituted at render time; the worker reads this file fresh on +> every loop iteration so it never points at a long-merged task. + +## Active plan pointer + +- plan_slug: `{{PLAN_SLUG}}` +- next subtask index: `{{SUBTASK_INDEX}}` +- next subtask title: `{{NEXT_TITLE}}` + +{{EXHAUSTED_NOTICE}} + +## Next steps + +1. `mcp__colony__hivemind_context` — confirm Colony reachable. +2. `mcp__colony__task_ready_for_agent({ agent: $CODEX_FLEET_AGENT_NAME, limit: 1 })` + — accept whatever the matchmaker hands back; the title above is a + preview, not a hard claim. +3. Preflight per `scripts/codex-fleet/worker-prompt.md` (writable-root + gate, tier + specialty gate, dep-already-claimed gate). +4. Claim → work → finish via the `gx branch finish --via-pr --cleanup` + contract documented in `worker-prompt.md`. + +## Description (preview) + +{{NEXT_DESCRIPTION}} + +--- +Render contract: this file is written atomically via `mv` from a tmp +file in the same directory, so workers reading it never see a partial +write.