From 81adab42d276fe7db4bcdf495fecd715ec0a6215 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Wed, 12 Aug 2026 16:59:20 -0700 Subject: [PATCH 1/3] Enforce explicit decision ownership before implementation Multi-agent consensus could silently turn ambiguous, user-owned choices into implementation details, while post-review findings could be routed back to writers without another authority check. Bundle a durable lifecycle into the launcher, require a committed decision and complete approved capsule before writable workers start, return accepted TODOs through pre-implementation, and require current-diff governance reviews plus the existing technical gate before completion. This remains normal-path enforcement; the README documents the trusted-supervisor boundary needed for strict enforcement. --- README.md | 70 +- bin/orchestrator.sh | 51 ++ bin/prompt-bundle.sh | 63 ++ bin/subagent.sh | 88 ++- bin/workflow.sh | 625 ++++++++++++++++++ launch.sh | 68 +- orchestrator_prompt.md | 25 +- prompts/playbooks/agent-spawning.md | 15 +- prompts/playbooks/implementation-lifecycle.md | 162 +++++ prompts/playbooks/orchestration-routing.md | 10 + prompts/roles/decision-authority-reviewer.md | 39 ++ tests/lifecycle.sh | 164 +++++ tests/run.sh | 18 +- 13 files changed, 1370 insertions(+), 28 deletions(-) create mode 100755 bin/orchestrator.sh create mode 100755 bin/prompt-bundle.sh create mode 100755 bin/workflow.sh create mode 100644 prompts/playbooks/implementation-lifecycle.md create mode 100644 prompts/roles/decision-authority-reviewer.md create mode 100755 tests/lifecycle.sh diff --git a/README.md b/README.md index 3053f71..950ae74 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" --> Capsule["Approved decision capsule"] + UserDecision --> Capsule + Capsule --> 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,40 @@ 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/ +``` + +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 decision capsule, 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 capsule 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 \ + --decision-capsule CAPSULE_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 +134,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 +175,17 @@ an advanced path. ```bash tests/run.sh ``` + +## Enforcement Caveat + +Decision-authority review, decision-capsule 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, capsule 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..1c60ac8 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" +} + +lifecycle_assignment_capsule() { + 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 capsule + 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" + capsule="$(read_assignment_value "$name" decision_capsule || true)" + [[ -f "$capsule" ]] || die "assignment decision capsule is missing: $capsule" + printf '%s\n' "$capsule" +} + 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="" decision_capsule="" decision_capsule_sha256="" + if [[ "$role" == "exploitation" ]] && lifecycle_enforced; then + decision_revision="$(workflow_value "$workflow_id" decision_revision)" + decision_capsule="$(workflow_value "$workflow_id" decision_capsule)" + decision_capsule_sha256="$(workflow_value "$workflow_id" decision_capsule_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 +decision_capsule=$decision_capsule +decision_capsule_sha256=$decision_capsule_sha256 workflow_id=$workflow_id node_id=$node_id depends_on=$depends_on @@ -1303,6 +1362,16 @@ spawn_subagent() { fi fi + local lifecycle_capsule="" + if lifecycle_capsule="$(lifecycle_assignment_capsule "$name")"; then + [[ -n "$instruction_file" ]] || \ + die "lifecycle-enforced exploitation spawn requires --instruction-file with the complete decision capsule" + local required_capsule + required_capsule="$(cat "$lifecycle_capsule")" + [[ -n "$required_capsule" && "$instruction" == *"$required_capsule"* ]] || \ + die "exploitation instruction does not contain the complete approved decision capsule" + 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 lifecycle_capsule="" + if lifecycle_capsule="$(lifecycle_assignment_capsule "$name")"; then + instruction+=$'\n\n## Approved Decision Capsule\n\n' + instruction+="$(cat "$lifecycle_capsule")" + 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..96927b7 --- /dev/null +++ b/bin/workflow.sh @@ -0,0 +1,625 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${MULTIAGENT_ROOT:-$(pwd)}" +STATE_DIR="${MULTIAGENT_STATE_DIR:-$ROOT/.multiagent}" +export MULTIAGENT_ROOT="$ROOT" +export MULTIAGENT_STATE_DIR="$STATE_DIR" + +python3 - "$@" <<'PY' +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 + + +STATE_DIR = Path(os.environ["MULTIAGENT_STATE_DIR"]) +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 workflow_dir(workflow_id): + validate_id("workflow ID", workflow_id) + return 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", "decision_capsule", + "decision_capsule_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": "", + "decision_capsule": "", + "decision_capsule_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_capsule(state): + capsule_text = state.get("decision_capsule", "") + if not capsule_text: + die("implementation gate requires a decision capsule") + capsule = Path(capsule_text) + if not capsule.is_file(): + die(f"decision capsule is missing: {capsule}") + actual = sha256(capsule) + if actual != state.get("decision_capsule_sha256"): + die("decision capsule 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 = 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_capsule(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_capsule(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)) + capsule = Path(args.decision_capsule).resolve() + if not capsule.is_file(): + die(f"decision capsule not found: {capsule}") + state.update({ + "preimplementation_gate": "passed", + "decision_id": args.decision_id, + "plan_id": args.plan_id, + "decision_revision": args.decision_revision, + "decision_capsule": str(capsule), + "decision_capsule_sha256": sha256(capsule), + "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["decision_capsule"] = "" + state["decision_capsule_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['decision_capsule_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("--decision-capsule", 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) + +args = parser.parse_args() +args.func(args) +PY 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/orchestrator_prompt.md b/orchestrator_prompt.md index 44ae1c5..06a6b0b 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 +capsule before another implementation 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 decision capsule 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..355addf 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 decision capsule. 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..cf253ec --- /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 decision capsule 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 +``` + +Build a complete decision capsule 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 capsule with: + +```bash +bin/workflow.sh prepare-implementation "$MULTIAGENT_WORKFLOW_ID" \ + --decision-id DECISION_ID \ + --plan-id PLAN_ID \ + --decision-revision REVISION \ + --decision-capsule CAPSULE_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 decision capsule; +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 decision capsule; +- `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..c133511 --- /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 decision capsule. 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 capsule 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..d2848c2 --- /dev/null +++ b/tests/lifecycle.sh @@ -0,0 +1,164 @@ +#!/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 + +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)" + +CAPSULE="$TEST_TMP/decision-capsule.md" +printf '%s\n' \ + '# Decision capsule' \ + 'decision: DEC-1' \ + 'plan: PLAN-1' \ + 'authority: orchestrator' \ + 'must-not-do: change public behavior' >"$CAPSULE" + +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 \ + --decision-capsule "$CAPSULE" --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 \ + --decision-capsule "$CAPSULE" --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" "decision_capsule_sha256=" + +printf '\ncapsule drift\n' >>"$CAPSULE" +if wf gate WF-LIFECYCLE implementation --decision-id DEC-1 --plan-id PLAN-1 \ + >"$TEST_TMP/capsule-drift.out" 2>&1; then + echo "expected changed capsule to invalidate the implementation gate" >&2 + exit 1 +fi +assert_contains "$TEST_TMP/capsule-drift.out" "decision capsule 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_CAPSULE="$TEST_TMP/loop-capsule.md" +printf 'revision 1\n' >"$LOOP_CAPSULE" +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 \ + --decision-capsule "$LOOP_CAPSULE" --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_CAPSULE" +loop prepare-implementation WF-LOOP \ + --decision-id DEC-LOOP --plan-id PLAN-LOOP --decision-revision 2 \ + --decision-capsule "$LOOP_CAPSULE" --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" From b6ac554a7b953b1546436c7f34b53ef1230085ca Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Wed, 12 Aug 2026 17:11:43 -0700 Subject: [PATCH 2/3] Clarify approved implementation context terminology The previous capsule name obscured the boundary between structured lifecycle metadata and the semantic document handed to workers. Rename the document, state fields, CLI option, validator, prompts, and tests to approved implementation context. The helper is now validated_assignment_context_path because it validates assignment metadata and returns the context path rather than creating a container. --- README.md | 18 +++---- bin/subagent.sh | 42 ++++++++-------- bin/workflow.sh | 50 +++++++++---------- orchestrator_prompt.md | 6 +-- prompts/playbooks/agent-spawning.md | 2 +- prompts/playbooks/implementation-lifecycle.md | 18 +++---- prompts/roles/decision-authority-reviewer.md | 6 +-- tests/lifecycle.sh | 30 +++++------ 8 files changed, 86 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 950ae74..df3bc99 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ flowchart TD Pre --> Authority["Independent authority review"] Authority --> Choice{"User-owned decision?"} Choice -- "yes" --> UserDecision["Ask user and record choice"] - Choice -- "no" --> Capsule["Approved decision capsule"] - UserDecision --> Capsule - Capsule --> DAG["Assignments and dependency DAG"] + 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"] @@ -95,11 +95,11 @@ $MULTIAGENT_STATE_DIR/workflows/$MULTIAGENT_WORKFLOW_ID/lifecycle/ 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 decision capsule, not only a partial assignment -summary. Any accepted review finding creates a TODO and returns through +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 capsule and assignment. +committed decision whose selected plan matches the context and assignment. Inspect and advance the state with: @@ -107,7 +107,7 @@ Inspect and advance the state with: 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 \ - --decision-capsule CAPSULE_PATH --authority-review REVIEW_ID + --implementation-context CONTEXT_PATH --authority-review REVIEW_ID bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" implementation bin/workflow.sh completion-check "$MULTIAGENT_WORKFLOW_ID" ``` @@ -178,7 +178,7 @@ tests/run.sh ## Enforcement Caveat -Decision-authority review, decision-capsule handoff, lifecycle TODO convergence, +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 @@ -188,4 +188,4 @@ 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, capsule revision, and assignment scope before starting a worker. +approval, context revision, and assignment scope before starting a worker. diff --git a/bin/subagent.sh b/bin/subagent.sh index 1c60ac8..ce3a067 100755 --- a/bin/subagent.sh +++ b/bin/subagent.sh @@ -563,12 +563,12 @@ gate_implementation_assignment() { die "workflow implementation gate rejected assignment for workflow $workflow_id" } -lifecycle_assignment_capsule() { +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 capsule + 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)" @@ -579,9 +579,9 @@ lifecycle_assignment_capsule() { 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" - capsule="$(read_assignment_value "$name" decision_capsule || true)" - [[ -f "$capsule" ]] || die "assignment decision capsule is missing: $capsule" - printf '%s\n' "$capsule" + 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() { @@ -806,11 +806,11 @@ assignment_create() { start_commit="$(git -C "$ROOT" rev-parse "$start_commit^{commit}")" fi - local dir owned_file item normalized decision_revision="" decision_capsule="" decision_capsule_sha256="" + 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)" - decision_capsule="$(workflow_value "$workflow_id" decision_capsule)" - decision_capsule_sha256="$(workflow_value "$workflow_id" decision_capsule_sha256)" + 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" @@ -841,8 +841,8 @@ role=$role decision_id=$decision_id plan_id=$plan_id decision_revision=$decision_revision -decision_capsule=$decision_capsule -decision_capsule_sha256=$decision_capsule_sha256 +implementation_context=$implementation_context +implementation_context_sha256=$implementation_context_sha256 workflow_id=$workflow_id node_id=$node_id depends_on=$depends_on @@ -1362,14 +1362,14 @@ spawn_subagent() { fi fi - local lifecycle_capsule="" - if lifecycle_capsule="$(lifecycle_assignment_capsule "$name")"; then + 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 decision capsule" - local required_capsule - required_capsule="$(cat "$lifecycle_capsule")" - [[ -n "$required_capsule" && "$instruction" == *"$required_capsule"* ]] || \ - die "exploitation instruction does not contain the complete approved decision capsule" + 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 @@ -1653,10 +1653,10 @@ restore_subagent() { local instruction command instruction="$(restore_instruction "$name" "$prior_status" "$dir")" - local lifecycle_capsule="" - if lifecycle_capsule="$(lifecycle_assignment_capsule "$name")"; then - instruction+=$'\n\n## Approved Decision Capsule\n\n' - instruction+="$(cat "$lifecycle_capsule")" + 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" { diff --git a/bin/workflow.sh b/bin/workflow.sh index 96927b7..5e48676 100755 --- a/bin/workflow.sh +++ b/bin/workflow.sh @@ -110,8 +110,8 @@ def read_env(path): def write_env(path, data): order = [ "workflow_id", "phase", "iteration", "preimplementation_gate", - "decision_id", "plan_id", "decision_revision", "decision_capsule", - "decision_capsule_sha256", "authority_review_id", "candidate_diff_hash", + "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) @@ -167,8 +167,8 @@ def initial_state(workflow_id): "decision_id": "", "plan_id": "", "decision_revision": "", - "decision_capsule": "", - "decision_capsule_sha256": "", + "implementation_context": "", + "implementation_context_sha256": "", "authority_review_id": "", "candidate_diff_hash": "", "reviewed_diff_hash": "", @@ -220,16 +220,16 @@ def review_by_id(rows, review_id): return next((row for row in rows if row.get("review_id") == review_id), None) -def validate_capsule(state): - capsule_text = state.get("decision_capsule", "") - if not capsule_text: - die("implementation gate requires a decision capsule") - capsule = Path(capsule_text) - if not capsule.is_file(): - die(f"decision capsule is missing: {capsule}") - actual = sha256(capsule) - if actual != state.get("decision_capsule_sha256"): - die("decision capsule changed after pre-implementation approval") +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): @@ -264,7 +264,7 @@ def implementation_gate(workflow_id, expected_decision="", expected_plan="", all die(f"implementation gate requires phase=implementation, got {state.get('phase')}") if state.get("preimplementation_gate") != "passed": die("implementation gate has not passed") - validate_capsule(state) + 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: @@ -303,7 +303,7 @@ def completion_check(workflow_id): missing = required_post_reviews(p, state) if missing: die("completion requires passing current-diff reviews: " + ",".join(missing)) - validate_capsule(state) + validate_implementation_context(state) return state @@ -337,16 +337,16 @@ def cmd_prepare(args): ] if blockers: die("pre-implementation blocked by active evidence/decision TODOs: " + ",".join(blockers)) - capsule = Path(args.decision_capsule).resolve() - if not capsule.is_file(): - die(f"decision capsule not found: {capsule}") + 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, - "decision_capsule": str(capsule), - "decision_capsule_sha256": sha256(capsule), + "implementation_context": str(context), + "implementation_context_sha256": sha256(context), "authority_review_id": args.authority_review, "updated_at": now(), }) @@ -388,8 +388,8 @@ def cmd_transition(args): state["iteration"] = str(int(state.get("iteration", "1")) + 1) state["preimplementation_gate"] = "pending" state["decision_revision"] = "" - state["decision_capsule"] = "" - state["decision_capsule_sha256"] = "" + state["implementation_context"] = "" + state["implementation_context_sha256"] = "" state["authority_review_id"] = "" state["candidate_diff_hash"] = "" state["reviewed_diff_hash"] = "" @@ -524,7 +524,7 @@ def cmd_record_review(args): 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['decision_capsule_sha256']}") + 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']}") @@ -558,7 +558,7 @@ 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("--decision-capsule", required=True) +prepare.add_argument("--implementation-context", required=True) prepare.add_argument("--authority-review", required=True) prepare.set_defaults(func=cmd_prepare) diff --git a/orchestrator_prompt.md b/orchestrator_prompt.md index 06a6b0b..5078223 100644 --- a/orchestrator_prompt.md +++ b/orchestrator_prompt.md @@ -74,7 +74,7 @@ writable worker launch. Every post-implementation finding returns through pre-implementation TODO analysis, evidence collection, decision ownership, and a revised decision -capsule before another implementation iteration. You own convergence and +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. @@ -157,8 +157,8 @@ 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 decision capsule in - every writable worker instruction. + 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. diff --git a/prompts/playbooks/agent-spawning.md b/prompts/playbooks/agent-spawning.md index 355addf..40803e4 100644 --- a/prompts/playbooks/agent-spawning.md +++ b/prompts/playbooks/agent-spawning.md @@ -11,7 +11,7 @@ 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 decision capsule. +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 diff --git a/prompts/playbooks/implementation-lifecycle.md b/prompts/playbooks/implementation-lifecycle.md index cf253ec..18fae32 100644 --- a/prompts/playbooks/implementation-lifecycle.md +++ b/prompts/playbooks/implementation-lifecycle.md @@ -28,7 +28,7 @@ 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 decision capsule first. +decisions, authority, and the approved implementation context first. ## Pre-Implementation @@ -64,19 +64,19 @@ SUBAGENT_CLI="$VERIFIER_CLI" bin/subagent.sh spawn decision-authority-reviewer \ --role reviewer --instruction-file AUTHORITY_REVIEW_INPUT ``` -Build a complete decision capsule 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, +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 capsule with: +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 \ - --decision-capsule CAPSULE_PATH \ + --implementation-context CONTEXT_PATH \ --authority-review REVIEW_ID bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" implementation ``` @@ -89,8 +89,8 @@ workers. 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 decision capsule; -a decision ID alone is insufficient. +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. @@ -107,7 +107,7 @@ bin/workflow.sh transition "$MULTIAGENT_WORKFLOW_ID" post-implementation \ Run independent reviews against the frozen candidate diff: -- `decision-drift`: compare the diff to the authorized decision capsule; +- `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. diff --git a/prompts/roles/decision-authority-reviewer.md b/prompts/roles/decision-authority-reviewer.md index c133511..e10d714 100644 --- a/prompts/roles/decision-authority-reviewer.md +++ b/prompts/roles/decision-authority-reviewer.md @@ -6,8 +6,8 @@ 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 decision capsule. Do not rely only on the -orchestrator's summary when primary evidence is available. +alternatives, and the proposed approved implementation context. Do not rely +only on the orchestrator's summary when primary evidence is available. Determine: @@ -15,7 +15,7 @@ Determine: - 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 capsule faithfully preserves the approved contract. +- 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, diff --git a/tests/lifecycle.sh b/tests/lifecycle.sh index d2848c2..b768a53 100755 --- a/tests/lifecycle.sh +++ b/tests/lifecycle.sh @@ -26,13 +26,13 @@ git -C "$TEST_REPO" add README.md git -C "$TEST_REPO" commit -q -m initial TEST_BRANCH="$(git -C "$TEST_REPO" branch --show-current)" -CAPSULE="$TEST_TMP/decision-capsule.md" +IMPLEMENTATION_CONTEXT="$TEST_TMP/approved-implementation-context.md" printf '%s\n' \ - '# Decision capsule' \ + '# Approved implementation context' \ 'decision: DEC-1' \ 'plan: PLAN-1' \ 'authority: orchestrator' \ - 'must-not-do: change public behavior' >"$CAPSULE" + 'must-not-do: change public behavior' >"$IMPLEMENTATION_CONTEXT" PROMPT_BUNDLE="$TEST_TMP/orchestrator-bundle.md" "$FRAMEWORK_ROOT/bin/prompt-bundle.sh" \ @@ -67,7 +67,7 @@ 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 \ - --decision-capsule "$CAPSULE" --authority-review AUTH-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 @@ -77,7 +77,7 @@ 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 \ - --decision-capsule "$CAPSULE" --authority-review AUTH-1 >/dev/null + --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" \ @@ -87,15 +87,15 @@ MULTIAGENT_ROOT="$TEST_REPO" MULTIAGENT_STATE_DIR="$TEST_STATE" \ --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" "decision_capsule_sha256=" +assert_contains "$TEST_STATE/assignments/worker-lifecycle/assignment.env" "implementation_context_sha256=" -printf '\ncapsule drift\n' >>"$CAPSULE" +printf '\ncontext drift\n' >>"$IMPLEMENTATION_CONTEXT" if wf gate WF-LIFECYCLE implementation --decision-id DEC-1 --plan-id PLAN-1 \ - >"$TEST_TMP/capsule-drift.out" 2>&1; then - echo "expected changed capsule to invalidate the implementation gate" >&2 + >"$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/capsule-drift.out" "decision capsule changed" +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 @@ -110,8 +110,8 @@ fi assert_contains "$TEST_TMP/invalid-skip.out" "requires --destination or --resume-condition" LOOP_STATE="$TEST_TMP/loop-state" -LOOP_CAPSULE="$TEST_TMP/loop-capsule.md" -printf 'revision 1\n' >"$LOOP_CAPSULE" +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" "$@" } @@ -127,7 +127,7 @@ 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 \ - --decision-capsule "$LOOP_CAPSULE" --authority-review AUTH-LOOP >/dev/null + --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 \ @@ -140,10 +140,10 @@ assert_contains "$LOOP_STATE/workflows/WF-LOOP/lifecycle/lifecycle.env" "iterati loop record-review WF-LOOP AUTH-LOOP-2 \ --type decision-authority --verdict pass --evidence "revised authority passed" >/dev/null -printf 'revision 2\n' >"$LOOP_CAPSULE" +printf 'revision 2\n' >"$LOOP_CONTEXT" loop prepare-implementation WF-LOOP \ --decision-id DEC-LOOP --plan-id PLAN-LOOP --decision-revision 2 \ - --decision-capsule "$LOOP_CAPSULE" --authority-review AUTH-LOOP-2 >/dev/null + --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 \ From dca8df6bf6f64fad0bc3d80ca62623ecc5e28d96 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Wed, 12 Aug 2026 17:17:00 -0700 Subject: [PATCH 3/3] Make lifecycle implementation importable The embedded Python heredoc made workflow.sh look inert and prevented normal Python tooling, syntax highlighting, and imports. Move the state machine into multiagent_framework.workflow and keep workflow.sh as a compatibility launcher without changing commands or state format. --- README.md | 3 + bin/workflow.sh | 622 +----------------------------- multiagent_framework/workflow.py | 632 +++++++++++++++++++++++++++++++ tests/lifecycle.sh | 3 + 4 files changed, 643 insertions(+), 617 deletions(-) create mode 100644 multiagent_framework/workflow.py diff --git a/README.md b/README.md index df3bc99..fa3f309 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ records prompt hashes, and initializes durable lifecycle state under: $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 diff --git a/bin/workflow.sh b/bin/workflow.sh index 5e48676..2534861 100755 --- a/bin/workflow.sh +++ b/bin/workflow.sh @@ -3,623 +3,11 @@ 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}" -python3 - "$@" <<'PY' -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 - - -STATE_DIR = Path(os.environ["MULTIAGENT_STATE_DIR"]) -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 workflow_dir(workflow_id): - validate_id("workflow ID", workflow_id) - return 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 = 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) - -args = parser.parse_args() -args.func(args) -PY +exec python3 -m multiagent_framework.workflow "$@" 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/tests/lifecycle.sh b/tests/lifecycle.sh index b768a53..d5fd457 100755 --- a/tests/lifecycle.sh +++ b/tests/lifecycle.sh @@ -5,6 +5,9 @@ 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"