aw-spec-cli 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  2. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
  3. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  4. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
  5. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +107 -48
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +17 -9
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +265 -20
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +89 -15
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +354 -52
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
  15. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +651 -76
  16. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +600 -59
  17. package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
  18. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  19. package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
  20. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
  21. package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
  22. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  23. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
  24. package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
  25. package/assets/.appweaver/stack-profiles/node-express.json +1 -0
  26. package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
  27. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  28. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  29. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  30. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  31. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  32. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  33. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  34. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  35. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  36. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
  37. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  38. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  39. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  40. package/package.json +1 -1
  41. package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
@@ -22,10 +22,12 @@
22
22
  # -> {checklist_file, created}
23
23
  # list-domains -> {domains[]}
24
24
  # set-domain --domain <name> -> {domain, map_path, map_exists, written}
25
- # stack-list [--kind backend|frontend|all] [--preselect <text>] [--include-none] [--suggest]
25
+ # stack-list [--kind backend|frontend|all] [--preselect <text>] [--profile <id|label>] [--include-none] [--suggest]
26
26
  # -> {profiles[], ids[], select_options[], count, preselect,
27
- # detected_profile, detected_root, root_options[], none_sentinel}
28
- # (--suggest also detects, so the menu opens on what the repo IS)
27
+ # detected_profile, detected_root, detected_summary,
28
+ # root_options[], none_sentinel}
29
+ # (--suggest also detects, so the menu opens on what the repo IS;
30
+ # --profile builds root_options for the stack already chosen)
29
31
  # stack-detect [--kind <k>] [--exclude-root <p>]… -> {matches[], backend, frontend, count}
30
32
  # (matches the repo's OWN files against the profiles; writes nothing)
31
33
  # lane-declare --profile <id|label|none|detected> [--kind <k>] [--id <lane>] [--source-path|--root <p>]
@@ -51,6 +53,10 @@
51
53
  # design_file/ui_design_file (aliases of the two plans), spec_exists, bootstrap_pending, needs_bootstrap}
52
54
  # checklist-read --file <path> -> {exists, total, checked, items[]}
53
55
  # checklist-apply --file <path> --results <json>-> {exists, before_checked, after_checked, total, newly_passing[], regressions[]}
56
+ # plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>]
57
+ # [--test-path <p>] [--reported <json list>]... [--list-only]
58
+ # -> {ok, total, names[], names_csv, elements[], gaps[], gap_count, gaps_text}
59
+ # (which plan elements the lane's code names and the model reported done)
54
60
  # detect-project -> {scaffolded, markers[]}
55
61
  # foundation-list [--args '<F> <A> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}
56
62
  # foundation-locate --name <F> --app <A> [--out <d>]
@@ -76,7 +82,11 @@
76
82
  # four-document model is gone. "Which files is this slice made of" is now an OKF
77
83
  # query: aw-xref concept-paths / concept-upsert --merge.)
78
84
  # build-compile [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
79
- # test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
85
+ # test-run [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail,
86
+ # failing_excerpt, failing_lanes[],
87
+ # no_setup, setup_reason, setup_missing[]}
88
+ # (failing_excerpt = the lines that NAME the failure, per failing lane;
89
+ # no_setup = no suite ran because no test command is wired up, with why)
80
90
  # run-migrate [--feature-dir <path>] -> {ok, tool, ran, cmd, log_tail}
81
91
  # deploy-dispatch -> {ok, configured, ran, cmd, log_tail}
82
92
  # cr-setup --cr-id <id> [--feature-dir <path>] [--title <t>] [--wave <n>]
@@ -742,9 +752,10 @@ cmd_stack_list() {
742
752
  case "$1" in
743
753
  --kind) fwd+=("--kind" "$2"); shift 2 ;;
744
754
  --preselect) fwd+=("--preselect" "$2"); shift 2 ;;
755
+ --profile) fwd+=("--profile" "$2"); shift 2 ;;
745
756
  --include-none) fwd+=("--include-none"); shift ;;
746
757
  --suggest) fwd+=("--suggest"); shift ;;
747
- --help|-h) echo "stack-list [--kind backend|frontend|all] [--preselect <text>] [--include-none] [--suggest]"; return 0 ;;
758
+ --help|-h) echo "stack-list [--kind backend|frontend|all] [--preselect <text>] [--profile <id|label>] [--include-none] [--suggest]"; return 0 ;;
748
759
  *) echo "stack-list: unknown arg '$1'" >&2; return 1 ;;
749
760
  esac
750
761
  done
@@ -1905,15 +1916,23 @@ _foundation_stack() {
1905
1916
  # ──────────────────────────── foundation-list ──────────────────────────────
1906
1917
  # Enumerate available foundations (foundations/<stack>/<Name>/ that carry a real
1907
1918
  # <Name>.pack.txt) so the workflow can let the user PICK one — deterministic, no LLM. An optional
1908
- # --args "<Foundation> <App> [out]" is parsed as a direct-argument fast path:
1919
+ # --args "<Foundation> <App>" is parsed as a direct-argument fast path:
1909
1920
  # preselect_name is echoed back only when it names a real foundation; preselect_app
1910
- # / out_dir carry any 2nd/3rd tokens through (empty/"." otherwise).
1921
+ # carries any 2nd token through (empty otherwise).
1922
+ #
1923
+ # A THIRD token is no longer an output directory. A scaffold lands in the source path of
1924
+ # the lane it is scaffolding, which is the only thing that keeps generated code inside a
1925
+ # declared lane, so there is no caller-chosen destination left to accept. It comes back as
1926
+ # `rejected_out` for the workflow to refuse by name rather than be silently ignored —
1927
+ # a path argument that is quietly dropped is how someone believes they scaffolded a
1928
+ # sandbox and finds they overwrote their service.
1911
1929
  cmd_foundation_list() {
1912
- local raw=""
1930
+ local raw="" eco_filter=""
1913
1931
  while [ $# -gt 0 ]; do
1914
1932
  case "$1" in
1915
1933
  --args) raw="$2"; shift 2 ;;
1916
- --help|-h) echo "foundation-list [--args '<Foundation> <App> [out]'] -> {foundations[], count, preselect_name, preselect_app, out_dir}"; return 0 ;;
1934
+ --ecosystem) eco_filter="$2"; shift 2 ;;
1935
+ --help|-h) echo "foundation-list [--args '<Foundation> <App>'] [--ecosystem <eco>] -> {foundations[], count, preselect_name, preselect_app, rejected_out}"; return 0 ;;
1917
1936
  *) echo "foundation-list: unknown arg '$1'" >&2; return 1 ;;
1918
1937
  esac
1919
1938
  done
