diff --git a/README.ko.md b/README.ko.md index daeee72..6278c8e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -94,7 +94,7 @@ $EDITOR .sdlc/config.md # build, test, lint, run, e2e, `lint`, `run`, `e2e` 명령, 화면이나 API를 다룰 `qa` 도구, `human_gates`(기본값 `intent`, 모든 plan을 직접 승인하려면 `plan` 추가, 위험 플래그가 있는 plan은 설정과 상관없이 사람이 승인), `knowledge` 폴더, `repos`(리뷰가 묶을 저장소. -프로젝트 루트 아래 저장소가 많으면 나열하고, 비우면 루트 아래 전부)입니다. `.sdlc/`는 +프로젝트 루트 아래 저장소가 많으면 나열하거나 리뷰마다 `sdlc.sh review pass --repos "<경로> ..." "<메모>"`로 지정하고, 비우면 루트 아래 전부)입니다. `.sdlc/`는 `.git/info/exclude`로 git에서 빠집니다. 팀이 배운 것을 공유하려면 `knowledge:`를 git이 추적하는 폴더로 지정하세요. diff --git a/README.md b/README.md index 27e7fcd..a82d46b 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ Closed features move to `.sdlc/archive/`. Verified facts move to the `lint`, `run` and `e2e` commands, the `qa` tool that drives screens or APIs, `human_gates` (default `intent`; add `plan` to approve every plan yourself — a plan with a risk flag always asks), the `knowledge` folder, and `repos` (the -repositories a review binds — list them when the project root holds many; +repositories a review binds — list them when the project root holds many, +or name them per review with `sdlc.sh review pass --repos " ..." ""`; empty means every one under it). `.sdlc/` stays out of git through `.git/info/exclude`; point `knowledge:` at a tracked folder to share what the team learns. diff --git a/SKILL.md b/SKILL.md index 6fef882..8d6f45d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -77,7 +77,8 @@ finds `.git`. `.sdlc/config.md` holds every project value: `build`, `test`, `lint`, `run`, `e2e`, `qa` (the tool that drives screens or APIs), `human_gates` (default `intent`), `knowledge` (the durable knowledge folder) and `repos` (the -repositories a review binds; empty means every one under the project root). +repositories a review binds; empty means every one under the project root; +`review --repos` names them for one review). Records are working knowledge, not source: `init` keeps `.sdlc/` out of git through `.git/info/exclude`, changing no tracked file. Closed features move diff --git a/VERSION b/VERSION index c043eea..276cbf9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.1 +2.3.0 diff --git a/log/changelog-v2.3.0.md b/log/changelog-v2.3.0.md new file mode 100644 index 0000000..ea87f38 --- /dev/null +++ b/log/changelog-v2.3.0.md @@ -0,0 +1,51 @@ +# v2.3.0 — fewer repeated reviews, faster review on large roots + +Four fixes from two days of use on a project root holding about 350 git +repositories and worktrees, where 8 features ran through the loop. + +## Changes + +- **Review carries over when nothing it judged moved.** `approve plan` used + to clear the review PASS every time, so a human resuming a BLOCKED route to + retry one scenario had to run a new diff review on unchanged code. One + feature did this 12 times: rounds 5 to 15 reviewed the same two commits and + found nothing. Now the PASS carries to the new build entry when the + build's review was a PASS, every HEAD it bound is unchanged, intent.md is + unchanged, and PLAN.md is unchanged outside its Scenarios section. + `approve plan` then prints `review PASS carried` and the next step is QA. + A new commit in a bound repository, a change to intent.md or to any other + PLAN.md section (HTML comments aside), a round in review.md after the + recorded review that is not PASS (a passing plan gap check is fine), a review bound to a working tree (`--uncommitted`) or a `.state` + written by an older kit still needs a new round. The PLAN.md hash outside Scenarios is stored as + `planbody:` in `.state`. +- **`review --repos " ..."`.** Binding every repository under the root + took a median of 109 s per `review` (31 recordings, 201 s at worst); + run alone, the repository scan took 125 s, and 0.14 s with three listed + repositories. A global `repos:` does not fit a root where each feature + commits in a different worktree, so `--repos`, given before the note, + replaces `repos:` for one review, with the same checks and commas between + entries. It goes with `--uncommitted` in either order. An empty list is + refused, as are a repeated flag and `--repos` on a FAIL. A Commit outside + the bound repositories now names both `config repos:` (with its current + list) and `review --repos` in its error. +- **BLOCKED choices.** On a human route for a BLOCKED row, Evidence now + offers all three choices: remove the scenario (the approval records it), + retry with what is missing and a limit on attempts, or drop. Before, only + retry was described, and one feature asked the human to approve a new + retry 9 times. +- **Delivery Target errors quote the value.** Three intents in those days first + failed with `Delivery Target must be local, pr or deploy` because detail + followed the value (`deploy (dev → stg)`, `local commit → push`). The + error now quotes what it found, or says `missing`, and points detail to a + `- Note:` line, which the intent template now has. Phase 1 says `pr` also + covers pushing a branch for review with no pull request. + +Selftest adds `reposflag`, `carry` and `target` (119 checks); each fails on +v2.2.1 for the reported reason. + +## Compatibility + +Existing records stay valid. A feature whose `.state` has no `planbody:` +keeps the v2.2.1 behavior until its next plan approval, which records it. +Messages for `repos:` errors now start with `config repos:` or +`review --repos`, naming where the list came from. diff --git a/phases/1-intent.md b/phases/1-intent.md index 05e3748..6664288 100644 --- a/phases/1-intent.md +++ b/phases/1-intent.md @@ -22,8 +22,11 @@ risks in that understanding — confirmed before anyone explores deeply. gets built or leaves the authorized scope. Ask them one at a time and record each answer in place as `— resolved: `. Other uncertainty is written as `[assumed: why]`. - - **Delivery** — `local`, `pr` or `deploy`, and `Authorized: yes — - ""` when they authorized it, else `no`. + - **Delivery** — `local`, `pr` or `deploy` alone on the Target line + (`pr` also covers pushing a branch for review with no pull request), + details such as branch or environment on a `Note:` line, and + `Authorized: yes — ""` when they authorized it, else + `no`. 4. Several independent outcomes → one slug each; list them under Understood as. 5. A bug report also records what was done, what happened against what was diff --git a/phases/3-build.md b/phases/3-build.md index b0fe8ec..aa1f186 100644 --- a/phases/3-build.md +++ b/phases/3-build.md @@ -34,7 +34,10 @@ approval, review the uncommitted change instead. The reviewer appends a round to `review.md` ending in `VERDICT: PASS` or `VERDICT: FAIL`. Record it with `sdlc.sh review pass|fail ""`: the script reads the verdict of the newest round (every recording needs a new one) and binds a PASS to the -HEAD of every repository under the project root (or in config `repos:`); +HEAD of every repository under the project root (or in config `repos:`, or +in `--repos " ..."` given before the note, which replaces `repos:` for +that review: name the repositories the change touches when the root holds +many); `score` and `close` compare only the repositories named on evidence `Commit` lines, and each Commit must be that HEAD. For a review of the uncommitted change, record `sdlc.sh review pass --uncommitted ""`: the diff --git a/phases/4-evidence.md b/phases/4-evidence.md index 1c9955b..0acea00 100644 --- a/phases/4-evidence.md +++ b/phases/4-evidence.md @@ -31,7 +31,11 @@ checked out — then counts and routes: BLOCKED counts as not passed. Each kind of return is capped at three; after that the route is human. On a human route, show the human the score lines, the failing rows and their logs; they resume with `sdlc.sh approve plan - --human` or drop the feature. + --human` or drop the feature. For a BLOCKED row, offer all three: +remove the scenario (the approval records the removal and the gap), retry +with what is missing and a limit on attempts, or drop. When intent.md, the +HEADs a PASS bound (not a working tree) and PLAN.md outside Scenarios are +unchanged, the approval keeps the review PASS: go straight to QA. ## Deliver diff --git a/sdlc.sh b/sdlc.sh index 6a432aa..536ac0d 100755 --- a/sdlc.sh +++ b/sdlc.sh @@ -9,7 +9,7 @@ usage() { Usage: sdlc.sh init [--knowledge path] | new slug | status [slug] [--json] sdlc.sh next slug | check intent|plan slug sdlc.sh approve intent|plan slug --human words|--agent review - sdlc.sh review slug pass|fail note | review slug pass --uncommitted note + sdlc.sh review slug pass|fail [--uncommitted] [--repos "path ..."] note sdlc.sh score slug sdlc.sh close slug shipped|dropped reason | help | --version USAGE @@ -48,7 +48,7 @@ config() { gates=$(printf '%s' "$gates" | awk '{gsub(/,/," ");for(i=1;i<=NF;i++){v=tolower($i);if(v!="intent"&&v!="plan"){bad=$i;break}if(!seen[v]++)out=out (out==""?"":" ") v}}END{if(bad!="")print "!" bad;else print out}') case "$gates" in '!'*) err "unknown human_gates value '${gates#!}' (write intent, plan, or leave it empty): the human approves intent and plan"; gates='intent plan';; esac knowledge=$(value "$STORE/config.md" knowledge) - bound=$(value "$STORE/config.md" repos) + bound=$(value "$STORE/config.md" repos); boundby='config repos:' if [ -z "$knowledge" ]; then if [ -d "$STORE/memory" ]; then knowledge=.sdlc/memory; else knowledge=.sdlc/knowledge; fi fi @@ -71,11 +71,11 @@ feature() { load_state } load_state() { - seq=0; entry=0; fails=0; rp=0; rb=0; lastfail=; intent=; plan=; review=; route=; score=; closed=; planned=; scored=; rounds=0; withdrawn=; heads= + seq=0; entry=0; fails=0; rp=0; rb=0; lastfail=; intent=; plan=; review=; route=; score=; closed=; planned=; scored=; rounds=0; withdrawn=; heads=; planbody= if [ -f "$F/.state" ]; then while IFS= read -r line; do key=${line%%:*}; val=${line#*:}; val=${val# } - case "$key" in seq) seq=$val;; entry) entry=$val;; fails) fails=$val;; returns_plan) rp=$val;; returns_build) rb=$val;; lastfail) lastfail=$val;; intent) intent=$val;; plan) plan=$val;; review) review=$val;; route) route=$val;; score) score=$val;; closed) closed=$val;; planned) planned=$val;; scored) scored=$val;; rounds) rounds=$val;; withdrawn) withdrawn=$val;; heads) heads=$val;; esac + case "$key" in seq) seq=$val;; entry) entry=$val;; fails) fails=$val;; returns_plan) rp=$val;; returns_build) rb=$val;; lastfail) lastfail=$val;; intent) intent=$val;; plan) plan=$val;; review) review=$val;; route) route=$val;; score) score=$val;; closed) closed=$val;; planned) planned=$val;; scored) scored=$val;; rounds) rounds=$val;; withdrawn) withdrawn=$val;; heads) heads=$val;; planbody) planbody=$val;; esac done < "$F/.state" fi read -r ish iwho iseq its inote <<< "$intent" @@ -88,7 +88,7 @@ save_state() { { printf 'seq: %s\nentry: %s\nfails: %s\nreturns_plan: %s\nreturns_build: %s\nlastfail: %s\n' "$seq" "$entry" "$fails" "$rp" "$rb" "$lastfail" printf 'planned: %s\nscored: %s\nrounds: %s\nwithdrawn: %s\n' "$planned" "$scored" "$rounds" "$withdrawn" - for key in intent plan review route score closed heads; do eval 'val=$'"$key"; [ -z "$val" ] || printf '%s: %s\n' "$key" "$val"; done + for key in intent plan planbody review route score closed heads; do eval 'val=$'"$key"; [ -z "$val" ] || printf '%s: %s\n' "$key" "$val"; done } > "$tmp" mv "$tmp" "$F/.state" } @@ -110,7 +110,7 @@ parse() { if(section~/^understood as/&&line~/^- O[0-9]+[ \t]*:/){id=line;sub(/^[^-]*- /,"",id);sub(/[ \t]*:.*/,"",id);text=line;sub(/^[^:]*:[ \t]*/,"",text);if(text!=""&&text!~/^64){if(kind=="C")problem("invalid Commit: " hash)}else{print kind "\034" tolower(hash) "\034" repo;if(kind=="C")commits++}} if(line!~/^\|/)next @@ -132,7 +132,7 @@ parse() { } } END{ - question();if(mode=="intent"){if(!outcomes)problem("Understood as needs a filled O criterion");if(target!="local"&&target!="pr"&&target!="deploy")problem("Delivery Target must be local, pr or deploy")} + question();if(mode=="intent"){if(!outcomes)problem("Understood as needs a filled O criterion");if(target!="local"&&target!="pr"&&target!="deploy")problem("Delivery Target must be local, pr or deploy, alone on its line (" (rawtarget==""?"missing":"found \"" rawtarget "\"") "): put any detail on a \"- Note:\" line")} if(mode=="plan"){if(NR>150)problem("PLAN.md has " NR " lines; keep it to 150 and move detail into refs/");if(!flags)problem("Risk flags: line missing");if(!count)problem("Scenarios need at least one row");n=split(objectives,os," ");for(i=1;i<=n;i++)if(os[i]!=""&&!covered[os[i]])problem(os[i] " has no must scenario")} if(mode=="evidence"){if(!commits)problem("Commit lines missing");if(!count)problem("Results need at least one row");n=split(scenarios " " scored,ss," ");for(i=1;i<=n;i++)if(ss[i]!=""&&!seen[ss[i]]&&!missing[ss[i]]++)problem("evidence.md is missing " ss[i] ": scenarios are never removed")} }' "$2" @@ -190,7 +190,7 @@ route_current() { [ "$rid" = "$eid" ] && [ "$rentry" = "$entry" ]; } authorized() { local v re='^yes([^a-z0-9_]|$)'; v=$(one_line "$1" | tr '[:upper:]' '[:lower:]'); [[ $v =~ $re ]]; } delivery_authority() { dreason= - case "$1" in local) return 0;; pr|deploy) :;; *) dreason='Delivery Target must be local, pr or deploy'; return 1;; esac + case "$1" in local) return 0;; pr|deploy) :;; *) found=missing; [ -z "$1" ] || found="found \"$1\""; dreason="Delivery Target must be local, pr or deploy ($found)"; return 1;; esac if authorized "$eauth" || { [ "$1" = "$itarget" ] && authorized "$iauth"; }; then return 0; fi dreason="authorize $1"; return 1 } @@ -212,10 +212,11 @@ plan_history() { } filled() { [ -n "$1" ] && [[ $1 != \<* ]]; } -# Prints the number of VERDICT lines in review.md (HTML comments skipped) and the newest verdict word. +# Prints the number of VERDICT lines in review.md (HTML comments skipped), how many rounds after the first +# $1 are not PASS, and the newest verdict word (last: it may be empty). verdicts() { [ -f "$F/review.md" ] || { printf '0\n'; return; } - awk "$UNCOMMENT"'{if(uncomment())next;gsub(/[`*]/,"");if(tolower($0)~/^[ \t]*verdict[ \t]*:/){n++;sub(/^[^:]*:[ \t]*/,"");v=toupper($0);if(v~/(PASS|FAIL)[ \t]*[|\/][ \t]*(PASS|FAIL)/)v="UNDECIDED";else sub(/[^A-Z].*$/,"",v)}}END{print n+0, v}' "$F/review.md" + awk -v k="${1:-0}" "$UNCOMMENT"'{if(uncomment())next;gsub(/[`*]/,"");if(tolower($0)~/^[ \t]*verdict[ \t]*:/){n++;sub(/^[^:]*:[ \t]*/,"");v=toupper($0);if(v~/(PASS|FAIL)[ \t]*[|\/][ \t]*(PASS|FAIL)/)v="UNDECIDED";else sub(/[^A-Z].*$/,"",v);if(n>k&&v!="PASS")bad++}}END{print n+0, bad+0, v}' "$F/review.md" } qa_action() { local need= id @@ -285,7 +286,7 @@ validate_evidence() { done } # HEAD and toplevel of the project root's repository and of every repository inside the root (or only -# of config repos:, spaces or commas between entries), tab-separated pairs on one line: a review PASS +# of config repos: or review --repos, spaces or commas between entries), tab-separated pairs on one line: a review PASS # binds them all. With review --uncommitted, a repository with uncommitted changes records HEAD+tree, # the tree its working tree would commit, so a commit-gated project can review first and commit that # exact change after. Without it, nothing is fingerprinted and HEAD alone is bound, as in v2.1.0. @@ -293,15 +294,15 @@ review_heads() { root_repo if [ -n "$bound" ]; then read -r -a list <<< "${bound//,/ }" - [ "${#list[@]}" -gt 0 ] || die "repos: '$bound' lists no repository: name one, or leave repos: empty to bind every repository" + [ "${#list[@]}" -gt 0 ] || die "$boundby '$bound' lists no repository: name one, or leave it empty to bind every repository" proot=$(cd "$ROOT" && pwd -P) for path in "${list[@]}"; do # "." names the repository holding the project root; any other entry is a repository's own top folder. - case "$path" in /*|[A-Za-z]:*|\<*) die "repos: '$path' must be a path relative to the project root";; esac + case "$path" in /*|[A-Za-z]:*|\<*) die "$boundby '$path' must be a path relative to the project root";; esac repo_path "$path"; preal=$(cd "$REPO" 2>/dev/null && pwd -P) || preal= - case "$preal/" in "$proot"/*) :;; *) die "repos: '$path' is not a directory under the project root";; esac - top=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null) || die "repos: '$path' is not in a git repository" - [ "$preal" = "$proot" ] || [ "$preal" = "$(cd "$top" && pwd -P)" ] || die "repos: '$path' is not the top folder of a git repository (it is inside $top)" + case "$preal/" in "$proot"/*) :;; *) die "$boundby '$path' is not a directory under the project root";; esac + top=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null) || die "$boundby '$path' is not in a git repository" + [ "$preal" = "$proot" ] || [ "$preal" = "$(cd "$top" && pwd -P)" ] || die "$boundby '$path' is not the top folder of a git repository (it is inside $top)" done fi { if [ -n "$bound" ]; then for path in "${list[@]}"; do printf '%s\n' "$ROOT/$path"; done @@ -321,6 +322,16 @@ review_heads() { printf '%s\t%s\n' "$head" "$top" done; } | sort -u | awk '{printf "%s%s", (NR>1 ? "\t" : ""), $0}' } +# True when review_heads' record still holds HEAD-only: every bound repository is at the HEAD it recorded. +# A bound working tree (review --uncommitted, head+tree) never equals HEAD, so it never carries. +heads_unchanged() { + local rhead top; [ -n "$heads" ] || return 1 + while IFS=$'\t' read -r rhead top; do + [ "$(git -C "$top" rev-parse -q --verify HEAD 2>/dev/null)" = "$rhead" ] || return 1 + done < <(printf '%s' "$heads" | awk -F'\t' '{for(j=1;j pass|fail note, or review pass --uncommitted note' 2; feature "$1"; mutable + rusage='review pass|fail note, or review pass --uncommitted note; --repos "path ..." before the note names the repositories to bind' + [ "$#" -ge 3 ] || die "$rusage" 2 + rslug=$1; rverdict=$2; shift 2; uncommitted=; rrepos= + # --repos replaces config repos: for this review only. + while [ "$#" -gt 1 ]; do case "$1" in --uncommitted) [ -z "$uncommitted" ] || die "$rusage" 2; uncommitted=1; shift;; + --repos) [ "$#" -gt 2 ] && [ -z "$rrepos" ] || die "$rusage" 2; rrepos=1; bound=$(one_line "$2"); boundby='review --repos'; shift 2;; *) die "$rusage" 2;; esac; done + case "$1" in --uncommitted|--repos) die "$rusage" 2;; esac + [ -z "$rrepos" ] || [ -n "$bound" ] || die 'review --repos lists no repository: name one, or leave --repos out' 2 + set -- "$rslug" "$rverdict" "$1"; feature "$1"; mutable case "$2" in pass|fail) :;; *) die 'review verdict must be pass or fail' 2;; esac - [ -z "$uncommitted" ] || [ "$2" = pass ] || die '--uncommitted binds a PASS: review fail note' 2 + [ -z "$uncommitted$rrepos" ] || [ "$2" = pass ] || die '--uncommitted binds a PASS, as --repos does: review fail note' 2 note=$(one_line "$3"); [ -n "$note" ] || die 'review note required' 2 gate_plan || die "$greason" - read -r nrounds actual_verdict <<< "$(verdicts)" + read -r nrounds _ actual_verdict <<< "$(verdicts)" [ -n "$actual_verdict" ] || die 'review.md has no VERDICT line' [ "$nrounds" -gt "$rounds" ] || die 'review.md has no new round since the last review or plan approval: the reviewer appends one' [ "$actual_verdict" = "$(printf '%s' "$2" | tr '[:lower:]' '[:upper:]')" ] || die "review.md's last VERDICT is $actual_verdict" diff --git a/templates/config.md b/templates/config.md index baf6fec..6062a69 100644 --- a/templates/config.md +++ b/templates/config.md @@ -14,5 +14,5 @@ qa: human_gates: intent # durable knowledge: scouts read it first, Evidence adds verified facts to it knowledge: .sdlc/knowledge -# repositories a review PASS binds: top folders, separated by spaces or commas, of repositories under the project root (relative, no spaces in a path), "." for the repository holding the root; empty = every repository under the root +# repositories a review PASS binds: top folders, separated by spaces or commas, of repositories under the project root (relative, no spaces in a path), "." for the repository holding the root; empty = every repository under the root; review --repos replaces it for one review repos: diff --git a/templates/intent.md b/templates/intent.md index 285ffd7..1d083f7 100644 --- a/templates/intent.md +++ b/templates/intent.md @@ -22,4 +22,5 @@ ## Delivery - Target: +- Note: - Authorized: diff --git a/tests/selftest.sh b/tests/selftest.sh index 3d618b0..7cc2e33 100755 --- a/tests/selftest.sh +++ b/tests/selftest.sh @@ -585,7 +585,7 @@ regression_repos() { replace '/## Results/ {print "- Commit: '"$(git -C services/a rev-parse HEAD)"' services/a"} {print}' "$E" route deliver replace '/## Results/ {print "- Commit: '"$(git -C services/b rev-parse HEAD)"' services/b"} {print}' "$E" - expect 1 score "$SLUG"; contains 'services/b was not under review'; contains '(config repos: . services/a)' + expect 1 score "$SLUG"; contains 'services/b was not under review'; contains 'config repos: (now: . services/a) or review --repos names' printf 'human_gates: intent\nrepos: ., services/a\n' > .sdlc/config.md reviewed "$SLUG" pass 'comma-separated repos' case "$(state heads)" in *'/services/b'*|*'/services/c'*) fail 'comma-separated repos: bound an unlisted repository';; esac @@ -843,15 +843,129 @@ regression_interrupt() { [ ! -e "$dir" ] || { rm -rf "$dir"; fail 'interrupted review left its temporary index'; } ok 'an interrupted review removes its temporary index' } +regression_reposflag() { + repo regression-reposflag + printf 'services/\n' >> .git/info/exclude + for name in a b; do nested_repo "services/$name"; done + printf 'human_gates: intent\nrepos: services/b\n' > .sdlc/config.md + feature flag-repos + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 1; expect 0 approve plan "$SLUG" --agent reviewed + reviewed "$SLUG" pass --repos 'services/a' 'review names its repository' + case "$(state heads)" in *'/services/a') :;; *) fail "review --repos did not bind services/a: $(state heads)";; esac + [ "$(state heads | awk -F'\t' '{print NF}')" -eq 2 ] || fail "review --repos bound more than services/a: $(state heads)" + evidence + replace '/^- Commit:/ {print "- Commit: '"$(git -C services/a rev-parse HEAD)"' services/a"; next} {print}' "$E" + route deliver + replace '/^- Commit:/ {print "- Commit: '"$(git -C services/b rev-parse HEAD)"' services/b"; next} {print}' "$E" + expect 1 score "$SLUG"; contains 'services/b was not under review'; contains 'or review --repos names' + printf 'VERDICT: PASS\n' >> "$F/review.md" + expect 0 review "$SLUG" pass --repos 'services/a' --uncommitted 'flags in either order' + printf 'VERDICT: PASS\n' >> "$F/review.md" + expect 0 review "$SLUG" pass --uncommitted --repos 'services/a, services/b' 'commas between entries' + [ "$(state heads | awk -F'\t' '{print NF}')" -eq 4 ] || fail "review --repos with a comma did not bind both: $(state heads)" + printf 'VERDICT: PASS\n' >> "$F/review.md" + expect 2 review "$SLUG" pass --repos '' 'empty list'; contains 'review --repos lists no repository' + expect 2 review "$SLUG" pass --repos 'services/a'; contains 'review pass|fail note' + expect 2 review "$SLUG" pass --repos 'services/a' --repos 'services/b' 'repeated flag'; contains 'review pass|fail note' + printf 'VERDICT: FAIL\n' >> "$F/review.md" + expect 2 review "$SLUG" fail --repos 'services/a' 'flag on a FAIL'; contains 'as --repos does' + printf 'VERDICT: PASS\n' >> "$F/review.md" + expect 1 review "$SLUG" pass --repos 'services/none' 'missing path'; contains "review --repos 'services/none' is not a directory under the project root" + ok 'review --repos replaces config repos: for one review, takes either flag order, and refuses an empty or missing list' +} +regression_carry() { + repo regression-carry + ready blocked-retry 2 + evidence S1:BLOCKED:; route human + replace '/^\| S2 / {sub(/run real CLI/, "run the CLI with a fixture")} {print}' "$F/PLAN.md" + expect 0 approve plan "$SLUG" --human 'retry S1 with a fixture'; contains 'review PASS carried' + case "$(state review)" in "pass $(state entry) "*' carried: independent review passed') :;; *) fail "review not carried: $(state review)";; esac + evidence S1:BLOCKED:; route human + printf '## Round — plan gap check\nVERDICT: PASS\n' >> "$F/review.md" + expect 0 approve plan "$SLUG" --human 'retry S1 again after a passing gap check'; contains 'review PASS carried' + case "$(state review)" in *'carried: carried:'*) fail "carried note stacked: $(state review)";; esac + evidence; route deliver + ok 'a plan re-approval that changes only Scenarios keeps the review PASS while every reviewed HEAD is unchanged' + ready code-moved 2 + evidence S1:BLOCKED:; route human + printf 'fix\n' >> README; git commit -qam fix; SHA=$(git rev-parse HEAD) + expect 0 approve plan "$SLUG" --human 'retry after a new commit' + case "$OUTPUT" in *carried*) fail 'review carried across a new commit';; esac + [ -z "$(state review)" ] && [ -z "$(state heads)" ] || fail 'a new commit kept the review' + evidence; expect 1 score "$SLUG"; contains 'review PASS required' + ready plan-moved 2 + evidence S1:BLOCKED:; route human + replace '/^## Scenarios/ {print "## Changes"; print "1. README — one more step"} {print}' "$F/PLAN.md" + expect 0 approve plan "$SLUG" --human 'plan changed outside Scenarios' + [ -z "$(state review)" ] || fail 'a PLAN.md change outside Scenarios kept the review' + ready legacy-state 2 + evidence S1:BLOCKED:; route human + replace '!/^planbody:/' "$F/.state" + expect 0 approve plan "$SLUG" --human 'state from an older kit' + [ -z "$(state review)" ] || fail 'a state without planbody kept the review' + printf 'work in progress\n' > wip.txt + ready tree-bound 2 --uncommitted + case "$(state heads)" in *+*) :;; *) fail "review --uncommitted bound no working tree: $(state heads)";; esac + rm wip.txt + replace '/^\| S2 / {sub(/run real CLI/, "run the CLI again")} {print}' "$F/PLAN.md" + expect 0 approve plan "$SLUG" --agent 'scenario wording' + [ -z "$(state review)" ] || fail 'a review bound to a working tree was carried' + ready intent-moved 2 + evidence S1:BLOCKED:; route human + printf -- '- O2: The operator also observes the log.\n' > "$TMP/o2" + replace '/^- O1:/ {print; while ((getline l < "'"$TMP/o2"'") > 0) print l; next} {print}' "$F/intent.md" + replace '/^\| S1 / {sub(/\| O1 \|$/, "| O1 O2 |")} {print}' "$F/PLAN.md" + expect 0 approve intent "$SLUG" --human 'added O2' + expect 0 approve plan "$SLUG" --human 'S1 covers O2' + [ -z "$(state review)" ] || fail 'an intent change kept the review' + ready build-return 2 + evidence S2:FAIL:code; route build + replace '/^\| S2 / {sub(/run real CLI/, "run the CLI after the fix")} {print}' "$F/PLAN.md" + expect 0 approve plan "$SLUG" --agent 'scenario wording after a build return' + [ -z "$(state review)" ] || fail 'a review from before a build return was carried' + ready unrecorded-round 2 + evidence S1:BLOCKED:; route human + printf 'VERDICT: FAIL\n' >> "$F/review.md" + expect 0 approve plan "$SLUG" --human 'retry with a newer round unrecorded' + [ -z "$(state review)" ] || fail 'a PASS was carried past a newer unrecorded round' + feature commented-change + expect 0 approve intent "$SLUG" --human 'approved scope' + plan 2; replace '/^## Scenarios/ {print "## Changes"; print ""} {print}' "$F/PLAN.md" + expect 0 approve plan "$SLUG" --agent reviewed; reviewed "$SLUG" pass 'independent review passed' + evidence S1:BLOCKED:; route human + replace '{print} /^-->/ {print "1. README — a change after a commented heading"}' "$F/PLAN.md" + expect 0 approve plan "$SLUG" --human 'change hidden after a commented heading' + [ -z "$(state review)" ] || fail 'a commented Scenarios heading hid a change outside Scenarios' + ok 'the review is not carried after a new commit, a change to intent.md or PLAN.md outside Scenarios, a build return, an unrecorded round, a state without planbody, or a bound working tree' +} +regression_target() { + repo regression-target + feature target-detail + replace '{sub(/^- Target: local$/, "- Target: deploy (dev then stg)")} {print}' "$F/intent.md" + expect 1 approve intent "$SLUG" --human 'approved scope'; contains 'found "deploy (dev then stg)"'; contains 'put any detail on a "- Note:" line' + replace '!/^- Target:/' "$F/intent.md" + expect 1 approve intent "$SLUG" --human 'approved scope'; contains 'or deploy, alone on its line (missing)' + intent + replace '/^- Target:/ {print; print "- Note: push the branch after review"; next} {print}' "$F/intent.md" + expect 0 approve intent "$SLUG" --human 'approved scope' + ready target-close 1 + evidence; route deliver; delivery 'deploy (dev)' + expect 1 close "$SLUG" shipped 'detail after the target'; contains 'or deploy (found "deploy (dev)")' + case "$OUTPUT" in *'")deploy'*|*'"deploy (dev)"deploy'*) fail "Target repeated: $OUTPUT";; esac + ok 'an intent or evidence Target error quotes what it found once and points intent detail to a Note line' +} run_regressions() { case "$1" in stop) regression_stop;; scenarios) regression_scenarios;; delivery) regression_delivery;; review) regression_review;; other) regression_other;; init) regression_init;; ultra) regression_ultra;; round2-[abcde]) regression_round2 "${1#round2-}";; round3) regression_round3;; repos) regression_repos;; cells) regression_cells;; paths) regression_paths;; uncommitted) regression_uncommitted;; nested) regression_nested;; interrupt) regression_interrupt;; scoped) regression_scoped;; fingerprint) regression_fingerprint;; optin) regression_optin;; + reposflag) regression_reposflag;; carry) regression_carry;; target) regression_target;; all) regression_stop; regression_scenarios; regression_delivery; regression_review; regression_other; regression_init; regression_ultra for round2 in a b c d e; do regression_round2 "$round2"; done; regression_round3 - regression_repos; regression_cells; regression_paths; regression_uncommitted; regression_nested; regression_interrupt; regression_scoped; regression_fingerprint; regression_optin;; + regression_repos; regression_cells; regression_paths; regression_uncommitted; regression_nested; regression_interrupt; regression_scoped; regression_fingerprint; regression_optin + regression_reposflag; regression_carry; regression_target;; *) fail "unknown regression group: $1";; esac }