commit 38ecedcaa32c2c5d684da5e785cb4dc72dd80925 Author: ducoterra Date: Fri Aug 21 02:30:51 2026 -0400 init diff --git a/phased-execution/SKILL.md b/phased-execution/SKILL.md new file mode 100644 index 0000000..1be2cd9 --- /dev/null +++ b/phased-execution/SKILL.md @@ -0,0 +1,71 @@ +--- +name: phased-execution +description: Runs the .agent/phases/ phased-execution pipeline (ported from opencode's next-phase/auto-phase commands). Use when the user asks to run the next phase, run all phases, run the phase pipeline, or check pipeline status. Each phase executes in a separate pi subprocess so this chat's context stays small. +--- + +# Phased Execution + +Phase state lives in files, not chat: + +- `.agent/PLAN.md` — master plan; LOCKED DECISIONS are binding +- `.agent/phases/todo/NN_name.md` — pending phases (alphanumerical sort = execution order) +- `.agent/phases/complete/` — finished phases +- `.agent/reports/` — per-phase executor reports, stderr, and validation logs +- `.agent/validate.sh` — the pass/fail gate for every phase + +The scripts run each phase in a **separate pi process** (fresh context) with +bounded fixer retries (the failed executor's session is resumed, so retries +keep its work). A phase only moves to `complete/` after the child exits 0 +**and** `.agent/validate.sh` passes. This chat only dispatches and relays +results — do not implement phase code yourself; that is what the subprocess +is for. + +## Commands + +(Resolve `scripts/` against this skill's directory.) + +Run the next phase, or a specific one: + +```bash +bash scripts/run-phase.sh # first pending phase +bash scripts/run-phase.sh 03_api.md # specific phase (warns if out of order) +``` + +Run the whole pipeline — every pending phase, in order, stopping at the first +phase that fails after all retries: + +```bash +bash scripts/auto-phase.sh +``` + +Re-running `auto-phase.sh` after a failure continues where it stopped. + +## After a run + +Relay to the user: the phase name, its executor report (printed at the end of +the script output), and the validation outcome. On failure, point the user at +`.agent/reports/.a*.{md,err,validate}` and offer to resume the failed +executor's session: `pi --session-dir .agent/phase-sessions -c` (or suggest +running the script again to retry automatically). + +## Configuration (environment variables) + +| Var | Default | Meaning | +|-----|---------|---------| +| `MAX_FIX_ATTEMPTS` | `3` | Fixer retries per phase | +| `PHASE_MODEL` | session default | `--model` for child executors (e.g. `anthropic/claude-sonnet-4-5`) | +| `PHASE_THINKING` | session default | `--thinking` level for child executors | +| `PHASE_COMMIT` | `0` | `1` = `git commit --no-gpg-sign` after each passing phase | +| `PI_TRUST` | `0` | `1` = pass `--approve` (load project `.pi/` settings/skills into children) | +| `FRESH_FIX` | `0` | `1` = fixer retries start fresh instead of resuming the failed session | +| `QUIET` | `0` | `1` = suppress live progress display (reports are still written) | + +## Setup notes + +- First run creates `.agent/validate.sh` from `assets/validate.sh` if missing. + It must be adapted to the project's real checks — it is the authoritative + quality gate. +- Phase files are created by the `/to-phase`, `/audit-create`, `/new-project`, + and `/new-python-*` prompt templates. +- Child executor sessions are kept in `.agent/phase-sessions/`; add it to + `.gitignore` if the project is versioned. diff --git a/phased-execution/assets/executor-prompt.md b/phased-execution/assets/executor-prompt.md new file mode 100644 index 0000000..f7f16fe --- /dev/null +++ b/phased-execution/assets/executor-prompt.md @@ -0,0 +1,25 @@ +You are a phase executor in a phased build pipeline. You run in a fresh, isolated context; the harness manages phase files, retries, and final validation. + +Target phase file: `.agent/phases/todo/{{PHASE}}` + +## Steps +1. Read `.agent/PLAN.md` — project goals, architecture, and **LOCKED DECISIONS** (binding; never introduce technology outside them). +2. Read `AGENTS.md` if present. +3. Read every file in `.agent/phases/complete/` so your work stays architecturally consistent with what is already built. +4. Read the target phase file and complete **every** task in it, in order. +5. Write the unit and integration tests required by the phase's Testing & Quality section. Do not omit parts of the code to inflate coverage. +6. Run the project's full test suite and linter. If anything fails — including the phase's coverage criterion — fix it and re-run until green. +7. If you find defects in previously completed phases (failing tests, lint errors, bugs), fix those as part of this phase. + +## Rules +- Work only on the target phase; never start work from other files in `todo/`. +- Do **not** move, rename, or edit the phase file, other files in `.agent/phases/todo/`, `.agent/PLAN.md`, or anything in `.agent/phases/complete/`. The harness moves the phase file on success. +- Do not assume the code is correct; fix any errors you find while testing. +- Leave the repository functional when you finish. + +## Final response +When everything is green, reply with a report of **at most 15 lines**: +- What was implemented (short bullet list) +- Test / lint / coverage results (exact commands and outcomes) +- Notable decisions or deviations +- The next pending phase, if any diff --git a/phased-execution/assets/validate.sh b/phased-execution/assets/validate.sh new file mode 100755 index 0000000..e7f36b5 --- /dev/null +++ b/phased-execution/assets/validate.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# .agent/validate.sh — validation gate for the phased-execution pipeline. +# +# A phase is only moved to .agent/phases/complete/ if this script exits 0. +# Adapt the checks below to this project's real test suite, linter, and +# coverage floor, then commit the result. +set -uo pipefail +rc=0 + +if [[ -f pyproject.toml || -f pytest.ini || -f setup.py ]]; then + python3 -m pytest -q || rc=1 + if command -v ruff >/dev/null 2>&1; then + ruff check . || rc=1 + fi +fi + +if [[ -f package.json ]]; then + npm test --silent || rc=1 +fi + +if [[ $rc -ne 0 ]]; then + echo "validation FAILED (see output above)" +fi +exit "$rc" diff --git a/phased-execution/scripts/auto-phase.sh b/phased-execution/scripts/auto-phase.sh new file mode 100755 index 0000000..eb59a5b --- /dev/null +++ b/phased-execution/scripts/auto-phase.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# auto-phase.sh — run the full phased pipeline, no LLM in the loop. +# +# Processes every file in .agent/phases/todo/ in alphanumerical order. +# Each phase runs in its own pi process (fresh context); on failure the +# executor's session is resumed for up to MAX_FIX_ATTEMPTS fixer rounds. +# A phase moves to .agent/phases/complete/ only after the child exits 0 +# AND .agent/validate.sh passes. Stops at the first phase that cannot be +# completed — re-run this script to continue where it stopped. +# +# Env: see SKILL.md (MAX_FIX_ATTEMPTS, PHASE_MODEL, PHASE_THINKING, +# PHASE_COMMIT, PI_TRUST, FRESH_FIX). +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib.sh" + +cd "$(find_root)" || die "no .agent/phases/todo found in this or parent directories (run /to-phase or /audit-create first)" + +build_pi_args +delivered=() +while phase="$(next_phase)"; do + [[ -n "$phase" ]] || break + execute_phase "$phase" || exit 1 + delivered+=("$phase") +done + +echo +echo "✓ pipeline done — ${#delivered[@]} phase(s) delivered this run: ${delivered[*]:-none}" +remaining="$(ls -1 "$PHASE_TODO" 2>/dev/null | grep -cE '^[0-9]' || true)" +echo " remaining in $PHASE_TODO/: $remaining" diff --git a/phased-execution/scripts/lib.sh b/phased-execution/scripts/lib.sh new file mode 100755 index 0000000..1ede3dc --- /dev/null +++ b/phased-execution/scripts/lib.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# lib.sh — shared logic for the phased-execution skill. +# Sourced by run-phase.sh and auto-phase.sh. Not meant to be run directly. +# +# Phase state lives in files, not chat context: +# .agent/PLAN.md master plan, LOCKED DECISIONS (binding) +# .agent/phases/todo/ pending phases, NN_name.md, sorted = execution order +# .agent/phases/complete/ finished phases +# .agent/reports/ per-phase executor reports, stderr, validation logs +# .agent/phase-sessions/ child pi session files (resumable fixers) +# +# The pass/fail gate is .agent/validate.sh. A phase only moves to complete/ +# after the child executor exits 0 AND validation passes. + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/executor-prompt.md" + +PHASE_TODO=".agent/phases/todo" +PHASE_DONE=".agent/phases/complete" +PHASE_REPORTS=".agent/reports" +PHASE_SESSIONS=".agent/phase-sessions" +MAX_FIX_ATTEMPTS="${MAX_FIX_ATTEMPTS:-3}" + +warn() { echo "⚠ $*" >&2; } +die() { echo "✗ ERROR: $*" >&2; exit 1; } + +# --- project root ------------------------------------------------------------- +# Walk up from $PWD to the nearest directory containing .agent/phases/todo. +find_root() { + local d + d="$(pwd)" + while :; do + if [[ -d "$d/.agent/phases/todo" ]]; then printf '%s\n' "$d"; return 0; fi + [[ "$d" == "/" ]] && return 1 + d="$(dirname "$d")" + done +} + +# --- phase selection ---------------------------------------------------------- +# First pending phase (alphanumerical sort), or empty when none remain. +next_phase() { + ( cd "$PHASE_TODO" 2>/dev/null && ls -1 | grep -E '^[0-9]' | sort | head -n1 ) || true +} + +# --- child pi arguments ------------------------------------------------------- +build_pi_args() { + PI_ARGS=() + [[ -n "${PHASE_MODEL:-}" ]] && PI_ARGS+=(--model "$PHASE_MODEL") + [[ -n "${PHASE_THINKING:-}" ]] && PI_ARGS+=(--thinking "$PHASE_THINKING") + if [[ "${PI_TRUST:-0}" == "1" ]]; then + PI_ARGS+=(--approve) # load project .pi/ settings, skills, extensions + else + PI_ARGS+=(--no-approve) # deterministic: global config + AGENTS.md only + fi +} + +# Most recently modified child session file (or empty). +latest_child_session() { + ls -t "$PHASE_SESSIONS"/*.jsonl 2>/dev/null | head -n1 || true +} + +# --- prompts ------------------------------------------------------------------ +first_prompt() { + local phase="$1" + [[ -f "$EXECUTOR_PROMPT_FILE" ]] || die "missing $EXECUTOR_PROMPT_FILE" + sed "s|{{PHASE}}|$phase|g" "$EXECUTOR_PROMPT_FILE" +} + +fix_prompt() { + local errors="$1" + { + echo "Your previous attempt at this phase was rejected by the harness." + echo "The failures from the last attempt are below. Review them, fix the code, and re-run the full test suite and linter until everything is green. Do not start other phases' work." + echo + echo "Failure output (may be truncated):" + echo '```' + printf '%s\n' "$errors" | tail -c 6000 + echo '```' + echo + echo "When everything is green, reply with the same report as before (at most 15 lines: what was fixed, test/lint/coverage results, notable decisions, next pending phase)." + } +} + +# --- child executor ----------------------------------------------------------- +# run_child +# Attempt 1: fresh session in .agent/phase-sessions/. +# Attempt N>1: resume attempt N-1's session (unless FRESH_FIX=1 or no session +# was created — in that case a fresh ephemeral session with the failure context). +# Progress (tool calls + assistant text) streams to the terminal live via +# scripts/progress.mjs, which also writes the final assistant message to: +# .agent/reports/.a.md +# Child stderr → .agent/reports/.a.err +# QUIET=1 suppresses the progress display (report file is still written). +# Sets CHILD_RC. +run_child() { + local phase="$1" attempt="$2" prompt="$3" + local base="${phase%.md}" + local out="$PHASE_REPORTS/$base.a$attempt.md" + local errf="$PHASE_REPORTS/$base.a$attempt.err" + local ref="$PHASE_REPORTS/.ref" + local session + local PROGRESS=(node "$SKILL_DIR/scripts/progress.mjs" "$out") + [[ "${QUIET:-0}" == "1" ]] && PROGRESS+=(--quiet) + + if (( attempt == 1 )); then + touch "$ref" + pi "${PI_ARGS[@]}" --session-dir "$PHASE_SESSIONS" --name "$base" --mode json "$prompt" 2>"$errf" | "${PROGRESS[@]}" + else + touch "$ref" + session="$(latest_child_session)" + if [[ "${FRESH_FIX:-0}" != "1" && -n "$session" && "$session" -nt "$ref" ]]; then + echo " (resuming failed executor session: ${session##*/})" + pi "${PI_ARGS[@]}" --session "$session" --mode json "$prompt" 2>"$errf" | "${PROGRESS[@]}" + else + echo " (no resumable session — starting fresh)" + pi "${PI_ARGS[@]}" --no-session --mode json "$prompt" 2>"$errf" | "${PROGRESS[@]}" + fi + fi + CHILD_RC=${PIPESTATUS[0]} +} + +# --- validation gate ---------------------------------------------------------- +ensure_validate() { + if [[ ! -f .agent/validate.sh ]]; then + cp "$SKILL_DIR/assets/validate.sh" .agent/validate.sh + chmod +x .agent/validate.sh + warn "no .agent/validate.sh found — created it from the skill template." + warn "adapt it to this project's real test/lint/coverage commands; it is the pass/fail gate for every phase." + fi +} + +# run_validation ; returns 0 iff .agent/validate.sh exits 0. +run_validation() { + bash .agent/validate.sh >"$1" 2>&1 +} + +# --- one phase, with bounded fixer retries ------------------------------------ +# execute_phase +# Returns 0 and moves the phase to complete/ on success; returns 1 after +# MAX_FIX_ATTEMPTS failed attempts (phase file is left in todo/). +execute_phase() { + local phase="$1" + local base="${phase%.md}" + local attempt=1 errors="" + command -v node >/dev/null 2>&1 || die "node not found on PATH (needed to render phase progress)" + mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS" + ensure_validate + + while (( attempt <= MAX_FIX_ATTEMPTS )); do + echo "━━ $phase — attempt $attempt/$MAX_FIX_ATTEMPTS ━━" + if (( attempt == 1 )); then + run_child "$phase" 1 "$(first_prompt "$phase")" + else + run_child "$phase" "$attempt" "$(fix_prompt "$errors")" + fi + errors="" + if (( CHILD_RC != 0 )); then + errors+="[child pi exited with code $CHILD_RC]"$'\n'"$(tail -c 4000 "$PHASE_REPORTS/$base.a$attempt.err" 2>/dev/null)" + fi + if ! run_validation "$PHASE_REPORTS/$base.a$attempt.validate"; then + errors+="[.agent/validate.sh FAILED]"$'\n'"$(tail -n 120 "$PHASE_REPORTS/$base.a$attempt.validate" 2>/dev/null)" + fi + + if [[ -z "$errors" ]]; then + mv -f "$PHASE_TODO/$phase" "$PHASE_DONE/$phase" + echo "✓ $phase → complete" + if [[ "${PHASE_COMMIT:-0}" == "1" ]]; then + if git add -A 2>/dev/null && git commit --no-gpg-sign -m "phase: $phase" >/dev/null 2>&1; then + echo " (committed)" + else + warn "git commit failed (continuing)" + fi + fi + echo "── executor report ──" + cat "$PHASE_REPORTS/$base.a$attempt.md" + return 0 + fi + attempt=$(( attempt + 1 )) + done + + echo "✗ $phase FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/." >&2 + echo " logs: $PHASE_REPORTS/$base.a*.{md,err,validate}" >&2 + echo " resume: pi --session-dir $PHASE_SESSIONS -c" >&2 + return 1 +} diff --git a/phased-execution/scripts/progress.mjs b/phased-execution/scripts/progress.mjs new file mode 100644 index 0000000..4d9c9f0 --- /dev/null +++ b/phased-execution/scripts/progress.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// progress.mjs — render a `pi --mode json` event stream as human-readable +// progress on stdout, and write the final assistant message to a report file. +// +// Usage: pi --mode json "prompt" | node progress.mjs [--quiet] +// +// Quiet mode still writes the report file (for harness logs); it only +// suppresses the progress display. + +import { writeFileSync } from "node:fs"; + +const args = process.argv.slice(2); +const reportPath = args.find((a) => !a.startsWith("--")); +const quiet = args.includes("--quiet"); +if (!reportPath) { + console.error("usage: progress.mjs [--quiet]"); + process.exit(2); +} + +const useColor = process.stdout.isTTY && !process.env.NO_COLOR; +const c = { + dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s), + cyan: (s) => (useColor ? `\x1b[36m${s}\x1b[0m` : s), + red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s), + yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s), + green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s), +}; + +const out = (s = "") => { + if (!quiet) process.stdout.write(s + "\n"); +}; + +function truncate(s, n) { + s = String(s ?? "").replace(/\s+/g, " ").trim(); + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} + +function summarizeTool(name, a = {}) { + switch (name) { + case "bash": + return "$ " + truncate(a.command ?? a.cmd, 110); + case "read": + return truncate(a.path, 90) + (a.offset ? `:${a.offset}` : ""); + case "write": + return truncate(a.path, 90); + case "edit": + return truncate(a.path, 90); + case "grep": + return `${truncate(a.pattern, 40)} in ${truncate(a.path ?? ".", 60)}`; + case "find": + return truncate(a.pattern, 40) + (a.path ? " in " + truncate(a.path, 60) : ""); + case "ls": + return truncate(a.path ?? ".", 90); + default: { + let s = ""; + try { + s = JSON.stringify(a); + } catch { + s = String(a); + } + return truncate(s, 100); + } + } +} + +function textOf(message) { + if (!message) return ""; + if (typeof message.content === "string") return message.content; + if (Array.isArray(message.content)) + return message.content.filter((b) => b.type === "text").map((b) => b.text).join(""); + return ""; +} + +function wrap(text, indent, width = 100) { + const pad = " ".repeat(indent); + const lines = []; + for (const raw of text.split("\n")) { + if (!raw) { + lines.push(""); + continue; + } + let line = raw; + while (line.length > width - indent) { + let cut = line.lastIndexOf(" ", width - indent); + if (cut < 20) cut = width - indent; + lines.push(pad + line.slice(0, cut).trimEnd()); + line = line.slice(cut).trimStart(); + } + lines.push(pad + line); + } + return lines.join("\n"); +} + +let sessionId = ""; +let lastUsage = null; +let lastAssistantText = ""; +let sawError = false; + +function handle(ev) { + switch (ev.type) { + case "session": + sessionId = ev.id ?? ""; + break; + case "tool_execution_start": + out(" " + c.cyan("⏺ " + ev.toolName) + " " + c.dim(summarizeTool(ev.toolName, ev.args))); + break; + case "tool_execution_end": + if (ev.isError) out(" " + c.red("✗ " + ev.toolName + " failed")); + break; + case "message_update": + if (ev.usage) lastUsage = ev.usage; + break; + case "message_end": { + const m = ev.message ?? {}; + if (m.role === "assistant") { + const text = textOf(m); + if (text) { + lastAssistantText = text; + out(wrap(text, 2)); + out(""); + } + if (m.stopReason === "error") { + sawError = true; + out(c.red(" ✗ " + (m.errorMessage ?? "assistant error"))); + } + } + break; + } + default: + break; + } +} + +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buf += chunk; + let idx; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + let ev; + try { + ev = JSON.parse(line); + } catch { + out(c.yellow(" ? " + truncate(line, 120))); + continue; + } + handle(ev); + } +}); + +process.stdin.on("end", () => { + writeFileSync( + reportPath, + lastAssistantText ? lastAssistantText + "\n" : "(no final assistant message — see the .err log)\n", + ); + const u = lastUsage ?? {}; + const bits = []; + if (u.input) bits.push(`↑${u.input}`); + if (u.output) bits.push(`↓${u.output}`); + if (u.cacheRead) bits.push(`R${u.cacheRead}`); + if (sessionId) bits.push("session " + String(sessionId).slice(0, 8)); + if (bits.length) out(c.dim(" · " + bits.join(" "))); + out(sawError ? c.red(" · child finished with errors") : c.green(" · child done")); +}); diff --git a/phased-execution/scripts/run-phase.sh b/phased-execution/scripts/run-phase.sh new file mode 100755 index 0000000..521ee9d --- /dev/null +++ b/phased-execution/scripts/run-phase.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# run-phase.sh — execute exactly one phase in a fresh pi context. +# +# Usage: +# run-phase.sh # first pending phase (alphanumerical order) +# run-phase.sh 03_api.md # a specific pending phase (warns if out of order) +# +# Env: see SKILL.md (MAX_FIX_ATTEMPTS, PHASE_MODEL, PHASE_THINKING, +# PHASE_COMMIT, PI_TRUST, FRESH_FIX). +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib.sh" + +cd "$(find_root)" || die "no .agent/phases/todo found in this or parent directories (run /to-phase or /audit-create first)" + +phase="${1:-}" +if [[ -n "$phase" ]]; then + [[ "$phase" == *.md ]] || phase="$phase.md" + [[ -f "$PHASE_TODO/$phase" ]] || die "$phase not found in $PHASE_TODO/" + first="$(next_phase)" + if [[ -n "$first" && "$first" != "$phase" ]]; then + warn "dependency order: $first is pending before $phase — running out of order" + fi +else + phase="$(next_phase)" + [[ -n "$phase" ]] || { echo "✓ no pending phases in $PHASE_TODO/ — project complete."; exit 0; } +fi + +build_pi_args +execute_phase "$phase"