@@ -1922,13 +1941,19 @@ cmd_foundation_list() {
1922
1941
  # WHICH foundations this repo may be offered. A foundation bundle is a whole
1923
1942
  # compiling project in one ecosystem's languages and build system, so unpacking
1924
1943
  # a .NET one into a Spring repo does not produce a scaffold — it produces a
1925
- # second, foreign project beside the real one. The declared lanes already say
1926
- # which ecosystems this repo is, so filter to those and let the gate say why
1927
- # when that leaves nothing. Nothing declared (a bare greenfield repo, before
1928
- # select-stack) filters nothing: there is no answer yet to filter on.
1944
+ # second, foreign project beside the real one.
1945
+ #
1946
+ # --ecosystem narrows to the ONE lane being scaffolded, which is stricter than the
1947
+ # union of every declared lane: a repo with a .NET service and a Node UI must not be
1948
+ # offered its .NET bundle while the UI lane is the target. Without it the filter falls
1949
+ # back to every declared lane's ecosystem, and to nothing at all on a bare repo.
1929
1950
  local -a lane_ecos=()
1930
1951
  local e
1931
- while IFS= read -r e; do [ -n "$e" ] && lane_ecos+=("$e"); done < <(_aw_ecosystems)
1952
+ if [ -n "$eco_filter" ]; then
1953
+ lane_ecos=("$eco_filter")
1954
+ else
1955
+ while IFS= read -r e; do [ -n "$e" ] && lane_ecos+=("$e"); done < <(_aw_ecosystems)
1956
+ fi
1932
1957
 
1933
1958
  local -a foundations=() all=() eco_of=()
1934
1959
  local rel name eco keep
@@ -1943,22 +1968,22 @@ cmd_foundation_list() {
1943
1968
  fi
1944
1969
  [ "$keep" = true ] && foundations+=("$name")
1945
1970
  done < <(_foundation_dirs "$repo_root")
1946
- # Optional direct-argument fast path: "<FoundationName> <AppName> [OutputDir]".
1947
- local pre_name="" pre_app="" out_dir="."
1971
+ # Optional direct-argument fast path: "<FoundationName> <AppName>".
1972
+ local pre_name="" pre_app="" rejected_out=""
1948
1973
  if [ -n "$raw" ]; then
1949
1974
  # shellcheck disable=SC2086
1950
1975
  set -- $raw
1951
- [ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] && out_dir="$3"
1976
+ [ -n "${1:-}" ] && pre_name="$1"; [ -n "${2:-}" ] && pre_app="$2"; [ -n "${3:-}" ] && rejected_out="$3"
1952
1977
  fi
1953
1978
  # Only preselect a name that actually names an available foundation.
1954
1979
  local match="" f
1955
1980
  for f in "${foundations[@]}"; do [ "$f" = "$pre_name" ] && match="$pre_name"; done
1956
- printf '{"foundations":%s,"count":%s,"all_foundations":%s,"all_count":%s,"ecosystems":%s,"lane_ecosystems":%s,"filtered":%s,"preselect_name":"%s","preselect_app":"%s","out_dir":"%s"}\n' \
1981
+ printf '{"foundations":%s,"count":%s,"all_foundations":%s,"all_count":%s,"ecosystems":%s,"lane_ecosystems":%s,"filtered":%s,"preselect_name":"%s","preselect_app":"%s","rejected_out":"%s"}\n' \
1957
1982
  "$(json_str_array "${foundations[@]}")" "${#foundations[@]}" \
1958
1983
  "$(json_str_array "${all[@]}")" "${#all[@]}" \
1959
1984
  "$(json_str_array "${eco_of[@]}")" "$(json_str_array "${lane_ecos[@]}")" \
1960
1985
  "$([ ${#lane_ecos[@]} -gt 0 ] && echo true || echo false)" \
1961
- "$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$out_dir")"
1986
+ "$(json_escape "$match")" "$(json_escape "$pre_app")" "$(json_escape "$rejected_out")"
1962
1987
  }
1963
1988
 
1964
1989
  # ─────────────────────────── foundation-locate ─────────────────────────────
@@ -2021,17 +2046,23 @@ cmd_foundation_locate() {
2021
2046
 
2022
2047
  # ─────────────────────────── foundation-unpack ─────────────────────────────
2023
2048
  # Run the OS-selected unpacker for the foundation (or reference) pack.
2049
+ #
2050
+ # --out is the LANE'S SOURCE PATH, so every file a pack carries lands inside the subtree
2051
+ # its lane owns. --test-path is the lane's other subtree: a pack that carries its own test
2052
+ # project puts it under the source path like everything else, and aw-foundation.py lifts it
2053
+ # onto the path the lane actually keeps tests in. See that module for when it is a no-op.
2024
2054
  cmd_foundation_unpack() {
2025
- local name="" app="" out="." dry=false force="no" reference=false
2055
+ local name="" app="" out="." dry=false force="no" reference=false test_path=""
2026
2056
  while [ $# -gt 0 ]; do
2027
2057
  case "$1" in
2028
2058
  --name) name="$2"; shift 2 ;;
2029
2059
  --app) app="$2"; shift 2 ;;
2030
2060
  --out) out="$2"; shift 2 ;;
2061
+ --test-path) test_path="$2"; shift 2 ;;
2031
2062
  --dry-run) dry=true; shift ;;
2032
2063
  --force) force="$2"; shift 2 ;;
2033
2064
  --reference) reference=true; shift ;;
2034
- --help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
2065
+ --help|-h) echo "foundation-unpack --name <F> --app <A> --out <d> [--test-path <d>] [--dry-run] [--force <yes|no>] [--reference]"; return 0 ;;
2035
2066
  *) echo "foundation-unpack: unknown arg '$1'" >&2; return 1 ;;
2036
2067
  esac
2037
2068
  done
@@ -2067,9 +2098,29 @@ cmd_foundation_unpack() {
2067
2098
  [ -n "$written" ] || written=0
2068
2099
  local has_coll=false
2069
2100
  if [ "$dry" = true ] && [ $rc -ne 0 ]; then has_coll=true; fi
2070
- printf '{"ok":%s,"mode":"%s","written":%s,"has_collisions":%s,"stdout":"%s"}\n' \
2101
+
2102
+ # Put the pack's test tree on the lane's test path. Only after a REAL unpack that
2103
+ # worked: a dry run has written nothing to lift, and a failed one leaves a tree
2104
+ # nobody should be rearranging.
2105
+ local tests_moved=0 tests_msg="" redirect=""
2106
+ if [ "$dry" != true ] && [ $rc -eq 0 ] && [ -n "$test_path" ]; then
2107
+ local aw_fnd="$SCRIPT_DIR/../python/aw-foundation.py"
2108
+ if [ -f "$aw_fnd" ] && command -v python3 >/dev/null 2>&1; then
2109
+ local -a rargs=(--repo "$repo_root" redirect-tests --out "$out" --test-path "$test_path")
2110
+ { [ "$force" = "yes" ] || [ "$reference" = true ]; } && rargs+=(--force)
2111
+ redirect=$(python3 "$aw_fnd" "${rargs[@]}" 2>/dev/null | tail -1)
2112
+ if [ -n "$redirect" ]; then
2113
+ tests_moved=$(printf '%s' "$redirect" | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline()).get("moved",0))' 2>/dev/null || echo 0)
2114
+ tests_msg=$(printf '%s' "$redirect" | python3 -c 'import json,sys; print(json.loads(sys.stdin.readline()).get("message",""))' 2>/dev/null || echo "")
2115
+ fi
2116
+ fi
2117
+ fi
2118
+ [ -n "$tests_moved" ] || tests_moved=0
2119
+
2120
+ printf '{"ok":%s,"mode":"%s","written":%s,"has_collisions":%s,"tests_moved":%s,"tests_message":"%s","stdout":"%s"}\n' \
2071
2121
  "$([ $rc -eq 0 ] && echo true || echo false)" "$([ "$dry" = true ] && echo dry || echo real)" \
2072
- "$written" "$has_coll" "$(json_escape "$(printf '%s' "$outp" | tail -c 800)")"
2122
+ "$written" "$has_coll" "$tests_moved" "$(json_escape "$tests_msg")" \
2123
+ "$(json_escape "$(printf '%s' "$outp" | tail -c 800)")"
2073
2124
  }
2074
2125
 
2075
2126
  # ─────────────────────── foundation-stash-dbcontext ────────────────────────
