diff --git a/README.md b/README.md index 2aa5140..bde7b06 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,18 @@ A lightweight, model-agnostic repository template for agentic software engineeri `docs/PROJECT_REQUIREMENTS.md`. After you explicitly approve those requirements, a separate project-planning session creates the architecture, necessary ADRs, and roadmap. The script does not commit or push. -4. Review the planning worktree, run `./scripts/verify.sh`, commit and push the - planning branch, then merge it through a PR before feature development. +4. In the planning worktree, review and revise the planning until the review + passes, then approve it: + + ```bash + ./scripts/review-planning.sh + ./scripts/revise-planning.sh --review .agents/reviews/planning-project-bootstrap-review-01.json + ./scripts/review-planning.sh + ./scripts/finish-planning.sh + ``` + + Commit and push the planning branch as `finish-planning.sh` shows, then merge + it through a PR before feature development. 5. Create a GitHub Issue only for the next actionable roadmap feature, from its block in `docs/roadmap.md`: diff --git a/docs/agentic-workflow.md b/docs/agentic-workflow.md index 3e2461c..9a08740 100644 --- a/docs/agentic-workflow.md +++ b/docs/agentic-workflow.md @@ -69,12 +69,17 @@ Recommended sequence: - `docs/roadmap.md` - required ADRs under `docs/decisions/` -8. Review and verify the bootstrap artifacts. -9. Commit and push the planning branch. -10. Open a Pull Request. -11. Merge the approved bootstrap into `main`. -12. Convert only ready roadmap items into GitHub Issues. -13. Start feature clarification and development. +8. An independent agent reviews the planning (`review-planning.sh`). The + planner decides per finding and revises the adopted ones + (`revise-planning.sh`). Repeat until the review passes. +9. Approve the planning with `finish-planning.sh`, which records + `docs/PLANNING_APPROVAL.md`. +10. Verify, commit, and push the planning branch. +11. Open a Pull Request. +12. Merge the approved bootstrap into `main`. +13. Convert only ready roadmap items into GitHub Issues with + `create-feature-issue.sh`, which requires a current planning approval. +14. Start feature clarification and development. Declining requirements approval or an agent failure preserves the worktree and stops later phases. The script does not fall back to another model, implement diff --git a/docs/development.md b/docs/development.md index ab414a1..b30c009 100644 --- a/docs/development.md +++ b/docs/development.md @@ -150,17 +150,9 @@ Declining requirements approval or an agent failure stops the workflow and leaves the planning worktree intact. Inspect or revise it there; the script never substitutes another model or removes user work automatically. -After successful planning, review the artifacts and complete the process -manually: - -```bash -cd ../-planning-project-bootstrap -./scripts/verify.sh -git add docs/PROJECT_DESCRIPTION.md # only when --description was used -git add docs/PROJECT_REQUIREMENTS.md docs/architecture.md docs/roadmap.md docs/decisions -git commit -m "Plan project bootstrap" -git push -u origin planning/project-bootstrap -``` +After successful planning, continue in the planning worktree with the planning +review, revision, and approval described below. `finish-planning.sh` prints +the exact commit and push commands. ### Planning review @@ -218,6 +210,42 @@ without deciding again. After a revision the review is stale by design. Run `review-planning.sh` for the next round, and repeat until the review passes. +### Planning approval + +When the latest review round is resolved, approve the planning: + +```bash +./scripts/finish-planning.sh +``` + +The script refuses when a required document is missing, the requirements are +not approved, there is no planning review, the latest review is stale, the +latest round has a critical or major finding, a finding of the latest round +has no revision decision, an adopted finding is not applied yet, or a finding +of the latest round is escalated. It shows every rejected, deferred, and +escalated finding of all rounds, also before a refusal. A finding escalated in +an earlier round is not resolved by a newer review alone: you confirm that it +was resolved, and the approval records that. The approval covers exactly the +reviewed planning; a document that changes while the script waits for your +answer is refused. + +The approval is recorded in `docs/PLANNING_APPROVAL.md`, which is committed with +the planning: the time, the planning branch, the final review round, the +review rounds, the findings that were not adopted, and the fingerprint of the +planning documents. Use it for the planning PR description, because review +and revision files are not committed. + +Any later change to a planning document invalidates the approval: + +```bash +./scripts/finish-planning.sh --check +``` + +exits 0 when the approval still matches the documents and 1 when it is missing +or stale. `create-feature-issue.sh ` refuses to create a feature +Issue from a roadmap without a current approval, so a roadmap change after the +planning PR needs a new review round and approval. + Open and merge a planning PR before creating Issues for actionable roadmap features. The script never implements features, creates Issues, commits, pushes, opens or merges a PR, or deploys. diff --git a/scripts/create-feature-issue.sh b/scripts/create-feature-issue.sh index 1b70ce9..81a62e0 100755 --- a/scripts/create-feature-issue.sh +++ b/scripts/create-feature-issue.sh @@ -5,6 +5,10 @@ set -euo pipefail # Creates the GitHub Issue for one roadmap feature from its block in # docs/roadmap.md, or an Issue from an explicit title and body file. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +source "$script_dir/lib/fingerprint.sh" +source "$script_dir/lib/planning.sh" + usage() { echo "Usage:" echo " $0 " @@ -138,6 +142,15 @@ create_from_roadmap() { block="$(roadmap_section "$roadmap" "$feature" block)" [[ "$block" =~ [^[:space:]] ]] || fail "the roadmap block of $feature is empty." + # Features start only from a roadmap whose planning is approved as it is now. + tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/create-feature-issue.XXXXXX")" + trap 'rm -rf "$tmp_work"' EXIT + local approval_status=0 + local approval_reason + approval_reason="$(planning_approval_status "$root" "$tmp_work")" || approval_status=$? + [[ "$approval_status" -eq 0 ]] || + fail "$approval_reason Approve the planning with ./scripts/finish-planning.sh before creating feature Issues." + require_gh existing="$(issues_for_feature "$feature")" || @@ -148,9 +161,6 @@ create_from_roadmap() { exit 1 fi - # Global, so the exit trap can still remove it after this function returns. - tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/create-feature-issue.XXXXXX")" - trap 'rm -rf "$tmp_work"' EXIT { printf '%s\n' "$block" | awk 'NF { found = 1 } found' | awk '{ lines[NR] = $0 } END { last = NR; while (last > 0 && lines[last] !~ /[^[:space:]]/) last--; for (i = 1; i <= last; i++) print lines[i] }' echo diff --git a/scripts/finish-planning.sh b/scripts/finish-planning.sh new file mode 100755 index 0000000..a360a87 --- /dev/null +++ b/scripts/finish-planning.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +source "$script_dir/lib/review-data.sh" +source "$script_dir/lib/fingerprint.sh" +source "$script_dir/lib/planning.sh" + +usage() { + echo "Usage:" + echo " $0 Check the planning and record your approval (planning worktree)." + echo " $0 --check Report whether the recorded approval still matches the documents." + echo + echo "--check exits 0 when the approval is current, 1 when it is missing or stale," + echo "and 2 on an error." + exit 2 +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +root="$(git rev-parse --show-toplevel)" +review_data_require_jq +tmp_work="$(mktemp -d "${TMPDIR:-/tmp}/finish-planning.XXXXXX")" +trap 'rm -rf "$tmp_work"' EXIT + +case "$#:${1:-}" in + 0:) ;; + 1:--check) + status=0 + reason="$(planning_approval_status "$root" "$tmp_work")" || status=$? + case "$status" in + 0) echo "Current: $PLANNING_APPROVAL_FILE matches the planning documents." ;; + 1) echo "Not approved: $reason"; echo "Finish the planning again with ./scripts/finish-planning.sh in a planning worktree." ;; + *) echo "Error: $reason" >&2 ;; + esac + exit "$status" + ;; + *) usage ;; +esac + +branch="$(git branch --show-current)" +[[ "$branch" == planning/* ]] || + fail "finish-planning.sh must run in a planning worktree (branch planning/). Current branch: $branch" +name="${branch#planning/}" + +# 1. The planning documents exist and the requirements are approved. +for required in docs/PROJECT_REQUIREMENTS.md docs/architecture.md docs/roadmap.md; do + [[ -f "$root/$required" && ! -L "$root/$required" && -s "$root/$required" ]] || + fail "required planning document is missing or empty: $required" +done +grep -Fqx "Status: Approved" "$root/docs/PROJECT_REQUIREMENTS.md" || + fail "the project requirements are not approved." + +# 2. The latest planning review exists, is valid, and is current. +shopt -s nullglob +reviews=("$root/.agents/reviews/planning-${name}-review-"[0-9][0-9].json) +shopt -u nullglob +[[ "${#reviews[@]}" -gt 0 ]] || fail "no planning review exists. Run ./scripts/review-planning.sh first." +latest="${reviews[${#reviews[@]}-1]}" +latest_relative="${latest#"$root"/}" +review_errors="$(review_artifact_errors "$latest")" +[[ -z "$review_errors" ]] || fail "the latest planning review is invalid: ${review_errors//$'\n'/; }" +[[ "$(jq -r '.kind' "$latest")" == "planning" && "$(jq -r '.branch' "$latest")" == "$branch" ]] || + fail "$latest_relative is not a planning review of $branch." + +current=0 +review_is_current "$root" "$tmp_work" "$latest" || current=$? +case "$current" in + 0) ;; + 1) fail "the latest planning review ($latest_relative) is stale: a planning document changed after it. Run ./scripts/review-planning.sh again." ;; + *) fail "could not compute the fingerprint of the planning documents." ;; +esac + +# Every rejected, deferred, or escalated decision of every round, oldest first. +shopt -s nullglob +revisions=("$root/.agents/reviews/planning-${name}-review-"[0-9][0-9]-revision.json) +shopt -u nullglob +decisions_file="$tmp_work/decisions.json" +if [[ "${#revisions[@]}" -gt 0 ]]; then + jq -s '[.[] | .review_round as $round | .decisions[] + | select(.decision != "ADOPT") | . + {round: $round}]' "${revisions[@]}" >"$decisions_file" +else + echo '[]' >"$decisions_file" +fi + +round="$(jq -r '.round' "$latest")" +verdict="$(jq -r '.verdict' "$latest")" + +# Show every finding that was not adopted before any refusal, so the reasons +# are visible. +if [[ "$(jq 'length' "$decisions_file")" -gt 0 ]]; then + echo "Findings that were not adopted:" + jq -r '.[] | "- Round \(.round), \(.decision): \(.finding_id) [\(.severity)] \(.title)\n Rationale: \(.rationale)"' "$decisions_file" + echo +fi + +# 3. The latest round is resolved: no critical or major finding, every finding +# decided, nothing adopted but not yet applied, and nothing escalated. +blocking="$(jq '[.findings[] | select(.severity == "critical" or .severity == "major")] | length' "$latest")" +[[ "$blocking" -eq 0 ]] || + fail "the latest planning review (round $round) has $blocking critical or major finding(s). Revise with ./scripts/revise-planning.sh and review again." + +revision="${latest%.json}-revision.json" +if [[ "$(jq '.findings | length' "$latest")" -gt 0 ]]; then + [[ -f "$revision" ]] || + fail "the findings of round $round have no revision decisions. Run ./scripts/revise-planning.sh --review $latest_relative." + revision_errors="$(revision_artifact_errors "$revision" "$latest")" + [[ -z "$revision_errors" ]] || fail "the revision of round $round is invalid: ${revision_errors//$'\n'/; }" + [[ "$(jq '[.decisions[] | select(.decision == "ADOPT")] | length' "$revision")" -eq 0 ]] || + fail "round $round has adopted findings that are not applied yet. Run ./scripts/revise-planning.sh --review $latest_relative and review again." +fi + +escalated_now="$(jq --argjson round "$round" '[.[] | select(.round == $round and .decision == "ESCALATE")] | length' "$decisions_file")" +[[ "$escalated_now" -eq 0 ]] || + fail "round $round has $escalated_now escalated finding(s). Resolve them through Project Grill and a new review round, or decide that they do not apply." + +# 4. Escalations of earlier rounds need an explicit resolution by the human; +# a newer review alone does not resolve them. +earlier_escalations="$(jq -r --argjson round "$round" ' + .[] | select(.round < $round and .decision == "ESCALATE") | "- Round \(.round): \(.finding_id) \(.title)" +' "$decisions_file")" +if [[ -n "$earlier_escalations" ]]; then + echo "Escalated in earlier rounds:" + printf '%s\n' "$earlier_escalations" + printf "Was each of these resolved, by changing the requirements through Project Grill or by deciding that it does not apply? [y/N] " + resolved="" + read -r resolved || true + case "$resolved" in + y | Y | yes | YES) ;; + *) fail "the escalated findings of earlier rounds are not resolved." ;; + esac + echo +fi + +# 5. Ask for approval of exactly the reviewed planning. +reviewed_fingerprint="$(jq -r '.reviewed_tree' "$latest")" +echo "Planning ready for approval:" +echo " Branch: $branch" +echo " Final review: $latest_relative (round $round, ${verdict//_/ })" +echo + +printf "Approve this planning? [y/N] " +approval="" +read -r approval || true +case "$approval" in + y | Y | yes | YES) ;; + *) + echo "Declined; the planning approval was not recorded." + exit 0 + ;; +esac + +# 6. Record the approval for the reviewed planning, and only if it did not +# change while you were deciding. +fingerprint="$(fingerprint_files "$root" "$tmp_work" "${PLANNING_SCOPE[@]}")" || + fail "could not compute the fingerprint of the planning documents." +[[ "$fingerprint" == "$reviewed_fingerprint" ]] || + fail "a planning document changed after the final review; the approval was not recorded. Run ./scripts/review-planning.sh again." +{ + echo "# Planning Approval" + echo + echo "Status: Approved" + echo "Approved at: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" + echo "Planning branch: $branch" + echo "Final review: round $round, ${verdict//_/ }, by $(jq -r '"\(.reviewer.agent) (\(.reviewer.model))"' "$latest")" + echo "Planning fingerprint: $fingerprint" + echo + echo "Recorded by \`scripts/finish-planning.sh\`. The approval covers these planning" + echo "documents; any change to them invalidates it (\`./scripts/finish-planning.sh --check\`):" + echo + for path in "${PLANNING_SCOPE[@]}"; do + echo "- \`$path\`" + done + echo + echo "## Review rounds" + echo + for review in "${reviews[@]}"; do + jq -r '"- Round \(.round): \(.verdict | gsub("_"; " ")), \(.findings | length) finding(s), by \(.reviewer.agent) (\(.reviewer.model))"' "$review" + done + echo + if [[ -n "$earlier_escalations" ]]; then + echo "## Escalations confirmed as resolved" + echo + printf '%s\n' "$earlier_escalations" + echo + fi + echo "## Findings that were not adopted" + echo + if [[ "$(jq 'length' "$decisions_file")" -eq 0 ]]; then + echo "None." + else + jq -r '.[] | "- Round \(.round), \(.decision): \(.finding_id) [\(.severity)] \(.title). \(.rationale)"' "$decisions_file" + fi +} >"$root/$PLANNING_APPROVAL_FILE" + +echo +echo "Recorded the approval in $PLANNING_APPROVAL_FILE." +echo +echo "Next steps:" +echo " ./scripts/verify.sh" +to_add=() +for path in "${PLANNING_SCOPE[@]}"; do + [[ ! -e "$root/$path" ]] || to_add+=("$path") +done +echo " git add ${to_add[*]} $PLANNING_APPROVAL_FILE" +echo " git commit -m \"Plan project bootstrap\"" +echo " git push -u origin \"$branch\"" +echo +echo "Open the planning PR; $PLANNING_APPROVAL_FILE summarizes the review rounds and the" +echo "findings that were not adopted for its description. After the merge, create" +echo "feature Issues with ./scripts/create-feature-issue.sh ." diff --git a/scripts/lib/planning.sh b/scripts/lib/planning.sh new file mode 100644 index 0000000..ae492a6 --- /dev/null +++ b/scripts/lib/planning.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# The planning documents and the planning approval. +# Source this file after fingerprint.sh; do not execute it. + +# The planning scope: every document of the project planning. It names the +# optional description and the decisions directory even when they are absent, +# so adding, changing, or deleting any planning document changes its +# fingerprint. +PLANNING_SCOPE=(docs/PROJECT_DESCRIPTION.md docs/PROJECT_REQUIREMENTS.md docs/architecture.md docs/roadmap.md docs/decisions) +PLANNING_APPROVAL_FILE="docs/PLANNING_APPROVAL.md" + +# planning_approval_status +# Exits 0 when the recorded planning approval matches the current planning +# documents, 1 when it is missing or stale, and 2 when it is malformed. Prints +# the reason for a non-zero status. +planning_approval_status() { + local root="$1" + local scratch="$2" + local approval="$root/$PLANNING_APPROVAL_FILE" + local recorded + local current + + if [[ ! -f "$approval" ]]; then + echo "the planning has not been approved: $PLANNING_APPROVAL_FILE is missing." + return 1 + fi + grep -Fqx "Status: Approved" "$approval" || { + echo "$PLANNING_APPROVAL_FILE does not record an approval." + return 2 + } + recorded="$(sed -n 's/^Planning fingerprint: \([0-9a-f]\{40,64\}\)$/\1/p' "$approval")" + [[ "$(printf '%s' "$recorded" | grep -c .)" -eq 1 ]] || { + echo "$PLANNING_APPROVAL_FILE must contain exactly one valid planning fingerprint." + return 2 + } + current="$(fingerprint_files "$root" "$scratch" "${PLANNING_SCOPE[@]}")" || { + echo "could not compute the fingerprint of the planning documents." + return 2 + } + if [[ "$current" != "$recorded" ]]; then + echo "a planning document changed after the planning was approved." + return 1 + fi +} diff --git a/scripts/review-planning.sh b/scripts/review-planning.sh index f75e151..866b891 100755 --- a/scripts/review-planning.sh +++ b/scripts/review-planning.sh @@ -7,6 +7,7 @@ source "$script_dir/lib/agent.sh" source "$script_dir/lib/review-data.sh" source "$script_dir/lib/fingerprint.sh" source "$script_dir/lib/review-run.sh" +source "$script_dir/lib/planning.sh" fail() { echo "Error: $*" >&2 @@ -46,11 +47,8 @@ agent_resolve "$root" planning-reviewer "$AGENT_CLI_PROVIDER" "$AGENT_CLI_MODEL" agent="$AGENT_PROVIDER" model="$AGENT_MODEL" -# The reviewed scope: the planning documents, and nothing else. It names the -# optional description and the decisions directory even when they are absent -# or empty, so adding, changing, or deleting any planning document makes the -# review stale. -scope=(docs/PROJECT_DESCRIPTION.md docs/PROJECT_REQUIREMENTS.md docs/architecture.md docs/roadmap.md docs/decisions) +# The reviewed scope: the planning documents, and nothing else. +scope=("${PLANNING_SCOPE[@]}") # The documents whose contents the reviewer receives. paths=() @@ -180,5 +178,5 @@ echo if [[ "$(jq '.findings | length' "$out.json")" -gt 0 ]]; then echo "Next: ./scripts/revise-planning.sh --review $review_relative.json" else - echo "Next: commit the planning and open the planning PR as described in docs/development.md." + echo "Next: ./scripts/finish-planning.sh" fi diff --git a/scripts/revise-planning.sh b/scripts/revise-planning.sh index 7bfe2fe..28407ba 100755 --- a/scripts/revise-planning.sh +++ b/scripts/revise-planning.sh @@ -224,6 +224,7 @@ adopted="$(jq -r --slurpfile review "$review_path" ' if [[ -z "$adopted" ]]; then echo echo "No finding was adopted; no planning document is changed." + echo "Next: ./scripts/finish-planning.sh, once no escalated finding remains." exit 0 fi diff --git a/scripts/verify.conf b/scripts/verify.conf index ac490f8..43d0332 100644 --- a/scripts/verify.conf +++ b/scripts/verify.conf @@ -32,6 +32,7 @@ create-feature-issue: ./tests/create-feature-issue-test.sh review-feature: ./tests/review-feature-test.sh review-planning: ./tests/review-planning-test.sh revise-planning: ./tests/revise-planning-test.sh +finish-planning: ./tests/finish-planning-test.sh triage-review: ./tests/triage-review-test.sh apply-triage: ./tests/apply-triage-test.sh start-planning: ./tests/start-planning-test.sh diff --git a/tests/create-feature-issue-test.sh b/tests/create-feature-issue-test.sh index abecad3..da3ca68 100755 --- a/tests/create-feature-issue-test.sh +++ b/tests/create-feature-issue-test.sh @@ -55,10 +55,23 @@ GH chmod +x "$tmp/bin/gh" repo="$tmp/repo" -mkdir -p "$repo/scripts" "$repo/docs" +mkdir -p "$repo/scripts/lib" "$repo/docs" cp "$source_root/scripts/create-feature-issue.sh" "$repo/scripts/" +cp "$source_root"/scripts/lib/*.sh "$repo/scripts/lib/" git -C "$repo" init -q -b main +# Records a planning approval that matches the current planning documents. +approve_planning() { + local fingerprint + fingerprint="$( + source "$source_root/scripts/lib/fingerprint.sh" + source "$source_root/scripts/lib/planning.sh" + fingerprint_files "$repo" "$tmp" "${PLANNING_SCOPE[@]}" + )" + printf '# Planning Approval\n\nStatus: Approved\nPlanning fingerprint: %s\n' "$fingerprint" \ + >"$repo/docs/PLANNING_APPROVAL.md" +} + write_roadmap() { cat >"$repo/docs/roadmap.md" } @@ -133,6 +146,15 @@ Pagination comes later. - Second. ROADMAP +# Feature Issues require a planning approval that matches the roadmap. +expect_nothing_created "the planning is not approved" F02 +grep -Fq "finish-planning.sh" "$tmp/out.log" || fail "a missing planning approval did not point to finish-planning.sh" +approve_planning +cp "$repo/docs/roadmap.md" "$tmp/roadmap.saved" +printf '\n\n' >>"$repo/docs/roadmap.md" +expect_nothing_created "the roadmap changed after the planning approval" F02 +cp "$tmp/roadmap.saved" "$repo/docs/roadmap.md" + # After approval, the Issue is created from exactly the feature's block, with # the feature ID first in its title. run_create y F02 || { diff --git a/tests/finish-planning-test.sh b/tests/finish-planning-test.sh new file mode 100755 index 0000000..999b390 --- /dev/null +++ b/tests/finish-planning-test.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Run as on CI: without the user's global or system Git configuration, so a +# test cannot depend on a local Git identity or setting. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + +source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/finish-planning-test.XXXXXX")" + +cleanup() { + rm -rf "$tmp" +} +trap cleanup EXIT + +fail() { + echo "finish-planning test failed: $*" >&2 + exit 1 +} + +source "$source_root/tests/lib-fakes.sh" +make_fake_agents "$tmp/bin" + +reviews=".agents/reviews" +counter=0 + +# Creates a planning repository. Review rounds are added with add_review. +setup_repo() { + local repo="$tmp/$1" + + mkdir -p "$repo/docs/decisions" "$repo/$reviews" + copy_workflow "$repo" + printf '# Project Requirements\n\nStatus: Approved\nApproved at: 2026-01-01T00:00:00Z\n' >"$repo/docs/PROJECT_REQUIREMENTS.md" + printf '# Architecture\n\nLocal storage.\n' >"$repo/docs/architecture.md" + printf '# Roadmap\n\n## F01 — Recipes\n\n- Goal: list recipes.\n' >"$repo/docs/roadmap.md" + git -C "$repo" init -q -b main + git -C "$repo" config user.name "Finish Test" + git -C "$repo" config user.email "finish-test@example.com" + git -C "$repo" add . + git -C "$repo" commit -qm "Seed" + git -C "$repo" switch -q -c planning/project-bootstrap + + printf '%s\n' "$repo" +} + +# add_review +add_review() { + local repo="$1" + local review + review="$repo/$reviews/planning-project-bootstrap-review-$(printf '%02d' "$2").json" + + jq -n --argjson round "$2" --arg verdict "$3" --argjson findings "$4" '{ + schema: "review/v1", kind: "planning", issue: null, round: $round, + branch: "planning/project-bootstrap", base: "origin/main", merge_base: "aaaa", head: "bbbb", + reviewed_tree: "", reviewed_paths: ["docs/PROJECT_DESCRIPTION.md", "docs/PROJECT_REQUIREMENTS.md", "docs/architecture.md", "docs/roadmap.md", "docs/decisions"], + reviewer: {agent: "codex", model: "model-r"}, created_at: "2026-01-01T00:00:00Z", + verdict: $verdict, limitations: "", findings: $findings + }' >"$review" + record_reviewed_tree "$repo" "$review" +} + +# add_revision +add_revision() { + local repo="$1" + local review="$repo/$reviews/planning-project-bootstrap-review-$(printf '%02d' "$2").json" + + jq --arg decision "$3" '{ + schema: "revision/v1", + source_review: ".agents/reviews/planning-project-bootstrap-review-\(.round | tostring | if length == 1 then "0" + . else . end).json", + branch, review_round: .round, reviewed_tree, + planner: {agent: "claude", model: "model-p"}, + approved_at: "2026-01-02T00:00:00Z", + decisions: [.findings[] | {finding_id: .id, severity, title, decision: $decision, rationale: "Considered."}] + }' "$review" >"${review%.json}-revision.json" +} + +finding() { + printf '{"id": "%s", "severity": "%s", "title": "%s", "evidence": "docs/roadmap.md", "impact": "Impact.", "recommendation": "Change it."}' "$1" "$2" "$3" +} + +run_finish() { + local repo="$1" + local answer="$2" + shift 2 + + ( + cd "$repo" + printf '%s\n' "$answer" | PATH="$tmp/bin:/usr/bin:/bin" ./scripts/finish-planning.sh "$@" + ) >"$repo.out" 2>&1 +} + +expect_refused() { + local repo="$1" + local description="$2" + + if run_finish "$repo" y; then + cat "$repo.out" >&2 + fail "expected refusal: $description" + fi + [[ ! -e "$repo/docs/PLANNING_APPROVAL.md" ]] || fail "an approval was recorded although: $description" +} + +check_status() { + local status=0 + (cd "$1" && PATH="$tmp/bin:/usr/bin:/bin" ./scripts/finish-planning.sh --check >/dev/null 2>&1) || status=$? + echo "$status" +} + +# A planning whose latest round passed is approved after confirmation; the +# approval lists the rounds and the findings that were not adopted. +repo="$(setup_repo approve)" +add_review "$repo" 1 CHANGES_REQUIRED "[$(finding M1 major "Offline use is unplanned"), $(finding MIN1 minor "Vague name")]" +add_revision "$repo" 1 ADOPT +jq '.decisions[1].decision = "REJECT" | .decisions[1].rationale = "The name follows the requirements."' \ + "$repo/$reviews/planning-project-bootstrap-review-01-revision.json" >"$tmp/revision.tmp" +mv "$tmp/revision.tmp" "$repo/$reviews/planning-project-bootstrap-review-01-revision.json" +printf '\n## F02 — Offline use\n' >>"$repo/docs/roadmap.md" +add_review "$repo" 2 PASS "[]" +run_finish "$repo" y || { + cat "$repo.out" >&2 + fail "approving a passed planning failed" +} +approval="$repo/docs/PLANNING_APPROVAL.md" +grep -Fqx "Status: Approved" "$approval" || fail "the approval was not recorded" +grep -Eq '^Planning fingerprint: [0-9a-f]{40}$' "$approval" || fail "the approval lacks the planning fingerprint" +grep -Fq "Round 1, REJECT: MIN1 [minor] Vague name" "$approval" || fail "the approval lacks a rejected finding" +grep -Fq "Round 2: PASS" "$approval" || fail "the approval lacks the final round" +grep -Fq "Vague name" "$repo.out" || fail "a rejected finding was not shown before approval" +grep -Fq "git add docs/PROJECT_REQUIREMENTS.md docs/architecture.md docs/roadmap.md docs/decisions docs/PLANNING_APPROVAL.md" "$repo.out" || + fail "the commit step does not list the existing planning documents" + +# --check reports a current approval, and a stale one after any planning change. +[[ "$(check_status "$repo")" -eq 0 ]] || fail "a fresh approval is not current" +printf 'Unrelated.\n' >"$repo/notes.txt" +[[ "$(check_status "$repo")" -eq 0 ]] || fail "an unrelated change invalidated the approval" +printf '# ADR 001: Storage\n' >"$repo/docs/decisions/001-storage.md" +[[ "$(check_status "$repo")" -eq 1 ]] || fail "a new ADR did not invalidate the approval" +rm "$repo/docs/decisions/001-storage.md" +printf 'Changed.\n' >>"$repo/docs/architecture.md" +[[ "$(check_status "$repo")" -eq 1 ]] || fail "an architecture change did not invalidate the approval" +repo_without="$(setup_repo no-approval)" +[[ "$(check_status "$repo_without")" -eq 1 ]] || fail "a missing approval was not reported" + +# Minor findings that were all rejected or deferred can be approved. +repo="$(setup_repo minor-handled)" +add_review "$repo" 1 PASS_WITH_MINOR_FINDINGS "[$(finding MIN1 minor "Vague name"), $(finding S1 suggestion "Sharing")]" +add_revision "$repo" 1 DEFER +run_finish "$repo" y || { + cat "$repo.out" >&2 + fail "approving with deferred minor findings failed" +} + +# Declining records nothing. +repo="$(setup_repo decline)" +add_review "$repo" 1 PASS "[]" +run_finish "$repo" n || fail "declining returned an error" +[[ ! -e "$repo/docs/PLANNING_APPROVAL.md" ]] || fail "a declined approval was recorded" + +# Refusals. +repo="$(setup_repo no-review)" +expect_refused "$repo" "there is no planning review" + +repo="$(setup_repo stale)" +add_review "$repo" 1 PASS "[]" +printf 'Changed.\n' >>"$repo/docs/roadmap.md" +expect_refused "$repo" "the latest review is stale" + +repo="$(setup_repo blocking)" +add_review "$repo" 1 CHANGES_REQUIRED "[$(finding M1 major "Offline use is unplanned")]" +add_revision "$repo" 1 ESCALATE +expect_refused "$repo" "the latest review has a major finding" + +repo="$(setup_repo undecided)" +add_review "$repo" 1 PASS_WITH_MINOR_FINDINGS "[$(finding MIN1 minor "Vague name")]" +expect_refused "$repo" "a minor finding has no revision decision" + +repo="$(setup_repo not-applied)" +add_review "$repo" 1 PASS_WITH_MINOR_FINDINGS "[$(finding MIN1 minor "Vague name")]" +add_revision "$repo" 1 ADOPT +expect_refused "$repo" "an adopted finding is not applied" + +repo="$(setup_repo escalated)" +add_review "$repo" 1 PASS_WITH_MINOR_FINDINGS "[$(finding MIN1 minor "Vague name")]" +add_revision "$repo" 1 ESCALATE +expect_refused "$repo" "a finding is escalated" +grep -Fq "Round 1, ESCALATE: MIN1 [minor] Vague name" "$repo.out" || fail "a refusal did not show the escalated finding" +grep -Fq "Rationale: Considered." "$repo.out" || fail "a refusal did not show the rationale" + +# An escalation of an earlier round is not resolved by a newer review alone: +# the human confirms its resolution, and the approval records that. +repo="$(setup_repo earlier-escalation)" +add_review "$repo" 1 PASS_WITH_MINOR_FINDINGS "[$(finding MIN1 minor "Sharing scope is open")]" +add_revision "$repo" 1 ESCALATE +printf 'Sharing is out of scope.\n' >>"$repo/docs/PROJECT_REQUIREMENTS.md" +add_review "$repo" 2 PASS "[]" +if run_finish "$repo" $'n\ny'; then + fail "an earlier escalation that was not confirmed as resolved was approved" +fi +[[ ! -e "$repo/docs/PLANNING_APPROVAL.md" ]] || fail "an approval was recorded with an unresolved earlier escalation" +run_finish "$repo" $'y\ny' || { + cat "$repo.out" >&2 + fail "approving after confirming an earlier escalation failed" +} +grep -Fq "## Escalations confirmed as resolved" "$repo/docs/PLANNING_APPROVAL.md" || + fail "the confirmed resolution of an earlier escalation was not recorded" + +# A planning document that changes while the approval question waits is not approved. +repo="$(setup_repo changes-during-approval)" +add_review "$repo" 1 PASS "[]" +mkfifo "$tmp/answer" +( + cd "$repo" + PATH="$tmp/bin:/usr/bin:/bin" ./scripts/finish-planning.sh <"$tmp/answer" +) >"$repo.out" 2>&1 & +finisher=$! +exec 3>"$tmp/answer" +for _ in $(seq 1 100); do + grep -Fq "Approve this planning?" "$repo.out" 2>/dev/null && break + sleep 0.1 +done +printf 'Changed while deciding.\n' >>"$repo/docs/roadmap.md" +echo y >&3 +exec 3>&- +status=0 +wait "$finisher" || status=$? +[[ "$status" -ne 0 ]] || fail "a planning that changed during the approval question was approved" +[[ ! -e "$repo/docs/PLANNING_APPROVAL.md" ]] || fail "an approval was recorded for a changed planning" + +repo="$(setup_repo unapproved-requirements)" +add_review "$repo" 1 PASS "[]" +printf '# Project Requirements\n\nStatus: Draft\n' >"$repo/docs/PROJECT_REQUIREMENTS.md" +expect_refused "$repo" "the requirements are not approved" + +repo="$(setup_repo not-planning)" +add_review "$repo" 1 PASS "[]" +git -C "$repo" switch -q -c feature/1-x +expect_refused "$repo" "the branch is not a planning branch" + +echo "finish-planning tests passed"