From 30fd0ab6248a16bc400b73ce65d78e1b6aab79ff Mon Sep 17 00:00:00 2001 From: Ahmet Taspinar Date: Fri, 2 Oct 2026 23:55:02 +0200 Subject: [PATCH] Add finish-planning.sh with an approval tied to the planning documents finish-planning.sh checks that the requirements are approved, the latest planning review is current, and its round is resolved: no critical or major finding, a decision for every finding, nothing adopted but not applied, and no escalation. Earlier escalations need an explicit resolution. It shows every finding that was not adopted and records the approval in docs/PLANNING_APPROVAL.md with the reviewed fingerprint of the planning documents. finish-planning.sh --check reports whether the approval still matches the documents, and create-feature-issue.sh requires a current approval. The planning scope is shared in scripts/lib/planning.sh. Closes #47 Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- docs/agentic-workflow.md | 17 +- docs/development.md | 50 ++++-- scripts/create-feature-issue.sh | 16 +- scripts/finish-planning.sh | 215 ++++++++++++++++++++++++++ scripts/lib/planning.sh | 45 ++++++ scripts/review-planning.sh | 10 +- scripts/revise-planning.sh | 1 + scripts/verify.conf | 1 + tests/create-feature-issue-test.sh | 24 ++- tests/finish-planning-test.sh | 240 +++++++++++++++++++++++++++++ 11 files changed, 604 insertions(+), 29 deletions(-) create mode 100755 scripts/finish-planning.sh create mode 100644 scripts/lib/planning.sh create mode 100755 tests/finish-planning-test.sh 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"