@@ -2086,7 +2137,9 @@ cmd_foundation_stash_dbcontext() {
2086
2137
  done
2087
2138
  local repo_root; repo_root=$(get_repo_root)
2088
2139
  [[ "$out" != /* ]] && out="$repo_root/$out"
2089
- local ctx; ctx=$(find "$out/src" -name "ApplicationDbContext.cs" 2>/dev/null | head -1)
2140
+ # Searched from the unpack root itself, which IS the lane's source path — a `src/`
2141
+ # segment underneath it is the old repo-root layout and is not there any more.
2142
+ local ctx; ctx=$(find "$out" -name "ApplicationDbContext.cs" 2>/dev/null | head -1)
2090
2143
  if [ -z "$ctx" ]; then printf '{"found":false,"dbcontext":"","backup":""}\n'; return 0; fi
2091
2144
  local bak="$ctx.foundation.bak"
2092
2145
  cp "$ctx" "$bak"
@@ -2973,7 +3026,9 @@ cmd_test_run() {
2973
3026
  fi
2974
3027
 
2975
3028
  local tool="none" ran=false ok=true cmd="" log; log=$(mktemp)
2976
- local matched=false scoped=false fence_green=false filter="" t2
3029
+ # The runner's exit code, kept because 126/127 is every shell's way of saying the
3030
+ # command itself could not be executed — a missing runner, not a failing suite.
3031
+ local matched=false scoped=false fence_green=false filter="" t2 rc=0
2977
3032
 
2978
3033
  local dotnet_present=false
2979
3034
  if ls "$repo_root"/*.sln >/dev/null 2>&1 || find "$repo_root" -maxdepth 5 -name "*.csproj" 2>/dev/null | grep -q .; then
@@ -3006,6 +3061,16 @@ print(json.dumps({
3006
3061
  "scope": os.environ.get("AW_SCOPE", "repo"), "slices": os.environ.get("AW_SLICES", ""),
3007
3062
  "scoped": False, "matched": bool(re.search(r"Total:\s*[1-9]", log)),
3008
3063
  "fence_green": ok, "cmd": d.get("cmd", ""), "log_tail": log,
3064
+ # The failing lines of EVERY failing lane, budgeted per lane by run-phase — what the
3065
+ # fix loop reads, in place of a tail that may belong to a lane that passed.
3066
+ "failing_excerpt": d.get("failing_excerpt", ""),
3067
+ "failing_lanes": d.get("failing_lanes", []),
3068
+ # NOTHING ran a suite, because no lane has a test command wired up — a different
3069
+ # state from a red suite, and the one a fix loop cannot help with. (No apostrophes
3070
+ # in this block: it is a single-quoted python3 -c program.)
3071
+ "no_setup": bool(d.get("no_setup")),
3072
+ "setup_reason": d.get("setup_reason", ""),
3073
+ "setup_missing": d.get("setup_missing", []),
3009
3074
  "lanes_run": d.get("lanes_run", []), "lanes_skipped": d.get("lanes_skipped", []),
3010
3075
  }))
3011
3076
  '
@@ -3024,16 +3089,16 @@ print(json.dumps({
3024
3089
  else
3025
3090
  for t2 in $slices; do filter="${filter:+$filter|}FullyQualifiedName~$t2"; done
3026
3091
  scoped=true; cmd="dotnet test --nologo --filter \"$filter\""; ran=true
3027
- if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else ok=false; fi
3092
+ if ( cd "$repo_root" && dotnet test --nologo --filter "$filter" ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
3028
3093
  fi
3029
3094
  else
3030
3095
  cmd="dotnet test --nologo"; ran=true
3031
- if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else ok=false; fi
3096
+ if ( cd "$repo_root" && dotnet test --nologo ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
3032
3097
  fi
3033
3098
  else ok=false; fi
3034
3099
  elif [ -f "$repo_root/package.json" ] && command -v npm >/dev/null 2>&1; then
3035
3100
  tool="npm"; cmd="npm test"; ran=true
3036
- if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else ok=false; fi
3101
+ if ( cd "$repo_root" && npm test ) >"$log" 2>&1; then ok=true; else rc=$?; ok=false; fi
3037
3102
  else
3038
3103
  tool="none"; ran=false; ok=true
3039
3104
  fi
@@ -3053,10 +3118,30 @@ print(json.dumps({
3053
3118
  fence_green=$ok
3054
3119
  fi
3055
3120
 
3056
- local t; t=$(tail -c 1400 "$log" 2>/dev/null || true); rm -f "$log"
3057
- printf '{"ok":%s,"tool":"%s","ran":%s,"scope":"%s","slices":"%s","scoped":%s,"matched":%s,"fence_green":%s,"cmd":"%s","log_tail":"%s"}\n' \
3121
+ local t; t=$(tail -c 1400 "$log" 2>/dev/null || true)
3122
+ # The lines that NAME the failure, which a positional tail does not reliably contain:
3123
+ # a .NET run opens with its restore banner and a runner closes with a summary, so the
3124
+ # last 1400 characters can be entirely green while the failure sits above them.
3125
+ local excerpt="" label no_setup=false setup_reason="" helper_out=""
3126
+ label=$(printf '%s' "$tool${cmd:+ · $cmd}" | tr '\n' ' ' | cut -c1-80)
3127
+ if [ "$fence_green" != true ]; then
3128
+ helper_out=$(python3 "$SCRIPT_DIR/../python/aw-testlog.py" --file "$log" \
3129
+ --label "$label" --exit "$rc" 2>/dev/null || true)
3130
+ excerpt=$(printf '%s' "$helper_out" | python3 -c 'import json,sys
3131
+ try: sys.stdout.write(json.load(sys.stdin).get("excerpt", ""))
3132
+ except Exception: pass' 2>/dev/null || true)
3133
+ # "The command never ran" — no test script, no runner, no test project. Not a red
3134
+ # suite, and the callers gate on it rather than sending a fix loop after nothing.
3135
+ setup_reason=$(printf '%s' "$helper_out" | python3 -c 'import json,sys
3136
+ try: sys.stdout.write(json.load(sys.stdin).get("reason", ""))
3137
+ except Exception: pass' 2>/dev/null || true)
3138
+ [ -n "$setup_reason" ] && no_setup=true
3139
+ fi
3140
+ rm -f "$log"
3141
+ printf '{"ok":%s,"tool":"%s","ran":%s,"scope":"%s","slices":"%s","scoped":%s,"matched":%s,"fence_green":%s,"cmd":"%s","log_tail":"%s","failing_excerpt":"%s","failing_lanes":[],"no_setup":%s,"setup_reason":"%s","setup_missing":[]}\n' \
3058
3142
  "$ok" "$(json_escape "$tool")" "$ran" "$(json_escape "$scope")" "$(json_escape "$slices")" \
3059
- "$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")"
3143
+ "$scoped" "$matched" "$fence_green" "$(json_escape "$cmd")" "$(json_escape "$t")" \
3144
+ "$(json_escape "$excerpt")" "$no_setup" "$(json_escape "$setup_reason")"
3060
3145
  }
3061
3146
 
3062
3147
  cmd_run_migrate() {
@@ -3312,6 +3397,349 @@ cmd_cr_wave() {
3312
3397
  "$(json_escape "$dspec")" "$(json_escape "$dmodel")"
3313
3398
  }
3314
3399
 
3400
+ # ──────────────────────────── cr-wave-derive ───────────────────────────────
3401
+ # THE WAVE A CHANGE'S OWN DOCUMENTS IMPLY, from their `## Depends on` sections.
3402
+ #
3403
+ # cr-setup stamps a new change at the highest wave already open, which is right for a
3404
+ # change that stands alone and wrong for one that builds on another: a chained delta lands
3405
+ # at its predecessor's wave, where the blocker fence reads the two as independent and lets
3406
+ # them merge in either order. The delta documents already say otherwise — an author writes
3407
+ # the predecessor under `## Depends on`, with a reason — and nothing acted on it.
3408
+ #
3409
+ # This makes that declaration the ordering authority: required wave = the highest wave
3410
+ # among the changes named there, plus one; 1 when it names none. `specify` stamps it once
3411
+ # the model delta is authored, and the merge fence checks it, so the number and the prose
3412
+ # cannot drift apart. A change sitting HIGHER than required is fine and left alone — that
3413
+ # is deliberate slack, not a contradiction.
3414
+ cmd_cr_wave_derive() {
3415
+ local cr_id="" feature_dir_arg="" stamp=false
3416
+ while [ $# -gt 0 ]; do
3417
+ case "$1" in
3418
+ --cr-id) cr_id="$2"; shift 2 ;;
3419
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3420
+ --stamp) stamp=true; shift ;;
3421
+ --help|-h) echo "cr-wave-derive --cr-id <id> [--feature-dir <path>] [--stamp] -> {wave, required_wave, ok, depends_on[], unknown[], stamped, message}"; return 0 ;;
3422
+ *) echo "cr-wave-derive: unknown arg '$1'" >&2; return 1 ;;
3423
+ esac
3424
+ done
3425
+ cr_id=$(_cr_id_token "$cr_id")
3426
+ [ -n "$cr_id" ] || { echo "cr-wave-derive: --cr-id is required" >&2; return 1; }
3427
+ local repo_root; repo_root=$(get_repo_root)
3428
+ local fd="$feature_dir_arg"
3429
+ [ -n "$fd" ] || fd=$(read_feature_json_feature_directory "$repo_root")
3430
+ [ -n "$fd" ] || { echo "cr-wave-derive: no feature set and no --feature-dir" >&2; return 1; }
3431
+ [[ "$fd" != /* ]] && fd="$repo_root/$fd"
3432
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3433
+ AW_CR="$cr_id" AW_FD="$fd" AW_CTX="$ctx_dir" AW_STAMP="$stamp" python3 - <<'PY'
3434
+ import glob, json, os, re
3435
+
3436
+ cr = os.environ["AW_CR"]
3437
+ fd = os.environ["AW_FD"]
3438
+ ctx = os.environ["AW_CTX"]
3439
+ stamp = os.environ.get("AW_STAMP", "") == "true"
3440
+
3441
+ CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
3442
+ WAVE_RX = re.compile(r"^\*\*Wave\*\*:\s*(\d+)", re.M)
3443
+
3444
+
3445
+ def read(path):
3446
+ try:
3447
+ return open(path, encoding="utf-8").read()
3448
+ except Exception:
3449
+ return ""
3450
+
3451
+
3452
+ def wave_of(cr_id):
3453
+ """A change states its wave in the header of its own delta documents."""
3454
+ for p in (os.path.join(ctx, "domain-model.%s.delta.md" % cr_id),
3455
+ os.path.join(fd, "changes", cr_id, "delta-spec.md")):
3456
+ m = WAVE_RX.search(read(p))
3457
+ if m:
3458
+ return int(m.group(1))
3459
+ for p in glob.glob(os.path.join(glob.escape(os.path.join(fd, "changes", cr_id)), "delta-*.md")):
3460
+ m = WAVE_RX.search(read(p))
3461
+ if m:
3462
+ return int(m.group(1))
3463
+ return None
3464
+
3465
+
3466
+ def depends_ids(text):
3467
+ """The CR ids named under `## Depends on`, up to the next heading of that level."""
3468
+ m = re.search(r"^##\s+Depends on\s*$(.*?)(?=^##\s|\Z)", text, re.M | re.S)
3469
+ if not m:
3470
+ return []
3471
+ body = re.sub(r"<!--.*?-->", " ", m.group(1), flags=re.S)
3472
+ return [i for i in CR_RX.findall(body) if i != cr]
3473
+
3474
+
3475
+ change_dir = os.path.join(fd, "changes", cr)
3476
+ docs = [os.path.join(ctx, "domain-model.%s.delta.md" % cr),
3477
+ os.path.join(change_dir, "delta-spec.md")]
3478
+ docs += sorted(glob.glob(os.path.join(glob.escape(change_dir), "delta-*plan*.md")))
3479
+ present = [p for p in docs if os.path.isfile(p)]
3480
+
3481
+ deps, seen = [], set()
3482
+ for p in present:
3483
+ for i in depends_ids(read(p)):
3484
+ if i not in seen:
3485
+ seen.add(i)
3486
+ deps.append(i)
3487
+
3488
+ known, unknown = [], []
3489
+ for d in deps:
3490
+ w = wave_of(d)
3491
+ (known if w is not None else unknown).append({"cr_id": d, "wave": w})
3492
+ required = max([d["wave"] for d in known], default=0) + 1
3493
+ current = wave_of(cr)
3494
+ ok = current is not None and current >= required
3495
+
3496
+ stamped = []
3497
+ if stamp and current is not None and current < required:
3498
+ line = "**Wave**: %d" % required
3499
+ pat = re.compile(r"^\*\*Wave\*\*:.*$", re.M)
3500
+ for p in present:
3501
+ text = read(p)
3502
+ if pat.search(text):
3503
+ open(p, "w", encoding="utf-8").write(pat.sub(lambda _m: line, text, count=1))
3504
+ stamped.append(os.path.basename(p))
3505
+ if stamped:
3506
+ current, ok = required, True
3507
+
3508
+ if not deps:
3509
+ msg = "%s declares no predecessor, so wave %d is the floor; it carries %s" % (
3510
+ cr, required, current if current is not None else "no wave")
3511
+ elif stamped:
3512
+ msg = "%s depends on %s, so it was raised to wave %d in %s" % (
3513
+ cr, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3514
+ required, ", ".join(stamped))
3515
+ elif ok:
3516
+ msg = "%s is at wave %s, at or above the %d its `## Depends on` requires" % (
3517
+ cr, current, required)
3518
+ else:
3519
+ msg = ("%s is at wave %s but depends on %s, so it must sit at wave %d — a predecessor "
3520
+ "at the same or a higher wave is not ordered behind it at all" % (
3521
+ cr, current, ", ".join("%s (wave %s)" % (d["cr_id"], d["wave"]) for d in known),
3522
+ required))
3523
+ if unknown:
3524
+ msg += ". Named but not found open: %s" % ", ".join(u["cr_id"] for u in unknown)
3525
+
3526
+ print(json.dumps({
3527
+ "cr_id": cr, "wave": current, "required_wave": required, "ok": ok,
3528
+ "depends_on": known, "unknown": unknown, "depends_count": len(deps),
3529
+ "stamped": stamped, "documents": [os.path.basename(p) for p in present],
3530
+ "message": msg,
3531
+ }))
3532
+ PY
3533
+ }
3534
+
3535
+ # ────────────────────────────── cr-rebase ──────────────────────────────────
3536
+ # RE-STAMP a change's base versions onto the masters it is being merged into — but only
3537
+ # when nothing that landed in between touched what this delta touches.
3538
+ #
3539
+ # `**Base version**` is written once, by cr-setup, and the merge fence refuses when it no
3540
+ # longer matches the master. Nothing could ever update it: every other site reads it, and
3541
+ # every authoring prompt forbids the model from writing it, so the only documented way out
3542
+ # of that refusal was hand-editing the delta-spec in the middle of an automated loop.
3543
+ #
3544
+ # WHAT "APPLIES CLEANLY" MEANS HERE, because a stamp that is not earned is worse than a
3545
+ # refusal: the masters moved, and every change that landed since this delta was stamped
3546
+ # touched OTHER aggregates than the ones this delta names. Then the delta still describes
3547
+ # the document it was written against, and the stamp is bookkeeping. A landed change
3548
+ # sharing an aggregate — or one whose own model delta cannot be read — leaves it refused,
3549
+ # and a human rebases. `**Base context version**` is compared and re-stamped the same way,
3550
+ # which is the first time anything reads it.
3551
+ cmd_cr_rebase() {
3552
+ local cr_id="" feature_dir_arg="" stamp=false
3553
+ while [ $# -gt 0 ]; do
3554
+ case "$1" in
3555
+ --cr-id) cr_id="$2"; shift 2 ;;
3556
+ --feature-dir) feature_dir_arg="$2"; shift 2 ;;
3557
+ --stamp) stamp=true; shift ;;
3558
+ --help|-h) echo "cr-rebase --cr-id <id> [--feature-dir <path>] [--stamp] -> {ok, clean, moved, context_moved, base_version, current_version, landed_since[], conflicts[], stamped[], message}"; return 0 ;;
3559
+ *) echo "cr-rebase: unknown arg '$1'" >&2; return 1 ;;
3560
+ esac
3561
+ done
3562
+ cr_id=$(_cr_id_token "$cr_id")
3563
+ [ -n "$cr_id" ] || { echo "cr-rebase: --cr-id is required" >&2; return 1; }
3564
+ local repo_root; repo_root=$(get_repo_root)
3565
+ local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-rebase: no feature set and no --feature-dir" >&2; return 1; }
3566
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
3567
+ AW_CR="$cr_id" AW_FD="$fd" AW_CTX="$ctx_dir" AW_STAMP="$stamp" python3 - <<'PY'
3568
+ import glob, json, os, re
3569
+
3570
+ cr = os.environ["AW_CR"]
3571
+ fd = os.environ["AW_FD"]
3572
+ ctx = os.environ["AW_CTX"]
3573
+ stamp = os.environ.get("AW_STAMP", "") == "true"
3574
+
3575
+ CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
3576
+ TICK_RX = re.compile(r"`([^`]+)`")
3577
+
3578
+
3579
+ def read(path):
3580
+ try:
3581
+ return open(path, encoding="utf-8").read()
3582
+ except Exception:
3583
+ return ""
3584
+
3585
+
3586
+ def header(text, name):
3587
+ m = re.search(r"^\*\*%s\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$" % re.escape(name), text, re.M)
3588
+ if not m:
3589
+ return ""
3590
+ v = m.group(1).strip()
3591
+ return "" if (v.startswith("[") and v.endswith("]")) else v
3592
+
3593
+
3594
+ def norm(v):
3595
+ v = (v or "").strip()
3596
+ return "" if not v else (v if v.startswith("v") else "v" + v)
3597
+
3598
+
3599
+ def as_tuple(v):
3600
+ parts = re.findall(r"\d+", v or "")
3601
+ return tuple(int(p) for p in parts) if parts else None
3602
+
3603
+
3604
+ def aggregates(text):
3605
+ """The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
3606
+ m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
3607
+ if not m:
3608
+ return set()
3609
+ value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
3610
+ names = TICK_RX.findall(value)
3611
+ if not names:
3612
+ names = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
3613
+ names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
3614
+ for n in names if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
3615
+ return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
3616
+
3617
+
3618
+ def landed_since(text, base):
3619
+ """CR ids in Change History rows whose version is ABOVE `base` — what moved the master
3620
+ since this delta was stamped. A row with no readable version counts, because a row this
3621
+ cannot place is one it cannot prove is older."""
3622
+ ids, base_t = [], as_tuple(base)
3623
+ for line in text.split("\n"):
3624
+ if not line.strip().startswith("|"):
3625
+ continue
3626
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
3627
+ if len(cells) < 2 or set("".join(cells)) <= set("-: "):
3628
+ continue
3629
+ row_t = as_tuple(cells[0]) if re.match(r"^v?\d", cells[0]) else None
3630
+ if row_t is None:
3631
+ continue
3632
+ if base_t is not None and row_t <= base_t:
3633
+ continue
3634
+ for i in CR_RX.findall(line):
3635
+ if i != cr and i not in ids:
3636
+ ids.append(i)
3637
+ return ids
3638
+
3639
+
3640
+ def model_delta_of(cr_id):
3641
+ """A change's model half: beside the master while open, in its archive once closed."""
3642
+ p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
3643
+ if os.path.isfile(p):
3644
+ return read(p)
3645
+ for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
3646
+ a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
3647
+ if os.path.isfile(a):
3648
+ return read(a)
3649
+ return ""
3650
+
3651
+
3652
+ my_spec = os.path.join(fd, "changes", cr, "delta-spec.md")
3653
+ my_model = os.path.join(ctx, "domain-model.%s.delta.md" % cr)
3654
+ spec_master = os.path.join(fd, "spec.md")
3655
+ model_master = os.path.join(ctx, "domain-model.md")
3656
+
3657
+ spec_text, model_text = read(my_spec), read(my_model)
3658
+ base_version = norm(header(spec_text, "Base version"))
3659
+ base_ctx = norm(header(model_text, "Base context version"))
3660
+
3661
+ current = ""
3662
+ try:
3663
+ current = norm(json.load(open(os.path.join(fd, "active_version.json"), encoding="utf-8")).get("version", ""))
3664
+ except Exception:
3665
+ current = ""
3666
+ if not current:
3667
+ current = norm(header(read(spec_master), "Version"))
3668
+ current_ctx = norm(header(read(model_master), "Version"))
3669
+
3670
+ moved = bool(base_version and current and base_version != current)
3671
+ ctx_moved = bool(base_ctx and current_ctx and base_ctx != current_ctx)
3672
+
3673
+ # WHAT LANDED IN BETWEEN, and whether any of it touched what this delta touches. A delta
3674
+ # whose aggregates nobody else moved still applies to the master it was written against;
3675
+ # one whose aggregates a landed change rewrote does not, and no stamp can make it.
3676
+ mine = aggregates(model_text)
3677
+ intervening = landed_since(read(spec_master), base_version) if moved else []
3678
+ if ctx_moved:
3679
+ for i in landed_since(read(model_master), base_ctx):
3680
+ if i not in intervening:
3681
+ intervening.append(i)
3682
+
3683
+ rows, conflicts = [], []
3684
+ for i in intervening:
3685
+ theirs = aggregates(model_delta_of(i))
3686
+ shared = sorted(mine & theirs) if (mine and theirs) else []
3687
+ # Unknown aggregates fail CLOSED: a change this cannot read is one it cannot clear.
3688
+ unknown = not theirs
3689
+ rows.append({"cr_id": i, "aggregates": sorted(theirs), "shared": shared, "unknown": unknown})
3690
+ if shared or unknown:
3691
+ conflicts.append({"cr_id": i, "shared": shared, "unknown": unknown})
3692
+
3693
+ clean = (moved or ctx_moved) and not conflicts
3694
+ stamped = []
3695
+ if stamp and clean:
3696
+ if moved and os.path.isfile(my_spec):
3697
+ open(my_spec, "w", encoding="utf-8").write(
3698
+ re.sub(r"^\*\*Base version\*\*:.*$", "**Base version**: %s" % current,
3699
+ spec_text, count=1, flags=re.M))
3700
+ stamped.append({"file": os.path.basename(my_spec), "header": "Base version",
3701
+ "from": base_version, "to": current})
3702
+ if ctx_moved and os.path.isfile(my_model):
3703
+ open(my_model, "w", encoding="utf-8").write(
3704
+ re.sub(r"^\*\*Base context version\*\*:.*$", "**Base context version**: %s" % current_ctx,
3705
+ model_text, count=1, flags=re.M))
3706
+ stamped.append({"file": os.path.basename(my_model), "header": "Base context version",
3707
+ "from": base_ctx, "to": current_ctx})
3708
+ if stamped:
3709
+ moved = ctx_moved = False
3710
+
3711
+ ok = not (moved or ctx_moved)
3712
+ if not (base_version or base_ctx):
3713
+ msg = "%s carries no base stamp, so there is nothing to compare — treated as not moved" % cr
3714
+ elif ok and not stamped:
3715
+ msg = "%s is stamped against the current masters (%s, context %s)" % (cr, current or "—", current_ctx or "—")
3716
+ elif stamped:
3717
+ msg = "%s was re-stamped: %s. Nothing that landed in between (%s) touched its aggregates (%s)" % (
3718
+ cr, "; ".join("%s %s → %s" % (s["header"], s["from"], s["to"]) for s in stamped),
3719
+ ", ".join(r["cr_id"] for r in rows) or "no recorded change", ", ".join(sorted(mine)) or "none named")
3720
+ else:
3721
+ parts = []
3722
+ for c in conflicts:
3723
+ parts.append("%s (%s)" % (c["cr_id"], "aggregates unreadable" if c["unknown"]
3724
+ else "also touched " + ", ".join(c["shared"])))
3725
+ msg = ("%s was authored against %s%s but the masters are at %s%s, and what landed in "
3726
+ "between overlaps it: %s" % (
3727
+ cr, base_version or "—", "" if not base_ctx else " (context %s)" % base_ctx,
3728
+ current or "—", "" if not current_ctx else " (context %s)" % current_ctx,
3729
+ "; ".join(parts)))
3730
+
3731
+ print(json.dumps({
3732
+ "cr_id": cr, "ok": ok, "clean": clean, "moved": moved, "context_moved": ctx_moved,
3733
+ "base_version": base_version, "current_version": current,
3734
+ "base_context_version": base_ctx, "current_context_version": current_ctx,
3735
+ "aggregates": sorted(mine), "landed_since": rows,
3736
+ "conflicts": conflicts, "conflict_count": len(conflicts),
3737
+ "conflict_ids": ", ".join(c["cr_id"] for c in conflicts),
3738
+ "stamped": stamped, "message": msg,
3739
+ }))
3740
+ PY
3741
+ }
3742
+
3315
3743
  # ─────────────────────────────── cr-list ───────────────────────────────────
3316
3744
  # The changes currently OPEN on a feature, in wave order. This is what lets a delta
3317
3745
  # stage ASK which change it is working on instead of inferring one: many may be open at
@@ -3555,11 +3983,12 @@ PY
3555
3983
 
3556
3984
  # ─────────────────────────────── cr-overlap ────────────────────────────────
3557
3985
  # Which OTHER open changes touch the same aggregates as this one — read off the
3558
- # `## Aggregate: <Name>` headers of the pending model deltas, which is the merge unit
3559
- # domain-model.md is organised by.
3986
+ # `**Aggregates touched**:` header of each pending model delta. That header names the
3987
+ # `## Aggregate: <Name>` sections of domain-model.md the delta merges into; the delta
3988
+ # itself carries no such headings, the master does.
3560
3989
  #
3561
3990
  # DETERMINISTIC ON PURPOSE. Two deltas collide when they change the same aggregate, and
3562
- # that is a fact about two files' headings — a regex answers it exactly, at no cost, and
3991
+ # that is a fact about two files' headers — a regex answers it exactly, at no cost, and
3563
3992
  # the same way every run. Handing the question to a model instead would make the one
3564
3993
  # check that can be exact the one check that can hallucinate.
3565
3994
  #
@@ -3602,14 +4031,27 @@ def read(p):
3602
4031
  def wave_of(text, default=1):
3603
4032
  m = re.search(r"^\*\*Wave\*\*:\s*(\d+)", text, re.M)
3604
4033
  return int(m.group(1)) if m else default
3605
- # The merge unit. A placeholder heading left by the template names no aggregate, so an
3606
- # unfilled model delta reports none rather than colliding with every other change on
3607
- # "<Name>".
4034
+ # The aggregates a model delta declares, from its `**Aggregates touched**:` header — the
4035
+ # same list finalize-delta opens master sections for. Backticked names are the template's
4036
+ # form and win when present, so prose around them ("`Appointment` (lifecycle), `Patient`
4037
+ # (read)") adds nothing; a value with no backticks is split on , ; / & "and" and each
4038
+ # piece's leading name is taken. The template placeholder (`[Root]`) and "None — behaviour
4039
+ # only" name no aggregate, so an unfilled or model-free delta reports none rather than
4040
+ # colliding with every other change.
3608
4041
  def aggregates_of(text):
4042
+ m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$", text, re.M)
4043
+ if not m:
4044
+ return []
4045
+ value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
4046
+ names = re.findall(r"`([^`]+)`", value)
4047
+ if not names:
4048
+ names = [p.group(0) for p in
4049
+ (re.match(r"[A-Za-z][A-Za-z0-9_]*", piece.strip())
4050
+ for piece in re.split(r"[,;/&]|\band\b", value)) if p]
3609
4051
  out = []
3610
- for m in re.finditer(r"^##\s*Aggregate:\s*(.+?)\s*$", text, re.M):
3611
- name = m.group(1).strip().strip("`")
3612
- if not name or name.startswith("<") or name.startswith("["):
4052
+ for name in names:
4053
+ name = name.strip()
4054
+ if not name or name[0] in "<[" or re.match(r"none\b", name, re.I):
3613
4055
  continue
3614
4056
  if name not in out:
3615
4057
  out.append(name)
@@ -4065,17 +4507,28 @@ for f in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
4065
4507
  blockers = [v for k, v in sorted(opens.items()) if k != cr and v["wave"] < wave]
4066
4508
 
4067
4509
  # THE MASTERS CAN MOVE WITHOUT THE VERSION MOVING. finalize-delta merges and close-delta
4068
- # bumps, so between the two spec.md carries a change the number does not yet reflect — and a
4069
- # second delta sharing the same base_version would compare equal and merge onto masters that
4070
- # had already shifted under it. An id appearing in spec.md is the on-disk proof that the
4071
- # merge ran (it is the Change History row finalize-delta appends), which is the same
4072
- # evidence cr-recall-check reads for the same question.
4073
- spec_text = ""
4510
+ # bumps, so between the two the masters carry a change the number does not yet reflect — and
4511
+ # a second delta sharing the same base_version would compare equal and merge onto masters
4512
+ # that had already shifted under it. The proof is the Change History row finalize-delta
4513
+ # appends, carrying the id in its CR cell.
4514
+ #
4515
+ # aw-crstate owns that test, for every reader of it. It replaces `cr in spec.md` — which
4516
+ # fired on an id quoted in prose and matched a prefix of a longer id — and it looks where a
4517
+ # merge can actually show: this feature's spec AND the context model every feature shares,
4518
+ # so a SIBLING feature's merged-unclosed change is no longer invisible. A sibling blocks
4519
+ # only where it touched an aggregate this delta also names (`blocking`); a merge in this
4520
+ # feature blocks outright, because it moved this delta's own master.
4521
+ merged_open = []
4074
4522
  try:
4075
- spec_text = open(os.path.join(fd, "spec.md"), encoding="utf-8").read()
4076
- except Exception:
4077
- pass
4078
- merged_open = [v for k, v in sorted(opens.items()) if k != cr and k in spec_text]
4523
+ import importlib.util
4524
+ _mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
4525
+ _spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
4526
+ _m = importlib.util.module_from_spec(_spec)
4527
+ _spec.loader.exec_module(_m)
4528
+ merged_open = [r for r in _m.merged_open(ctx, fd, cr) if r["blocking"]]
4529
+ except Exception as _e:
4530
+ # Fail CLOSED on an unreadable helper: report the state as blocking rather than clear.
4531
+ merged_open = [{"cr_id": "(merged-open check failed: %s)" % _e, "blocking": True}]
4079
4532
 
4080
4533
  print(json.dumps({
4081
4534
  "ok": True, "cr_id": cr, "wave": wave,
@@ -4127,9 +4580,11 @@ cmd_cr_recall_check() {
4127
4580
  [ -n "$cr_id" ] || { echo "cr-recall-check: --cr-id required" >&2; return 1; }
4128
4581
  local repo_root; repo_root=$(get_repo_root)
4129
4582
  local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "cr-recall-check: no feature set and no --feature-dir" >&2; return 1; }
4130
- AW_FD="$fd" AW_CR="$cr_id" python3 - <<'PY'
4583
+ local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
4584
+ AW_FD="$fd" AW_CR="$cr_id" AW_ROOT="$repo_root" AW_CTX="$ctx_dir" python3 - <<'PY'
4131
4585
  import json, os, re
4132
4586
  fd = os.environ["AW_FD"]; cr = os.environ["AW_CR"]
4587
+ root = os.environ.get("AW_ROOT", ""); ctx = os.environ.get("AW_CTX", "")
4133
4588
  changes = os.path.join(fd, "changes")
4134
4589
  cr_dir = os.path.join(changes, cr)
4135
4590
 
@@ -4142,13 +4597,24 @@ def wave_of(d):
4142
4597
  return int(m.group(1))
4143
4598
  return 1
4144
4599
 
4600
+ # MERGED = a Change History row whose CR cell IS this change, in this feature's spec or in
4601
+ # the context model every feature shares — aw-crstate owns that test for all three readers
4602
+ # of it. The old test was `cr in line`, which fired on an id quoted in prose and matched a
4603
+ # prefix of a longer id.
4145
4604
  spec = os.path.join(fd, "spec.md")
4146
- merged, evidence = False, ""
4147
- if os.path.isfile(spec):
4148
- for line in open(spec, encoding="utf-8").read().splitlines():
4149
- if cr in line:
4150
- merged, evidence = True, line.strip()
4151
- break
4605
+ merged, evidence, merged_doc = False, "", ""
4606
+ try:
4607
+ import importlib.util
4608
+ _mod_path = os.path.join(root, ".appweaver", "scripts", "python", "aw-crstate.py")
4609
+ _spec = importlib.util.spec_from_file_location("aw_crstate", _mod_path)
4610
+ _m = importlib.util.module_from_spec(_spec)
4611
+ _spec.loader.exec_module(_m)
4612
+ evidence, merged_doc = _m.merged_in([spec, os.path.join(ctx, "domain-model.md")], cr)
4613
+ merged = bool(evidence)
4614
+ except Exception:
4615
+ # Fail CLOSED: an unreadable helper must not report "never merged", which is the
4616
+ # answer that lets close-delta archive a change nobody merged.
4617
+ merged, evidence = True, "(merged check unavailable — treated as merged)"
4152
4618
 
4153
4619
  wave = wave_of(cr_dir) if os.path.isdir(cr_dir) else 1
4154
4620
  dependants = []
@@ -4190,31 +4656,87 @@ PY
4190
4656
  # WHY THE GATE AND NOT THE AUTHOR. Stamping at `plan` would record "this plan was authored
4191
4657
  # from that spec". The question implement depends on is stronger — "were these plans PROVEN
4192
4658
  # against this spec" — and a plan authored from the current spec can still be wrong. So the
4193
- # receipt is written where the proof happens, by analyze-plan, on green, and it hashes every
4194
- # document the gate actually read: the spec, the context's domain model (check 4 reads it)
4195
- # and each lane plan that was present.
4659
+ # receipt is written where the proof happens, by analyze-plan, and it hashes every document
4660
+ # the gate actually read: the spec, the context's domain model (check 4 reads it) and each
4661
+ # lane plan that was present.
4662
+ #
4663
+ # THE RECEIPT CARRIES THE VERDICT, and every master-mode run writes one. A receipt written
4664
+ # only on a clean pass is never written on a feature whose spec misses a domain constraint —
4665
+ # a LIFT-FAILURE analyze-plan may not close — and then the staleness check has nothing to
4666
+ # compare. The verdict splits the gaps by who can close them (plan-gaps):
4667
+ # conformant no gaps
4668
+ # routed-gaps only gaps /appweaver specify must close — implement warns and generates
4669
+ # gaps at least one gap a plan rewrite closes — implement refuses
4196
4670
  #
4197
4671
  # The absence of a receipt is INFORMATION, not an error: it means this feature has never
4198
4672
  # been through the gate, which every tree that predates this op is true of. It is reported
4199
- # as untracked and blocks nothing. Only a receipt that EXISTS and no longer matches is a
4200
- # refusal, because that tree has been gated once and the bytes have moved since.
4673
+ # as untracked and blocks nothing. A receipt that EXISTS refuses when the bytes have moved
4674
+ # since, or when its verdict is `gaps`. A receipt written before the verdict existed was
4675
+ # written on a clean pass, and reads as `conformant`.
4201
4676
  _plan_gate_file() { printf '%s/.aw-plan-gate.json' "$1"; }
4202
4677
 
4678
+ # Which analyze-plan gaps a plan rewrite can close, and which only /appweaver specify can.
4679
+ # A gap routes to specify when the analyze pass tagged it `route: specify` (a constraint the
4680
+ # spec never lifted, structure domain-model.md does not define, behaviour the spec does not
4681
+ # describe), or when its text opens with LIFT-FAILURE whatever the tag says. Anything else —
4682
+ # an untagged gap included — is a plan gap. Unreadable input is one plan gap: a verdict that
4683
+ # could not be read must not pass.
4684
+ cmd_plan_gaps() {
4685
+ local gaps=""
4686
+ while [ $# -gt 0 ]; do
4687
+ case "$1" in
4688
+ --gaps) gaps="$2"; shift 2 ;;
4689
+ --help|-h) echo "plan-gaps --gaps <json list> -> {verdict, plan_ok, plan_gaps, routed_gaps, routed[], routed_csv}"; return 0 ;;
4690
+ *) echo "plan-gaps: unknown arg '$1'" >&2; return 1 ;;
4691
+ esac
4692
+ done
4693
+ AW_GAPS="$gaps" python3 - <<'PY'
4694
+ import json, os
4695
+ raw = (os.environ.get("AW_GAPS") or "").strip()
4696
+ try:
4697
+ gaps = json.loads(raw) if raw else []
4698
+ if not isinstance(gaps, list):
4699
+ raise ValueError("not a list")
4700
+ readable = True
4701
+ except Exception:
4702
+ gaps, readable = [], False
4703
+ plan, routed = 0, []
4704
+ for g in gaps:
4705
+ d = g if isinstance(g, dict) else {"gap": str(g)}
4706
+ text = str(d.get("gap", "")).strip()
4707
+ if text.upper().startswith("LIFT-FAILURE") or str(d.get("route", "")).strip().lower() == "specify":
4708
+ ref = str(d.get("spec_ref", "") or "").strip()
4709
+ routed.append(("%s: %s" % (ref, text)) if ref and ref != "—" else text)
4710
+ else:
4711
+ plan += 1
4712
+ if not readable:
4713
+ plan += 1
4714
+ verdict = "gaps" if plan else ("routed-gaps" if routed else "conformant")
4715
+ print(json.dumps({"ok": True, "readable": readable, "verdict": verdict, "plan_ok": plan == 0,
4716
+ "plan_gaps": plan, "routed_gaps": len(routed), "routed": routed,
4717
+ "routed_csv": "; ".join(routed)}))
4718
+ PY
4719
+ }
4720
+
4203
4721
  cmd_plan_gate_stamp() {
4204
- local feature_dir_arg="" spec_version="" mode="master"
4722
+ local feature_dir_arg="" spec_version="" mode="master" verdict="conformant" plan_gaps="0" routed="[]"
4205
4723
  while [ $# -gt 0 ]; do
4206
4724
  case "$1" in
4207
4725
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4208
4726
  --spec-version) spec_version="$2"; shift 2 ;;
4209
4727
  --mode) mode="$2"; shift 2 ;;
4210
- --help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] -> {file, gated, hashed[], count}"; return 0 ;;
4728
+ --verdict) verdict="$2"; shift 2 ;;
4729
+ --plan-gaps) plan_gaps="$2"; shift 2 ;;
4730
+ --routed) routed="$2"; shift 2 ;;
4731
+ --help|-h) echo "plan-gate-stamp [--feature-dir <path>] [--spec-version <v>] [--mode <text>] [--verdict conformant|routed-gaps|gaps] [--plan-gaps <n>] [--routed <json list>] -> {file, gated, verdict, hashed[], count}"; return 0 ;;
4211
4732
  *) echo "plan-gate-stamp: unknown arg '$1'" >&2; return 1 ;;
4212
4733
  esac
4213
4734
  done
4214
4735
  local repo_root; repo_root=$(get_repo_root)
4215
4736
  local fd; fd=$(_resolve_feature_dir "$feature_dir_arg" "$repo_root") || { echo "plan-gate-stamp: no feature set and no --feature-dir" >&2; return 1; }
4216
4737
  local ctx_dir; ctx_dir=$(_context_dir_for_feature "$fd" "$repo_root")
4217
- AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode" python3 - <<'PY'
4738
+ AW_FD="$fd" AW_CTX="$ctx_dir" AW_ROOT="$repo_root" AW_VER="$spec_version" AW_MODE="$mode" \
4739
+ AW_VERDICT="$verdict" AW_PLAN_GAPS="$plan_gaps" AW_ROUTED="$routed" python3 - <<'PY'
4218
4740
  import datetime, hashlib, json, os
4219
4741
  fd = os.environ["AW_FD"]; ctx = os.environ["AW_CTX"]; root = os.environ["AW_ROOT"]
4220
4742
  def sha(p):
@@ -4236,21 +4758,34 @@ plans = {}
4236
4758
  for name in ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md"):
4237
4759
  h = sha(os.path.join(fd, name))
4238
4760
  if h: plans[name] = h
4761
+ # An unrecognised verdict is recorded as `gaps`, the one that refuses.
4762
+ verdict = os.environ.get("AW_VERDICT", "").strip()
4763
+ if verdict not in ("conformant", "routed-gaps", "gaps"):
4764
+ verdict = "gaps"
4765
+ try: plan_gaps = int(os.environ.get("AW_PLAN_GAPS", "0") or 0)
4766
+ except ValueError: plan_gaps = 0
4767
+ try: routed = [str(x) for x in json.loads(os.environ.get("AW_ROUTED") or "[]")]
4768
+ except Exception: routed = []
4239
4769
  rec = {
4240
4770
  "gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
4241
4771
  "gate_mode": os.environ.get("AW_MODE", "master"),
4242
4772
  "spec_version": os.environ.get("AW_VER", "").strip(),
4773
+ "verdict": verdict,
4774
+ "plan_gaps": plan_gaps,
4775
+ "routed_gaps": routed,
4243
4776
  "spec_sha": sha(os.path.join(fd, "spec.md")),
4244
4777
  "model_sha": sha(os.path.join(ctx, "domain-model.md")),
4245
4778
  "plans": plans,
4246
- "note": ("Written by analyze-plan when it PASSED. It records the bytes that were "
4247
- "proven against each other, not a version — the plans' **Version**: headers "
4248
- "are stamped in lockstep by close-delta and cannot answer this. implement "
4249
- "reads it through plan-gate-check."),
4779
+ "note": ("Written by every master-mode analyze-plan run. It records the bytes that were "
4780
+ "checked against each other and the verdict on them, not a version — the plans' "
4781
+ "**Version**: headers are stamped in lockstep by close-delta and cannot answer "
4782
+ "this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` "
4783
+ "refuse; `routed-gaps` lists what /appweaver specify still has to lift."),
4250
4784
  }
4251
4785
  p = os.path.join(fd, ".aw-plan-gate.json")
4252
4786
  open(p, "w", encoding="utf-8").write(json.dumps(rec, indent=2) + "\n")
4253
4787
  print(json.dumps({"file": rel(p), "gated": rec["gated"], "spec_version": rec["spec_version"],
4788
+ "verdict": verdict, "plan_gaps": plan_gaps, "routed_gaps": len(routed),
4254
4789
  "hashed": sorted(plans.keys()), "count": len(plans), "ok": True}))
4255
4790
  PY
4256
4791
  }
@@ -4260,7 +4795,7 @@ cmd_plan_gate_check() {
4260
4795
  while [ $# -gt 0 ]; do
4261
4796
  case "$1" in
4262
4797
  --feature-dir) feature_dir_arg="$2"; shift 2 ;;
4263
- --help|-h) echo "plan-gate-check [--feature-dir <path>] -> {tracked, gate_fresh, spec_moved, model_moved, plans_changed[], plans_changed_csv, gated, reason}"; return 0 ;;
4798
+ --help|-h) echo "plan-gate-check [--feature-dir <path>] -> {tracked, gate_fresh, verdict, may_generate, plan_gaps, routed_gaps[], routed_csv, spec_moved, model_moved, plans_changed[], plans_changed_csv, gated, reason}"; return 0 ;;
4264
4799
  *) echo "plan-gate-check: unknown arg '$1'" >&2; return 1 ;;
4265
4800
  esac
4266
4801
  done
@@ -4279,16 +4814,22 @@ rec = None
4279
4814
  try: rec = json.load(open(p, encoding="utf-8"))
4280
4815
  except Exception: rec = None
4281
4816
  out = {"file": os.path.relpath(p, root), "tracked": bool(rec), "gate_fresh": False,
4817
+ "verdict": "", "may_generate": True, "plan_gaps": 0, "routed_gaps": [], "routed_csv": "",
4282
4818
  "spec_moved": False, "model_moved": False, "plans_changed": [],
4283
4819
  "plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
4284
4820
  if not rec:
4285
4821
  # Never gated. Not a failure and not a stale plan — an unknown, and the only honest
4286
4822
  # thing to report about a tree that has not run analyze-plan since this op existed.
4287
- out["reason"] = ("no .aw-plan-gate.json — this feature has not passed analyze-plan "
4288
- "since the gate began leaving a receipt")
4823
+ out["reason"] = ("no .aw-plan-gate.json — this feature has not been through analyze-plan "
4824
+ "in master mode since the gate began leaving a receipt")
4289
4825
  else:
4290
4826
  out["gated"] = rec.get("gated", "")
4291
4827
  out["spec_version"] = rec.get("spec_version", "")
4828
+ # A receipt from before the verdict existed was only ever written on a clean pass.
4829
+ out["verdict"] = rec.get("verdict") or "conformant"
4830
+ out["plan_gaps"] = rec.get("plan_gaps") or 0
4831
+ out["routed_gaps"] = [str(x) for x in (rec.get("routed_gaps") or [])]
4832
+ out["routed_csv"] = "; ".join(out["routed_gaps"])
4292
4833
  out["spec_moved"] = bool(rec.get("spec_sha")) and rec["spec_sha"] != sha(os.path.join(fd, "spec.md"))
4293
4834
  # A model recorded as absent that is still absent has not moved. A context that
4294
4835
  # GAINED a domain model since the gate has moved: check 4 never ran against it.
@@ -4300,18 +4841,48 @@ else:
4300
4841
  out["plans_changed"] = changed
4301
4842
  out["plans_changed_csv"] = ", ".join(changed)
4302
4843
  out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
4303
- if out["gate_fresh"]:
4304
- out["reason"] = "the spec, the model and every gated plan are byte-identical to what analyze-plan passed on %s" % out["gated"]
4305
- else:
4844
+ # Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
4845
+ out["may_generate"] = out["gate_fresh"] and out["verdict"] != "gaps"
4846
+ if not out["gate_fresh"]:
4306
4847
  bits = []
4307
4848
  if out["spec_moved"]: bits.append("spec.md has changed")
4308
4849
  if out["model_moved"]: bits.append("domain-model.md has changed")
4309
4850
  if changed: bits.append("%s changed (a re-derive by `plan main`, or a hand edit)" % ", ".join(changed))
4310
- out["reason"] = "since analyze-plan passed on %s: %s" % (out["gated"], "; ".join(bits))
4851
+ out["reason"] = "since analyze-plan checked them on %s: %s" % (out["gated"], "; ".join(bits))
4852
+ elif out["verdict"] == "gaps":
4853
+ out["reason"] = ("analyze-plan on %s left %s gap(s) a plan rewrite closes, unrepaired"
4854
+ % (out["gated"], out["plan_gaps"] or "some"))
4855
+ elif out["verdict"] == "routed-gaps":
4856
+ out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
4857
+ "analyze-plan checked on %s; it left %d gap(s) only /appweaver specify "
4858
+ "can close: %s" % (out["gated"], len(out["routed_gaps"]), out["routed_csv"]))
4859
+ else:
4860
+ out["reason"] = "the spec, the model and every gated plan are byte-identical to what analyze-plan passed on %s" % out["gated"]
4311
4861
  print(json.dumps(out))
4312
4862
  PY
4313
4863
  }
4314
4864
 
4865
+ # ──────────────────────────────── plan-coverage ─────────────────────────────
4866
+ # Which of a lane plan's elements the generated code realises: each element the plan
4867
+ # names, whether the lane's code names it, and whether the generating model reported it
4868
+ # done. implement runs it after each lane generates, so a pass that stopped part-way is
4869
+ # caught instead of looking like a finished one. One implementation
4870
+ # (python/aw-plan-coverage.py), called identically by both shells.
4871
+ cmd_plan_coverage() {
4872
+ case "${1:-}" in
4873
+ --help|-h) echo "plan-coverage --plan <path> --kind backend|frontend [--mode bootstrap|delta] [--source-path <p>] [--test-path <p>] [--reported <json list>]... [--list-only] -> {ok, total, names[], names_csv, elements[], gaps[], gap_names[], gap_count, gaps_text, message}"; return 0 ;;
4874
+ esac
4875
+ local repo_root; repo_root=$(get_repo_root)
4876
+ local core="$SCRIPT_DIR/../python/aw-plan-coverage.py"
4877
+ if [ ! -f "$core" ]; then
4878
+ echo "plan-coverage: core not found at $core" >&2; return 1
4879
+ fi
4880
+ if ! command -v python3 >/dev/null 2>&1; then
4881
+ echo "plan-coverage: python3 is required" >&2; return 1
4882
+ fi
4883
+ python3 "$core" --repo "$repo_root" "$@"
4884
+ }
4885
+
4315
4886
  # Stamp a version into the MASTER documents' `**Version**:` headers — the
4316
4887
  # deterministic twin of cr-archive's active_version.json bump. close-delta calls
4317
4888
  # this with the version cr-archive just computed, so all THREE carriers of the
@@ -4454,6 +5025,8 @@ case "$sub" in
4454
5025
  cr-overlap) cmd_cr_overlap "$@" ;;
4455
5026
  cr-activate) cmd_cr_activate "$@" ;;
4456
5027
  cr-wave) cmd_cr_wave "$@" ;;
5028
+ cr-wave-derive) cmd_cr_wave_derive "$@" ;;
5029
+ cr-rebase) cmd_cr_rebase "$@" ;;
4457
5030
  cr-archive) cmd_cr_archive "$@" ;;
4458
5031
  cr-recall-check) cmd_cr_recall_check "$@" ;;
4459
5032
  cr-merge-check) cmd_cr_merge_check "$@" ;;
@@ -4462,8 +5035,10 @@ case "$sub" in
4462
5035
  context-paths) cmd_context_paths "$@" ;;
4463
5036
  model-scaffold) cmd_model_scaffold "$@" ;;
4464
5037
  version-stamp) cmd_version_stamp "$@" ;;
5038
+ plan-gaps) cmd_plan_gaps "$@" ;;
4465
5039
  plan-gate-stamp) cmd_plan_gate_stamp "$@" ;;
4466
5040
  plan-gate-check) cmd_plan_gate_check "$@" ;;
5041
+ plan-coverage) cmd_plan_coverage "$@" ;;
4467
5042
  checklist-read) cmd_checklist_read "$@" ;;
4468
5043
  checklist-apply) cmd_checklist_apply "$@" ;;
4469
5044
  detect-project) cmd_detect_project "$@" ;;