diff --git a/README.md b/README.md index 3053f71..fa3f309 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,23 @@ the artifacts behind each transition. ```mermaid flowchart TD - User["User task"] --> Orchestrator["Orchestrator CLI"] - Orchestrator --> DAG["Assignments and dependency DAG"] + User["User task"] --> Pre["Pre-implementation"] + Pre --> Authority["Independent authority review"] + Authority --> Choice{"User-owned decision?"} + Choice -- "yes" --> UserDecision["Ask user and record choice"] + Choice -- "no" --> Context["Approved implementation context"] + UserDecision --> Context + Context --> DAG["Assignments and dependency DAG"] DAG --> WorkerA["Worker A"] DAG --> WorkerB["Worker B"] WorkerA --> Repo["Target Git repository"] WorkerB --> Repo Repo --> Snapshot["Exact diff snapshot"] - Snapshot --> Verifier["Independent verifier"] - Verifier --> Findings["Findings, todos, and recheck evidence"] - Findings --> Gate{"Final gate"} - Gate -- "reject and repair" --> Orchestrator + Snapshot --> Reviews["Post-implementation reviews"] + Reviews --> Findings["Findings, todos, and recheck evidence"] + Findings --> Todo{"Active TODO?"} + Todo -- "yes" --> Pre + Todo -- "no" --> Gate{"Lifecycle and technical gates"} Gate -- "hash-bound evidence passes" --> Result["Accepted patch"] ``` @@ -77,6 +83,43 @@ Launches are clean by default. Explicit crash recovery is opt-in: ./launch.sh --resume --session multiagent --root /absolute/path/to/target-repo ``` +## Implementation Lifecycle + +`launch.sh` bundles the orchestrator role with the mandatory lifecycle prompt, +records prompt hashes, and initializes durable lifecycle state under: + +```text +$MULTIAGENT_STATE_DIR/workflows/$MULTIAGENT_WORKFLOW_ID/lifecycle/ +``` + +`bin/workflow.sh` is the shell entry point; the importable lifecycle state +machine and CLI implementation live in `multiagent_framework/workflow.py`. + +The enforced normal path is `pre-implementation -> implementation -> +post-implementation`. An independent authority review identifies consequential +choices and whether the user or orchestrator owns each one. Writable workers +receive the complete approved implementation context, not only a partial +assignment summary. Any accepted review finding creates a TODO and returns through +pre-implementation before another edit iteration. +The implementation permit also verifies that `bin/decision.sh` contains a +committed decision whose selected plan matches the context and assignment. + +Inspect and advance the state with: + +```bash +bin/workflow.sh status "$MULTIAGENT_WORKFLOW_ID" +bin/workflow.sh prepare-implementation "$MULTIAGENT_WORKFLOW_ID" \ + --decision-id DECISION_ID --plan-id PLAN_ID --decision-revision REVISION \ + --implementation-context CONTEXT_PATH --authority-review REVIEW_ID +bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" implementation +bin/workflow.sh completion-check "$MULTIAGENT_WORKFLOW_ID" +``` + +`MULTIAGENT_LIFECYCLE_ENFORCEMENT=1` is the default. Existing structured +technical findings and repair TODOs remain authoritative. Running +`bin/orchestrator.sh complete` requires both the lifecycle completion gate and +`bin/subagent.sh gate-check`. + The default roles use Codex for orchestration and verification and Claude for workers. `WORKER_CLI`: worker CLI for manual worker windows, default `claude`. `VERIFIER_CLI`: verifier CLI, default `codex`. CLI choices, recovery, ownership @@ -94,8 +137,8 @@ contracts and workflows: `hidden-contract-ledger`, and hidden-contract edge cases; - **Scope Guard Workflow**, **Validation Coordinator Workflow**, the validation lease table, `validation-run`, and `validation-lease-acquire`; -- **Verifier Workflow**, its compact contract ledger, and - `MULTIAGENT_VERIFIER_MAX_ITERATIONS=3`; +- **Verifier Workflow**, its compact contract ledger, and the + `MULTIAGENT_VERIFIER_MAX_ITERATIONS=3` escalation threshold; - Codex UI dashboard watching through `bin/watch.sh`, backed by tmux pane logs under `.multiagent/logs`, blocked-agent state, and workflow DAG nodes; - preflight checks that prevent a scaffold, shim, or proxy behavior from being @@ -135,3 +178,17 @@ an advanced path. ```bash tests/run.sh ``` + +## Enforcement Caveat + +Decision-authority review, approved-context handoff, lifecycle TODO convergence, +and completion are enforced by the orchestrator prompt plus normal-path checks +in `bin/workflow.sh`, `bin/subagent.sh`, and `bin/orchestrator.sh`. This makes +ordinary violations fail visibly, but it is not a security or capability +boundary: an orchestrator with direct shell and state-file access can bypass or +disable these checks. + +Revisit this limitation before treating the workflow as strict enforcement. +The stronger design is a trusted supervisor that exclusively owns writable +worker launch and independently validates TODO state, decision ownership, user +approval, context revision, and assignment scope before starting a worker. diff --git a/bin/orchestrator.sh b/bin/orchestrator.sh new file mode 100755 index 0000000..86e25e5 --- /dev/null +++ b/bin/orchestrator.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${MULTIAGENT_ROOT:-$(pwd)}" +STATE_DIR="${MULTIAGENT_STATE_DIR:-$ROOT/.multiagent}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" + +usage() { + cat <<'USAGE' +Usage: + bin/orchestrator.sh complete + +Runs the normal-path completion gates for the active orchestrated workflow. +USAGE +} + +complete_run() { + if [[ "${MULTIAGENT_LIFECYCLE_ENFORCEMENT:-0}" == "1" ]]; then + local workflow_id="${MULTIAGENT_WORKFLOW_ID:-}" + [[ -n "$workflow_id" ]] || { + echo "orchestrator: lifecycle enforcement requires MULTIAGENT_WORKFLOW_ID" >&2 + exit 1 + } + MULTIAGENT_STATE_DIR="$STATE_DIR" "$SCRIPT_DIR/workflow.sh" completion-check "$workflow_id" >/dev/null + local phase + phase="$(MULTIAGENT_STATE_DIR="$STATE_DIR" "$SCRIPT_DIR/workflow.sh" value "$workflow_id" phase)" + if [[ "$phase" != "complete" ]]; then + echo "orchestrator: workflow must transition to complete before run completion (current: $phase)" >&2 + exit 1 + fi + fi + + MULTIAGENT_ROOT="$ROOT" MULTIAGENT_STATE_DIR="$STATE_DIR" "$SCRIPT_DIR/subagent.sh" gate-check >/dev/null + printf 'run completed\t%s\n' "${MULTIAGENT_RUN_ID:-${MULTIAGENT_WORKFLOW_ID:-unknown}}" +} + +case "${1:-}" in + complete) + shift + [[ $# -eq 0 ]] || { usage >&2; exit 2; } + complete_run + ;; + -h|--help|"") + usage + ;; + *) + echo "orchestrator: unknown command: $1" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/bin/prompt-bundle.sh b/bin/prompt-bundle.sh new file mode 100755 index 0000000..14fb7ef --- /dev/null +++ b/bin/prompt-bundle.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + bin/prompt-bundle.sh --orchestrator PATH --lifecycle PATH --output PATH + +Builds the canonical initial orchestrator prompt from the role prompt and the +mandatory implementation lifecycle playbook. +USAGE +} + +die() { + echo "prompt-bundle: $*" >&2 + exit 1 +} + +orchestrator="" +lifecycle="" +output="" +while [[ $# -gt 0 ]]; do + case "$1" in + --orchestrator) + orchestrator="${2:-}" + shift 2 + ;; + --lifecycle) + lifecycle="${2:-}" + shift 2 + ;; + --output) + output="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +[[ -f "$orchestrator" ]] || die "orchestrator prompt not found: $orchestrator" +[[ -f "$lifecycle" ]] || die "lifecycle prompt not found: $lifecycle" +[[ -n "$output" ]] || die "--output is required" + +mkdir -p "$(dirname "$output")" +tmp="$(mktemp "$(dirname "$output")/.orchestrator-prompt.XXXXXX")" +trap 'rm -f "$tmp"' EXIT +{ + printf '%s\n\n' '----- BEGIN ORCHESTRATOR ROLE -----' + cat "$orchestrator" + printf '\n%s\n\n' '----- END ORCHESTRATOR ROLE -----' + printf '%s\n\n' '----- BEGIN MANDATORY IMPLEMENTATION LIFECYCLE -----' + cat "$lifecycle" + printf '\n%s\n' '----- END MANDATORY IMPLEMENTATION LIFECYCLE -----' +} >"$tmp" +mv "$tmp" "$output" +trap - EXIT +printf 'prompt bundle built\t%s\n' "$output" diff --git a/bin/subagent.sh b/bin/subagent.sh index 1e05427..ce3a067 100755 --- a/bin/subagent.sh +++ b/bin/subagent.sh @@ -6,12 +6,13 @@ ROOT="${MULTIAGENT_ROOT:-$(pwd)}" STATE_DIR="${MULTIAGENT_STATE_DIR:-$ROOT/.multiagent}" LOG_DIR="${MULTIAGENT_LOG_DIR:-$STATE_DIR/logs}" POLICY_FILE="${MULTIAGENT_WRITE_POLICY:-$ROOT/docs/write-policy.paths}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" CODEX_BIN="${CODEX_BIN:-codex}" CLAUDE_BIN="${CLAUDE_BIN:-claude}" WORKER_CLI="${WORKER_CLI:-claude}" SUBAGENT_CLI="${SUBAGENT_CLI:-$WORKER_CLI}" VERIFIER_CLI="${VERIFIER_CLI:-codex}" -MULTIAGENT_HELPER="${MULTIAGENT_HELPER:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")}" +MULTIAGENT_HELPER="${MULTIAGENT_HELPER:-$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")}" MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER="${MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER:-1}" PROMPT_MODULE_ROOT="${MULTIAGENT_PROMPT_MODULE_ROOT:-$ROOT}" FRAMEWORK_MODULE_ROOT="${MULTIAGENT_FRAMEWORK_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" @@ -172,7 +173,9 @@ role_prompt_path() { local role="$2" local lower_name lower_name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" - if [[ "$lower_name" == *build-verifier* ]]; then + if [[ "$lower_name" == *decision-authority-reviewer* ]]; then + printf '%s\n' "$PROMPT_MODULE_ROOT/prompts/roles/decision-authority-reviewer.md" + elif [[ "$lower_name" == *build-verifier* ]]; then printf '%s\n' "$PROMPT_MODULE_ROOT/prompts/roles/build-verifier.md" elif [[ "$role" == "verifier" || "$role" == "reviewer" || "$lower_name" == *verifier* || "$lower_name" == *review* ]]; then printf '%s\n' "$PROMPT_MODULE_ROOT/prompts/verifier.md" @@ -537,6 +540,50 @@ get_assignment_status() { fi } +lifecycle_enforced() { + [[ "${MULTIAGENT_LIFECYCLE_ENFORCEMENT:-0}" == "1" ]] +} + +workflow_value() { + local workflow_id="$1" + local key="$2" + "$SCRIPT_DIR/workflow.sh" value "$workflow_id" "$key" +} + +gate_implementation_assignment() { + local workflow_id="$1" + local decision_id="$2" + local plan_id="$3" + [[ -x "$SCRIPT_DIR/workflow.sh" ]] || die "missing lifecycle helper: $SCRIPT_DIR/workflow.sh" + [[ -n "$workflow_id" ]] || die "lifecycle enforcement requires --workflow-id for exploitation assignments" + [[ -n "$decision_id" ]] || die "lifecycle enforcement requires --decision-id for exploitation assignments" + [[ -n "$plan_id" ]] || die "lifecycle enforcement requires --plan-id for exploitation assignments" + "$SCRIPT_DIR/workflow.sh" gate "$workflow_id" implementation \ + --decision-id "$decision_id" --plan-id "$plan_id" >/dev/null || \ + die "workflow implementation gate rejected assignment for workflow $workflow_id" +} + +validated_assignment_context_path() { + local name="$1" + lifecycle_enforced || return 1 + [[ -f "$(assignment_meta_file "$name")" ]] || return 1 + + local role workflow_id decision_id plan_id assignment_revision current_revision context_path + role="$(read_assignment_value "$name" role || true)" + [[ "$role" == "exploitation" ]] || return 1 + workflow_id="$(read_assignment_value "$name" workflow_id || true)" + decision_id="$(read_assignment_value "$name" decision_id || true)" + plan_id="$(read_assignment_value "$name" plan_id || true)" + gate_implementation_assignment "$workflow_id" "$decision_id" "$plan_id" + assignment_revision="$(read_assignment_value "$name" decision_revision || true)" + current_revision="$(workflow_value "$workflow_id" decision_revision)" + [[ -n "$assignment_revision" && "$assignment_revision" == "$current_revision" ]] || \ + die "assignment decision revision is stale: assignment=${assignment_revision:-missing} workflow=$current_revision" + context_path="$(read_assignment_value "$name" implementation_context || true)" + [[ -f "$context_path" ]] || die "assignment approved implementation context is missing: $context_path" + printf '%s\n' "$context_path" +} + status_is_active_worker() { local status="$1" case "$status" in @@ -748,6 +795,10 @@ assignment_create() { die "invalid role '$role' (expected exploitation|exploration|reflection|architecture|qa|verifier|scout)" ;; esac + if [[ "$role" == "exploitation" ]] && lifecycle_enforced; then + [[ -n "$workflow_id" ]] || workflow_id="${MULTIAGENT_WORKFLOW_ID:-}" + gate_implementation_assignment "$workflow_id" "$decision_id" "$plan_id" + fi if [[ -z "$start_commit" ]]; then start_commit="$(git -C "$ROOT" rev-parse HEAD)" else @@ -755,7 +806,12 @@ assignment_create() { start_commit="$(git -C "$ROOT" rev-parse "$start_commit^{commit}")" fi - local dir owned_file item normalized + local dir owned_file item normalized decision_revision="" implementation_context="" implementation_context_sha256="" + if [[ "$role" == "exploitation" ]] && lifecycle_enforced; then + decision_revision="$(workflow_value "$workflow_id" decision_revision)" + implementation_context="$(workflow_value "$workflow_id" implementation_context)" + implementation_context_sha256="$(workflow_value "$workflow_id" implementation_context_sha256)" + fi dir="$(assignment_dir "$name")" mkdir -p "$dir" owned_file="$(assignment_owned_file "$name")" @@ -784,6 +840,9 @@ verifier_cli=$VERIFIER_CLI role=$role decision_id=$decision_id plan_id=$plan_id +decision_revision=$decision_revision +implementation_context=$implementation_context +implementation_context_sha256=$implementation_context_sha256 workflow_id=$workflow_id node_id=$node_id depends_on=$depends_on @@ -1303,6 +1362,16 @@ spawn_subagent() { fi fi + local implementation_context_path="" + if implementation_context_path="$(validated_assignment_context_path "$name")"; then + [[ -n "$instruction_file" ]] || \ + die "lifecycle-enforced exploitation spawn requires --instruction-file with the complete approved implementation context" + local required_context + required_context="$(cat "$implementation_context_path")" + [[ -n "$required_context" && "$instruction" == *"$required_context"* ]] || \ + die "exploitation instruction does not contain the complete approved implementation context" + fi + local dir dir="$(subagent_dir "$name")" mkdir -p "$dir" "$LOG_DIR" @@ -1333,8 +1402,8 @@ EOF cat "$prompt_file" } >>"$dir/transcript.log" fi - printf -v command "cd %q && export MULTIAGENT_SESSION=%q MULTIAGENT_ROOT=%q MULTIAGENT_STATE_DIR=%q MULTIAGENT_LOG_DIR=%q MULTIAGENT_WRITE_POLICY=%q MULTIAGENT_SUBAGENT_NAME=%q MULTIAGENT_HELPER=%q WORKER_CLI=%q SUBAGENT_CLI=%q VERIFIER_CLI=%q CODEX_BIN=%q CLAUDE_BIN=%q MULTIAGENT_CODEX_EXEC=%q PATH=%q && %s; rc=\$?; printf '\\nfinal status: codex exec exited rc=%%s\\n' \$rc; sleep infinity" \ - "$ROOT" "$SESSION" "$ROOT" "$STATE_DIR" "$LOG_DIR" "$POLICY_FILE" "$name" "$MULTIAGENT_HELPER" "$WORKER_CLI" "$cli" "$VERIFIER_CLI" "$CODEX_BIN" "$CLAUDE_BIN" "${MULTIAGENT_CODEX_EXEC:-0}" "$PATH" "$(build_cli_command "$cli" "$ROOT" "$prompt_file" "$output_file")" + printf -v command "cd %q && export MULTIAGENT_SESSION=%q MULTIAGENT_ROOT=%q MULTIAGENT_STATE_DIR=%q MULTIAGENT_LOG_DIR=%q MULTIAGENT_WRITE_POLICY=%q MULTIAGENT_WORKFLOW_ID=%q MULTIAGENT_LIFECYCLE_ENFORCEMENT=%q MULTIAGENT_SUBAGENT_NAME=%q MULTIAGENT_HELPER=%q WORKER_CLI=%q SUBAGENT_CLI=%q VERIFIER_CLI=%q CODEX_BIN=%q CLAUDE_BIN=%q MULTIAGENT_CODEX_EXEC=%q PATH=%q && %s; rc=\$?; printf '\\nfinal status: codex exec exited rc=%%s\\n' \$rc; sleep infinity" \ + "$ROOT" "$SESSION" "$ROOT" "$STATE_DIR" "$LOG_DIR" "$POLICY_FILE" "${MULTIAGENT_WORKFLOW_ID:-}" "${MULTIAGENT_LIFECYCLE_ENFORCEMENT:-0}" "$name" "$MULTIAGENT_HELPER" "$WORKER_CLI" "$cli" "$VERIFIER_CLI" "$CODEX_BIN" "$CLAUDE_BIN" "${MULTIAGENT_CODEX_EXEC:-0}" "$PATH" "$(build_cli_command "$cli" "$ROOT" "$prompt_file" "$output_file")" tmux new-window -d -t "$SESSION" -n "$name" "$command" pipe_log "$name" set_status "$name" "running" @@ -1584,6 +1653,11 @@ restore_subagent() { local instruction command instruction="$(restore_instruction "$name" "$prior_status" "$dir")" + local implementation_context_path="" + if implementation_context_path="$(validated_assignment_context_path "$name")"; then + instruction+=$'\n\n## Approved Implementation Context\n\n' + instruction+="$(cat "$implementation_context_path")" + fi printf '%s\n' "$(timestamp) prior_status=$prior_status action=$action reason=$reason force=$force cli=$cli" >>"$dir/restore_events.log" { printf '\n----- restore seed %s -----\n' "$(timestamp)" @@ -1599,8 +1673,8 @@ restore_subagent() { prompt_file="$dir/restore-instruction.txt" printf '%s\n' "$instruction" >"$prompt_file" fi - printf -v command "cd %q && export MULTIAGENT_SESSION=%q MULTIAGENT_ROOT=%q MULTIAGENT_STATE_DIR=%q MULTIAGENT_LOG_DIR=%q MULTIAGENT_WRITE_POLICY=%q MULTIAGENT_SUBAGENT_NAME=%q MULTIAGENT_HELPER=%q MULTIAGENT_SUBAGENT_RESTORED=1 WORKER_CLI=%q SUBAGENT_CLI=%q VERIFIER_CLI=%q CODEX_BIN=%q CLAUDE_BIN=%q MULTIAGENT_CODEX_EXEC=%q PATH=%q && %s; rc=\$?; printf '\\nfinal status: codex exec exited rc=%%s\\n' \$rc; sleep infinity" \ - "$ROOT" "$SESSION" "$ROOT" "$STATE_DIR" "$LOG_DIR" "$POLICY_FILE" "$name" "$MULTIAGENT_HELPER" "$WORKER_CLI" "$cli" "$VERIFIER_CLI" "$CODEX_BIN" "$CLAUDE_BIN" "${MULTIAGENT_CODEX_EXEC:-0}" "$PATH" "$(build_cli_command "$cli" "$ROOT" "$prompt_file" "$output_file")" + printf -v command "cd %q && export MULTIAGENT_SESSION=%q MULTIAGENT_ROOT=%q MULTIAGENT_STATE_DIR=%q MULTIAGENT_LOG_DIR=%q MULTIAGENT_WRITE_POLICY=%q MULTIAGENT_WORKFLOW_ID=%q MULTIAGENT_LIFECYCLE_ENFORCEMENT=%q MULTIAGENT_SUBAGENT_NAME=%q MULTIAGENT_HELPER=%q MULTIAGENT_SUBAGENT_RESTORED=1 WORKER_CLI=%q SUBAGENT_CLI=%q VERIFIER_CLI=%q CODEX_BIN=%q CLAUDE_BIN=%q MULTIAGENT_CODEX_EXEC=%q PATH=%q && %s; rc=\$?; printf '\\nfinal status: codex exec exited rc=%%s\\n' \$rc; sleep infinity" \ + "$ROOT" "$SESSION" "$ROOT" "$STATE_DIR" "$LOG_DIR" "$POLICY_FILE" "${MULTIAGENT_WORKFLOW_ID:-}" "${MULTIAGENT_LIFECYCLE_ENFORCEMENT:-0}" "$name" "$MULTIAGENT_HELPER" "$WORKER_CLI" "$cli" "$VERIFIER_CLI" "$CODEX_BIN" "$CLAUDE_BIN" "${MULTIAGENT_CODEX_EXEC:-0}" "$PATH" "$(build_cli_command "$cli" "$ROOT" "$prompt_file" "$output_file")" tmux new-window -d -t "$SESSION" -n "$name" "$command" pipe_log "$name" set_status "$name" "running" diff --git a/bin/workflow.sh b/bin/workflow.sh new file mode 100755 index 0000000..2534861 --- /dev/null +++ b/bin/workflow.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${MULTIAGENT_ROOT:-$(pwd)}" +STATE_DIR="${MULTIAGENT_STATE_DIR:-$ROOT/.multiagent}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +FRAMEWORK_ROOT="${MULTIAGENT_FRAMEWORK_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd -P)}" + +export MULTIAGENT_ROOT="$ROOT" +export MULTIAGENT_STATE_DIR="$STATE_DIR" +export PYTHONPATH="$FRAMEWORK_ROOT${PYTHONPATH:+:$PYTHONPATH}" + +exec python3 -m multiagent_framework.workflow "$@" diff --git a/launch.sh b/launch.sh index c141258..cb8d7b2 100755 --- a/launch.sh +++ b/launch.sh @@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_ROOT="$SCRIPT_DIR" ROOT="${MULTIAGENT_ROOT:-$DEFAULT_ROOT}" PROMPT_FILE="${MULTIAGENT_PROMPT:-$SCRIPT_DIR/orchestrator_prompt.md}" +LIFECYCLE_PROMPT="${MULTIAGENT_LIFECYCLE_PROMPT:-$SCRIPT_DIR/prompts/playbooks/implementation-lifecycle.md}" PROMPT_MODULE_ROOT="${MULTIAGENT_PROMPT_MODULE_ROOT:-$SCRIPT_DIR}" CODEX_BIN="${CODEX_BIN:-codex}" CLAUDE_BIN="${CLAUDE_BIN:-claude}" @@ -14,6 +15,9 @@ WORKER_CLI="${WORKER_CLI:-claude}" SUBAGENT_CLI="${SUBAGENT_CLI:-$WORKER_CLI}" VERIFIER_CLI="${VERIFIER_CLI:-codex}" VERIFIER_MAX_ITERATIONS="${MULTIAGENT_VERIFIER_MAX_ITERATIONS:-3}" +MULTIAGENT_RUN_ID="${MULTIAGENT_RUN_ID:-run_$(date -u +%Y%m%dT%H%M%SZ)_$$}" +MULTIAGENT_WORKFLOW_ID="${MULTIAGENT_WORKFLOW_ID:-}" +MULTIAGENT_LIFECYCLE_ENFORCEMENT="${MULTIAGENT_LIFECYCLE_ENFORCEMENT:-1}" ATTACH=1 RESUME=0 @@ -40,8 +44,11 @@ Environment: MULTIAGENT_STATE_DIR Persisted subagent state, default: $MULTIAGENT_ROOT/.multiagent MULTIAGENT_LOG_DIR tmux pane logs, default: $MULTIAGENT_STATE_DIR/logs MULTIAGENT_WRITE_POLICY Repo write policy, default: $MULTIAGENT_ROOT/docs/write-policy.paths - MULTIAGENT_VERIFIER_MAX_ITERATIONS Verifier follow-up loop cap, default: 3 + MULTIAGENT_VERIFIER_MAX_ITERATIONS Verifier escalation threshold, default: 3 MULTIAGENT_PROMPT Orchestrator prompt, default: /orchestrator_prompt.md + MULTIAGENT_LIFECYCLE_PROMPT Mandatory lifecycle prompt, default: /prompts/playbooks/implementation-lifecycle.md + MULTIAGENT_WORKFLOW_ID Durable lifecycle workflow ID, default: current run ID + MULTIAGENT_LIFECYCLE_ENFORCEMENT Gate normal implementation spawn/completion paths, default: 1 MULTIAGENT_PROMPT_MODULE_ROOT Directory containing prompts/, default: launcher directory MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER Require accepted verifier evidence for the exact source diff, default: 1 ORCHESTRATOR_CLI Orchestrator CLI, default: codex @@ -90,6 +97,11 @@ done STATE_DIR="${MULTIAGENT_STATE_DIR:-$ROOT/.multiagent}" LOG_DIR="${MULTIAGENT_LOG_DIR:-$STATE_DIR/logs}" POLICY_FILE="${MULTIAGENT_WRITE_POLICY:-$ROOT/docs/write-policy.paths}" +ACTIVE_WORKFLOW_FILE="$STATE_DIR/runtime_state/active-workflow-id" +if [[ "$RESUME" -eq 1 && -z "$MULTIAGENT_WORKFLOW_ID" && -f "$ACTIVE_WORKFLOW_FILE" ]]; then + MULTIAGENT_WORKFLOW_ID="$(tr -d '\r\n' <"$ACTIVE_WORKFLOW_FILE")" +fi +[[ -n "$MULTIAGENT_WORKFLOW_ID" ]] || MULTIAGENT_WORKFLOW_ID="$MULTIAGENT_RUN_ID" require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then @@ -182,6 +194,26 @@ if [[ ! -f "$PROMPT_FILE" ]]; then exit 1 fi +if [[ ! -f "$LIFECYCLE_PROMPT" ]]; then + echo "Missing implementation lifecycle prompt: $LIFECYCLE_PROMPT" >&2 + exit 1 +fi + +case "$MULTIAGENT_LIFECYCLE_ENFORCEMENT" in + 0|1) ;; + *) + echo "MULTIAGENT_LIFECYCLE_ENFORCEMENT must be 0 or 1" >&2 + exit 2 + ;; +esac + +for helper in "$SCRIPT_DIR/bin/prompt-bundle.sh" "$SCRIPT_DIR/bin/workflow.sh"; do + if [[ ! -x "$helper" ]]; then + echo "Missing lifecycle helper: $helper" >&2 + exit 1 + fi +done + if [[ ! -x "$SCRIPT_DIR/bin/write-policy.sh" ]]; then echo "Missing write policy helper: $SCRIPT_DIR/bin/write-policy.sh" >&2 exit 1 @@ -197,12 +229,16 @@ export MULTIAGENT_SESSION="$SESSION" export MULTIAGENT_ROOT="$ROOT" export MULTIAGENT_RESUME="$RESUME" export MULTIAGENT_PROMPT="$PROMPT_FILE" +export MULTIAGENT_LIFECYCLE_PROMPT="$LIFECYCLE_PROMPT" export MULTIAGENT_PROMPT_MODULE_ROOT="$PROMPT_MODULE_ROOT" export MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER="${MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER:-1}" export MULTIAGENT_STATE_DIR="$STATE_DIR" export MULTIAGENT_LOG_DIR="$LOG_DIR" export MULTIAGENT_WRITE_POLICY="$POLICY_FILE" export MULTIAGENT_VERIFIER_MAX_ITERATIONS="$VERIFIER_MAX_ITERATIONS" +export MULTIAGENT_RUN_ID +export MULTIAGENT_WORKFLOW_ID +export MULTIAGENT_LIFECYCLE_ENFORCEMENT export ORCHESTRATOR_CLI export WORKER_CLI export SUBAGENT_CLI @@ -213,8 +249,25 @@ export MULTIAGENT_CODEX_EXEC="${MULTIAGENT_CODEX_EXEC:-0}" export MULTIAGENT_EXTRA_PATH="${MULTIAGENT_EXTRA_PATH:-}" export PATH -mkdir -p "$STATE_DIR/subagents" "$STATE_DIR/assignments" "$STATE_DIR/worktrees" "$LOG_DIR" +mkdir -p "$STATE_DIR/subagents" "$STATE_DIR/assignments" "$STATE_DIR/worktrees" "$STATE_DIR/runtime_state" "$LOG_DIR" "$SCRIPT_DIR/bin/write-policy.sh" init +PROMPT_BUNDLE="$STATE_DIR/runtime_state/orchestrator-prompt-bundle.md" +"$SCRIPT_DIR/bin/prompt-bundle.sh" \ + --orchestrator "$PROMPT_FILE" \ + --lifecycle "$LIFECYCLE_PROMPT" \ + --output "$PROMPT_BUNDLE" >/dev/null +python3 - "$PROMPT_FILE" "$LIFECYCLE_PROMPT" "$PROMPT_BUNDLE" >"$STATE_DIR/runtime_state/prompt-sha256.tsv" <<'PY' +import hashlib +import sys +from pathlib import Path + +for value in sys.argv[1:]: + path = Path(value) + print(f"{hashlib.sha256(path.read_bytes()).hexdigest()}\t{path}") +PY +"$SCRIPT_DIR/bin/workflow.sh" init-or-resume "$MULTIAGENT_WORKFLOW_ID" --resume "$RESUME" >/dev/null +printf '%s\n' "$MULTIAGENT_WORKFLOW_ID" >"$ACTIVE_WORKFLOW_FILE" +export MULTIAGENT_PROMPT="$PROMPT_BUNDLE" if [[ "$RESUME" -eq 1 ]]; then RESUME_LABEL="resume" else @@ -228,13 +281,17 @@ ORCHESTRATOR_BOOTSTRAP_SCRIPT="$STATE_DIR/orchestrator-bootstrap.sh" printf 'export MULTIAGENT_SESSION=%q\n' "$SESSION" printf 'export MULTIAGENT_ROOT=%q\n' "$ROOT" printf 'export MULTIAGENT_RESUME=%q\n' "$RESUME" - printf 'export MULTIAGENT_PROMPT=%q\n' "$PROMPT_FILE" + printf 'export MULTIAGENT_PROMPT=%q\n' "$PROMPT_BUNDLE" + printf 'export MULTIAGENT_LIFECYCLE_PROMPT=%q\n' "$LIFECYCLE_PROMPT" printf 'export MULTIAGENT_PROMPT_MODULE_ROOT=%q\n' "$PROMPT_MODULE_ROOT" printf 'export MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER=%q\n' "${MULTIAGENT_REQUIRE_HASH_BOUND_VERIFIER:-1}" printf 'export MULTIAGENT_STATE_DIR=%q\n' "$STATE_DIR" printf 'export MULTIAGENT_LOG_DIR=%q\n' "$LOG_DIR" printf 'export MULTIAGENT_WRITE_POLICY=%q\n' "$POLICY_FILE" printf 'export MULTIAGENT_VERIFIER_MAX_ITERATIONS=%q\n' "$VERIFIER_MAX_ITERATIONS" + printf 'export MULTIAGENT_RUN_ID=%q\n' "$MULTIAGENT_RUN_ID" + printf 'export MULTIAGENT_WORKFLOW_ID=%q\n' "$MULTIAGENT_WORKFLOW_ID" + printf 'export MULTIAGENT_LIFECYCLE_ENFORCEMENT=%q\n' "$MULTIAGENT_LIFECYCLE_ENFORCEMENT" printf 'export ORCHESTRATOR_CLI=%q\n' "$ORCHESTRATOR_CLI" printf 'export WORKER_CLI=%q\n' "$WORKER_CLI" printf 'export SUBAGENT_CLI=%q\n' "$SUBAGENT_CLI" @@ -245,7 +302,7 @@ ORCHESTRATOR_BOOTSTRAP_SCRIPT="$STATE_DIR/orchestrator-bootstrap.sh" printf 'export MULTIAGENT_EXTRA_PATH=%q\n' "$MULTIAGENT_EXTRA_PATH" printf 'export PATH=%q\n' "$PATH" printf 'printf %q %q %q\n' 'Multiagent launch mode: MULTIAGENT_RESUME=%s (%s)\n' "$RESUME" "$RESUME_LABEL" - build_cli_command "$ORCHESTRATOR_CLI" "$ROOT" "$PROMPT_FILE" + build_cli_command "$ORCHESTRATOR_CLI" "$ROOT" "$PROMPT_BUNDLE" printf '\n' } > "$ORCHESTRATOR_BOOTSTRAP_SCRIPT" chmod 700 "$ORCHESTRATOR_BOOTSTRAP_SCRIPT" @@ -257,6 +314,9 @@ pipe_log orchestrator echo "Started tmux session: $SESSION" echo "Attach with: tmux attach -t $SESSION" echo "Resume mode: $RESUME" +echo "Workflow ID: $MULTIAGENT_WORKFLOW_ID" +echo "Lifecycle enforcement: $MULTIAGENT_LIFECYCLE_ENFORCEMENT" +echo "Prompt bundle: $PROMPT_BUNDLE" echo "Subagent state: $STATE_DIR" echo "Logs: $LOG_DIR" echo "Dashboard: MULTIAGENT_SESSION=$(printf '%q' "$SESSION") MULTIAGENT_ROOT=$(printf '%q' "$ROOT") $SCRIPT_DIR/bin/watch.sh" diff --git a/multiagent_framework/workflow.py b/multiagent_framework/workflow.py new file mode 100644 index 0000000..a8ed42a --- /dev/null +++ b/multiagent_framework/workflow.py @@ -0,0 +1,632 @@ +"""Durable implementation lifecycle state machine and command-line interface.""" + +from __future__ import annotations + +import argparse +import csv +import fcntl +import hashlib +import os +import re +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +PHASES = {"pre-implementation", "implementation", "post-implementation", "complete"} +ACTIVE_TODO_STATUSES = {"open", "assigned", "in-progress"} +TODO_KINDS = {"direct", "evidence", "decision"} +REVIEW_TYPES = {"decision-authority", "decision-drift", "scope", "technical", "reflection"} +POST_REVIEW_TYPES = {"decision-drift", "scope", "technical", "reflection"} +TODO_FIELDS = [ + "todo_id", "kind", "summary", "origin", "status", "assignment_id", + "resolution", "reason_code", "reason", "evidence", "authority", + "destination", "resume_condition", "iteration", "updated_at", +] +REVIEW_FIELDS = [ + "review_id", "type", "verdict", "diff_hash", "evidence", "iteration", "recorded_at", +] + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def die(message): + print(f"workflow: {message}", file=sys.stderr) + raise SystemExit(1) + + +def validate_id(label, value): + if not value or not ID_RE.fullmatch(value): + die(f"invalid {label}: {value}") + + +def configured_state_dir(): + """Resolve state storage when a command runs, not when this module imports.""" + root = Path(os.environ.get("MULTIAGENT_ROOT", os.getcwd())) + return Path(os.environ.get("MULTIAGENT_STATE_DIR", root / ".multiagent")) + + +def workflow_dir(workflow_id): + validate_id("workflow ID", workflow_id) + return configured_state_dir() / "workflows" / workflow_id / "lifecycle" + + +def paths(workflow_id): + base = workflow_dir(workflow_id) + return { + "base": base, + "state": base / "lifecycle.env", + "todos": base / "todos.tsv", + "reviews": base / "reviews.tsv", + "events": base / "events.log", + "lock": base / ".lock", + } + + +class Lock: + def __init__(self, path): + path.parent.mkdir(parents=True, exist_ok=True) + self.handle = path.open("a+", encoding="utf-8") + + def __enter__(self): + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX) + return self + + def __exit__(self, *_): + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + + +def atomic_text(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def read_env(path): + if not path.is_file(): + die(f"workflow lifecycle does not exist: {path.parent.parent.name}") + data = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + data[key] = value + return data + + +def write_env(path, data): + order = [ + "workflow_id", "phase", "iteration", "preimplementation_gate", + "decision_id", "plan_id", "decision_revision", "implementation_context", + "implementation_context_sha256", "authority_review_id", "candidate_diff_hash", + "reviewed_diff_hash", "resume_count", "created_at", "updated_at", + ] + text = "".join(f"{key}={data.get(key, '')}\n" for key in order) + atomic_text(path, text) + + +def init_table(path, fields): + if path.exists(): + return + atomic_text(path, "\t".join(fields) + "\n") + + +def read_table(path, fields): + if not path.exists(): + return [] + with path.open("r", encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle, delimiter="\t")) + for row in rows: + for field in fields: + row.setdefault(field, "") + return rows + + +def write_table(path, fields, rows): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, delimiter="\t", lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def append_event(path, event, details=""): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(f"{now()}\t{event}\t{details}\n") + + +def initial_state(workflow_id): + stamp = now() + return { + "workflow_id": workflow_id, + "phase": "pre-implementation", + "iteration": "1", + "preimplementation_gate": "pending", + "decision_id": "", + "plan_id": "", + "decision_revision": "", + "implementation_context": "", + "implementation_context_sha256": "", + "authority_review_id": "", + "candidate_diff_hash": "", + "reviewed_diff_hash": "", + "resume_count": "0", + "created_at": stamp, + "updated_at": stamp, + } + + +def initialize(workflow_id, resume): + p = paths(workflow_id) + with Lock(p["lock"]): + if p["state"].exists(): + state = read_env(p["state"]) + if not resume: + die(f"workflow already exists: {workflow_id}; use resume mode") + if state.get("phase") not in PHASES: + die(f"persisted workflow has invalid phase: {state.get('phase')}") + state["resume_count"] = str(int(state.get("resume_count", "0")) + 1) + state["updated_at"] = now() + write_env(p["state"], state) + init_table(p["todos"], TODO_FIELDS) + init_table(p["reviews"], REVIEW_FIELDS) + append_event(p["events"], "workflow_resumed", f"phase={state['phase']}") + print(f"workflow resumed\t{workflow_id}\t{state['phase']}") + return + state = initial_state(workflow_id) + p["base"].mkdir(parents=True, exist_ok=True) + write_env(p["state"], state) + init_table(p["todos"], TODO_FIELDS) + init_table(p["reviews"], REVIEW_FIELDS) + append_event(p["events"], "workflow_initialized", f"resume_requested={int(resume)}") + print(f"workflow initialized\t{workflow_id}\tpre-implementation") + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def active_todos(rows): + return [row for row in rows if row.get("status") in ACTIVE_TODO_STATUSES] + + +def review_by_id(rows, review_id): + return next((row for row in rows if row.get("review_id") == review_id), None) + + +def validate_implementation_context(state): + context_text = state.get("implementation_context", "") + if not context_text: + die("implementation gate requires approved implementation context") + context = Path(context_text) + if not context.is_file(): + die(f"approved implementation context is missing: {context}") + actual = sha256(context) + if actual != state.get("implementation_context_sha256"): + die("approved implementation context changed after pre-implementation approval") + + +def read_simple_env(path): + if not path.is_file(): + return {} + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if "=" in line: + key, value = line.split("=", 1) + values[key] = value + return values + + +def validate_committed_decision(decision_id, plan_id): + decision_dir = configured_state_dir() / "decisions" / decision_id + metadata = read_simple_env(decision_dir / "decision.env") + outcome = read_simple_env(decision_dir / "outcome.env") + if metadata.get("status") != "committed": + die(f"decision ledger is not committed: {decision_id}") + if outcome.get("selected_plan") != plan_id: + die( + f"decision ledger selected plan {outcome.get('selected_plan', 'missing')} " + f"does not match requested plan {plan_id}" + ) + + +def implementation_gate(workflow_id, expected_decision="", expected_plan="", allow_pre=False): + p = paths(workflow_id) + state = read_env(p["state"]) + valid_phases = {"implementation", "pre-implementation"} if allow_pre else {"implementation"} + if state.get("phase") not in valid_phases: + die(f"implementation gate requires phase=implementation, got {state.get('phase')}") + if state.get("preimplementation_gate") != "passed": + die("implementation gate has not passed") + validate_implementation_context(state) + todos = active_todos(read_table(p["todos"], TODO_FIELDS)) + blockers = [row["todo_id"] for row in todos if row.get("kind") in {"evidence", "decision"}] + if blockers: + die("implementation blocked by active evidence/decision TODOs: " + ",".join(blockers)) + if expected_decision and expected_decision != state.get("decision_id"): + die(f"assignment decision {expected_decision} does not match workflow decision {state.get('decision_id')}") + if expected_plan and expected_plan != state.get("plan_id"): + die(f"assignment plan {expected_plan} does not match workflow plan {state.get('plan_id')}") + return state + + +def required_post_reviews(p, state): + iteration = state.get("iteration") + diff_hash = state.get("candidate_diff_hash") + rows = read_table(p["reviews"], REVIEW_FIELDS) + passed = { + row["type"] + for row in rows + if row.get("iteration") == iteration + and row.get("diff_hash") == diff_hash + and row.get("verdict") == "pass" + } + return sorted(POST_REVIEW_TYPES - passed) + + +def completion_check(workflow_id): + p = paths(workflow_id) + state = read_env(p["state"]) + if state.get("phase") not in {"post-implementation", "complete"}: + die(f"completion requires phase=post-implementation, got {state.get('phase')}") + active = active_todos(read_table(p["todos"], TODO_FIELDS)) + if active: + die("completion blocked by active TODOs: " + ",".join(row["todo_id"] for row in active)) + if not state.get("candidate_diff_hash"): + die("completion requires a candidate diff hash") + missing = required_post_reviews(p, state) + if missing: + die("completion requires passing current-diff reviews: " + ",".join(missing)) + validate_implementation_context(state) + return state + + +def cmd_status(args): + p = paths(args.workflow_id) + state = read_env(p["state"]) + todos = read_table(p["todos"], TODO_FIELDS) + reviews = read_table(p["reviews"], REVIEW_FIELDS) + print(p["state"].read_text(encoding="utf-8"), end="") + print(f"active_todo_count={len(active_todos(todos))}") + print(f"review_count={len(reviews)}") + + +def cmd_prepare(args): + validate_id("decision ID", args.decision_id) + validate_id("plan ID", args.plan_id) + validate_id("review ID", args.authority_review) + validate_committed_decision(args.decision_id, args.plan_id) + p = paths(args.workflow_id) + with Lock(p["lock"]): + state = read_env(p["state"]) + if state.get("phase") != "pre-implementation": + die("prepare-implementation requires phase=pre-implementation") + reviews = read_table(p["reviews"], REVIEW_FIELDS) + review = review_by_id(reviews, args.authority_review) + if not review or review.get("type") != "decision-authority" or review.get("verdict") != "pass": + die("prepare-implementation requires a passing decision-authority review") + blockers = [ + row["todo_id"] for row in active_todos(read_table(p["todos"], TODO_FIELDS)) + if row.get("kind") in {"evidence", "decision"} + ] + if blockers: + die("pre-implementation blocked by active evidence/decision TODOs: " + ",".join(blockers)) + context = Path(args.implementation_context).resolve() + if not context.is_file(): + die(f"approved implementation context not found: {context}") + state.update({ + "preimplementation_gate": "passed", + "decision_id": args.decision_id, + "plan_id": args.plan_id, + "decision_revision": args.decision_revision, + "implementation_context": str(context), + "implementation_context_sha256": sha256(context), + "authority_review_id": args.authority_review, + "updated_at": now(), + }) + write_env(p["state"], state) + append_event(p["events"], "implementation_prepared", f"decision_id={args.decision_id}\tplan_id={args.plan_id}\treview_id={args.authority_review}") + print(f"implementation prepared\t{args.workflow_id}\t{args.decision_id}\t{args.plan_id}") + + +def cmd_transition(args): + if args.phase not in PHASES: + die(f"invalid phase: {args.phase}") + p = paths(args.workflow_id) + with Lock(p["lock"]): + state = read_env(p["state"]) + current = state.get("phase") + target = args.phase + allowed = { + "pre-implementation": {"implementation"}, + "implementation": {"post-implementation"}, + "post-implementation": {"pre-implementation", "complete"}, + "complete": set(), + } + if target not in allowed.get(current, set()): + die(f"invalid lifecycle transition: {current} -> {target}") + if current == "pre-implementation": + implementation_gate(args.workflow_id, allow_pre=True) + state["phase"] = "implementation" + elif current == "implementation": + if not args.diff_hash: + die("implementation -> post-implementation requires --diff-hash") + state["phase"] = "post-implementation" + state["candidate_diff_hash"] = args.diff_hash + state["reviewed_diff_hash"] = "" + elif target == "pre-implementation": + active = active_todos(read_table(p["todos"], TODO_FIELDS)) + if not active: + die("post-implementation -> pre-implementation requires an active TODO") + state["phase"] = "pre-implementation" + state["iteration"] = str(int(state.get("iteration", "1")) + 1) + state["preimplementation_gate"] = "pending" + state["decision_revision"] = "" + state["implementation_context"] = "" + state["implementation_context_sha256"] = "" + state["authority_review_id"] = "" + state["candidate_diff_hash"] = "" + state["reviewed_diff_hash"] = "" + elif target == "complete": + completion_check(args.workflow_id) + state["phase"] = "complete" + state["reviewed_diff_hash"] = state.get("candidate_diff_hash", "") + state["updated_at"] = now() + write_env(p["state"], state) + append_event(p["events"], "phase_transitioned", f"from={current}\tto={target}\titeration={state['iteration']}") + print(f"workflow transitioned\t{args.workflow_id}\t{current}\t{target}") + + +def cmd_add_todo(args): + validate_id("TODO ID", args.todo_id) + if args.kind not in TODO_KINDS: + die(f"invalid TODO kind: {args.kind}") + p = paths(args.workflow_id) + with Lock(p["lock"]): + state = read_env(p["state"]) + rows = read_table(p["todos"], TODO_FIELDS) + if any(row["todo_id"] == args.todo_id for row in rows): + die(f"TODO already exists: {args.todo_id}") + rows.append({ + "todo_id": args.todo_id, "kind": args.kind, "summary": args.summary, + "origin": args.origin, "status": "open", "assignment_id": "", + "resolution": "", "reason_code": "", "reason": "", + "evidence": "", "authority": "", "destination": "", + "resume_condition": "", "iteration": state["iteration"], "updated_at": now(), + }) + write_table(p["todos"], TODO_FIELDS, rows) + append_event(p["events"], "todo_added", f"todo_id={args.todo_id}\tkind={args.kind}") + print(f"TODO added\t{args.workflow_id}\t{args.todo_id}\t{args.kind}") + + +def find_todo(rows, todo_id): + row = next((row for row in rows if row.get("todo_id") == todo_id), None) + if not row: + die(f"TODO does not exist: {todo_id}") + return row + + +def cmd_todo_status(args): + if args.status not in ACTIVE_TODO_STATUSES: + die(f"invalid active TODO status: {args.status}") + p = paths(args.workflow_id) + with Lock(p["lock"]): + read_env(p["state"]) + rows = read_table(p["todos"], TODO_FIELDS) + row = find_todo(rows, args.todo_id) + if row.get("status") not in ACTIVE_TODO_STATUSES: + die(f"cannot reactivate resolved TODO without a new TODO: {args.todo_id}") + if args.status in {"assigned", "in-progress"} and not args.assignment_id: + die(f"TODO status {args.status} requires --assignment-id") + row["status"] = args.status + row["assignment_id"] = args.assignment_id + row["updated_at"] = now() + write_table(p["todos"], TODO_FIELDS, rows) + append_event(p["events"], "todo_status_changed", f"todo_id={args.todo_id}\tstatus={args.status}") + print(f"TODO status\t{args.workflow_id}\t{args.todo_id}\t{args.status}") + + +def cmd_resolve_todo(args): + if args.resolution not in {"completed", "skipped"}: + die(f"invalid TODO resolution: {args.resolution}") + if not args.evidence: + die("TODO resolution requires --evidence") + if args.resolution == "skipped": + if args.reason_code not in {"out-of-scope", "unavailable-now"}: + die("skipped TODO requires --reason-code out-of-scope|unavailable-now") + if not args.reason or args.authority not in {"orchestrator", "user"}: + die("skipped TODO requires --reason and --authority orchestrator|user") + if args.reason_code == "unavailable-now" and not (args.destination or args.resume_condition): + die("unavailable-now skip requires --destination or --resume-condition") + p = paths(args.workflow_id) + with Lock(p["lock"]): + read_env(p["state"]) + rows = read_table(p["todos"], TODO_FIELDS) + row = find_todo(rows, args.todo_id) + if row.get("status") not in ACTIVE_TODO_STATUSES: + die(f"TODO is already resolved: {args.todo_id}") + row.update({ + "status": args.resolution, + "resolution": args.resolution, + "reason_code": args.reason_code, + "reason": args.reason, + "evidence": args.evidence, + "authority": args.authority, + "destination": args.destination, + "resume_condition": args.resume_condition, + "updated_at": now(), + }) + write_table(p["todos"], TODO_FIELDS, rows) + append_event(p["events"], "todo_resolved", f"todo_id={args.todo_id}\tresolution={args.resolution}\treason_code={args.reason_code}") + print(f"TODO resolved\t{args.workflow_id}\t{args.todo_id}\t{args.resolution}") + + +def cmd_record_review(args): + validate_id("review ID", args.review_id) + if args.type not in REVIEW_TYPES: + die(f"invalid review type: {args.type}") + if args.verdict not in {"pass", "findings"}: + die(f"invalid review verdict: {args.verdict}") + if not args.evidence: + die("review requires --evidence") + p = paths(args.workflow_id) + with Lock(p["lock"]): + state = read_env(p["state"]) + if args.type == "decision-authority": + if state.get("phase") != "pre-implementation": + die("decision-authority review requires phase=pre-implementation") + diff_hash = "-" + else: + if state.get("phase") != "post-implementation": + die(f"{args.type} review requires phase=post-implementation") + diff_hash = args.diff_hash or "" + if diff_hash != state.get("candidate_diff_hash"): + die("post-implementation review diff hash does not match candidate diff") + rows = read_table(p["reviews"], REVIEW_FIELDS) + if any(row["review_id"] == args.review_id for row in rows): + die(f"review already exists: {args.review_id}") + rows.append({ + "review_id": args.review_id, "type": args.type, "verdict": args.verdict, + "diff_hash": diff_hash, "evidence": args.evidence, + "iteration": state["iteration"], "recorded_at": now(), + }) + write_table(p["reviews"], REVIEW_FIELDS, rows) + append_event(p["events"], "review_recorded", f"review_id={args.review_id}\ttype={args.type}\tverdict={args.verdict}\tdiff_hash={diff_hash}") + print(f"review recorded\t{args.workflow_id}\t{args.review_id}\t{args.type}\t{args.verdict}") + + +def cmd_gate(args): + if args.gate == "implementation": + state = implementation_gate(args.workflow_id, args.decision_id, args.plan_id) + print(f"gate passed\t{args.workflow_id}\timplementation\t{state['decision_revision']}\t{state['implementation_context_sha256']}") + else: + state = completion_check(args.workflow_id) + print(f"gate passed\t{args.workflow_id}\tcompletion\t{state['candidate_diff_hash']}") + + +def cmd_value(args): + state = read_env(paths(args.workflow_id)["state"]) + if args.key not in state: + die(f"unknown lifecycle field: {args.key}") + print(state[args.key]) + + +parser = argparse.ArgumentParser(prog="bin/workflow.sh") +sub = parser.add_subparsers(dest="command", required=True) + +init = sub.add_parser("init") +init.add_argument("workflow_id") +init.set_defaults(func=lambda a: initialize(a.workflow_id, False)) + +ior = sub.add_parser("init-or-resume") +ior.add_argument("workflow_id") +ior.add_argument("--resume", choices=["0", "1"], required=True) +ior.set_defaults(func=lambda a: initialize(a.workflow_id, a.resume == "1")) + +status = sub.add_parser("status") +status.add_argument("workflow_id") +status.set_defaults(func=cmd_status) + +prepare = sub.add_parser("prepare-implementation") +prepare.add_argument("workflow_id") +prepare.add_argument("--decision-id", required=True) +prepare.add_argument("--plan-id", required=True) +prepare.add_argument("--decision-revision", required=True) +prepare.add_argument("--implementation-context", required=True) +prepare.add_argument("--authority-review", required=True) +prepare.set_defaults(func=cmd_prepare) + +transition = sub.add_parser("transition") +transition.add_argument("workflow_id") +transition.add_argument("phase") +transition.add_argument("--diff-hash", default="") +transition.set_defaults(func=cmd_transition) + +add_todo = sub.add_parser("add-todo") +add_todo.add_argument("workflow_id") +add_todo.add_argument("todo_id") +add_todo.add_argument("--kind", required=True) +add_todo.add_argument("--summary", required=True) +add_todo.add_argument("--origin", default="orchestrator") +add_todo.set_defaults(func=cmd_add_todo) + +todo_status = sub.add_parser("todo-status") +todo_status.add_argument("workflow_id") +todo_status.add_argument("todo_id") +todo_status.add_argument("status") +todo_status.add_argument("--assignment-id", default="") +todo_status.set_defaults(func=cmd_todo_status) + +resolve = sub.add_parser("resolve-todo") +resolve.add_argument("workflow_id") +resolve.add_argument("todo_id") +resolve.add_argument("--resolution", required=True) +resolve.add_argument("--evidence", required=True) +resolve.add_argument("--reason-code", default="") +resolve.add_argument("--reason", default="") +resolve.add_argument("--authority", default="") +resolve.add_argument("--destination", default="") +resolve.add_argument("--resume-condition", default="") +resolve.set_defaults(func=cmd_resolve_todo) + +review = sub.add_parser("record-review") +review.add_argument("workflow_id") +review.add_argument("review_id") +review.add_argument("--type", required=True) +review.add_argument("--verdict", required=True) +review.add_argument("--diff-hash", default="") +review.add_argument("--evidence", required=True) +review.set_defaults(func=cmd_record_review) + +gate = sub.add_parser("gate") +gate.add_argument("workflow_id") +gate.add_argument("gate", choices=["implementation", "completion"]) +gate.add_argument("--decision-id", default="") +gate.add_argument("--plan-id", default="") +gate.set_defaults(func=cmd_gate) + +complete = sub.add_parser("completion-check") +complete.add_argument("workflow_id") +complete.set_defaults(func=lambda a: print(f"completion ready\t{a.workflow_id}\t{completion_check(a.workflow_id)['candidate_diff_hash']}")) + +value = sub.add_parser("value") +value.add_argument("workflow_id") +value.add_argument("key") +value.set_defaults(func=cmd_value) + + +def main(argv=None): + """Run the workflow command-line interface.""" + args = parser.parse_args(argv) + args.func(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/orchestrator_prompt.md b/orchestrator_prompt.md index 44ae1c5..5078223 100644 --- a/orchestrator_prompt.md +++ b/orchestrator_prompt.md @@ -36,10 +36,12 @@ Modules: - Scope guard role template: `$PROMPT_DIR/prompts/roles/scope-guard.md` - Validation coordinator role template: `$PROMPT_DIR/prompts/roles/validation-coordinator.md` - Organizational learning roles: `$PROMPT_DIR/prompts/roles/organizational-learning.md` +- Decision authority reviewer: `$PROMPT_DIR/prompts/roles/decision-authority-reviewer.md` - Intent and contract playbook: `$PROMPT_DIR/prompts/playbooks/intent-contract.md` - Parallel execution playbook: `$PROMPT_DIR/prompts/playbooks/parallel-execution.md` - Validation scheduling playbook: `$PROMPT_DIR/prompts/playbooks/validation-scheduling.md` - Finding todo loop playbook: `$PROMPT_DIR/prompts/playbooks/finding-todo-loop.md` +- Implementation lifecycle playbook: `$PROMPT_DIR/prompts/playbooks/implementation-lifecycle.md` - Agent spawning playbook: `$PROMPT_DIR/prompts/playbooks/agent-spawning.md` - Orchestration routing playbook: `$PROMPT_DIR/prompts/playbooks/orchestration-routing.md` - DAG workflow playbook: `$PROMPT_DIR/prompts/playbooks/dag.md` @@ -62,6 +64,20 @@ Default to broad safe fan-out across independent owned paths. Load `$PROMPT_DIR/prompts/playbooks/parallel-execution.md` before planning parallel waves, competing explorations, or blocked-subtree routing. +## Mandatory Lifecycle + +The launcher includes `prompts/playbooks/implementation-lifecycle.md` in the +initial prompt. Treat it as the canonical phase and authority workflow. Read +the persisted lifecycle state and use `bin/workflow.sh` for transitions, +reviews, TODO convergence, and completion; do not bypass it with a direct +writable worker launch. + +Every post-implementation finding returns through pre-implementation TODO +analysis, evidence collection, decision ownership, and a revised decision +implementation context before another iteration. You own convergence and +reversible routing details. The user owns the substantive choices identified +by the lifecycle policy. Agent agreement is evidence, not authority. + ## Session Variables The launch script exports: @@ -71,8 +87,10 @@ The launch script exports: - `MULTIAGENT_RESUME`: `0` for clean launch, `1` for explicit resume mode. - `MULTIAGENT_PROMPT`: path to this prompt. - `MULTIAGENT_STATE_DIR`: durable subagent and assignment state. +- `MULTIAGENT_WORKFLOW_ID`: active durable implementation lifecycle. +- `MULTIAGENT_LIFECYCLE_ENFORCEMENT`: normal-path lifecycle gates (`1` by default). - `MULTIAGENT_WRITE_POLICY`: outside-write allowlist. -- `MULTIAGENT_VERIFIER_MAX_ITERATIONS`: accepted worker/verifier follow-up cap, default `3`. +- `MULTIAGENT_VERIFIER_MAX_ITERATIONS`: escalation threshold, default `3`; never an acceptance condition. - `ORCHESTRATOR_CLI`: CLI used for this orchestrator, default `codex`. - `WORKER_CLI`: CLI to use when manually spawning worker windows, default `claude`. - `SUBAGENT_CLI`: CLI used by `bin/subagent.sh spawn`, defaults to `WORKER_CLI`. @@ -137,6 +155,11 @@ selection. Core routing rules: +- Before any consequential or uncertain implementation decision, run the + independent decision authority reviewer. Ask the user before committing a + user-owned decision and preserve the complete approved implementation + context in every writable worker instruction. + - Use `prompts/roles/contract-scout.md` before implementation when user intent, proxy/scaffold, target-system, or broad contract risk is material. - Use `prompts/roles/acceptance-scout.md` before implementation when a patch diff --git a/prompts/playbooks/agent-spawning.md b/prompts/playbooks/agent-spawning.md index 641c2a6..40803e4 100644 --- a/prompts/playbooks/agent-spawning.md +++ b/prompts/playbooks/agent-spawning.md @@ -10,6 +10,8 @@ task-specific assignment. Also pass assignment ID, branch, owned paths, task statement, and the relevant contract ledger. For high-risk coding tasks, include the contract scout's `must-preserve` list and validation plan. The worker module contains shared worker rules and Ponytail implementation discipline. +For lifecycle-enforced exploitation work, also include the active workflow, +decision, plan, decision revision, and the complete approved implementation context. When the scout emits `historical-contract-ledger:`, copy that block verbatim into every implementation, repair, and verifier assignment. Do not replace it with a narrower locked hypothesis. Worker ownership and done criteria must @@ -23,6 +25,10 @@ Before spawning a worker, create durable assignment metadata: ```bash bin/subagent.sh assignment-create worker-01-task \ --assignment-id ASSIGNMENT_ID \ + --role exploitation \ + --workflow-id "$MULTIAGENT_WORKFLOW_ID" \ + --decision-id DECISION_ID \ + --plan-id PLAN_ID \ --branch BRANCH \ --owned PATH[,PATH...] bin/subagent.sh worktree-create worker-01-task @@ -115,11 +121,10 @@ Use the configurable iteration cap: MAX_ITERATIONS="${MULTIAGENT_VERIFIER_MAX_ITERATIONS:-3}" ``` -Stop the worker/verifier loop when the verifier suggests no follow-up, the -orchestrator accepts no follow-up, or the accepted follow-up count reaches -`MAX_ITERATIONS`. If the final allowed verifier pass still produces findings -you would otherwise accept, explicitly accept with residual risk, reject, or ask -the user. +Stop the worker/verifier loop only when no accepted follow-up remains. Reaching +`MAX_ITERATIONS` is an escalation threshold: reconsider the route, surface a +blocker, or ask the user. It never permits acceptance while required work or +unanswered user-owned decisions remain. The verifier module requires a verifier contract ledger, source-derived hidden-contract probes, assumption challenges, and the instruction to Run a diff --git a/prompts/playbooks/implementation-lifecycle.md b/prompts/playbooks/implementation-lifecycle.md new file mode 100644 index 0000000..18fae32 --- /dev/null +++ b/prompts/playbooks/implementation-lifecycle.md @@ -0,0 +1,162 @@ +# Implementation Lifecycle Playbook + +This playbook is mandatory for every orchestrated task. The launcher includes +it in the orchestrator's initial prompt. It is the canonical authority for task +phases, transitions, TODO convergence, and completion; role and routing +playbooks must not weaken its gates. + +## Durable State + +Read the active workflow before routing work: + +```bash +bin/workflow.sh status "$MULTIAGENT_WORKFLOW_ID" +``` + +Do not infer the current phase from conversation history. Use the persisted +phase and record every transition with `bin/workflow.sh transition`. + +## Phase Machine + +The only normal lifecycle is: + +```text +pre-implementation -> implementation -> post-implementation +post-implementation -> pre-implementation when active TODOs remain +post-implementation -> complete when terminal gates pass +``` + +Never route a post-implementation finding directly to implementation. Add it to +the TODO queue, return to pre-implementation, and reconsider evidence, +decisions, authority, and the approved implementation context first. + +## Pre-Implementation + +For every active TODO, determine whether it is: + +- direct implementation under an already approved contract; +- factual uncertainty requiring bounded evidence collection; or +- a choice requiring a decision and authority classification. + +Group TODOs that depend on the same choice. Record alternatives, assumptions, +evidence, and the proposed choice. Evidence collection must state its question, +sources, expected signal, and stop condition. + +Use `bin/decision.sh` for durable alternatives, assumptions, the committed plan, +and later reflection. The lifecycle record is the phase/authority gate around +that decision ledger; it does not replace the ledger. + +A decision is user-owned when it changes public behavior or contracts, roles or +responsibilities, persisted state or migration, security or trust boundaries, +destructive or difficult-to-reverse behavior, material scope or cost, or a +prior explicit user decision. Treat uncertain authority as user-owned. Evidence +may clarify a choice but does not transfer authority. + +For consequential or uncertain decisions, run the independent +`decision-authority-reviewer` role. It must check both the proposed authority +and whether the TODOs or proposed assignment contain omitted decisions. Ask the +user before committing any user-owned decision. + +Spawn that review read-only through the normal subagent path, for example: + +```bash +SUBAGENT_CLI="$VERIFIER_CLI" bin/subagent.sh spawn decision-authority-reviewer \ + --role reviewer --instruction-file AUTHORITY_REVIEW_INPUT +``` + +Create an approved implementation context document containing the selected +plan, decision and plan IDs, authority and approval basis, intended outcome, +rejected alternatives and reasons, must-do and must-not-do constraints, migration choice, +responsibility boundary, affected paths, unresolved questions, and revision. +Commit the selected alternative with `bin/decision.sh commit`, then record the +passed authority review and approved context with: + +```bash +bin/workflow.sh prepare-implementation "$MULTIAGENT_WORKFLOW_ID" \ + --decision-id DECISION_ID \ + --plan-id PLAN_ID \ + --decision-revision REVISION \ + --implementation-context CONTEXT_PATH \ + --authority-review REVIEW_ID +bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" implementation +``` + +Do not leave active evidence or decision TODOs when entering implementation. +Direct implementation TODOs may remain active and must be assigned to bounded +workers. + +## Implementation + +Spawn bounded exploitation workers only after the implementation gate passes. +Every assignment must reference the active workflow, decision, and plan. The +worker's first instruction must contain the complete current approved +implementation context; a decision ID alone is insufficient. + +Do not silently change the approved plan. A newly discovered choice or factual +uncertainty becomes a TODO and returns through pre-implementation. + +When implementation stops, capture worker output, stop or freeze every writer, +record the candidate diff hash, and enter post-implementation: + +```bash +bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" post-implementation \ + --diff-hash DIFF_HASH +``` + +## Post-Implementation + +Run independent reviews against the frozen candidate diff: + +- `decision-drift`: compare the diff to the authorized implementation context; +- `scope`: check scope, simplicity, ownership, and unnecessary complexity; +- `technical`: verify behavior and the accepted contract; +- `reflection`: compare expected and actual results and identify improvements. + +Record each review with `bin/workflow.sh record-review`. Every actionable +finding must be added with `bin/workflow.sh add-todo`; a review with findings is +not a terminal review. + +Technical verifier findings must also use the existing structured +`finding-create -> todo-create -> resolution-create -> todo-close` protocol in +`prompts/playbooks/finding-todo-loop.md`. Mirror each accepted repair item into +the lifecycle queue using the finding or TODO ID as `--origin`. Resolve the +lifecycle item only after the structured repair evidence passes. The lifecycle +queue governs iteration and decision reconsideration; the structured finding +store governs technical closure. + +Resolve a TODO only as: + +- `completed`, with implementation and validation evidence; or +- `skipped`, with `out-of-scope` or `unavailable-now`, a concrete reason, + evidence, deciding authority, and a destination or resume condition when the + work remains relevant. + +Do not use a skip to weaken the accepted contract. User approval is required +to skip a user-owned requirement or accept user-visible residual risk. + +If active TODOs remain, return to pre-implementation: + +```bash +bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" pre-implementation +``` + +This increments the iteration and invalidates the prior implementation permit. + +## Completion + +Complete only when every TODO is completed or validly skipped, no user-owned +decision is unanswered, and all four required reviews pass against the current +candidate diff hash: + +```bash +bin/workflow.sh completion-check "$MULTIAGENT_WORKFLOW_ID" +bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" complete +bin/orchestrator.sh complete +``` + +The final command also runs `bin/subagent.sh gate-check`, so lifecycle reviews +cannot substitute for hash-bound technical finding and TODO closure. + +`MULTIAGENT_VERIFIER_MAX_ITERATIONS` is an escalation threshold, not an +acceptance condition. At the threshold, reconsider the route, surface a +blocker, or ask the user. Never accept merely because the threshold was reached. diff --git a/prompts/playbooks/orchestration-routing.md b/prompts/playbooks/orchestration-routing.md index 7057a2e..180c11e 100644 --- a/prompts/playbooks/orchestration-routing.md +++ b/prompts/playbooks/orchestration-routing.md @@ -4,6 +4,11 @@ Use this playbook when the orchestrator must decide which specialist role or workflow to run next. Keep the core orchestrator prompt focused on intent, ownership, and decisions; load these details only when routing work. +All implementation routing occurs inside the persisted lifecycle from +`prompts/playbooks/implementation-lifecycle.md`: pre-implementation authority +review, bounded implementation, independent post-implementation reviews, then +either completion or a TODO-driven return to pre-implementation. + Before implementation, load `prompts/playbooks/intent-contract.md` if the contract is ambiguous or proxy/scaffold risk is present. Before planning multi-worker waves or competing explorations, load @@ -117,6 +122,11 @@ findings become todo queue items with done criteria, and a todo is retired only through `bin/subagent.sh todo-close ...` after a verifier accepts the worker's resolution evidence. +Mirror every accepted follow-up into the lifecycle TODO queue. If any active +lifecycle TODO remains, return from post-implementation to pre-implementation +before spawning another writable worker so evidence and decision ownership are +re-evaluated. + If a worker reports `required-path-outside-owned:` or otherwise names an exact source path needed outside its owned paths, treat that as a blocking finding/todo input. The next repair assignment must include those exact paths in `--owned` diff --git a/prompts/roles/decision-authority-reviewer.md b/prompts/roles/decision-authority-reviewer.md new file mode 100644 index 0000000..e10d714 --- /dev/null +++ b/prompts/roles/decision-authority-reviewer.md @@ -0,0 +1,39 @@ +# Decision Authority Reviewer + +You are an independent read-only governance reviewer. You do not implement, +select a user-owned option, edit decision records, approve skips, or coordinate +workers. + +Review the original user request and follow-ups, relevant prior user or wiki +decisions, repository evidence, active TODOs, proposed decisions and +alternatives, and the proposed approved implementation context. Do not rely +only on the orchestrator's summary when primary evidence is available. + +Determine: + +- whether each consequential choice is explicitly recorded; +- whether bounded evidence collection could remove factual uncertainty; +- whether each decision is orchestrator-owned or user-owned; +- whether the proposed worker assignment embeds an unrecorded choice; and +- whether the implementation context faithfully preserves the approved contract. + +User-owned triggers include public behavior or contracts, roles or +responsibilities, persisted state or migration, security or trust boundaries, +destructive or difficult-to-reverse behavior, material scope or cost, and +conflict with a prior explicit user decision. Treat uncertain authority as +user-owned. + +Return only: + +1. `verdict:` `orchestrator-may-decide`, `user-choice-required`, or + `insufficient-context`. +2. `authority-findings:` each decision, owner, trigger, and evidence. +3. `omitted-decisions:` consequential choices not represented in the records. +4. `evidence-requests:` bounded questions, sources, expected signals, and stop + conditions. +5. `user-question:` compact alternatives and tradeoffs when user choice is + required; otherwise `none`. + +Do not use agent agreement or majority preference as authority. A passing +review means the orchestrator may proceed under the recorded authority; it is +not approval of a user-owned choice. diff --git a/tests/lifecycle.sh b/tests/lifecycle.sh new file mode 100755 index 0000000..d5fd457 --- /dev/null +++ b/tests/lifecycle.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +TEST_TMP="$(mktemp -d)" +trap 'rm -rf "$TEST_TMP"' EXIT + +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$FRAMEWORK_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + python3 -c 'from multiagent_framework.workflow import main; assert callable(main)' + +assert_contains() { + local file="$1" + local expected="$2" + grep -Fq -- "$expected" "$file" || { + echo "expected $file to contain: $expected" >&2 + exit 1 + } +} + +TEST_REPO="$TEST_TMP/repo" +TEST_STATE="$TEST_TMP/state" +mkdir -p "$TEST_REPO" "$TEST_STATE" +git -C "$TEST_REPO" init -q +git -C "$TEST_REPO" config user.email test@example.com +git -C "$TEST_REPO" config user.name "Lifecycle Test" +git -C "$TEST_REPO" config commit.gpgsign false +printf 'test\n' >"$TEST_REPO/README.md" +git -C "$TEST_REPO" add README.md +git -C "$TEST_REPO" commit -q -m initial +TEST_BRANCH="$(git -C "$TEST_REPO" branch --show-current)" + +IMPLEMENTATION_CONTEXT="$TEST_TMP/approved-implementation-context.md" +printf '%s\n' \ + '# Approved implementation context' \ + 'decision: DEC-1' \ + 'plan: PLAN-1' \ + 'authority: orchestrator' \ + 'must-not-do: change public behavior' >"$IMPLEMENTATION_CONTEXT" + +PROMPT_BUNDLE="$TEST_TMP/orchestrator-bundle.md" +"$FRAMEWORK_ROOT/bin/prompt-bundle.sh" \ + --orchestrator "$FRAMEWORK_ROOT/orchestrator_prompt.md" \ + --lifecycle "$FRAMEWORK_ROOT/prompts/playbooks/implementation-lifecycle.md" \ + --output "$PROMPT_BUNDLE" >/dev/null +assert_contains "$PROMPT_BUNDLE" "BEGIN MANDATORY IMPLEMENTATION LIFECYCLE" +assert_contains "$PROMPT_BUNDLE" "post-implementation -> pre-implementation" + +wf() { + MULTIAGENT_STATE_DIR="$TEST_STATE" "$FRAMEWORK_ROOT/bin/workflow.sh" "$@" +} + +wf init WF-LIFECYCLE >/dev/null +MULTIAGENT_STATE_DIR="$TEST_STATE" "$FRAMEWORK_ROOT/bin/decision.sh" init DEC-1 \ + --title "Lifecycle decision" --owner orchestrator >/dev/null +MULTIAGENT_STATE_DIR="$TEST_STATE" "$FRAMEWORK_ROOT/bin/decision.sh" add-alternative DEC-1 \ + --plan-id PLAN-1 --summary "Implement approved lifecycle plan" \ + --proposed-by orchestrator >/dev/null +MULTIAGENT_STATE_DIR="$TEST_STATE" "$FRAMEWORK_ROOT/bin/decision.sh" commit DEC-1 \ + --selected-plan PLAN-1 --reason "Authority review and evidence support this plan" >/dev/null +if wf transition WF-LIFECYCLE implementation >"$TEST_TMP/no-permit.out" 2>&1; then + echo "expected implementation without a permit to fail" >&2 + exit 1 +fi +assert_contains "$TEST_TMP/no-permit.out" "implementation gate has not passed" + +wf record-review WF-LIFECYCLE AUTH-1 \ + --type decision-authority --verdict pass \ + --evidence "independent authority review passed" >/dev/null +wf add-todo WF-LIFECYCLE TODO-EVIDENCE \ + --kind evidence --summary "inspect persisted state" >/dev/null +if wf prepare-implementation WF-LIFECYCLE \ + --decision-id DEC-1 --plan-id PLAN-1 --decision-revision 1 \ + --implementation-context "$IMPLEMENTATION_CONTEXT" --authority-review AUTH-1 \ + >"$TEST_TMP/evidence-open.out" 2>&1; then + echo "expected active evidence TODO to block implementation" >&2 + exit 1 +fi +assert_contains "$TEST_TMP/evidence-open.out" "active evidence/decision TODOs" +wf resolve-todo WF-LIFECYCLE TODO-EVIDENCE \ + --resolution completed --evidence "state inspected" >/dev/null +wf prepare-implementation WF-LIFECYCLE \ + --decision-id DEC-1 --plan-id PLAN-1 --decision-revision 1 \ + --implementation-context "$IMPLEMENTATION_CONTEXT" --authority-review AUTH-1 >/dev/null +wf transition WF-LIFECYCLE implementation >/dev/null + +MULTIAGENT_ROOT="$TEST_REPO" MULTIAGENT_STATE_DIR="$TEST_STATE" \ + MULTIAGENT_WORKFLOW_ID=WF-LIFECYCLE MULTIAGENT_LIFECYCLE_ENFORCEMENT=1 \ + "$FRAMEWORK_ROOT/bin/subagent.sh" assignment-create worker-lifecycle \ + --assignment-id LIFE-1 --role exploitation \ + --workflow-id WF-LIFECYCLE --decision-id DEC-1 --plan-id PLAN-1 \ + --branch "$TEST_BRANCH" --owned README.md >/dev/null +assert_contains "$TEST_STATE/assignments/worker-lifecycle/assignment.env" "decision_revision=1" +assert_contains "$TEST_STATE/assignments/worker-lifecycle/assignment.env" "implementation_context_sha256=" + +printf '\ncontext drift\n' >>"$IMPLEMENTATION_CONTEXT" +if wf gate WF-LIFECYCLE implementation --decision-id DEC-1 --plan-id PLAN-1 \ + >"$TEST_TMP/context-drift.out" 2>&1; then + echo "expected changed implementation context to invalidate the implementation gate" >&2 + exit 1 +fi +assert_contains "$TEST_TMP/context-drift.out" "approved implementation context changed" + +SKIP_STATE="$TEST_TMP/skip-state" +MULTIAGENT_STATE_DIR="$SKIP_STATE" "$FRAMEWORK_ROOT/bin/workflow.sh" init WF-SKIP >/dev/null +MULTIAGENT_STATE_DIR="$SKIP_STATE" "$FRAMEWORK_ROOT/bin/workflow.sh" add-todo WF-SKIP TODO-SKIP \ + --kind evidence --summary "requires unavailable environment" >/dev/null +if MULTIAGENT_STATE_DIR="$SKIP_STATE" "$FRAMEWORK_ROOT/bin/workflow.sh" resolve-todo WF-SKIP TODO-SKIP \ + --resolution skipped --reason-code unavailable-now --reason "environment unavailable" \ + --authority orchestrator --evidence "probe failed" >"$TEST_TMP/invalid-skip.out" 2>&1; then + echo "expected unavailable-now skip without destination to fail" >&2 + exit 1 +fi +assert_contains "$TEST_TMP/invalid-skip.out" "requires --destination or --resume-condition" + +LOOP_STATE="$TEST_TMP/loop-state" +LOOP_CONTEXT="$TEST_TMP/loop-implementation-context.md" +printf 'revision 1\n' >"$LOOP_CONTEXT" +loop() { + MULTIAGENT_STATE_DIR="$LOOP_STATE" "$FRAMEWORK_ROOT/bin/workflow.sh" "$@" +} +loop init WF-LOOP >/dev/null +MULTIAGENT_STATE_DIR="$LOOP_STATE" "$FRAMEWORK_ROOT/bin/decision.sh" init DEC-LOOP \ + --title "Loop decision" --owner orchestrator >/dev/null +MULTIAGENT_STATE_DIR="$LOOP_STATE" "$FRAMEWORK_ROOT/bin/decision.sh" add-alternative DEC-LOOP \ + --plan-id PLAN-LOOP --summary "Implement and re-evaluate findings" \ + --proposed-by orchestrator >/dev/null +MULTIAGENT_STATE_DIR="$LOOP_STATE" "$FRAMEWORK_ROOT/bin/decision.sh" commit DEC-LOOP \ + --selected-plan PLAN-LOOP --reason "Recorded lifecycle plan" >/dev/null +loop record-review WF-LOOP AUTH-LOOP \ + --type decision-authority --verdict pass --evidence "authority passed" >/dev/null +loop prepare-implementation WF-LOOP \ + --decision-id DEC-LOOP --plan-id PLAN-LOOP --decision-revision 1 \ + --implementation-context "$LOOP_CONTEXT" --authority-review AUTH-LOOP >/dev/null +loop transition WF-LOOP implementation >/dev/null +loop transition WF-LOOP post-implementation --diff-hash DIFF-LOOP >/dev/null +loop record-review WF-LOOP TECH-FINDING \ + --type technical --verdict findings --diff-hash DIFF-LOOP \ + --evidence "repair required" >/dev/null +loop add-todo WF-LOOP TODO-FOLLOWUP \ + --kind direct --summary "repair verifier finding" --origin TECH-FINDING >/dev/null +loop transition WF-LOOP pre-implementation >/dev/null +assert_contains "$LOOP_STATE/workflows/WF-LOOP/lifecycle/lifecycle.env" "iteration=2" + +loop record-review WF-LOOP AUTH-LOOP-2 \ + --type decision-authority --verdict pass --evidence "revised authority passed" >/dev/null +printf 'revision 2\n' >"$LOOP_CONTEXT" +loop prepare-implementation WF-LOOP \ + --decision-id DEC-LOOP --plan-id PLAN-LOOP --decision-revision 2 \ + --implementation-context "$LOOP_CONTEXT" --authority-review AUTH-LOOP-2 >/dev/null +loop transition WF-LOOP implementation >/dev/null +loop transition WF-LOOP post-implementation --diff-hash DIFF-FINAL >/dev/null +loop resolve-todo WF-LOOP TODO-FOLLOWUP \ + --resolution completed --evidence "repair and verifier recheck passed" >/dev/null +for review_type in decision-drift scope technical reflection; do + loop record-review WF-LOOP "REVIEW-$review_type" \ + --type "$review_type" --verdict pass --diff-hash DIFF-FINAL \ + --evidence "$review_type passed" >/dev/null +done +loop completion-check WF-LOOP >/dev/null +loop transition WF-LOOP complete >/dev/null +MULTIAGENT_ROOT="$TEST_REPO" MULTIAGENT_STATE_DIR="$LOOP_STATE" \ + MULTIAGENT_WORKFLOW_ID=WF-LOOP MULTIAGENT_RUN_ID=RUN-LIFECYCLE \ + MULTIAGENT_LIFECYCLE_ENFORCEMENT=1 \ + "$FRAMEWORK_ROOT/bin/orchestrator.sh" complete >"$TEST_TMP/complete.out" +assert_contains "$TEST_TMP/complete.out" $'run completed\tRUN-LIFECYCLE' + +echo "implementation lifecycle tests passed" diff --git a/tests/run.sh b/tests/run.sh index 5bea8db..c7ac6d5 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -260,7 +260,12 @@ assert_file_contains "$LAUNCH_BOOTSTRAP" "export WORKER_CLI=claude" assert_file_contains "$LAUNCH_BOOTSTRAP" "export SUBAGENT_CLI=claude" assert_file_contains "$LAUNCH_BOOTSTRAP" "export VERIFIER_CLI=codex" assert_file_contains "$LAUNCH_BOOTSTRAP" "Multiagent\\ launch\\ mode:" -assert_file_contains "$LAUNCH_BOOTSTRAP" "$(printf '%q' "$ROOT/orchestrator_prompt.md")" +assert_file_contains "$LAUNCH_BOOTSTRAP" "$(printf '%q' "$LAUNCH_STATE/runtime_state/orchestrator-prompt-bundle.md")" +assert_file_contains "$LAUNCH_BOOTSTRAP" "export MULTIAGENT_LIFECYCLE_ENFORCEMENT=1" +assert_file_contains "$LAUNCH_STATE/runtime_state/orchestrator-prompt-bundle.md" "BEGIN ORCHESTRATOR ROLE" +assert_file_contains "$LAUNCH_STATE/runtime_state/orchestrator-prompt-bundle.md" "BEGIN MANDATORY IMPLEMENTATION LIFECYCLE" +LAUNCH_WORKFLOW_ID="$(tr -d '\r\n' <"$LAUNCH_STATE/runtime_state/active-workflow-id")" +assert_file_contains "$LAUNCH_STATE/workflows/$LAUNCH_WORKFLOW_ID/lifecycle/lifecycle.env" "phase=pre-implementation" if grep -Fq "$LAUNCH_TARGET/orchestrator_prompt.md" "$MOCK_TMUX_LOG" "$TMPDIR/launch.out" "$LAUNCH_BOOTSTRAP"; then echo "expected launch to use script-dir orchestrator prompt, not target-root prompt" >&2 cat "$MOCK_TMUX_LOG" >&2 @@ -311,7 +316,9 @@ MOCK_TMUX_HAS_SESSION=0 \ MULTIAGENT_STATE_DIR="$TMPDIR/launch-explicit-state" \ MULTIAGENT_WRITE_POLICY="$TMPDIR/launch-explicit-policy/write-policy.paths" \ "$ROOT/launch.sh" --session launch-explicit-prompt --root "$LAUNCH_TARGET" --no-attach >"$TMPDIR/launch-explicit.out" -assert_file_contains "$TMPDIR/launch-explicit-state/orchestrator-bootstrap.sh" "$(printf '%q' "$EXPLICIT_PROMPT")" +assert_file_contains "$TMPDIR/launch-explicit-state/orchestrator-bootstrap.sh" "$(printf '%q' "$TMPDIR/launch-explicit-state/runtime_state/orchestrator-prompt-bundle.md")" +assert_file_contains "$TMPDIR/launch-explicit-state/runtime_state/orchestrator-prompt-bundle.md" "custom prompt" +assert_file_contains "$TMPDIR/launch-explicit-state/runtime_state/orchestrator-prompt-bundle.md" "BEGIN MANDATORY IMPLEMENTATION LIFECYCLE" REPAIR_STATE="$TMPDIR/repair-state" mkdir -p "$REPAIR_STATE" @@ -719,7 +726,11 @@ assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "Agent Spawning assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "Ponytail implementation discipline" assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "Ponytail over-engineering pass" assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "hidden-contract probes" -assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" 'verifier suggests no follow-up' +assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" 'MAX_ITERATIONS` is an escalation threshold' +if grep -Fq -- "accepted follow-up count reaches" "$ROOT/prompts/playbooks/agent-spawning.md"; then + echo "iteration threshold must not be an acceptance condition" >&2 + exit 1 +fi assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "todo-create" assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "todo-close" assert_file_contains "$ROOT/prompts/playbooks/agent-spawning.md" "gate-check" @@ -5987,3 +5998,4 @@ fi echo "DAG workflow tests passed" echo "organizational learning tests passed" +"$ROOT/tests/lifecycle.sh"