[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6E5VG1R7FF1025BZAV6BDTS":3,"skill-same:01M6E5VG1R7FF1025BZAV6BDTS":82},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":17,"publishedAt":33,"linkOnly":34,"markdownContent":35,"markdownBody":36,"frontMatter":37,"refName":39,"commitSha":40,"skillDirectory":41,"forksCount":13,"repositoryHtmlUrl":42,"repositoryHomepageUrl":6,"ownerHtmlUrl":43,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":44,"install":45,"resources":58},"01M6E5VG1R7FF1025BZAV6BDTS","wave-wrapup",null,"Finalize a wave — PR review, merge sequencing, issue cleanup, worktree cleanup, and handoff to retro","parametrization\u002F2real-team-framework","parametrization","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F1111097?v=4","01M6E5MC117M94CEXKX3C96PPX","01M6E5MC7YWG5N3VV4792VFK85",0,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002FSKILL.md",[],[18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"wrapup","wave wrapup","review","handoff","merge","pr review","pr","cleanup","worktree","retro","team","issue","worktree cleanup","name","sequencing","2026-11-10 10:12:30",false,"---\nname: wave-wrapup\ndescription: Finalize a wave — PR review, merge sequencing, issue cleanup, worktree cleanup, and handoff to retro\nargs: team_name, Phase number, Wave number\n---\n\nFinalize a wave by reviewing all open PRs, merging in dependency order, closing resolved issues, and cleaning up. This is the **exit gate** before running `\u002Fwave-retro`.\n\n> See [`.claude\u002Fteam\u002Flifecycle.md`](..\u002F..\u002Fteam\u002Flifecycle.md) § Wave Lifecycle for the canonical skill order and preconditions.\n\n> Note: all repo paths in bash blocks below are rooted at `$REPO_ROOT` to avoid cwd drift when the skill is invoked from a worktree or child-repo subdirectory (#149).\n\n## Instructions\n\n### 1. Inventory open PRs\n\nList all PRs targeting the wave's deployment branch:\n\n```bash\ngh pr list --state open --base \"deployments\u002Fphase-{P}\u002Fwave-{M}\" --json number,title,author,headRefName,reviews,isDraft,createdAt\n```\n\nAlso check for PRs targeting `main` that belong to this wave (by label or branch pattern):\n\n```bash\n# Canonical label is the phase-agnostic `wave-{X}` (#810); legacy\n# `p{N}-wave-{M}` is grandfathered — query both forms.\ngh pr list --state open --base main --label \"wave-{M}\" --json number,title,author,headRefName,reviews\ngh pr list --state open --base main --label \"p{P}-wave-{M}\" --json number,title,author,headRefName,reviews\n```\n\n### 2. Check CI status for each PR\n\nFor each open PR:\n\n```bash\ngh pr checks {NUMBER} --json name,conclusion,status\n```\n\nClassify each PR:\n| Status | Criteria | Action |\n|--------|----------|--------|\n| **Ready** | CI green, has peer review | Merge |\n| **Needs review** | CI green, no peer review | Request review |\n| **CI failing** | CI red | Fix before merge |\n| **Draft** | Marked as draft | Exclude (report only) |\n| **Blocked** | Has unmerged dependency | Defer until dependency merges |\n\n### 3. Determine merge order\n\nBuild a merge dependency graph:\n- Parse PR bodies for `Depends on #N` or `After #N` references\n- Check if any PR modifies files that another PR also modifies (merge conflict risk)\n- Independent PRs can merge in parallel; dependent PRs merge in order\n\nPresent the proposed merge sequence:\n\n```\n**Merge Sequence: Phase {P} Wave {M}**\n\n| Order | PR | Title | Status | Dependencies | Action |\n|-------|-----|-------|--------|--------------|--------|\n| 1     | #N  | ...   | Ready  | None         | Merge  |\n| 2     | #N  | ...   | Ready  | After #M     | Merge  |\n| —     | #N  | ...   | CI failing | — | Fix first |\n| —     | #N  | ...   | Draft  | — | Skip |\n```\n\n**Do NOT merge any PRs until the user approves the sequence.**\n\n### 4. Review each ready PR\n\nFor each PR marked \"Ready\", perform a review using charter format (same as `\u002Freview-pr`):\n\n```bash\ngh pr diff {NUMBER}\n```\n\nPost review comment:\n\n```\nRequestor: {Reviewer.Name}\nRequestee: {PR author}\nRequestOrReplied: Request\n\n**Review: {LGTM or issues}**\nMust-fix: {list or \"None\"}\nTech-debt: {list or \"None\"}\n```\n\nFor each tech-debt item, create a GitHub Issue labeled `tech-debt` and the next wave\u002Fphase label.\n\nIf must-fix items are found, do NOT merge — report and wait for fixes.\n\n### 5. Merge approved PRs\n\nAfter user approval, merge in the determined order:\n\n```bash\ngh pr merge {NUMBER} --merge --delete-branch\n```\n\nAfter each merge, verify:\n- CI passes on the target branch\n- No merge conflicts introduced for subsequent PRs\n\nIf a merge introduces CI failures, stop and report before continuing.\n\n### 6. Close resolved issues\n\nRun `\u002Fwave-audit` logic to close issues resolved by the merged PRs:\n\n```bash\n# For each merged PR, check for Closes\u002FFixes\u002FResolves references\ngh pr view {NUMBER} --json body\n```\n\nClose referenced issues with audit comments. Also check for issues matched by branch naming convention.\n\n### 7. Verify completeness\n\nCheck that all wave issues are resolved:\n\n```bash\ngh issue list --state open --label \"wave-{M}\" --json number,title\ngh issue list --state open --label \"p{P}-wave-{M}\" --json number,title  # grandfathered\n```\n\nFor any remaining open issues:\n- If the work was deferred, move to the next wave label\n- If the work was partially done, document what remains\n- Report all unresolved items\n\n### 8. Clean up worktrees (mandatory)\n\n**All wave worktrees MUST be removed before the wrapup is considered complete.** Stale worktrees accumulate across waves and cause branch contention.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n# Prune any stale worktree metadata\ngit -C \"$REPO_ROOT\" worktree prune\n\n# List all worktrees and identify wave-related ones\ngit -C \"$REPO_ROOT\" worktree list\n\n# Remove each wave worktree (branches matching wave assignees)\n# Example: git -C \"$REPO_ROOT\" worktree remove \"$REPO_ROOT\u002F.claude\u002Fworktrees\u002FW.Mwangi+0063-fix-branch-freshness-worktree\" --force\n```\n\nFor each worktree:\n1. Check if it has uncommitted changes (`git -C \u003Cpath> status --porcelain`)\n2. If clean, remove with `git worktree remove \u003Cpath>`\n3. If dirty, report to the user — do NOT force-remove without approval\n4. Delete the remote tracking branch if the PR was merged: `git push origin --delete \u003Cbranch>` — **feature\u002Fworktree branches only; NEVER delete a `deployments\u002Fphase-*\u002Fwave-*` branch** (wave branches are retained permanently per owner directive 2026-06-09 — see Step 11).\n\nReport what was cleaned:\n```\n**Worktree Cleanup:**\n- Removed: {count} worktrees\n- Skipped (dirty): {count}\n- Remote branches deleted: {count}\n```\n\n**Why:** Phase 2 Wave 1 left 6 stale worktrees after merge because cleanup wasn't enforced.\n\n### 9. Update documentation\n\nCheck if any merged PRs affect documentation:\n\n```bash\n# List files changed across all merged PRs\nfor pr in {merged_pr_numbers}; do\n    gh pr diff \"$pr\" --name-only\ndone\n```\n\nFlag any changes to:\n- API endpoints (update API docs)\n- Configuration files (update deployment docs)\n- Architecture (update diagrams)\n- Charter or process files (note for retro)\n\n### 9.5. Retro PR body-vs-diff sanity check (added P3W9 #414 — 2026-05-13)\n\nPer `charter\u002Fpull-requests.md § Retro PR Body-vs-Diff Discipline` (Skill enforcement clause): if a retro PR for this wave is already open, every charter\u002Fskill\u002Ftrust-matrix file claimed in its PR body MUST appear in the PR's diff. Direct-to-main commits for ratified retro outputs are forbidden — they bypass the two-reviewer gate and `validate_pr_ci_status`, and break the audit trail.\n\nRun this check before emitting the Step 10 wrapup table so any mismatch surfaces in the table itself:\n\n```bash\n# Discover the open retro PR for this wave (if any).\n# Retro PRs are conventionally titled `retro(P{P}W{M}…)`.\nRETRO_PR=$(gh pr list --repo noorinalabs\u002Fnoorinalabs-main --state open \\\n  --search 'retro( in:title' \\\n  --json number,title \\\n  --jq \".[] | select(.title | test(\\\"retro\\\\\\\\(P{P}W{M}\\\")) | .number\" | head -1)\n\nif [ -z \"$RETRO_PR\" ]; then\n  echo \"No open retro PR for P{P}W{M} — skipping body-vs-diff check.\"\nelse\n  gh pr view \"$RETRO_PR\" --repo noorinalabs\u002Fnoorinalabs-main --json files --jq '[.files[].path] | sort' > \u002Ftmp\u002Fretro_${RETRO_PR}_diff.json\n  gh pr view \"$RETRO_PR\" --repo noorinalabs\u002Fnoorinalabs-main --json body --jq '.body' > \u002Ftmp\u002Fretro_${RETRO_PR}_body.md\n\n  # Manually inspect \u002Ftmp\u002Fretro_${RETRO_PR}_body.md's \"Files changed\" section and\n  # compare each claimed path against \u002Ftmp\u002Fretro_${RETRO_PR}_diff.json. For each\n  # path claimed in the body but missing from the diff JSON, ABORT with a clear\n  # \"body claims X not in diff\" error and surface the mismatch in the Step 10\n  # wrapup table. Do NOT proceed to Step 10 until the retro author either\n  # commits the missing file to the retro branch (preferred) or amends the body\n  # to remove the unsupported claim.\nfi\n```\n\nWorked example of the failure mode this catches: PR [#124](https:\u002F\u002Fgithub.com\u002Fnoorinalabs\u002Fnoorinalabs-main\u002Fpull\u002F124) (W8 retro) body claimed 7 files, diff contained 2 (`feedback_log.md` + `ontology\u002Fchecksums.json`); the other 5 (`trust_matrix.md`, `charter\u002Fpull-requests.md`, `charter\u002Fhooks.md`, `skills\u002Fwave-retro\u002FSKILL.md`, `skills\u002Fwave-kickoff\u002FSKILL.md`) were committed direct-to-main as `2b92605` + `ecd1c76`, bypassing review and CI. The check above would have flagged all 5 missing paths and blocked Step 10 emission until the retro PR was fixed. Filed as [#126](https:\u002F\u002Fgithub.com\u002Fnoorinalabs\u002Fnoorinalabs-main\u002Fissues\u002F126); skill-side mirror filed as [#414](https:\u002F\u002Fgithub.com\u002Fnoorinalabs\u002Fnoorinalabs-main\u002Fissues\u002F414).\n\nThis step mirrors `\u002Fwave-retro` Step 6.5 by design — the same check fires from both skills so a body-vs-diff mismatch is caught whether the operator runs `\u002Fwave-retro` first (post-author check before requesting reviewers) or `\u002Fwave-wrapup` after the retro PR is open (pre-wrapup-table check). The two skills converge on the authoritative shape in `charter\u002Fpull-requests.md § Retro PR Body-vs-Diff Discipline`.\n\n### 10. Final wave report\n\n```\n**Wave Wrapup: Phase {P} Wave {M}**\n\n**PRs:**\n- Merged: {count}\n- Deferred: {count} (moved to next wave)\n- Still failing CI: {count}\n\n**Issues:**\n- Closed: {count}\n- Remaining open: {count} (deferred)\n\n**Tech-debt created:** {count} new issues\n\n**Staging promotion:** {success | failure | deferred (criterion #1 not yet live) | overridden: \u003Crationale>} {run URL if any}\n\n**Documentation:** {docs updated | docs need update | no doc changes}\n\n**Worktrees cleaned:** {count}\n\n**Next step:** Run `\u002Fwave-retro` for full retrospective with assessments and trust updates.\n```\n\n### 10.5. Write canonical counter keys to `cross-repo-status.json`\n\n> **High-volume remote-merge checkpoint (added P3W13 #566 — 2026-05-31).** Before the **first local bookkeeping commit** of the wrapup (the counter-key write below, the ontology rebuild commit, the wrap-marker commit), if this wave merged **N ≥ 10 PRs via `gh` against remote branches**, the local checkout may be many commits behind origin. Re-sync first:\n>\n> ```bash\n> REPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n> CUR_BRANCH=\"$(git -C \"$REPO_ROOT\" rev-parse --abbrev-ref HEAD)\"\n> git -C \"$REPO_ROOT\" fetch --quiet origin \"$CUR_BRANCH\"\n> BEHIND=$(git -C \"$REPO_ROOT\" rev-list --count \"HEAD..origin\u002F$CUR_BRANCH\" 2>\u002Fdev\u002Fnull || echo 0)\n> if [ \"${BEHIND:-0}\" -gt 0 ]; then\n>   echo \"Local is $BEHIND behind origin\u002F$CUR_BRANCH — re-syncing before bookkeeping commit.\"\n>   # Stash\u002Frelocate any in-progress local edits FIRST (a hard reset discards them).\n>   git -C \"$REPO_ROOT\" reset --hard \"origin\u002F$CUR_BRANCH\"\n> fi\n> ```\n>\n> **Why:** P3W13 merged 37 PRs remotely while the local parent sat 22 commits behind; the counter-key commit landed on a stale tree and needed a recovery `reset --hard` that discarded uncommitted session state (`.claude\u002Fannunaki\u002Ferrors.jsonl`). Re-syncing **before** the first bookkeeping write — and relocating any local edits first, since the reset is destructive — prevents both the stale-tree commit and the lossy recovery. Origin > local clone for all wrap-time state (charter `pull-requests.md § Origin > Local Clone`).\n\nWrite the **top-level** canonical counter keys that `\u002Fwave-retro` Step 2.5 verifies. Pre-#318 these were either missing or buried under `wave_{M}_summary.*`, which forced a manual followup commit at retro (P3W7 `fb459b2`). Post-#318 the skill writes them at wrapup time so retro reads cleanly.\n\nUse the shared `upsert_status_keys.py` helper at `.claude\u002Flib\u002F` — it does targeted text-level upsert that preserves the compact-inline shape of `cross-repo-status.json` (a naive `jq … > tmp && mv` reformats every compact line to pretty form, producing a 500-line cosmetic diff per wave — see `main#332`). The helper also validates JSON before AND after the rewrite. Promoted from `\u002Fwave-scope` to `.claude\u002Flib\u002F` per `main#292` (multi-consumer → shared lib).\n\n> **Mechanical computation (added P3W10 #421 — 2026-05-13).** Pre-#421 the\n> `CHANGES_REQUESTED_CYCLES` and `TOP_CONCENTRATION_PCT` placeholders here\n> were filled in by hand by the orchestrator; the resulting null\u002Fwrong\n> values had to be recomputed at retro for 3 consecutive waves (W4 80%,\n> W5 6→4, W9 null+null). The mechanical computation below eliminates the\n> recompute pattern by deriving both counters directly from the merged-PR\n> set across `wave_{M}_repos_in_scope`. `FINAL_PR_COUNT` remains the\n> already-computed Step 10 \"PRs: Merged\" number.\n>\n> **Cross-window filter (added P3W10 #423 — 2026-05-13).** When a wave-branch\n> is reused across partition events (W9 split mid-wave into pre-partition\n> non-deploy PRs + post-partition canonical 6 PRs — owner directive\n> 2026-05-12), `gh pr list --base \"deployments\u002Fphase-{P}\u002Fwave-{M}\"` returns\n> the union of ALL windows, not the canonical wave's window. W9 actuals:\n> 30 PRs returned vs 6 canonical → TOP_CONCENTRATION_PCT computed as 50%\n> against the cross-window set vs 67% canonical. The fix below uses\n> `wave_{M}_kicked_off_at` as a `mergedAt >= X` filter to scope the PR set\n> to the canonical window (Option A), plus a `FINAL_PR_COUNT`-vs-tally\n> cross-check that loud-fails on residual mismatch (Option B — defense in\n> depth for re-roll-within-window edge cases A misses).\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSTATUS=\"$REPO_ROOT\u002Fcross-repo-status.json\"\n\n# Counters are computed by the deterministic helper (main#688). It replaces the\n# hand-rolled bash that used to live here, which collapsed under zsh: a\n# `for R in $WAVE_REPOS_IN_SCOPE` loop does NOT word-split a parameter in zsh\n# (this org's shell — `feedback_zsh_shell_environment`), so the whole repo list\n# was passed to `gh` as one bogus `--repo` value → \"Could not resolve\n# repository\" → merged PR count 0 → division-by-zero. The helper issues every\n# `gh` call as `subprocess.run([...])` with an explicit arg list (no shell, no\n# word-split), applies the `wave_{M}_kicked_off_at` cross-window filter\n# (Option A — #423), and computes final_pr_count \u002F changes_requested_cycles \u002F\n# top_concentration_pct.\n#\n#   --expect {count_of_merged_PRs}  is the Option-B cross-check: loud-fail\n#     (exit 1, NO write) if the tallied PR count != Step 10's \"PRs: Merged\".\n#   --write                         upserts the three canonical top-level keys\n#     via upsert_status_keys.py, preserving the compact-inline file shape.\npython3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fwave_status.py\" counters {P} {M} \\\n    --expect {count_of_merged_PRs} --write\n\n# Read-back verify (memory `feedback_gh_pr_edit_silent_noop` family — any\n# upsert pipeline that silently fails produces zero diff but exit 0).\njq -r --arg m \"{M}\" '\n  \"wave_\" + $m + \"_final_pr_count = \" + (.[\"wave_\" + $m + \"_final_pr_count\"] | tostring),\n  \"wave_\" + $m + \"_changes_requested_cycles = \" + (.[\"wave_\" + $m + \"_changes_requested_cycles\"] | tostring),\n  \"wave_\" + $m + \"_top_concentration_pct = \" + (.[\"wave_\" + $m + \"_top_concentration_pct\"] | tostring)\n' \"$STATUS\"\n```\n\nOptionally also write a richer `wave_{M}_summary` block with wave-shape detail (per-tier PR breakdown, charter-change proposals, thesis text — see P3W7 `cross-repo-status.json` for the canonical shape). Top-level keys above remain **authoritative** for `\u002Fwave-retro` Step 2.5; the summary block is a supplementary surface for retro-prose composition.\n\n**Why top-level not nested:** `\u002Fwave-retro` Step 2.5 reads via `jq -r \".wave_${M}_final_pr_count\"` — a direct top-level lookup. Nesting under `wave_{M}_summary.final_pr_count` would require Step 2.5 changes per wave-counter-key, breaking the canonical-key contract. Top-level keeps the read-side simple.\n\n**Acceptance for \u002Fwave-retro Step 2.5:**\n- All three keys exist at top-level after `\u002Fwave-wrapup` completes.\n- Values match the rendered Step 10 wave report.\n- A `wave_{M}_summary` block, if also present, must not contradict the top-level values (top-level is authoritative).\n\nIf a key cannot be computed (e.g., no PRs merged this wave), write the literal `0` — `\u002Fwave-retro` Step 2.5 distinguishes \"0 cycles\" from \"key missing\" and only the latter is treated as drift.\n\n### 10.6. Per-engineer trust signals (added P6W17 #842 — Option B §4b)\n\nThe wave-level counters above describe the *wave*; the trust matrix needs the same evidence broken down **per engineer**. Extract the countable per-engineer signals from the merged-PR set so `\u002Fwave-retro` applies mechanical, evidence-anchored trust deltas instead of narrative self-grading. The signals are: `prs_merged`, `must_fix_received` (author), `must_fix_caught` (reviewer), `ci_red_merges`, `rework_cycles`, `review_false_positives`. The helper reuses `wave_status.merged_prs` (so the #423 cross-window filter and the no-shell list-arg-vector contract of main#688 carry over) and parses each PR's verdict comments for the `Requestor:` \u002F `RequestOrReplied:` shape.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n# Per-engineer signal JSON, persisted as a single top-level key so \u002Fwave-retro\n# Step 4 reads deterministic numbers rather than re-deriving them by hand.\nSIGNALS=$(python3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Ftrust_signals.py\" extract {P} {M})\npython3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fupsert_status_keys.py\" \"$REPO_ROOT\u002Fcross-repo-status.json\" \\\n    \"wave_{M}_trust_signals=$SIGNALS\"\n\n# Read-back verify (gh silent-no-op family) — the block must be present and parse.\njq -e --arg m \"{M}\" '.[\"wave_\" + $m + \"_trust_signals\"]' \"$REPO_ROOT\u002Fcross-repo-status.json\" >\u002Fdev\u002Fnull \\\n    && echo \"trust signals written\" || echo \"ERROR: trust signals not written\"\n```\n\n**Acceptance:** `wave_{M}_trust_signals` exists at top-level after wrapup, mapping each active engineer to their integer signal counts. `\u002Fwave-retro` Step 4 consumes it; if absent, retro re-extracts it directly (the helper is idempotent and reads from the same merged-PR set).\n\n### 10.7. Child structural-index pre-regen — BEFORE the wave→main PRs (added P7W19 retro)\n\nThe structural-ontology `staleness-check` only gates PRs to **main**, NOT wave-branch PRs. So a per-issue PR that added a tracked source file (`.py`\u002F`.cypher`\u002F`.ts`\u002F`.tsx`) without regenerating that child's structural index passes its own wave-branch CI but **reddens the wave→main PR** the moment Step 11 opens it — a fix-forward scramble mid-wrapup. Caught in P7W19: da#218 added `queries\u002Fvalidation\u002Fsanadset_orphan_inventory.cypher` and the wave→main PR (da#222) went red on staleness-check until the index was regenerated (`b84b478`).\n\nClose it pre-emptively: for each child repo in `wave_{M}_repos_in_scope` (i.e. excluding `noorinalabs-main`, whose index is handled by Step 12b), regenerate the child's structural index from the wave-branch HEAD and commit any diff to the wave branch BEFORE opening the integration PR, so the wave→main PR is green on staleness-check from the start.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\nwhile IFS= read -r R; do\n  [ \"$R\" = \"noorinalabs-main\" ] && continue\n  CHILD=\"$REPO_ROOT\u002F$R\"\n  [ -d \"$CHILD\" ] || { echo \"$R: not checked out locally — regenerate manually if it added tracked source\"; continue; }\n  # Child generator fetches ontology_gen from noorinalabs-main; regenerate in place.\n  ( cd \"$CHILD\" \\\n    && git fetch --quiet origin \"$BRANCH\" && git checkout --quiet \"$BRANCH\" && git pull --ff-only --quiet origin \"$BRANCH\" \\\n    && python3 scripts\u002Fstructural_ontology.py emit --gen-lib \"$REPO_ROOT\u002F.claude\u002Flib\" \\\n    && if ! git diff --quiet -- ontology\u002Fstructural\u002F; then\n         git add ontology\u002Fstructural\u002F \\\n         && git -c user.name=\"Aino Virtanen\" -c user.email=\"parametrization+Aino.Virtanen@gmail.com\" \\\n              commit -m \"ontology(structural): regenerate $R index pre-wave-merge ({P}W{M})\" \\\n         && git push origin \"$BRANCH\" \\\n         && echo \"$R: structural index regenerated + pushed\"\n       else echo \"$R: structural index already current\"; fi )\ndone \u003C\u003C\u003C \"$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\"\n```\n\nThis is the child analog of Step 12b (which regenerates the **parent** index). Run it here, before Step 11, not at 12b — by Step 12 the integration PRs are already open and would have already gone red. If a child repo is not checked out locally, surface it so the operator regenerates manually (or accepts the Step-11 fix-forward for that one repo). Source: memory `feedback_wave_branch_merge_not_squash` § Related child-repo wrap gotcha (P7W19 #222).\n\n### 11. Merge to main per repo (every wave)\n\n**Every wave's wrapup merges its wave branch to main** (changed 2026-06-09 — owner directive; previously gated to the final wave only). Each repo in `wave_{M}_repos_in_scope` has its OWN `deployments\u002Fphase-{P}\u002Fwave-{M}` branch (created by `\u002Fwave-kickoff` step 1) that needs its own PR to main. This is the symmetric counterpart of the multi-repo branch creation gap (main#238). Merging each wave keeps `main` continuously current: the next wave bases off main (`\u002Fwave-start` § 3 base determination; ref cut by `\u002Fwave-kickoff` Step 1), so an unmerged wave would strand its work the moment the following wave starts.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nWAVE_REPOS_IN_SCOPE=$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\n\n# zsh-safe iteration: a here-string into `while IFS= read -r` (NOT\n# `for R in $WAVE_REPOS_IN_SCOPE` — zsh does not word-split a parameter, so the\n# whole list would collapse into one bogus repo; main#688). The here-string\n# keeps the loop in the current shell — relevant for the array-accumulating\n# loops below.\nwhile IFS= read -r R; do\n  # Skip repos where the wave branch is already merged or doesn't exist\n  EXISTING=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002F$BRANCH\" --jq '.object.sha' 2>\u002Fdev\u002Fnull || true)\n  [ -z \"$EXISTING\" ] && { echo \"$R: no wave branch — skip\"; continue; }\n\n  # Check if there's anything to merge (compare branch HEAD vs main HEAD)\n  MAIN_SHA=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002Fmain\" --jq '.object.sha')\n  if [ \"$EXISTING\" = \"$MAIN_SHA\" ]; then\n    echo \"$R: wave branch ==  main, nothing to merge\"; continue\n  fi\n\n  # Create PR from this repo's wave branch to its own main\n  gh pr create --repo \"noorinalabs\u002F$R\" --base main --head \"$BRANCH\" \\\n    --title \"Phase {P} Wave {M} → main ($R)\" \\\n    --body \"Final wave merge for $R. All PRs reviewed and merged to wave branch.\"\ndone \u003C\u003C\u003C \"$WAVE_REPOS_IN_SCOPE\"\n```\n\nPrint a per-repo PR summary table (PR# or \"no merge needed\") and **wait for user approval before merging any PR**. Each PR must be merged independently.\n\n**Do NOT merge to main without user approval.** This is a significant action that affects all downstream repos.\n\n**Retain the wave branch — do NOT delete it on merge** (owner directive 2026-06-09). Merge each wave→main PR with `gh pr merge \u003CN> --merge` (**never** `--delete-branch`); the `deployments\u002Fphase-{P}\u002Fwave-{M}` branches are kept permanently as a historical \u002F rollback anchor for every wave. Caveat: if a repo has \"Automatically delete head branches\" enabled at the repo level, a merge deletes the head branch regardless of the flag — for these repos, either disable that setting or restore the branch immediately after merge (`git push origin \u003Cwave-sha>:refs\u002Fheads\u002Fdeployments\u002Fphase-{P}\u002Fwave-{M}`).\n\n### 11.5. Reachability gate — wave-branch propagation to main (every wave)\n\nAfter the per-repo wave→main PRs in Step 11 are merged (or declared not-needed), verify each wave-branch is actually reachable from `origin\u002Fmain`. This is the load-bearing enforcement counterpart to charter `state-claims.md § Sub-rule: merge_commit_sha reachability` — the rule's claim-time discipline becomes a wrapup-time gate.\n\nOrigin story: `main#339` — `deployments\u002Fphase-3\u002Fwave-7` ended up 10 ahead \u002F 15+ behind \u002F diverged from main with no wave→main PR ever opened. The wave was treated as \"closed\" because individual PRs into the wave-branch were merged, but the wave-branch itself never reached main. PR #305 (the `validate_commit_identity` backslash fix) and 11 hook fixtures sat stranded on a branch no operator was tracking.\n\nThis step catches that pattern at wrapup time, before the wave is declared closed.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nWAVE_REPOS_IN_SCOPE=$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\n\nSTRANDED=()\n# zsh-safe iteration via here-string into `while read` (main#688). The\n# here-string (NOT a `| while` pipe) keeps the loop in the current shell so the\n# STRANDED array survives past `done`.\nwhile IFS= read -r R; do\n  # Skip repos where the wave branch doesn't exist (scope-drop case)\n  WAVE_SHA=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002F$BRANCH\" --jq '.object.sha' 2>\u002Fdev\u002Fnull || true)\n  [ -z \"$WAVE_SHA\" ] && { echo \"$R: no wave branch — skip (scope-drop)\"; continue; }\n\n  # Compare wave-branch against main at origin (NOT local clone — per charter\n  # pull-requests.md § Origin > Local Clone)\n  COMPARE=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fcompare\u002Fmain...$BRANCH\" \\\n    --jq '{ahead_by, behind_by, status}')\n  AHEAD=$(echo \"$COMPARE\" | jq -r .ahead_by)\n  STATUS=$(echo \"$COMPARE\" | jq -r .status)\n\n  if [ \"$AHEAD\" -gt 0 ] || [ \"$STATUS\" = \"diverged\" ]; then\n    # Check if a wave→main PR exists in any state — explains the gap if so\n    PR_EXISTS=$(gh pr list --repo \"noorinalabs\u002F$R\" --base main --head \"$BRANCH\" \\\n      --state all --limit 5 --json number,state,mergedAt \\\n      --jq '[.[] | select(.state == \"MERGED\" or .state == \"OPEN\")] | length')\n    STRANDED+=(\"$R: ahead_by=$AHEAD status=$STATUS wave→main PRs found=$PR_EXISTS\")\n  else\n    echo \"$R: wave-branch reachable from main (ahead_by=$AHEAD, status=$STATUS) — OK\"\n  fi\ndone \u003C\u003C\u003C \"$WAVE_REPOS_IN_SCOPE\"\n\nif [ ${#STRANDED[@]} -gt 0 ]; then\n  echo \"════════════════════════════════════════════════════════════\"\n  echo \"BLOCKED: \u002Fwave-wrapup cannot close wave {M} — STRANDED repos:\"\n  for s in \"${STRANDED[@]}\"; do echo \"  $s\"; done\n  echo \"\"\n  echo \"Each STRANDED repo has wave-branch commits NOT reachable from origin\u002Fmain.\"\n  echo \"Fix-forward options:\"\n  echo \"  (a) Open the wave→main PR (re-run Step 11 if no PR exists)\"\n  echo \"  (b) Merge an already-OPEN wave→main PR\"\n  echo \"  (c) If stranding is INTENTIONAL (descoped wave, rolled-back work), set\"\n  echo \"      STRANDING_OVERRIDE_RATIONALE=\\\"\u003Cexplicit reason>\\\" before re-invoking\"\n  echo \"      \u002Fwave-wrapup. The override is logged to the wrapup report and to\"\n  echo \"      cross-repo-status.json under wave_{M}_stranding_override.\"\n  echo \"════════════════════════════════════════════════════════════\"\n  exit 1\nfi\n```\n\n**Override mechanism** (when stranding is intentional):\n\n```bash\n# Only use when the wave is deliberately not merged to main\n# (descoped, rolled back, or held for sequencing reasons)\nexport STRANDING_OVERRIDE_RATIONALE=\"P3W7 work descoped post-#339 audit; \\\n  wave-7 branch retained for historical reference, no propagation intended\"\n# Re-invoke \u002Fwave-wrapup — the gate sees the rationale, logs it, and proceeds\n```\n\nThe override is intentionally noisy: rationale is required (no empty string), logged to the wrapup report, and persisted to `cross-repo-status.json` under `wave_{M}_stranding_override` so subsequent \u002Fwave-retro and audit passes can surface it.\n\n### 11.5a. Deployable-merge verification gate — post-merge workflows went green (every wave)\n\nA wave→main merge is a **deployable merge**: it triggers push-to-main workflows that **never ran on the per-issue PRs** (publish\u002FTrivy, schema-drift, structural-ontology, …). Their CI gives **no pre-merge signal**, so a wave can be reachable-on-main (Step 11.5 green) yet have reddened `main` post-merge. This is exactly how `isnad-graph#1131` (libexpat `CVE-2026-45186`) slipped past `#1130`'s green PR and failed the GHCR publish on `main` (main#864).\n\nAfter Step 11.5 confirms reachability, verify each repo's wave→main merge commit actually went green across its post-merge-only workflows, using the deterministic oracle `.claude\u002Flib\u002Fverify_deployable_merge.py` (it polls the Actions runs for the exact merge SHA; a failed run, or a required workflow that produced **no** run at all, is a hard not-verified — the same empty-is-not-ready discipline as `pr_ci_state.py`). `gh pr merge` returns 0 the instant the merge commit exists — long before these workflows even start — so the merge's own exit status proves nothing.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nWAVE_REPOS_IN_SCOPE=$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\n\nUNVERIFIED=()\nwhile IFS= read -r R; do\n  # The deployable merge commit is origin\u002Fmain's HEAD for the repo after Step 11\n  # merged the wave→main PR. Resolve it at origin (not the local clone).\n  MAIN_SHA=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002Fmain\" --jq '.object.sha' 2>\u002Fdev\u002Fnull || true)\n  [ -z \"$MAIN_SHA\" ] && { echo \"$R: cannot read main — skip\"; continue; }\n\n  # --require-deployable verifies the post-merge-ONLY workflows (push\u002Ftag AND not\n  # pull_request) — the genuinely blind ones. Drop the flag to verify every\n  # merge-triggered workflow. The no-red safety net fails on ANY run that\n  # executed for the SHA and went red, so path-filtered workflows that fired are\n  # still covered. Exit codes: 0 = verified (incl. \"nothing required\" + no red),\n  # 1 = NOT verified (a red or silently-dropped required run), 2 = UNDETERMINED\n  # (gh\u002FAPI failure — cannot tell, so surface and treat as blocking).\n  python3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fverify_deployable_merge.py\" \\\n    \"noorinalabs\u002F$R\" \"$MAIN_SHA\" --require-deployable --timeout 1200 --poll 30\n  case $? in\n    0) echo \"$R: deployable merge verified green @ ${MAIN_SHA:0:8}\" ;;\n    1) UNVERIFIED+=(\"$R @ ${MAIN_SHA:0:8} — a post-merge workflow failed \u002F was dropped\") ;;\n    2) UNVERIFIED+=(\"$R @ ${MAIN_SHA:0:8} — UNDETERMINED (gh\u002FAPI error; re-run or investigate)\") ;;\n  esac\ndone \u003C\u003C\u003C \"$WAVE_REPOS_IN_SCOPE\"\n\nif [ ${#UNVERIFIED[@]} -gt 0 ]; then\n  echo \"════════════════════════════════════════════════════════════\"\n  echo \"BLOCKED: \u002Fwave-wrapup cannot close wave {M} — deployable merge NOT verified:\"\n  for u in \"${UNVERIFIED[@]}\"; do echo \"  $u\"; done\n  echo \"\"\n  echo \"A post-merge workflow failed (or never ran) on main after the wave→main merge.\"\n  echo \"This is a RED main gate — a stop, not a speed bump (charter no-force-merge).\"\n  echo \"Fix-forward options:\"\n  echo \"  (a) Fix the regression on main via a normal bug→PR→merge cycle (e.g. a\"\n  echo \"      base-image CVE re-pin like isnad-graph#1132), then re-run \u002Fwave-wrapup.\"\n  echo \"  (b) If the failure is a documented external\u002Fstanding item (advisory-DB\"\n  echo \"      drift, a no-fix base-image CVE under an active --ignore-vuln), set\"\n  echo \"      DEPLOYABLE_VERIFY_OVERRIDE_RATIONALE=\\\"\u003Creason + tracking issue>\\\" and\"\n  echo \"      re-invoke. The override is logged to the report and to\"\n  echo \"      cross-repo-status.json under wave_{M}_deployable_verify_override.\"\n  echo \"════════════════════════════════════════════════════════════\"\n  [ -z \"${DEPLOYABLE_VERIFY_OVERRIDE_RATIONALE:-}\" ] && exit 1\n  echo \"OVERRIDDEN: $DEPLOYABLE_VERIFY_OVERRIDE_RATIONALE\"\nfi\n```\n\nA genuinely external red (e.g. a newly-published advisory the fix has not yet propagated for — `feedback_pip_audit_strict_advisory_db_drift`, `feedback_trivy_base_image_cve_org_wide_gate`) is the only case for the override, and it MUST name a tracking issue. A red caused by the wave's own change is fixed forward — never overridden. Include the per-repo result in the Step 10 report; `\u002Fwave-retro` records it in the wave history row.\n\n### 11.6. Staging-promotion gate (Phase-3 end-state criterion #3)\n\nA wave is **not closeable until its merged code has been promoted to staging green**. This is the wrapup-time enforcement of Phase-3 end-state criterion #3 (`main#325`) and the charter rule `pull-requests.md § Wave-Wrapup Staging-Promotion Gate`. It runs AFTER the Step 11.5 reachability-to-main gate (code must be on main before it can be promoted to staging) and BEFORE the ontology rebuild.\n\nThe canonical staging deploy is `noorinalabs-deploy\u002F.github\u002Fworkflows\u002Fdeploy-stg.yml`. The gate inspects the latest run; blocks on red; **defers** (does not fail) when staging does not yet exist — criterion #3 is blocked by criterion #1 (live staging). An explicit rationale env var overrides a red\u002Fabsent run.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSTATUS=\"$REPO_ROOT\u002Fcross-repo-status.json\"\nUPSERT=\"$REPO_ROOT\u002F.claude\u002Flib\u002Fupsert_status_keys.py\"\nSTG_WORKFLOW=\"deploy-stg.yml\"\nDEPLOY_REPO=\"noorinalabs\u002Fnoorinalabs-deploy\"\n\n# Fetch the latest deploy-stg.yml run. Empty result = staging not live yet.\nSTG_RUN=$(gh run list --repo \"$DEPLOY_REPO\" --workflow \"$STG_WORKFLOW\" \\\n  --limit 1 --json databaseId,status,conclusion,url,headSha \\\n  --jq '.[0] \u002F\u002F empty' 2>\u002Fdev\u002Fnull || true)\n\nif [ -z \"$STG_RUN\" ]; then\n  # Criterion #1 not satisfied — defer, do NOT hard-fail. Logged, not silent.\n  STG_RESULT=\"deferred\"\n  STG_URL=\"\"\n  echo \"staging-promotion gate DEFERRED — criterion #1 (live staging) not yet satisfied\"\n  echo \"  (no $STG_WORKFLOW run history in $DEPLOY_REPO). Gate auto-activates once staging is live.\"\nelse\n  STG_STATUS=$(echo \"$STG_RUN\" | jq -r .status)\n  STG_CONCLUSION=$(echo \"$STG_RUN\" | jq -r .conclusion)\n  STG_URL=$(echo \"$STG_RUN\" | jq -r .url)\n\n  if [ \"$STG_STATUS\" != \"completed\" ]; then\n    echo \"staging deploy still in progress ($STG_STATUS) — re-run \u002Fwave-wrapup once $STG_URL completes,\"\n    echo \"or set STG_PROMOTION_OVERRIDE_RATIONALE to close anyway.\"\n    STG_CONCLUSION=\"in_progress\"\n  fi\n\n  if [ \"$STG_CONCLUSION\" = \"success\" ]; then\n    STG_RESULT=\"success\"\n    echo \"staging promotion GREEN — $STG_URL\"\n  elif [ -n \"${STG_PROMOTION_OVERRIDE_RATIONALE:-}\" ]; then\n    STG_RESULT=\"overridden\"\n    echo \"staging promotion NOT green ($STG_CONCLUSION) — OVERRIDDEN:\"\n    echo \"  $STG_PROMOTION_OVERRIDE_RATIONALE\"\n  else\n    echo \"════════════════════════════════════════════════════════════\"\n    echo \"BLOCKED: \u002Fwave-wrapup cannot close wave {M} — staging promotion is $STG_CONCLUSION.\"\n    echo \"  Latest $STG_WORKFLOW run: $STG_URL\"\n    echo \"Fix-forward options:\"\n    echo \"  (a) Fix the regression and re-trigger the staging deploy, then re-run \u002Fwave-wrapup.\"\n    echo \"  (b) Re-dispatch deploy-stg.yml manually:\"\n    echo \"      gh workflow run $STG_WORKFLOW --repo $DEPLOY_REPO\"\n    echo \"  (c) If a red\u002Fabsent staging run is INTENTIONALLY acceptable (staging infra\"\n    echo \"      mid-migration, meta-only wave with no deployable surface), set\"\n    echo \"      STG_PROMOTION_OVERRIDE_RATIONALE=\\\"\u003Cexplicit reason>\\\" before re-invoking.\"\n    echo \"════════════════════════════════════════════════════════════\"\n    exit 1\n  fi\nfi\n\n# Persist the result for \u002Fwave-retro Step 2.5 + audit passes. Compact-inline\n# preserved via upsert_status_keys.py (NOT jq>tmp>mv — see main#332).\npython3 \"$UPSERT\" \"$STATUS\" \\\n    \"wave_{M}_stg_promotion=\\\"${STG_RESULT}\\\"\" \\\n    \"wave_{M}_stg_promotion_url=\\\"${STG_URL}\\\"\"\n[ \"$STG_RESULT\" = \"overridden\" ] && python3 \"$UPSERT\" \"$STATUS\" \\\n    \"wave_{M}_stg_promotion_override_rationale=\\\"${STG_PROMOTION_OVERRIDE_RATIONALE}\\\"\"\n\n# Read-back verify (feedback_gh_pr_edit_silent_noop family).\njq -r --arg m \"{M}\" '\"wave_\" + $m + \"_stg_promotion = \" + (.[\"wave_\" + $m + \"_stg_promotion\"] | tostring)' \"$STATUS\"\n```\n\n**Override mechanism** (when a red\u002Fabsent staging run is acceptable):\n\n```bash\n# Only use when staging green is genuinely not achievable\u002Fapplicable for this wave\n# (staging infra mid-migration, meta-only wave with no deployable surface).\nexport STG_PROMOTION_OVERRIDE_RATIONALE=\"W13 is charter\u002Fskill-meta only; no service \\\n  image changed, so no staging deploy is produced. Gate overridden, criterion #3 \\\n  unaffected (no deployable surface to promote).\"\n# Re-invoke \u002Fwave-wrapup — the gate logs the rationale, persists it, and proceeds.\n```\n\nInclude the staging-promotion result (`success`\u002F`failure`\u002F`deferred`\u002F`overridden`) and the run URL in the Step 10 final wave report. `\u002Fwave-retro` records it in the wave history row alongside PR count and admin overrides.\n\n### 11.6a. Per-merge deploy watch (active — `\u002Fwatch-deploy`)\n\nThe Step 11.6 gate above inspects only the **latest** `deploy-stg.yml` run. That misses the failure mode deploy#418 surfaced: a wave→main merge in one repo triggers a deploy that fails (e.g. a user-service merge that broke the image pull), which is then masked when a later merge's deploy goes green and becomes \"latest\". To close this, **actively follow the deploy each wave→main merge triggered**, not just the most-recent run.\n\nFor each repo in `wave_{M}_repos_in_scope` that participates in the staging fan-in (`noorinalabs-isnad-graph`, `noorinalabs-user-service` — the repos whose `ghcr-publish.yml` dispatches `deploy-stg.yml`), take that repo's Step 11 wave→main merge commit and run:\n\n```\n\u002Fwatch-deploy stg \u003Cmerge_sha>\n```\n\n`\u002Fwatch-deploy` polls that specific dispatched deploy to a terminal state, classifies any failure, attempts a single bounded fix-forward (e.g. re-dispatch `stg-latest`), and escalates with a diagnosis otherwise. A wave is not closeable while any fan-in merge's deploy is red and unremediated — fold any escalation into the Step 11.6 block\u002Foverride decision above.\n\n**Publish-freshness check (P4W4 retro #3 \u002F main#647).** The per-merge watch above follows `deploy-stg.yml`, but a base-image CVE reddens the **publish** (`ghcr-publish.yml`) *upstream* of the deploy: the W4 openssl CVE-2026-45447 reddened isnad-graph's frontend publish and never reached `deploy-stg.yml`, so the deploy watch missed it entirely. For each fan-in repo, also inspect the latest `ghcr-publish.yml` run on its default branch and treat a red publish as a wave-closeability signal — classifying the cause the same way Step 5a of `\u002Fsession-start` does (base-image-CVE signal → `base-image-drift`, else `code\u002Fother`):\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n# Newline-joined so `while read` iterates safely; `for repo in $FANIN` would\n# collapse the whole string into one iteration under zsh (main#688). The\n# here-string below keeps the loop in the current shell so PUB_RED persists.\nFANIN=$'noorinalabs-isnad-graph\\nnoorinalabs-user-service'\nPUB_RED=()\nwhile IFS= read -r repo; do\n  # Only the fan-in repos actually in this wave's scope.\n  jq -e --arg r \"$repo\" '.[\"wave_{M}_repos_in_scope\"] | index($r)' \"$REPO_ROOT\u002Fcross-repo-status.json\" >\u002Fdev\u002Fnull 2>&1 || continue\n  branch=$(gh api \"repos\u002Fnoorinalabs\u002F$repo\" --jq '.default_branch' 2>\u002Fdev\u002Fnull || echo main)\n  # Latest ghcr-publish.yml run on the default branch (empty if the workflow\u002Frun is absent).\n  IFS=$'\\t' read -r run_id conclusion url \u003C \u003C(\n    gh api \"repos\u002Fnoorinalabs\u002F$repo\u002Factions\u002Fworkflows\u002Fghcr-publish.yml\u002Fruns?branch=$branch&per_page=1\" \\\n      --jq '(.workflow_runs[0] \u002F\u002F empty) | [.id, .conclusion, .html_url] | @tsv' 2>\u002Fdev\u002Fnull\n  )\n  case \"$conclusion\" in\n    failure|timed_out|cancelled|startup_failure)\n      # Best-effort base-image-CVE classification; degrades to code\u002Fother on any log-fetch failure.\n      cls=code\u002Fother\n      log=$(gh run view \"$run_id\" --repo \"noorinalabs\u002F$repo\" --log-failed 2>\u002Fdev\u002Fnull || true)\n      printf '%s' \"$log\" | grep -Eiq 'trivy|grype|\\bCVE-[0-9]{4}-[0-9]+|apk[ -].*(upgrade|CVE)|openssl.*(vuln|CVE|advisor)|base[ -]image' && cls=base-image-drift\n      PUB_RED+=(\"$repo :: ghcr-publish.yml :: $conclusion :: $cls :: $url\") ;;\n  esac\ndone \u003C\u003C\u003C \"$FANIN\"\nif [ ${#PUB_RED[@]} -gt 0 ]; then\n  printf 'RED fan-in publish run(s) — a wave is NOT closeable while a fan-in publish is red:\\n'\n  printf '  %s\\n' \"${PUB_RED[@]}\"\n  printf '%s\\n' \"${PUB_RED[@]}\" | grep -q base-image-drift && \\\n    printf '  NOTE: \"base-image-drift\" = upstream base-image CVE — fix-forward the base image (rebuild\u002Fbump), not the wave diff.\\n'\nelse\n  echo \"Fan-in publishes (ghcr-publish.yml) green on default branches.\"\nfi\n```\n\nA red fan-in publish — `base-image-drift` or otherwise — blocks wave closeability the same as a red deploy: fold it into the Step 11.6 block\u002Foverride decision above. Best-effort: on `gh api` failure, say so rather than reporting a false green.\n\nLanding-page and meta-only repos do not participate in the stg fan-in (no dispatch), so they have no per-merge deploy or publish to watch — skip them.\n\n**Production counterpart:** prod deploys are gated on owner approval (owner directive 2026-06-09). `\u002Fwave-wrapup` must NOT approve or trigger them. When the owner approves a queued prod deploy for this wave's promotion, run `\u002Fwatch-deploy prod \u003Csha>` to monitor it the same way; `\u002Fwatch-deploy` never advances or auto-remediates prod.\n\n### 12. Ontology update — semantic overlay + structural index (#862)\n\nThe ontology has two independent layers to update at wave close (#820\u002FC×T2):\n\n**12a. Semantic overlay** — run `\u002Fontology-rebuild` to process any hand-curated files that changed during this wave. Scope is `ontology\u002Fchecksums.json` dirty files only; structural is never resolved here.\n- If no dirty files, report \"Semantic overlay: up to date\" and skip\n- The resolver auto-updates docs where appropriate and flags recommend-only changes\n\n**12b. Structural index** — regenerate the parent repo's structural index and refresh the cross-repo aggregator. The wave may have added\u002Fchanged hooks, skills, or lib modules that should be reflected in the index before the wave is closed.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n\n# Check how many source files changed since the index was last generated\nSTRUCT_SHA=$(git -C \"$REPO_ROOT\" log -1 --format=\"%H\" -- ontology\u002Fstructural\u002Fllms.txt 2>\u002Fdev\u002Fnull || echo \"\")\nif [ -z \"$STRUCT_SHA\" ]; then\n  CHANGED=\"new\"\nelse\n  CHANGED=$(git -C \"$REPO_ROOT\" diff --name-only \"$STRUCT_SHA\"..HEAD -- \\\n    '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.cypher' '*.cql' 2>\u002Fdev\u002Fnull | wc -l | tr -d ' ')\nfi\n\nif [ \"${CHANGED:-0}\" = \"0\" ]; then\n  echo \"Structural index: current — no source files changed since last generation.\"\nelse\n  echo \"Structural index: ${CHANGED} source file(s) changed — regenerating.\"\n  PYTHONPATH=\"$REPO_ROOT\u002F.claude\u002Flib\" python3 -m ontology_gen \\\n    \"$REPO_ROOT\" --out \"$REPO_ROOT\u002Fontology\u002Fstructural\u002F\" 2>&1\n  # Refresh the central cross-repo graph\n  PYTHONPATH=\"$REPO_ROOT\u002F.claude\u002Flib\" python3 -m ontology_gen.aggregate \\\n    \"$REPO_ROOT\" 2>&1 || true\n\n  # Commit if anything changed\n  if ! git -C \"$REPO_ROOT\" diff --quiet ontology\u002Fstructural\u002F; then\n    git -C \"$REPO_ROOT\" add ontology\u002Fstructural\u002F\n    MSGFILE=\"$(mktemp)\"\n    printf 'ontology: regenerate structural index (wave-wrapup step 12b)\\n\\n%s source files changed this wave; re-ran ontology_gen + aggregate.\\n' \\\n      \"${CHANGED}\" > \"$MSGFILE\"\n    git -C \"$REPO_ROOT\" \\\n      -c user.name=\"Aino Virtanen\" \\\n      -c user.email=\"parametrization+Aino.Virtanen@gmail.com\" \\\n      commit -F \"$MSGFILE\"\n    rm -f \"$MSGFILE\"\n    echo \"Structural index committed.\"\n  fi\nfi\n```\n\nInclude both results in the final wave report (Step 10):\n- `Ontology: Semantic {N files resolved \u002F up to date}; Structural {current \u002F regenerated + committed}`\n\n**Note:** the structural layer at `ontology\u002Fstructural\u002F` is always-current-by-regeneration — `\u002Fontology-rebuild` does NOT resolve it (that was retired per #857\u002FC×T2). The generator above IS the structural resolve path.\n\n### 12.5. Generic-prompt genericize checkpoint (main#716)\n\nThe `suggest_generic_prompt` PostToolUse hook no longer nudges per-edit (that nudge was never actioned — a non-binding mid-task `systemMessage` with no state, no dedup, no closing loop → enforcement-hierarchy decay; `2real-team-framework\u002Fgeneric_prompts\u002F` gained **zero** files across every wave despite it firing constantly). It now **silently tracks** every touched `.claude\u002F` artifact into a pending ledger. This step is the closing loop: once per wave, enumerate the wave's new\u002Fchanged `.claude\u002F` artifacts (hooks\u002Fskills\u002Fcharter\u002Fsettings) that **lack a counterpart** in the framework's `generic_prompts\u002F` **and aren't already decided**, and make ONE deliberate genericize-or-skip pass. Decisions are recorded so the same artifact is never re-surfaced.\n\nTwo state files back this (see `.claude\u002Flib\u002Fgeneric_prompt_tracker.py`): `.claude\u002Fgeneric_prompt_pending.json` (volatile, gitignored — the candidate set) and `.claude\u002Fgeneric_prompt_ledger.json` (**version-controlled** — the durable genericize\u002Fskip decisions, the dedup memory).\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nTRACKER=\"$REPO_ROOT\u002F.claude\u002Flib\u002Fgeneric_prompt_tracker.py\"\n\n# (a) Belt-and-suspenders: the pending ledger is per-machine volatile state, so\n# augment it with a git-diff sweep of the meta repo's wave-window .claude\u002F\n# changes (in case pending was wiped, or edits happened in a different session).\n# Diff the wave branch's merge-base with main against HEAD; degrade to a no-op\n# if the wave branch is absent. zsh-safe `while read` (main#688).\nWAVE_BRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\nBASE=$(git -C \"$REPO_ROOT\" merge-base main \"$WAVE_BRANCH\" 2>\u002Fdev\u002Fnull || true)\nif [ -n \"$BASE\" ]; then\n  git -C \"$REPO_ROOT\" diff --name-only --diff-filter=d \"$BASE\"..HEAD -- '.claude\u002F**' 2>\u002Fdev\u002Fnull \\\n    | while IFS= read -r f; do\n        [ -n \"$f\" ] && python3 \"$TRACKER\" record-candidate \"$REPO_ROOT\u002F$f\" >\u002Fdev\u002Fnull\n      done\nfi\n\n# (b) List the undecided genericizable candidates — the deliberate worklist.\npython3 \"$TRACKER\" list --wave \"P{P}W{M}\"\n```\n\nFor **each** candidate the list prints, make a genericize-or-skip call and record it (this is what stops the artifact re-surfacing next wave):\n\n```bash\n# Genericize: draft the product-neutral prompt file in the framework repo\n# (strip project-specific names\u002Fpaths\u002Fteam\u002Frepo), then record the decision with\n# the generic file as the detail:\npython3 \"$TRACKER\" record \"hooks\u002F\u003Cname>.py\" genericized \\\n  --detail \"GENERIC_\u003CNAME>_PROMPT.md\" --wave \"P{P}W{M}\"\n\n# Skip: the artifact is too project-coupled to genericize (or not worth it) —\n# record WHY so it stays settled:\npython3 \"$TRACKER\" record \"skills\u002F\u003Cname>\u002FSKILL.md\" skipped \\\n  --detail \"\u003Cone-line reason — e.g. tightly coupled to noorinalabs wave lifecycle>\" \\\n  --wave \"P{P}W{M}\"\n```\n\n- If `list` reports no undecided candidates, report \"Generic-prompt checkpoint: nothing to genericize this wave\" and continue.\n- **Commit the ledger** (`.claude\u002Fgeneric_prompt_ledger.json`) as part of the wrapup — it is version-controlled durable state. The pending file is gitignored; do not commit it.\n- Include a one-line genericized\u002Fskipped tally in the Step 10 final wave report.\n- This is the deliberate batched replacement for the demoted per-edit hook; do NOT re-introduce a mid-task suggestion.\n\n### 13. Annunaki error attack\n\n> **Preferred surface is `\u002Fwave-retro` Step 7.6 (P3W9 #344).** Retro is the natural moment for this audit — findings feed the retro's charter-change proposals. Wrapup retains this step as a fallback for cases where retro is delayed or skipped. The run-marker below prevents double-execution.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nALREADY_RAN=$(jq -r \".wave_${M}_annunaki_attack_ran_at \u002F\u002F empty\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\n\nif [ -n \"$ALREADY_RAN\" ]; then\n  echo \"Annunaki-attack: already ran at $ALREADY_RAN (via \u002Fwave-retro Step 7.6). Skipping.\"\n  # Continue to Step 14.\nelse\n  # Proceed with the attack below; on completion write the marker.\nfi\n```\n\nRun `\u002Fannunaki-attack` to process any errors captured by the Annunaki monitor during this wave. This converts observed errors into preventative automation (hooks, skills, charter updates) before the wave closes.\n\n- If `.claude\u002Fannunaki\u002Ferrors.jsonl` is empty or missing, report \"Annunaki: No errors captured this wave\" and skip the attack — but still write the run-marker so retro's 7.6 doesn't re-check\n- Use the current wave label for any issues created\n- Include Annunaki-created issues and PRs in the final wave report totals\n- This step runs **before** the memory-to-automation audit so that new hooks\u002Fskills from error analysis are visible to the memory audit\n- On completion, write `wave_${M}_annunaki_attack_ran_at = \u003CISO-8601 UTC timestamp>` to `cross-repo-status.json`\n\n### 14. Memory-to-automation audit\n\n> **Preferred surface is `\u002Fwave-retro` Step 7.7 (P3W9 #344).** Retro is the natural moment for this audit — findings feed the retro's charter-change proposals and the Aino-spawned conversion issues count toward the same retro's per-engineer assessment + trust update pass. Wrapup retains this step as the canonical procedure body (referenced by retro's 7.7) and as a fallback for retro-delayed cases. The run-marker below prevents double-execution.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nALREADY_RAN=$(jq -r \".wave_${M}_memory_audit_ran_at \u002F\u002F empty\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\n\nif [ -n \"$ALREADY_RAN\" ]; then\n  echo \"Memory-to-automation audit: already ran at $ALREADY_RAN (via \u002Fwave-retro Step 7.7). Skipping.\"\n  # Continue to the rest of the wrapup.\nelse\n  # Proceed with the audit below; on completion write the marker.\nfi\n```\n\nExamine all memory files in the project memory directory for entries that describe behaviors, rules, or patterns that could be codified as a **hook**, **skill**, or **charter update** instead of remaining as soft memory. On completion, write `wave_${M}_memory_audit_ran_at = \u003CISO-8601 UTC timestamp>` to `cross-repo-status.json`.\n\n**Process:**\n\n1. **Read all memory files:**\n   ```bash\n   ls ~\u002F.claude\u002Fprojects\u002F*\u002Fmemory\u002F*.md\n   ```\n\n2. **For each memory file**, classify it:\n   | Category | Criteria | Action |\n   |----------|----------|--------|\n   | **Hook candidate** | Describes a rule that should be enforced automatically (e.g., \"always do X before Y\", \"never do Z\") | Create the hook, add to settings.json, create GH issue for bookkeeping |\n   | **Skill candidate** | Describes a repeatable multi-step workflow (e.g., \"when doing X, follow these steps\") | Create the skill in `.claude\u002Fskills\u002F`, create GH issue |\n   | **Charter update** | Describes a process rule or convention that should be documented for all agents | Update the relevant charter section, create GH issue |\n   | **Keep as memory** | User-specific context, preferences, or project state that doesn't fit the above | Leave as-is |\n\n3. **For each hook\u002Fskill\u002Fcharter candidate:**\n   a. Create a GitHub Issue describing the automation opportunity\n   b. **Assign to the best-fit team member** based on the charter mapping:\n      - Hooks and charter updates → Aino Virtanen (Standards & Quality Lead)\n      - Skills → Aino Virtanen or the domain expert for that workflow\n      - Code changes → the relevant repo's tech lead\n   c. **Spawn or message that person** with the issue details and full context\n   d. Wait for them to confirm completion\n   e. Once confirmed: verify the implementation (hook works, skill invokes, charter reads correctly)\n   f. Push changes and close the issue\n   g. **Delete or update the memory file** — if the memory's content is now fully captured in a hook\u002Fskill\u002Fcharter, remove it. If partially captured, update it to reference the new automation.\n\n4. **Report what was converted:**\n   ```\n   **Memory-to-Automation Audit**\n\n   | Memory File | Classification | Action Taken | Issue |\n   |-------------|---------------|--------------|-------|\n   | feedback_x.md | Hook | Created validate_x.py | #N |\n   | project_y.md | Keep | No action | — |\n   | ...         | ...           | ...          | ...   |\n   ```\n\n**Why:** Memory files accumulate rules and patterns that should be enforced automatically. If a memory says \"always do X\", that's a hook. If it says \"follow these steps for Y\", that's a skill. Leaving these as memories means they only work when the LLM happens to load them — hooks and skills are deterministic.\n\n**Designated owner:** Aino Virtanen handles most conversions (hooks, charter, standards). The orchestrator spawns her with the audit list and she reports back when done.\n\n## What remains manual\n\n- User must approve merge sequence before any PR is merged\n- Must-fix items require engineer action before merge\n- Deferred issues need user decision on next-wave placement\n- Final-wave merge to main requires explicit user approval\n- `\u002Fwave-retro` must be run separately after wrapup completes\n- Memory audit classifications are proposed — user can override keep\u002Fconvert decisions\n\n## Scope-Drop Reconciliation (added P3W4 retro 2026-05-05)\n\nBefore closing a wave, reconcile **declared scope vs delivered scope**. For each repo in `cross-repo-status.json` `wave_{N}_repos_in_scope`:\n\n```bash\ngh pr list --repo noorinalabs\u002F{repo} --state merged --base \"deployments\u002Fphase-{N}\u002Fwave-{M}\" --json number --jq 'length'\n```\n\nIf the count is **0**, the repo had declared work that did not ship. Resolve the drop EXPLICITLY — silent drops are not allowed.\n\n**Two valid outcomes:**\n\n1. **De-scoped during wave** — the work was correctly assessed as out-of-scope mid-wave. Move the repo from `wave_{N}_repos_in_scope` to a new `wave_{N}_repos_descoped_during_wave` array in `cross-repo-status.json` with a one-line reason field. Examples: theme misalignment surfaced after kickoff, dependency on next-wave work, planning error.\n\n2. **Carry-forward to next wave** — the work is still real but slipped. File or update the carry-forward issues, label them with the next wave's label, and add references to `cross-repo-status.json` `wave_{N+1}_carry_forward` array.\n\n**Why:** P3W4 declared `noorinalabs-isnad-ingest-platform` in scope but shipped 0 PRs to its wave branch. The drop was invisible at wrap-time because no check enforced reconciliation — the wave closed with a silent scope discrepancy that surfaced only at retro. Operationally, silent drops compound across waves: by W3-of-N, the declared scope drifts arbitrarily far from delivered, and planning-vs-execution accuracy becomes unmeasurable.\n\n**Acceptance:** A wave-wrapup is not complete until every repo in `wave_{N}_repos_in_scope` has either ≥1 PR merged to its wave branch OR an explicit de-scope\u002Fcarry-forward record. Run this check BEFORE the wave-merge ceremony.\n\n## Implementer-Substitution Reconciliation (added P3W5 retro 2026-05-06)\n\nSymmetric to § Scope-Drop Reconciliation, but for the inverted case: the declared implementer was replaced silently. Before closing a wave, reconcile **declared implementer vs actual PR author** for every PR merged to a wave branch.\n\n```bash\n# For each repo in scope, for each merged PR (zsh-safe `while read` fed from the\n# repos helper + process substitution — main#688). Command substitution like\n# `for repo in $(jq …)` happens to split in zsh, but a parameter\n# (`for repo in $REPOS`) would not; standardize on `while read` so the safe form\n# is the one operators copy.\nwhile IFS= read -r repo; do\n  while IFS= read -r pr; do\n    actual=$(gh pr view \"$pr\" --repo \"noorinalabs\u002F$repo\" --json author --jq '.author.login')\n    branch=$(gh pr view \"$pr\" --repo \"noorinalabs\u002F$repo\" --json headRefName --jq '.headRefName')\n    # Compare actual against wave_{M}_scope.tier_*[].implementer or .tier_*[].assignee for that issue.\n    # Branch prefix (e.g., \"T.Mansour\u002F...\") is the cheap proxy when author is the github org bot.\n  done \u003C \u003C(gh pr list --repo \"noorinalabs\u002F$repo\" --state merged --base \"deployments\u002Fphase-{N}\u002Fwave-{M}\" --json number --jq '.[].number')\ndone \u003C \u003C(python3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fwave_status.py\" repos {N} {M})\n```\n\nIf the actual author (or branch-prefix initials) does not match the kickoff-declared implementer, the substitution must be recorded EXPLICITLY — silent swaps are not allowed.\n\n**Required record:** add an entry to `wave_{N}_decisions.implementer_substitutions` in `cross-repo-status.json`:\n\n```json\n{\n  \"implementer_substitutions\": [\n    {\n      \"repo\": \"noorinalabs-data-acquisition\",\n      \"issue\": \"data-acquisition#36\",\n      \"declared\": \"Sofia Cardoso\",\n      \"actual\": \"Tarek Mansour\",\n      \"swapped_at\": \"2026-05-05T23:42:00Z\",\n      \"rationale\": \"\u003Cone-line reason — e.g., declared implementer unavailable; reassigned by Pipeline Mgr Dilara>\"\n    }\n  ]\n}\n```\n\n**Why:** P3W5 declared Sofia Cardoso as the T1A #263 implementer for data-acquisition; the actual PR (data-acquisition#37) was authored by Tarek Mansour with no recorded swap rationale. Same shape as W4's ingest-platform silent-drop, just inverted (silent-substitution vs silent-zero-PR). Both are scope-drift with no audit trail. Operationally, silent substitutions compound the same way silent drops do: trust matrix updates apply to the wrong engineer (Sofia gets credit she didn't earn, Tarek's first wave PR is invisible at retro), and planning-vs-execution accuracy degrades.\n\n**Acceptance:** A wave-wrapup is not complete until every PR with a declared-vs-actual mismatch has either an entry in `wave_{N}_decisions.implementer_substitutions` OR an explicit acknowledgment that the swap is benign (e.g., orchestrator-class spawn doing implementer-class work — already covered by other discipline). Run this check BEFORE the wave-merge ceremony, in the same pass as § Scope-Drop Reconciliation.\n","Finalize a wave by reviewing all open PRs, merging in dependency order, closing resolved issues, and cleaning up. This is the **exit gate** before running `\u002Fwave-retro`.\n\n> See [`.claude\u002Fteam\u002Flifecycle.md`](..\u002F..\u002Fteam\u002Flifecycle.md) § Wave Lifecycle for the canonical skill order and preconditions.\n\n> Note: all repo paths in bash blocks below are rooted at `$REPO_ROOT` to avoid cwd drift when the skill is invoked from a worktree or child-repo subdirectory (#149).\n\n## Instructions\n\n### 1. Inventory open PRs\n\nList all PRs targeting the wave's deployment branch:\n\n```bash\ngh pr list --state open --base \"deployments\u002Fphase-{P}\u002Fwave-{M}\" --json number,title,author,headRefName,reviews,isDraft,createdAt\n```\n\nAlso check for PRs targeting `main` that belong to this wave (by label or branch pattern):\n\n```bash\n# Canonical label is the phase-agnostic `wave-{X}` (#810); legacy\n# `p{N}-wave-{M}` is grandfathered — query both forms.\ngh pr list --state open --base main --label \"wave-{M}\" --json number,title,author,headRefName,reviews\ngh pr list --state open --base main --label \"p{P}-wave-{M}\" --json number,title,author,headRefName,reviews\n```\n\n### 2. Check CI status for each PR\n\nFor each open PR:\n\n```bash\ngh pr checks {NUMBER} --json name,conclusion,status\n```\n\nClassify each PR:\n| Status | Criteria | Action |\n|--------|----------|--------|\n| **Ready** | CI green, has peer review | Merge |\n| **Needs review** | CI green, no peer review | Request review |\n| **CI failing** | CI red | Fix before merge |\n| **Draft** | Marked as draft | Exclude (report only) |\n| **Blocked** | Has unmerged dependency | Defer until dependency merges |\n\n### 3. Determine merge order\n\nBuild a merge dependency graph:\n- Parse PR bodies for `Depends on #N` or `After #N` references\n- Check if any PR modifies files that another PR also modifies (merge conflict risk)\n- Independent PRs can merge in parallel; dependent PRs merge in order\n\nPresent the proposed merge sequence:\n\n```\n**Merge Sequence: Phase {P} Wave {M}**\n\n| Order | PR | Title | Status | Dependencies | Action |\n|-------|-----|-------|--------|--------------|--------|\n| 1     | #N  | ...   | Ready  | None         | Merge  |\n| 2     | #N  | ...   | Ready  | After #M     | Merge  |\n| —     | #N  | ...   | CI failing | — | Fix first |\n| —     | #N  | ...   | Draft  | — | Skip |\n```\n\n**Do NOT merge any PRs until the user approves the sequence.**\n\n### 4. Review each ready PR\n\nFor each PR marked \"Ready\", perform a review using charter format (same as `\u002Freview-pr`):\n\n```bash\ngh pr diff {NUMBER}\n```\n\nPost review comment:\n\n```\nRequestor: {Reviewer.Name}\nRequestee: {PR author}\nRequestOrReplied: Request\n\n**Review: {LGTM or issues}**\nMust-fix: {list or \"None\"}\nTech-debt: {list or \"None\"}\n```\n\nFor each tech-debt item, create a GitHub Issue labeled `tech-debt` and the next wave\u002Fphase label.\n\nIf must-fix items are found, do NOT merge — report and wait for fixes.\n\n### 5. Merge approved PRs\n\nAfter user approval, merge in the determined order:\n\n```bash\ngh pr merge {NUMBER} --merge --delete-branch\n```\n\nAfter each merge, verify:\n- CI passes on the target branch\n- No merge conflicts introduced for subsequent PRs\n\nIf a merge introduces CI failures, stop and report before continuing.\n\n### 6. Close resolved issues\n\nRun `\u002Fwave-audit` logic to close issues resolved by the merged PRs:\n\n```bash\n# For each merged PR, check for Closes\u002FFixes\u002FResolves references\ngh pr view {NUMBER} --json body\n```\n\nClose referenced issues with audit comments. Also check for issues matched by branch naming convention.\n\n### 7. Verify completeness\n\nCheck that all wave issues are resolved:\n\n```bash\ngh issue list --state open --label \"wave-{M}\" --json number,title\ngh issue list --state open --label \"p{P}-wave-{M}\" --json number,title  # grandfathered\n```\n\nFor any remaining open issues:\n- If the work was deferred, move to the next wave label\n- If the work was partially done, document what remains\n- Report all unresolved items\n\n### 8. Clean up worktrees (mandatory)\n\n**All wave worktrees MUST be removed before the wrapup is considered complete.** Stale worktrees accumulate across waves and cause branch contention.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n# Prune any stale worktree metadata\ngit -C \"$REPO_ROOT\" worktree prune\n\n# List all worktrees and identify wave-related ones\ngit -C \"$REPO_ROOT\" worktree list\n\n# Remove each wave worktree (branches matching wave assignees)\n# Example: git -C \"$REPO_ROOT\" worktree remove \"$REPO_ROOT\u002F.claude\u002Fworktrees\u002FW.Mwangi+0063-fix-branch-freshness-worktree\" --force\n```\n\nFor each worktree:\n1. Check if it has uncommitted changes (`git -C \u003Cpath> status --porcelain`)\n2. If clean, remove with `git worktree remove \u003Cpath>`\n3. If dirty, report to the user — do NOT force-remove without approval\n4. Delete the remote tracking branch if the PR was merged: `git push origin --delete \u003Cbranch>` — **feature\u002Fworktree branches only; NEVER delete a `deployments\u002Fphase-*\u002Fwave-*` branch** (wave branches are retained permanently per owner directive 2026-06-09 — see Step 11).\n\nReport what was cleaned:\n```\n**Worktree Cleanup:**\n- Removed: {count} worktrees\n- Skipped (dirty): {count}\n- Remote branches deleted: {count}\n```\n\n**Why:** Phase 2 Wave 1 left 6 stale worktrees after merge because cleanup wasn't enforced.\n\n### 9. Update documentation\n\nCheck if any merged PRs affect documentation:\n\n```bash\n# List files changed across all merged PRs\nfor pr in {merged_pr_numbers}; do\n    gh pr diff \"$pr\" --name-only\ndone\n```\n\nFlag any changes to:\n- API endpoints (update API docs)\n- Configuration files (update deployment docs)\n- Architecture (update diagrams)\n- Charter or process files (note for retro)\n\n### 9.5. Retro PR body-vs-diff sanity check (added P3W9 #414 — 2026-05-13)\n\nPer `charter\u002Fpull-requests.md § Retro PR Body-vs-Diff Discipline` (Skill enforcement clause): if a retro PR for this wave is already open, every charter\u002Fskill\u002Ftrust-matrix file claimed in its PR body MUST appear in the PR's diff. Direct-to-main commits for ratified retro outputs are forbidden — they bypass the two-reviewer gate and `validate_pr_ci_status`, and break the audit trail.\n\nRun this check before emitting the Step 10 wrapup table so any mismatch surfaces in the table itself:\n\n```bash\n# Discover the open retro PR for this wave (if any).\n# Retro PRs are conventionally titled `retro(P{P}W{M}…)`.\nRETRO_PR=$(gh pr list --repo noorinalabs\u002Fnoorinalabs-main --state open \\\n  --search 'retro( in:title' \\\n  --json number,title \\\n  --jq \".[] | select(.title | test(\\\"retro\\\\\\\\(P{P}W{M}\\\")) | .number\" | head -1)\n\nif [ -z \"$RETRO_PR\" ]; then\n  echo \"No open retro PR for P{P}W{M} — skipping body-vs-diff check.\"\nelse\n  gh pr view \"$RETRO_PR\" --repo noorinalabs\u002Fnoorinalabs-main --json files --jq '[.files[].path] | sort' > \u002Ftmp\u002Fretro_${RETRO_PR}_diff.json\n  gh pr view \"$RETRO_PR\" --repo noorinalabs\u002Fnoorinalabs-main --json body --jq '.body' > \u002Ftmp\u002Fretro_${RETRO_PR}_body.md\n\n  # Manually inspect \u002Ftmp\u002Fretro_${RETRO_PR}_body.md's \"Files changed\" section and\n  # compare each claimed path against \u002Ftmp\u002Fretro_${RETRO_PR}_diff.json. For each\n  # path claimed in the body but missing from the diff JSON, ABORT with a clear\n  # \"body claims X not in diff\" error and surface the mismatch in the Step 10\n  # wrapup table. Do NOT proceed to Step 10 until the retro author either\n  # commits the missing file to the retro branch (preferred) or amends the body\n  # to remove the unsupported claim.\nfi\n```\n\nWorked example of the failure mode this catches: PR [#124](https:\u002F\u002Fgithub.com\u002Fnoorinalabs\u002Fnoorinalabs-main\u002Fpull\u002F124) (W8 retro) body claimed 7 files, diff contained 2 (`feedback_log.md` + `ontology\u002Fchecksums.json`); the other 5 (`trust_matrix.md`, `charter\u002Fpull-requests.md`, `charter\u002Fhooks.md`, `skills\u002Fwave-retro\u002FSKILL.md`, `skills\u002Fwave-kickoff\u002FSKILL.md`) were committed direct-to-main as `2b92605` + `ecd1c76`, bypassing review and CI. The check above would have flagged all 5 missing paths and blocked Step 10 emission until the retro PR was fixed. Filed as [#126](https:\u002F\u002Fgithub.com\u002Fnoorinalabs\u002Fnoorinalabs-main\u002Fissues\u002F126); skill-side mirror filed as [#414](https:\u002F\u002Fgithub.com\u002Fnoorinalabs\u002Fnoorinalabs-main\u002Fissues\u002F414).\n\nThis step mirrors `\u002Fwave-retro` Step 6.5 by design — the same check fires from both skills so a body-vs-diff mismatch is caught whether the operator runs `\u002Fwave-retro` first (post-author check before requesting reviewers) or `\u002Fwave-wrapup` after the retro PR is open (pre-wrapup-table check). The two skills converge on the authoritative shape in `charter\u002Fpull-requests.md § Retro PR Body-vs-Diff Discipline`.\n\n### 10. Final wave report\n\n```\n**Wave Wrapup: Phase {P} Wave {M}**\n\n**PRs:**\n- Merged: {count}\n- Deferred: {count} (moved to next wave)\n- Still failing CI: {count}\n\n**Issues:**\n- Closed: {count}\n- Remaining open: {count} (deferred)\n\n**Tech-debt created:** {count} new issues\n\n**Staging promotion:** {success | failure | deferred (criterion #1 not yet live) | overridden: \u003Crationale>} {run URL if any}\n\n**Documentation:** {docs updated | docs need update | no doc changes}\n\n**Worktrees cleaned:** {count}\n\n**Next step:** Run `\u002Fwave-retro` for full retrospective with assessments and trust updates.\n```\n\n### 10.5. Write canonical counter keys to `cross-repo-status.json`\n\n> **High-volume remote-merge checkpoint (added P3W13 #566 — 2026-05-31).** Before the **first local bookkeeping commit** of the wrapup (the counter-key write below, the ontology rebuild commit, the wrap-marker commit), if this wave merged **N ≥ 10 PRs via `gh` against remote branches**, the local checkout may be many commits behind origin. Re-sync first:\n>\n> ```bash\n> REPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n> CUR_BRANCH=\"$(git -C \"$REPO_ROOT\" rev-parse --abbrev-ref HEAD)\"\n> git -C \"$REPO_ROOT\" fetch --quiet origin \"$CUR_BRANCH\"\n> BEHIND=$(git -C \"$REPO_ROOT\" rev-list --count \"HEAD..origin\u002F$CUR_BRANCH\" 2>\u002Fdev\u002Fnull || echo 0)\n> if [ \"${BEHIND:-0}\" -gt 0 ]; then\n>   echo \"Local is $BEHIND behind origin\u002F$CUR_BRANCH — re-syncing before bookkeeping commit.\"\n>   # Stash\u002Frelocate any in-progress local edits FIRST (a hard reset discards them).\n>   git -C \"$REPO_ROOT\" reset --hard \"origin\u002F$CUR_BRANCH\"\n> fi\n> ```\n>\n> **Why:** P3W13 merged 37 PRs remotely while the local parent sat 22 commits behind; the counter-key commit landed on a stale tree and needed a recovery `reset --hard` that discarded uncommitted session state (`.claude\u002Fannunaki\u002Ferrors.jsonl`). Re-syncing **before** the first bookkeeping write — and relocating any local edits first, since the reset is destructive — prevents both the stale-tree commit and the lossy recovery. Origin > local clone for all wrap-time state (charter `pull-requests.md § Origin > Local Clone`).\n\nWrite the **top-level** canonical counter keys that `\u002Fwave-retro` Step 2.5 verifies. Pre-#318 these were either missing or buried under `wave_{M}_summary.*`, which forced a manual followup commit at retro (P3W7 `fb459b2`). Post-#318 the skill writes them at wrapup time so retro reads cleanly.\n\nUse the shared `upsert_status_keys.py` helper at `.claude\u002Flib\u002F` — it does targeted text-level upsert that preserves the compact-inline shape of `cross-repo-status.json` (a naive `jq … > tmp && mv` reformats every compact line to pretty form, producing a 500-line cosmetic diff per wave — see `main#332`). The helper also validates JSON before AND after the rewrite. Promoted from `\u002Fwave-scope` to `.claude\u002Flib\u002F` per `main#292` (multi-consumer → shared lib).\n\n> **Mechanical computation (added P3W10 #421 — 2026-05-13).** Pre-#421 the\n> `CHANGES_REQUESTED_CYCLES` and `TOP_CONCENTRATION_PCT` placeholders here\n> were filled in by hand by the orchestrator; the resulting null\u002Fwrong\n> values had to be recomputed at retro for 3 consecutive waves (W4 80%,\n> W5 6→4, W9 null+null). The mechanical computation below eliminates the\n> recompute pattern by deriving both counters directly from the merged-PR\n> set across `wave_{M}_repos_in_scope`. `FINAL_PR_COUNT` remains the\n> already-computed Step 10 \"PRs: Merged\" number.\n>\n> **Cross-window filter (added P3W10 #423 — 2026-05-13).** When a wave-branch\n> is reused across partition events (W9 split mid-wave into pre-partition\n> non-deploy PRs + post-partition canonical 6 PRs — owner directive\n> 2026-05-12), `gh pr list --base \"deployments\u002Fphase-{P}\u002Fwave-{M}\"` returns\n> the union of ALL windows, not the canonical wave's window. W9 actuals:\n> 30 PRs returned vs 6 canonical → TOP_CONCENTRATION_PCT computed as 50%\n> against the cross-window set vs 67% canonical. The fix below uses\n> `wave_{M}_kicked_off_at` as a `mergedAt >= X` filter to scope the PR set\n> to the canonical window (Option A), plus a `FINAL_PR_COUNT`-vs-tally\n> cross-check that loud-fails on residual mismatch (Option B — defense in\n> depth for re-roll-within-window edge cases A misses).\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSTATUS=\"$REPO_ROOT\u002Fcross-repo-status.json\"\n\n# Counters are computed by the deterministic helper (main#688). It replaces the\n# hand-rolled bash that used to live here, which collapsed under zsh: a\n# `for R in $WAVE_REPOS_IN_SCOPE` loop does NOT word-split a parameter in zsh\n# (this org's shell — `feedback_zsh_shell_environment`), so the whole repo list\n# was passed to `gh` as one bogus `--repo` value → \"Could not resolve\n# repository\" → merged PR count 0 → division-by-zero. The helper issues every\n# `gh` call as `subprocess.run([...])` with an explicit arg list (no shell, no\n# word-split), applies the `wave_{M}_kicked_off_at` cross-window filter\n# (Option A — #423), and computes final_pr_count \u002F changes_requested_cycles \u002F\n# top_concentration_pct.\n#\n#   --expect {count_of_merged_PRs}  is the Option-B cross-check: loud-fail\n#     (exit 1, NO write) if the tallied PR count != Step 10's \"PRs: Merged\".\n#   --write                         upserts the three canonical top-level keys\n#     via upsert_status_keys.py, preserving the compact-inline file shape.\npython3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fwave_status.py\" counters {P} {M} \\\n    --expect {count_of_merged_PRs} --write\n\n# Read-back verify (memory `feedback_gh_pr_edit_silent_noop` family — any\n# upsert pipeline that silently fails produces zero diff but exit 0).\njq -r --arg m \"{M}\" '\n  \"wave_\" + $m + \"_final_pr_count = \" + (.[\"wave_\" + $m + \"_final_pr_count\"] | tostring),\n  \"wave_\" + $m + \"_changes_requested_cycles = \" + (.[\"wave_\" + $m + \"_changes_requested_cycles\"] | tostring),\n  \"wave_\" + $m + \"_top_concentration_pct = \" + (.[\"wave_\" + $m + \"_top_concentration_pct\"] | tostring)\n' \"$STATUS\"\n```\n\nOptionally also write a richer `wave_{M}_summary` block with wave-shape detail (per-tier PR breakdown, charter-change proposals, thesis text — see P3W7 `cross-repo-status.json` for the canonical shape). Top-level keys above remain **authoritative** for `\u002Fwave-retro` Step 2.5; the summary block is a supplementary surface for retro-prose composition.\n\n**Why top-level not nested:** `\u002Fwave-retro` Step 2.5 reads via `jq -r \".wave_${M}_final_pr_count\"` — a direct top-level lookup. Nesting under `wave_{M}_summary.final_pr_count` would require Step 2.5 changes per wave-counter-key, breaking the canonical-key contract. Top-level keeps the read-side simple.\n\n**Acceptance for \u002Fwave-retro Step 2.5:**\n- All three keys exist at top-level after `\u002Fwave-wrapup` completes.\n- Values match the rendered Step 10 wave report.\n- A `wave_{M}_summary` block, if also present, must not contradict the top-level values (top-level is authoritative).\n\nIf a key cannot be computed (e.g., no PRs merged this wave), write the literal `0` — `\u002Fwave-retro` Step 2.5 distinguishes \"0 cycles\" from \"key missing\" and only the latter is treated as drift.\n\n### 10.6. Per-engineer trust signals (added P6W17 #842 — Option B §4b)\n\nThe wave-level counters above describe the *wave*; the trust matrix needs the same evidence broken down **per engineer**. Extract the countable per-engineer signals from the merged-PR set so `\u002Fwave-retro` applies mechanical, evidence-anchored trust deltas instead of narrative self-grading. The signals are: `prs_merged`, `must_fix_received` (author), `must_fix_caught` (reviewer), `ci_red_merges`, `rework_cycles`, `review_false_positives`. The helper reuses `wave_status.merged_prs` (so the #423 cross-window filter and the no-shell list-arg-vector contract of main#688 carry over) and parses each PR's verdict comments for the `Requestor:` \u002F `RequestOrReplied:` shape.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n# Per-engineer signal JSON, persisted as a single top-level key so \u002Fwave-retro\n# Step 4 reads deterministic numbers rather than re-deriving them by hand.\nSIGNALS=$(python3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Ftrust_signals.py\" extract {P} {M})\npython3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fupsert_status_keys.py\" \"$REPO_ROOT\u002Fcross-repo-status.json\" \\\n    \"wave_{M}_trust_signals=$SIGNALS\"\n\n# Read-back verify (gh silent-no-op family) — the block must be present and parse.\njq -e --arg m \"{M}\" '.[\"wave_\" + $m + \"_trust_signals\"]' \"$REPO_ROOT\u002Fcross-repo-status.json\" >\u002Fdev\u002Fnull \\\n    && echo \"trust signals written\" || echo \"ERROR: trust signals not written\"\n```\n\n**Acceptance:** `wave_{M}_trust_signals` exists at top-level after wrapup, mapping each active engineer to their integer signal counts. `\u002Fwave-retro` Step 4 consumes it; if absent, retro re-extracts it directly (the helper is idempotent and reads from the same merged-PR set).\n\n### 10.7. Child structural-index pre-regen — BEFORE the wave→main PRs (added P7W19 retro)\n\nThe structural-ontology `staleness-check` only gates PRs to **main**, NOT wave-branch PRs. So a per-issue PR that added a tracked source file (`.py`\u002F`.cypher`\u002F`.ts`\u002F`.tsx`) without regenerating that child's structural index passes its own wave-branch CI but **reddens the wave→main PR** the moment Step 11 opens it — a fix-forward scramble mid-wrapup. Caught in P7W19: da#218 added `queries\u002Fvalidation\u002Fsanadset_orphan_inventory.cypher` and the wave→main PR (da#222) went red on staleness-check until the index was regenerated (`b84b478`).\n\nClose it pre-emptively: for each child repo in `wave_{M}_repos_in_scope` (i.e. excluding `noorinalabs-main`, whose index is handled by Step 12b), regenerate the child's structural index from the wave-branch HEAD and commit any diff to the wave branch BEFORE opening the integration PR, so the wave→main PR is green on staleness-check from the start.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\nwhile IFS= read -r R; do\n  [ \"$R\" = \"noorinalabs-main\" ] && continue\n  CHILD=\"$REPO_ROOT\u002F$R\"\n  [ -d \"$CHILD\" ] || { echo \"$R: not checked out locally — regenerate manually if it added tracked source\"; continue; }\n  # Child generator fetches ontology_gen from noorinalabs-main; regenerate in place.\n  ( cd \"$CHILD\" \\\n    && git fetch --quiet origin \"$BRANCH\" && git checkout --quiet \"$BRANCH\" && git pull --ff-only --quiet origin \"$BRANCH\" \\\n    && python3 scripts\u002Fstructural_ontology.py emit --gen-lib \"$REPO_ROOT\u002F.claude\u002Flib\" \\\n    && if ! git diff --quiet -- ontology\u002Fstructural\u002F; then\n         git add ontology\u002Fstructural\u002F \\\n         && git -c user.name=\"Aino Virtanen\" -c user.email=\"parametrization+Aino.Virtanen@gmail.com\" \\\n              commit -m \"ontology(structural): regenerate $R index pre-wave-merge ({P}W{M})\" \\\n         && git push origin \"$BRANCH\" \\\n         && echo \"$R: structural index regenerated + pushed\"\n       else echo \"$R: structural index already current\"; fi )\ndone \u003C\u003C\u003C \"$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\"\n```\n\nThis is the child analog of Step 12b (which regenerates the **parent** index). Run it here, before Step 11, not at 12b — by Step 12 the integration PRs are already open and would have already gone red. If a child repo is not checked out locally, surface it so the operator regenerates manually (or accepts the Step-11 fix-forward for that one repo). Source: memory `feedback_wave_branch_merge_not_squash` § Related child-repo wrap gotcha (P7W19 #222).\n\n### 11. Merge to main per repo (every wave)\n\n**Every wave's wrapup merges its wave branch to main** (changed 2026-06-09 — owner directive; previously gated to the final wave only). Each repo in `wave_{M}_repos_in_scope` has its OWN `deployments\u002Fphase-{P}\u002Fwave-{M}` branch (created by `\u002Fwave-kickoff` step 1) that needs its own PR to main. This is the symmetric counterpart of the multi-repo branch creation gap (main#238). Merging each wave keeps `main` continuously current: the next wave bases off main (`\u002Fwave-start` § 3 base determination; ref cut by `\u002Fwave-kickoff` Step 1), so an unmerged wave would strand its work the moment the following wave starts.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nWAVE_REPOS_IN_SCOPE=$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\n\n# zsh-safe iteration: a here-string into `while IFS= read -r` (NOT\n# `for R in $WAVE_REPOS_IN_SCOPE` — zsh does not word-split a parameter, so the\n# whole list would collapse into one bogus repo; main#688). The here-string\n# keeps the loop in the current shell — relevant for the array-accumulating\n# loops below.\nwhile IFS= read -r R; do\n  # Skip repos where the wave branch is already merged or doesn't exist\n  EXISTING=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002F$BRANCH\" --jq '.object.sha' 2>\u002Fdev\u002Fnull || true)\n  [ -z \"$EXISTING\" ] && { echo \"$R: no wave branch — skip\"; continue; }\n\n  # Check if there's anything to merge (compare branch HEAD vs main HEAD)\n  MAIN_SHA=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002Fmain\" --jq '.object.sha')\n  if [ \"$EXISTING\" = \"$MAIN_SHA\" ]; then\n    echo \"$R: wave branch ==  main, nothing to merge\"; continue\n  fi\n\n  # Create PR from this repo's wave branch to its own main\n  gh pr create --repo \"noorinalabs\u002F$R\" --base main --head \"$BRANCH\" \\\n    --title \"Phase {P} Wave {M} → main ($R)\" \\\n    --body \"Final wave merge for $R. All PRs reviewed and merged to wave branch.\"\ndone \u003C\u003C\u003C \"$WAVE_REPOS_IN_SCOPE\"\n```\n\nPrint a per-repo PR summary table (PR# or \"no merge needed\") and **wait for user approval before merging any PR**. Each PR must be merged independently.\n\n**Do NOT merge to main without user approval.** This is a significant action that affects all downstream repos.\n\n**Retain the wave branch — do NOT delete it on merge** (owner directive 2026-06-09). Merge each wave→main PR with `gh pr merge \u003CN> --merge` (**never** `--delete-branch`); the `deployments\u002Fphase-{P}\u002Fwave-{M}` branches are kept permanently as a historical \u002F rollback anchor for every wave. Caveat: if a repo has \"Automatically delete head branches\" enabled at the repo level, a merge deletes the head branch regardless of the flag — for these repos, either disable that setting or restore the branch immediately after merge (`git push origin \u003Cwave-sha>:refs\u002Fheads\u002Fdeployments\u002Fphase-{P}\u002Fwave-{M}`).\n\n### 11.5. Reachability gate — wave-branch propagation to main (every wave)\n\nAfter the per-repo wave→main PRs in Step 11 are merged (or declared not-needed), verify each wave-branch is actually reachable from `origin\u002Fmain`. This is the load-bearing enforcement counterpart to charter `state-claims.md § Sub-rule: merge_commit_sha reachability` — the rule's claim-time discipline becomes a wrapup-time gate.\n\nOrigin story: `main#339` — `deployments\u002Fphase-3\u002Fwave-7` ended up 10 ahead \u002F 15+ behind \u002F diverged from main with no wave→main PR ever opened. The wave was treated as \"closed\" because individual PRs into the wave-branch were merged, but the wave-branch itself never reached main. PR #305 (the `validate_commit_identity` backslash fix) and 11 hook fixtures sat stranded on a branch no operator was tracking.\n\nThis step catches that pattern at wrapup time, before the wave is declared closed.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nWAVE_REPOS_IN_SCOPE=$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\n\nSTRANDED=()\n# zsh-safe iteration via here-string into `while read` (main#688). The\n# here-string (NOT a `| while` pipe) keeps the loop in the current shell so the\n# STRANDED array survives past `done`.\nwhile IFS= read -r R; do\n  # Skip repos where the wave branch doesn't exist (scope-drop case)\n  WAVE_SHA=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002F$BRANCH\" --jq '.object.sha' 2>\u002Fdev\u002Fnull || true)\n  [ -z \"$WAVE_SHA\" ] && { echo \"$R: no wave branch — skip (scope-drop)\"; continue; }\n\n  # Compare wave-branch against main at origin (NOT local clone — per charter\n  # pull-requests.md § Origin > Local Clone)\n  COMPARE=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fcompare\u002Fmain...$BRANCH\" \\\n    --jq '{ahead_by, behind_by, status}')\n  AHEAD=$(echo \"$COMPARE\" | jq -r .ahead_by)\n  STATUS=$(echo \"$COMPARE\" | jq -r .status)\n\n  if [ \"$AHEAD\" -gt 0 ] || [ \"$STATUS\" = \"diverged\" ]; then\n    # Check if a wave→main PR exists in any state — explains the gap if so\n    PR_EXISTS=$(gh pr list --repo \"noorinalabs\u002F$R\" --base main --head \"$BRANCH\" \\\n      --state all --limit 5 --json number,state,mergedAt \\\n      --jq '[.[] | select(.state == \"MERGED\" or .state == \"OPEN\")] | length')\n    STRANDED+=(\"$R: ahead_by=$AHEAD status=$STATUS wave→main PRs found=$PR_EXISTS\")\n  else\n    echo \"$R: wave-branch reachable from main (ahead_by=$AHEAD, status=$STATUS) — OK\"\n  fi\ndone \u003C\u003C\u003C \"$WAVE_REPOS_IN_SCOPE\"\n\nif [ ${#STRANDED[@]} -gt 0 ]; then\n  echo \"════════════════════════════════════════════════════════════\"\n  echo \"BLOCKED: \u002Fwave-wrapup cannot close wave {M} — STRANDED repos:\"\n  for s in \"${STRANDED[@]}\"; do echo \"  $s\"; done\n  echo \"\"\n  echo \"Each STRANDED repo has wave-branch commits NOT reachable from origin\u002Fmain.\"\n  echo \"Fix-forward options:\"\n  echo \"  (a) Open the wave→main PR (re-run Step 11 if no PR exists)\"\n  echo \"  (b) Merge an already-OPEN wave→main PR\"\n  echo \"  (c) If stranding is INTENTIONAL (descoped wave, rolled-back work), set\"\n  echo \"      STRANDING_OVERRIDE_RATIONALE=\\\"\u003Cexplicit reason>\\\" before re-invoking\"\n  echo \"      \u002Fwave-wrapup. The override is logged to the wrapup report and to\"\n  echo \"      cross-repo-status.json under wave_{M}_stranding_override.\"\n  echo \"════════════════════════════════════════════════════════════\"\n  exit 1\nfi\n```\n\n**Override mechanism** (when stranding is intentional):\n\n```bash\n# Only use when the wave is deliberately not merged to main\n# (descoped, rolled back, or held for sequencing reasons)\nexport STRANDING_OVERRIDE_RATIONALE=\"P3W7 work descoped post-#339 audit; \\\n  wave-7 branch retained for historical reference, no propagation intended\"\n# Re-invoke \u002Fwave-wrapup — the gate sees the rationale, logs it, and proceeds\n```\n\nThe override is intentionally noisy: rationale is required (no empty string), logged to the wrapup report, and persisted to `cross-repo-status.json` under `wave_{M}_stranding_override` so subsequent \u002Fwave-retro and audit passes can surface it.\n\n### 11.5a. Deployable-merge verification gate — post-merge workflows went green (every wave)\n\nA wave→main merge is a **deployable merge**: it triggers push-to-main workflows that **never ran on the per-issue PRs** (publish\u002FTrivy, schema-drift, structural-ontology, …). Their CI gives **no pre-merge signal**, so a wave can be reachable-on-main (Step 11.5 green) yet have reddened `main` post-merge. This is exactly how `isnad-graph#1131` (libexpat `CVE-2026-45186`) slipped past `#1130`'s green PR and failed the GHCR publish on `main` (main#864).\n\nAfter Step 11.5 confirms reachability, verify each repo's wave→main merge commit actually went green across its post-merge-only workflows, using the deterministic oracle `.claude\u002Flib\u002Fverify_deployable_merge.py` (it polls the Actions runs for the exact merge SHA; a failed run, or a required workflow that produced **no** run at all, is a hard not-verified — the same empty-is-not-ready discipline as `pr_ci_state.py`). `gh pr merge` returns 0 the instant the merge commit exists — long before these workflows even start — so the merge's own exit status proves nothing.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nWAVE_REPOS_IN_SCOPE=$(jq -r \".wave_{M}_repos_in_scope[]\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\nBRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\n\nUNVERIFIED=()\nwhile IFS= read -r R; do\n  # The deployable merge commit is origin\u002Fmain's HEAD for the repo after Step 11\n  # merged the wave→main PR. Resolve it at origin (not the local clone).\n  MAIN_SHA=$(gh api \"repos\u002Fnoorinalabs\u002F$R\u002Fgit\u002Frefs\u002Fheads\u002Fmain\" --jq '.object.sha' 2>\u002Fdev\u002Fnull || true)\n  [ -z \"$MAIN_SHA\" ] && { echo \"$R: cannot read main — skip\"; continue; }\n\n  # --require-deployable verifies the post-merge-ONLY workflows (push\u002Ftag AND not\n  # pull_request) — the genuinely blind ones. Drop the flag to verify every\n  # merge-triggered workflow. The no-red safety net fails on ANY run that\n  # executed for the SHA and went red, so path-filtered workflows that fired are\n  # still covered. Exit codes: 0 = verified (incl. \"nothing required\" + no red),\n  # 1 = NOT verified (a red or silently-dropped required run), 2 = UNDETERMINED\n  # (gh\u002FAPI failure — cannot tell, so surface and treat as blocking).\n  python3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fverify_deployable_merge.py\" \\\n    \"noorinalabs\u002F$R\" \"$MAIN_SHA\" --require-deployable --timeout 1200 --poll 30\n  case $? in\n    0) echo \"$R: deployable merge verified green @ ${MAIN_SHA:0:8}\" ;;\n    1) UNVERIFIED+=(\"$R @ ${MAIN_SHA:0:8} — a post-merge workflow failed \u002F was dropped\") ;;\n    2) UNVERIFIED+=(\"$R @ ${MAIN_SHA:0:8} — UNDETERMINED (gh\u002FAPI error; re-run or investigate)\") ;;\n  esac\ndone \u003C\u003C\u003C \"$WAVE_REPOS_IN_SCOPE\"\n\nif [ ${#UNVERIFIED[@]} -gt 0 ]; then\n  echo \"════════════════════════════════════════════════════════════\"\n  echo \"BLOCKED: \u002Fwave-wrapup cannot close wave {M} — deployable merge NOT verified:\"\n  for u in \"${UNVERIFIED[@]}\"; do echo \"  $u\"; done\n  echo \"\"\n  echo \"A post-merge workflow failed (or never ran) on main after the wave→main merge.\"\n  echo \"This is a RED main gate — a stop, not a speed bump (charter no-force-merge).\"\n  echo \"Fix-forward options:\"\n  echo \"  (a) Fix the regression on main via a normal bug→PR→merge cycle (e.g. a\"\n  echo \"      base-image CVE re-pin like isnad-graph#1132), then re-run \u002Fwave-wrapup.\"\n  echo \"  (b) If the failure is a documented external\u002Fstanding item (advisory-DB\"\n  echo \"      drift, a no-fix base-image CVE under an active --ignore-vuln), set\"\n  echo \"      DEPLOYABLE_VERIFY_OVERRIDE_RATIONALE=\\\"\u003Creason + tracking issue>\\\" and\"\n  echo \"      re-invoke. The override is logged to the report and to\"\n  echo \"      cross-repo-status.json under wave_{M}_deployable_verify_override.\"\n  echo \"════════════════════════════════════════════════════════════\"\n  [ -z \"${DEPLOYABLE_VERIFY_OVERRIDE_RATIONALE:-}\" ] && exit 1\n  echo \"OVERRIDDEN: $DEPLOYABLE_VERIFY_OVERRIDE_RATIONALE\"\nfi\n```\n\nA genuinely external red (e.g. a newly-published advisory the fix has not yet propagated for — `feedback_pip_audit_strict_advisory_db_drift`, `feedback_trivy_base_image_cve_org_wide_gate`) is the only case for the override, and it MUST name a tracking issue. A red caused by the wave's own change is fixed forward — never overridden. Include the per-repo result in the Step 10 report; `\u002Fwave-retro` records it in the wave history row.\n\n### 11.6. Staging-promotion gate (Phase-3 end-state criterion #3)\n\nA wave is **not closeable until its merged code has been promoted to staging green**. This is the wrapup-time enforcement of Phase-3 end-state criterion #3 (`main#325`) and the charter rule `pull-requests.md § Wave-Wrapup Staging-Promotion Gate`. It runs AFTER the Step 11.5 reachability-to-main gate (code must be on main before it can be promoted to staging) and BEFORE the ontology rebuild.\n\nThe canonical staging deploy is `noorinalabs-deploy\u002F.github\u002Fworkflows\u002Fdeploy-stg.yml`. The gate inspects the latest run; blocks on red; **defers** (does not fail) when staging does not yet exist — criterion #3 is blocked by criterion #1 (live staging). An explicit rationale env var overrides a red\u002Fabsent run.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSTATUS=\"$REPO_ROOT\u002Fcross-repo-status.json\"\nUPSERT=\"$REPO_ROOT\u002F.claude\u002Flib\u002Fupsert_status_keys.py\"\nSTG_WORKFLOW=\"deploy-stg.yml\"\nDEPLOY_REPO=\"noorinalabs\u002Fnoorinalabs-deploy\"\n\n# Fetch the latest deploy-stg.yml run. Empty result = staging not live yet.\nSTG_RUN=$(gh run list --repo \"$DEPLOY_REPO\" --workflow \"$STG_WORKFLOW\" \\\n  --limit 1 --json databaseId,status,conclusion,url,headSha \\\n  --jq '.[0] \u002F\u002F empty' 2>\u002Fdev\u002Fnull || true)\n\nif [ -z \"$STG_RUN\" ]; then\n  # Criterion #1 not satisfied — defer, do NOT hard-fail. Logged, not silent.\n  STG_RESULT=\"deferred\"\n  STG_URL=\"\"\n  echo \"staging-promotion gate DEFERRED — criterion #1 (live staging) not yet satisfied\"\n  echo \"  (no $STG_WORKFLOW run history in $DEPLOY_REPO). Gate auto-activates once staging is live.\"\nelse\n  STG_STATUS=$(echo \"$STG_RUN\" | jq -r .status)\n  STG_CONCLUSION=$(echo \"$STG_RUN\" | jq -r .conclusion)\n  STG_URL=$(echo \"$STG_RUN\" | jq -r .url)\n\n  if [ \"$STG_STATUS\" != \"completed\" ]; then\n    echo \"staging deploy still in progress ($STG_STATUS) — re-run \u002Fwave-wrapup once $STG_URL completes,\"\n    echo \"or set STG_PROMOTION_OVERRIDE_RATIONALE to close anyway.\"\n    STG_CONCLUSION=\"in_progress\"\n  fi\n\n  if [ \"$STG_CONCLUSION\" = \"success\" ]; then\n    STG_RESULT=\"success\"\n    echo \"staging promotion GREEN — $STG_URL\"\n  elif [ -n \"${STG_PROMOTION_OVERRIDE_RATIONALE:-}\" ]; then\n    STG_RESULT=\"overridden\"\n    echo \"staging promotion NOT green ($STG_CONCLUSION) — OVERRIDDEN:\"\n    echo \"  $STG_PROMOTION_OVERRIDE_RATIONALE\"\n  else\n    echo \"════════════════════════════════════════════════════════════\"\n    echo \"BLOCKED: \u002Fwave-wrapup cannot close wave {M} — staging promotion is $STG_CONCLUSION.\"\n    echo \"  Latest $STG_WORKFLOW run: $STG_URL\"\n    echo \"Fix-forward options:\"\n    echo \"  (a) Fix the regression and re-trigger the staging deploy, then re-run \u002Fwave-wrapup.\"\n    echo \"  (b) Re-dispatch deploy-stg.yml manually:\"\n    echo \"      gh workflow run $STG_WORKFLOW --repo $DEPLOY_REPO\"\n    echo \"  (c) If a red\u002Fabsent staging run is INTENTIONALLY acceptable (staging infra\"\n    echo \"      mid-migration, meta-only wave with no deployable surface), set\"\n    echo \"      STG_PROMOTION_OVERRIDE_RATIONALE=\\\"\u003Cexplicit reason>\\\" before re-invoking.\"\n    echo \"════════════════════════════════════════════════════════════\"\n    exit 1\n  fi\nfi\n\n# Persist the result for \u002Fwave-retro Step 2.5 + audit passes. Compact-inline\n# preserved via upsert_status_keys.py (NOT jq>tmp>mv — see main#332).\npython3 \"$UPSERT\" \"$STATUS\" \\\n    \"wave_{M}_stg_promotion=\\\"${STG_RESULT}\\\"\" \\\n    \"wave_{M}_stg_promotion_url=\\\"${STG_URL}\\\"\"\n[ \"$STG_RESULT\" = \"overridden\" ] && python3 \"$UPSERT\" \"$STATUS\" \\\n    \"wave_{M}_stg_promotion_override_rationale=\\\"${STG_PROMOTION_OVERRIDE_RATIONALE}\\\"\"\n\n# Read-back verify (feedback_gh_pr_edit_silent_noop family).\njq -r --arg m \"{M}\" '\"wave_\" + $m + \"_stg_promotion = \" + (.[\"wave_\" + $m + \"_stg_promotion\"] | tostring)' \"$STATUS\"\n```\n\n**Override mechanism** (when a red\u002Fabsent staging run is acceptable):\n\n```bash\n# Only use when staging green is genuinely not achievable\u002Fapplicable for this wave\n# (staging infra mid-migration, meta-only wave with no deployable surface).\nexport STG_PROMOTION_OVERRIDE_RATIONALE=\"W13 is charter\u002Fskill-meta only; no service \\\n  image changed, so no staging deploy is produced. Gate overridden, criterion #3 \\\n  unaffected (no deployable surface to promote).\"\n# Re-invoke \u002Fwave-wrapup — the gate logs the rationale, persists it, and proceeds.\n```\n\nInclude the staging-promotion result (`success`\u002F`failure`\u002F`deferred`\u002F`overridden`) and the run URL in the Step 10 final wave report. `\u002Fwave-retro` records it in the wave history row alongside PR count and admin overrides.\n\n### 11.6a. Per-merge deploy watch (active — `\u002Fwatch-deploy`)\n\nThe Step 11.6 gate above inspects only the **latest** `deploy-stg.yml` run. That misses the failure mode deploy#418 surfaced: a wave→main merge in one repo triggers a deploy that fails (e.g. a user-service merge that broke the image pull), which is then masked when a later merge's deploy goes green and becomes \"latest\". To close this, **actively follow the deploy each wave→main merge triggered**, not just the most-recent run.\n\nFor each repo in `wave_{M}_repos_in_scope` that participates in the staging fan-in (`noorinalabs-isnad-graph`, `noorinalabs-user-service` — the repos whose `ghcr-publish.yml` dispatches `deploy-stg.yml`), take that repo's Step 11 wave→main merge commit and run:\n\n```\n\u002Fwatch-deploy stg \u003Cmerge_sha>\n```\n\n`\u002Fwatch-deploy` polls that specific dispatched deploy to a terminal state, classifies any failure, attempts a single bounded fix-forward (e.g. re-dispatch `stg-latest`), and escalates with a diagnosis otherwise. A wave is not closeable while any fan-in merge's deploy is red and unremediated — fold any escalation into the Step 11.6 block\u002Foverride decision above.\n\n**Publish-freshness check (P4W4 retro #3 \u002F main#647).** The per-merge watch above follows `deploy-stg.yml`, but a base-image CVE reddens the **publish** (`ghcr-publish.yml`) *upstream* of the deploy: the W4 openssl CVE-2026-45447 reddened isnad-graph's frontend publish and never reached `deploy-stg.yml`, so the deploy watch missed it entirely. For each fan-in repo, also inspect the latest `ghcr-publish.yml` run on its default branch and treat a red publish as a wave-closeability signal — classifying the cause the same way Step 5a of `\u002Fsession-start` does (base-image-CVE signal → `base-image-drift`, else `code\u002Fother`):\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n# Newline-joined so `while read` iterates safely; `for repo in $FANIN` would\n# collapse the whole string into one iteration under zsh (main#688). The\n# here-string below keeps the loop in the current shell so PUB_RED persists.\nFANIN=$'noorinalabs-isnad-graph\\nnoorinalabs-user-service'\nPUB_RED=()\nwhile IFS= read -r repo; do\n  # Only the fan-in repos actually in this wave's scope.\n  jq -e --arg r \"$repo\" '.[\"wave_{M}_repos_in_scope\"] | index($r)' \"$REPO_ROOT\u002Fcross-repo-status.json\" >\u002Fdev\u002Fnull 2>&1 || continue\n  branch=$(gh api \"repos\u002Fnoorinalabs\u002F$repo\" --jq '.default_branch' 2>\u002Fdev\u002Fnull || echo main)\n  # Latest ghcr-publish.yml run on the default branch (empty if the workflow\u002Frun is absent).\n  IFS=$'\\t' read -r run_id conclusion url \u003C \u003C(\n    gh api \"repos\u002Fnoorinalabs\u002F$repo\u002Factions\u002Fworkflows\u002Fghcr-publish.yml\u002Fruns?branch=$branch&per_page=1\" \\\n      --jq '(.workflow_runs[0] \u002F\u002F empty) | [.id, .conclusion, .html_url] | @tsv' 2>\u002Fdev\u002Fnull\n  )\n  case \"$conclusion\" in\n    failure|timed_out|cancelled|startup_failure)\n      # Best-effort base-image-CVE classification; degrades to code\u002Fother on any log-fetch failure.\n      cls=code\u002Fother\n      log=$(gh run view \"$run_id\" --repo \"noorinalabs\u002F$repo\" --log-failed 2>\u002Fdev\u002Fnull || true)\n      printf '%s' \"$log\" | grep -Eiq 'trivy|grype|\\bCVE-[0-9]{4}-[0-9]+|apk[ -].*(upgrade|CVE)|openssl.*(vuln|CVE|advisor)|base[ -]image' && cls=base-image-drift\n      PUB_RED+=(\"$repo :: ghcr-publish.yml :: $conclusion :: $cls :: $url\") ;;\n  esac\ndone \u003C\u003C\u003C \"$FANIN\"\nif [ ${#PUB_RED[@]} -gt 0 ]; then\n  printf 'RED fan-in publish run(s) — a wave is NOT closeable while a fan-in publish is red:\\n'\n  printf '  %s\\n' \"${PUB_RED[@]}\"\n  printf '%s\\n' \"${PUB_RED[@]}\" | grep -q base-image-drift && \\\n    printf '  NOTE: \"base-image-drift\" = upstream base-image CVE — fix-forward the base image (rebuild\u002Fbump), not the wave diff.\\n'\nelse\n  echo \"Fan-in publishes (ghcr-publish.yml) green on default branches.\"\nfi\n```\n\nA red fan-in publish — `base-image-drift` or otherwise — blocks wave closeability the same as a red deploy: fold it into the Step 11.6 block\u002Foverride decision above. Best-effort: on `gh api` failure, say so rather than reporting a false green.\n\nLanding-page and meta-only repos do not participate in the stg fan-in (no dispatch), so they have no per-merge deploy or publish to watch — skip them.\n\n**Production counterpart:** prod deploys are gated on owner approval (owner directive 2026-06-09). `\u002Fwave-wrapup` must NOT approve or trigger them. When the owner approves a queued prod deploy for this wave's promotion, run `\u002Fwatch-deploy prod \u003Csha>` to monitor it the same way; `\u002Fwatch-deploy` never advances or auto-remediates prod.\n\n### 12. Ontology update — semantic overlay + structural index (#862)\n\nThe ontology has two independent layers to update at wave close (#820\u002FC×T2):\n\n**12a. Semantic overlay** — run `\u002Fontology-rebuild` to process any hand-curated files that changed during this wave. Scope is `ontology\u002Fchecksums.json` dirty files only; structural is never resolved here.\n- If no dirty files, report \"Semantic overlay: up to date\" and skip\n- The resolver auto-updates docs where appropriate and flags recommend-only changes\n\n**12b. Structural index** — regenerate the parent repo's structural index and refresh the cross-repo aggregator. The wave may have added\u002Fchanged hooks, skills, or lib modules that should be reflected in the index before the wave is closed.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\n\n# Check how many source files changed since the index was last generated\nSTRUCT_SHA=$(git -C \"$REPO_ROOT\" log -1 --format=\"%H\" -- ontology\u002Fstructural\u002Fllms.txt 2>\u002Fdev\u002Fnull || echo \"\")\nif [ -z \"$STRUCT_SHA\" ]; then\n  CHANGED=\"new\"\nelse\n  CHANGED=$(git -C \"$REPO_ROOT\" diff --name-only \"$STRUCT_SHA\"..HEAD -- \\\n    '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.cypher' '*.cql' 2>\u002Fdev\u002Fnull | wc -l | tr -d ' ')\nfi\n\nif [ \"${CHANGED:-0}\" = \"0\" ]; then\n  echo \"Structural index: current — no source files changed since last generation.\"\nelse\n  echo \"Structural index: ${CHANGED} source file(s) changed — regenerating.\"\n  PYTHONPATH=\"$REPO_ROOT\u002F.claude\u002Flib\" python3 -m ontology_gen \\\n    \"$REPO_ROOT\" --out \"$REPO_ROOT\u002Fontology\u002Fstructural\u002F\" 2>&1\n  # Refresh the central cross-repo graph\n  PYTHONPATH=\"$REPO_ROOT\u002F.claude\u002Flib\" python3 -m ontology_gen.aggregate \\\n    \"$REPO_ROOT\" 2>&1 || true\n\n  # Commit if anything changed\n  if ! git -C \"$REPO_ROOT\" diff --quiet ontology\u002Fstructural\u002F; then\n    git -C \"$REPO_ROOT\" add ontology\u002Fstructural\u002F\n    MSGFILE=\"$(mktemp)\"\n    printf 'ontology: regenerate structural index (wave-wrapup step 12b)\\n\\n%s source files changed this wave; re-ran ontology_gen + aggregate.\\n' \\\n      \"${CHANGED}\" > \"$MSGFILE\"\n    git -C \"$REPO_ROOT\" \\\n      -c user.name=\"Aino Virtanen\" \\\n      -c user.email=\"parametrization+Aino.Virtanen@gmail.com\" \\\n      commit -F \"$MSGFILE\"\n    rm -f \"$MSGFILE\"\n    echo \"Structural index committed.\"\n  fi\nfi\n```\n\nInclude both results in the final wave report (Step 10):\n- `Ontology: Semantic {N files resolved \u002F up to date}; Structural {current \u002F regenerated + committed}`\n\n**Note:** the structural layer at `ontology\u002Fstructural\u002F` is always-current-by-regeneration — `\u002Fontology-rebuild` does NOT resolve it (that was retired per #857\u002FC×T2). The generator above IS the structural resolve path.\n\n### 12.5. Generic-prompt genericize checkpoint (main#716)\n\nThe `suggest_generic_prompt` PostToolUse hook no longer nudges per-edit (that nudge was never actioned — a non-binding mid-task `systemMessage` with no state, no dedup, no closing loop → enforcement-hierarchy decay; `2real-team-framework\u002Fgeneric_prompts\u002F` gained **zero** files across every wave despite it firing constantly). It now **silently tracks** every touched `.claude\u002F` artifact into a pending ledger. This step is the closing loop: once per wave, enumerate the wave's new\u002Fchanged `.claude\u002F` artifacts (hooks\u002Fskills\u002Fcharter\u002Fsettings) that **lack a counterpart** in the framework's `generic_prompts\u002F` **and aren't already decided**, and make ONE deliberate genericize-or-skip pass. Decisions are recorded so the same artifact is never re-surfaced.\n\nTwo state files back this (see `.claude\u002Flib\u002Fgeneric_prompt_tracker.py`): `.claude\u002Fgeneric_prompt_pending.json` (volatile, gitignored — the candidate set) and `.claude\u002Fgeneric_prompt_ledger.json` (**version-controlled** — the durable genericize\u002Fskip decisions, the dedup memory).\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nTRACKER=\"$REPO_ROOT\u002F.claude\u002Flib\u002Fgeneric_prompt_tracker.py\"\n\n# (a) Belt-and-suspenders: the pending ledger is per-machine volatile state, so\n# augment it with a git-diff sweep of the meta repo's wave-window .claude\u002F\n# changes (in case pending was wiped, or edits happened in a different session).\n# Diff the wave branch's merge-base with main against HEAD; degrade to a no-op\n# if the wave branch is absent. zsh-safe `while read` (main#688).\nWAVE_BRANCH=\"deployments\u002Fphase-{P}\u002Fwave-{M}\"\nBASE=$(git -C \"$REPO_ROOT\" merge-base main \"$WAVE_BRANCH\" 2>\u002Fdev\u002Fnull || true)\nif [ -n \"$BASE\" ]; then\n  git -C \"$REPO_ROOT\" diff --name-only --diff-filter=d \"$BASE\"..HEAD -- '.claude\u002F**' 2>\u002Fdev\u002Fnull \\\n    | while IFS= read -r f; do\n        [ -n \"$f\" ] && python3 \"$TRACKER\" record-candidate \"$REPO_ROOT\u002F$f\" >\u002Fdev\u002Fnull\n      done\nfi\n\n# (b) List the undecided genericizable candidates — the deliberate worklist.\npython3 \"$TRACKER\" list --wave \"P{P}W{M}\"\n```\n\nFor **each** candidate the list prints, make a genericize-or-skip call and record it (this is what stops the artifact re-surfacing next wave):\n\n```bash\n# Genericize: draft the product-neutral prompt file in the framework repo\n# (strip project-specific names\u002Fpaths\u002Fteam\u002Frepo), then record the decision with\n# the generic file as the detail:\npython3 \"$TRACKER\" record \"hooks\u002F\u003Cname>.py\" genericized \\\n  --detail \"GENERIC_\u003CNAME>_PROMPT.md\" --wave \"P{P}W{M}\"\n\n# Skip: the artifact is too project-coupled to genericize (or not worth it) —\n# record WHY so it stays settled:\npython3 \"$TRACKER\" record \"skills\u002F\u003Cname>\u002FSKILL.md\" skipped \\\n  --detail \"\u003Cone-line reason — e.g. tightly coupled to noorinalabs wave lifecycle>\" \\\n  --wave \"P{P}W{M}\"\n```\n\n- If `list` reports no undecided candidates, report \"Generic-prompt checkpoint: nothing to genericize this wave\" and continue.\n- **Commit the ledger** (`.claude\u002Fgeneric_prompt_ledger.json`) as part of the wrapup — it is version-controlled durable state. The pending file is gitignored; do not commit it.\n- Include a one-line genericized\u002Fskipped tally in the Step 10 final wave report.\n- This is the deliberate batched replacement for the demoted per-edit hook; do NOT re-introduce a mid-task suggestion.\n\n### 13. Annunaki error attack\n\n> **Preferred surface is `\u002Fwave-retro` Step 7.6 (P3W9 #344).** Retro is the natural moment for this audit — findings feed the retro's charter-change proposals. Wrapup retains this step as a fallback for cases where retro is delayed or skipped. The run-marker below prevents double-execution.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nALREADY_RAN=$(jq -r \".wave_${M}_annunaki_attack_ran_at \u002F\u002F empty\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\n\nif [ -n \"$ALREADY_RAN\" ]; then\n  echo \"Annunaki-attack: already ran at $ALREADY_RAN (via \u002Fwave-retro Step 7.6). Skipping.\"\n  # Continue to Step 14.\nelse\n  # Proceed with the attack below; on completion write the marker.\nfi\n```\n\nRun `\u002Fannunaki-attack` to process any errors captured by the Annunaki monitor during this wave. This converts observed errors into preventative automation (hooks, skills, charter updates) before the wave closes.\n\n- If `.claude\u002Fannunaki\u002Ferrors.jsonl` is empty or missing, report \"Annunaki: No errors captured this wave\" and skip the attack — but still write the run-marker so retro's 7.6 doesn't re-check\n- Use the current wave label for any issues created\n- Include Annunaki-created issues and PRs in the final wave report totals\n- This step runs **before** the memory-to-automation audit so that new hooks\u002Fskills from error analysis are visible to the memory audit\n- On completion, write `wave_${M}_annunaki_attack_ran_at = \u003CISO-8601 UTC timestamp>` to `cross-repo-status.json`\n\n### 14. Memory-to-automation audit\n\n> **Preferred surface is `\u002Fwave-retro` Step 7.7 (P3W9 #344).** Retro is the natural moment for this audit — findings feed the retro's charter-change proposals and the Aino-spawned conversion issues count toward the same retro's per-engineer assessment + trust update pass. Wrapup retains this step as the canonical procedure body (referenced by retro's 7.7) and as a fallback for retro-delayed cases. The run-marker below prevents double-execution.\n\n```bash\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nALREADY_RAN=$(jq -r \".wave_${M}_memory_audit_ran_at \u002F\u002F empty\" \"$REPO_ROOT\u002Fcross-repo-status.json\")\n\nif [ -n \"$ALREADY_RAN\" ]; then\n  echo \"Memory-to-automation audit: already ran at $ALREADY_RAN (via \u002Fwave-retro Step 7.7). Skipping.\"\n  # Continue to the rest of the wrapup.\nelse\n  # Proceed with the audit below; on completion write the marker.\nfi\n```\n\nExamine all memory files in the project memory directory for entries that describe behaviors, rules, or patterns that could be codified as a **hook**, **skill**, or **charter update** instead of remaining as soft memory. On completion, write `wave_${M}_memory_audit_ran_at = \u003CISO-8601 UTC timestamp>` to `cross-repo-status.json`.\n\n**Process:**\n\n1. **Read all memory files:**\n   ```bash\n   ls ~\u002F.claude\u002Fprojects\u002F*\u002Fmemory\u002F*.md\n   ```\n\n2. **For each memory file**, classify it:\n   | Category | Criteria | Action |\n   |----------|----------|--------|\n   | **Hook candidate** | Describes a rule that should be enforced automatically (e.g., \"always do X before Y\", \"never do Z\") | Create the hook, add to settings.json, create GH issue for bookkeeping |\n   | **Skill candidate** | Describes a repeatable multi-step workflow (e.g., \"when doing X, follow these steps\") | Create the skill in `.claude\u002Fskills\u002F`, create GH issue |\n   | **Charter update** | Describes a process rule or convention that should be documented for all agents | Update the relevant charter section, create GH issue |\n   | **Keep as memory** | User-specific context, preferences, or project state that doesn't fit the above | Leave as-is |\n\n3. **For each hook\u002Fskill\u002Fcharter candidate:**\n   a. Create a GitHub Issue describing the automation opportunity\n   b. **Assign to the best-fit team member** based on the charter mapping:\n      - Hooks and charter updates → Aino Virtanen (Standards & Quality Lead)\n      - Skills → Aino Virtanen or the domain expert for that workflow\n      - Code changes → the relevant repo's tech lead\n   c. **Spawn or message that person** with the issue details and full context\n   d. Wait for them to confirm completion\n   e. Once confirmed: verify the implementation (hook works, skill invokes, charter reads correctly)\n   f. Push changes and close the issue\n   g. **Delete or update the memory file** — if the memory's content is now fully captured in a hook\u002Fskill\u002Fcharter, remove it. If partially captured, update it to reference the new automation.\n\n4. **Report what was converted:**\n   ```\n   **Memory-to-Automation Audit**\n\n   | Memory File | Classification | Action Taken | Issue |\n   |-------------|---------------|--------------|-------|\n   | feedback_x.md | Hook | Created validate_x.py | #N |\n   | project_y.md | Keep | No action | — |\n   | ...         | ...           | ...          | ...   |\n   ```\n\n**Why:** Memory files accumulate rules and patterns that should be enforced automatically. If a memory says \"always do X\", that's a hook. If it says \"follow these steps for Y\", that's a skill. Leaving these as memories means they only work when the LLM happens to load them — hooks and skills are deterministic.\n\n**Designated owner:** Aino Virtanen handles most conversions (hooks, charter, standards). The orchestrator spawns her with the audit list and she reports back when done.\n\n## What remains manual\n\n- User must approve merge sequence before any PR is merged\n- Must-fix items require engineer action before merge\n- Deferred issues need user decision on next-wave placement\n- Final-wave merge to main requires explicit user approval\n- `\u002Fwave-retro` must be run separately after wrapup completes\n- Memory audit classifications are proposed — user can override keep\u002Fconvert decisions\n\n## Scope-Drop Reconciliation (added P3W4 retro 2026-05-05)\n\nBefore closing a wave, reconcile **declared scope vs delivered scope**. For each repo in `cross-repo-status.json` `wave_{N}_repos_in_scope`:\n\n```bash\ngh pr list --repo noorinalabs\u002F{repo} --state merged --base \"deployments\u002Fphase-{N}\u002Fwave-{M}\" --json number --jq 'length'\n```\n\nIf the count is **0**, the repo had declared work that did not ship. Resolve the drop EXPLICITLY — silent drops are not allowed.\n\n**Two valid outcomes:**\n\n1. **De-scoped during wave** — the work was correctly assessed as out-of-scope mid-wave. Move the repo from `wave_{N}_repos_in_scope` to a new `wave_{N}_repos_descoped_during_wave` array in `cross-repo-status.json` with a one-line reason field. Examples: theme misalignment surfaced after kickoff, dependency on next-wave work, planning error.\n\n2. **Carry-forward to next wave** — the work is still real but slipped. File or update the carry-forward issues, label them with the next wave's label, and add references to `cross-repo-status.json` `wave_{N+1}_carry_forward` array.\n\n**Why:** P3W4 declared `noorinalabs-isnad-ingest-platform` in scope but shipped 0 PRs to its wave branch. The drop was invisible at wrap-time because no check enforced reconciliation — the wave closed with a silent scope discrepancy that surfaced only at retro. Operationally, silent drops compound across waves: by W3-of-N, the declared scope drifts arbitrarily far from delivered, and planning-vs-execution accuracy becomes unmeasurable.\n\n**Acceptance:** A wave-wrapup is not complete until every repo in `wave_{N}_repos_in_scope` has either ≥1 PR merged to its wave branch OR an explicit de-scope\u002Fcarry-forward record. Run this check BEFORE the wave-merge ceremony.\n\n## Implementer-Substitution Reconciliation (added P3W5 retro 2026-05-06)\n\nSymmetric to § Scope-Drop Reconciliation, but for the inverted case: the declared implementer was replaced silently. Before closing a wave, reconcile **declared implementer vs actual PR author** for every PR merged to a wave branch.\n\n```bash\n# For each repo in scope, for each merged PR (zsh-safe `while read` fed from the\n# repos helper + process substitution — main#688). Command substitution like\n# `for repo in $(jq …)` happens to split in zsh, but a parameter\n# (`for repo in $REPOS`) would not; standardize on `while read` so the safe form\n# is the one operators copy.\nwhile IFS= read -r repo; do\n  while IFS= read -r pr; do\n    actual=$(gh pr view \"$pr\" --repo \"noorinalabs\u002F$repo\" --json author --jq '.author.login')\n    branch=$(gh pr view \"$pr\" --repo \"noorinalabs\u002F$repo\" --json headRefName --jq '.headRefName')\n    # Compare actual against wave_{M}_scope.tier_*[].implementer or .tier_*[].assignee for that issue.\n    # Branch prefix (e.g., \"T.Mansour\u002F...\") is the cheap proxy when author is the github org bot.\n  done \u003C \u003C(gh pr list --repo \"noorinalabs\u002F$repo\" --state merged --base \"deployments\u002Fphase-{N}\u002Fwave-{M}\" --json number --jq '.[].number')\ndone \u003C \u003C(python3 \"$REPO_ROOT\u002F.claude\u002Flib\u002Fwave_status.py\" repos {N} {M})\n```\n\nIf the actual author (or branch-prefix initials) does not match the kickoff-declared implementer, the substitution must be recorded EXPLICITLY — silent swaps are not allowed.\n\n**Required record:** add an entry to `wave_{N}_decisions.implementer_substitutions` in `cross-repo-status.json`:\n\n```json\n{\n  \"implementer_substitutions\": [\n    {\n      \"repo\": \"noorinalabs-data-acquisition\",\n      \"issue\": \"data-acquisition#36\",\n      \"declared\": \"Sofia Cardoso\",\n      \"actual\": \"Tarek Mansour\",\n      \"swapped_at\": \"2026-05-05T23:42:00Z\",\n      \"rationale\": \"\u003Cone-line reason — e.g., declared implementer unavailable; reassigned by Pipeline Mgr Dilara>\"\n    }\n  ]\n}\n```\n\n**Why:** P3W5 declared Sofia Cardoso as the T1A #263 implementer for data-acquisition; the actual PR (data-acquisition#37) was authored by Tarek Mansour with no recorded swap rationale. Same shape as W4's ingest-platform silent-drop, just inverted (silent-substitution vs silent-zero-PR). Both are scope-drift with no audit trail. Operationally, silent substitutions compound the same way silent drops do: trust matrix updates apply to the wrong engineer (Sofia gets credit she didn't earn, Tarek's first wave PR is invisible at retro), and planning-vs-execution accuracy degrades.\n\n**Acceptance:** A wave-wrapup is not complete until every PR with a declared-vs-actual mismatch has either an entry in `wave_{N}_decisions.implementer_substitutions` OR an explicit acknowledgment that the swap is benign (e.g., orchestrator-class spawn doing implementer-class work — already covered by other discipline). Run this check BEFORE the wave-merge ceremony, in the same pass as § Scope-Drop Reconciliation.\n",{"args":38,"name":5,"description":7},"team_name, Phase number, Wave number","main","53ec1032b93d41533a48603e8fae7a92f56cc66c","intake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework","https:\u002F\u002Fgithub.com\u002Fparametrization","2026-09-21 01:06:30",{"source":8,"skillName":5,"commands":46,"prompt":56,"zipAvailable":57},[47,50,53],{"tool":48,"command":49},"npx","npx skills add parametrization\u002F2real-team-framework --skill wave-wrapup",{"tool":51,"command":52},"bunx","bunx skills add parametrization\u002F2real-team-framework --skill wave-wrapup",{"tool":54,"command":55},"pnpm","pnpm dlx skills add parametrization\u002F2real-team-framework --skill wave-wrapup","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002FSKILL.md",true,[59,65,69,74,78],{"path":60,"resourceType":61,"resourceTypeDesc":62,"sizeBytes":63,"sourceHtmlUrl":64},"fixtures\u002FREADME.md","REFERENCE","参考文档",263,"https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002Ffixtures\u002FREADME.md",{"path":66,"resourceType":61,"resourceTypeDesc":62,"sizeBytes":67,"sourceHtmlUrl":68},"fixtures\u002Fw9_cross_window_prs.json",3393,"https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002Ffixtures\u002Fw9_cross_window_prs.json",{"path":70,"resourceType":71,"resourceTypeDesc":72,"sizeBytes":13,"sourceHtmlUrl":73},"tests\u002F__init__.py","SCRIPT","脚本","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002Ftests\u002F__init__.py",{"path":75,"resourceType":71,"resourceTypeDesc":72,"sizeBytes":76,"sourceHtmlUrl":77},"tests\u002Ftest_canonical_counter_keys.py",7759,"https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002Ftests\u002Ftest_canonical_counter_keys.py",{"path":79,"resourceType":71,"resourceTypeDesc":72,"sizeBytes":80,"sourceHtmlUrl":81},"tests\u002Ftest_cross_window_filter.py",6446,"https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-wrapup\u002Ftests\u002Ftest_cross_window_filter.py",[83,100,116,131,144,157],{"skillNo":84,"name":85,"nameZh":6,"description":86,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":87,"categories":88,"keywords":89,"publishedAt":33},"01M6E5V9GDF75GV4YD2WPSH912","wave-start","Initialize a new wave — worktree cleanup, branch creation, label setup, status file update","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-start\u002FSKILL.md",[],[90,91,31,92,28,30,93,94,95,96,25,97,98,26,99],"wave start","start","creation","initialize","file update","status","setup","branch","update","file",{"skillNo":101,"name":102,"nameZh":6,"description":103,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":104,"categories":105,"keywords":106,"publishedAt":33},"01M6E5V4VXEJ39V87VHN1A3AW9","wave-scope","Reconcile declared-vs-labeled wave scope between \u002Fwave-retro and \u002Fwave-kickoff — collect dispositions, fold in retro carry-forwards and memory must-includes, and refresh the meta-issue","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-scope\u002FSKILL.md",[],[107,108,109,110,111,112,27,113,114,29,115],"scope","wave scope","Fold","memory","collect","Meta","refresh","reconcile","kickoff",{"skillNo":117,"name":118,"nameZh":6,"description":119,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":120,"categories":121,"keywords":122,"publishedAt":33},"01M6E5TZNPGG5EXZEN1FKJJT6K","wave-retro","Automated wave retrospective — PR analysis, assessments, trust matrix updates, feedback log, charter change proposals","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-retro\u002FSKILL.md",[],[27,123,124,125,126,127,31,128,129,24,130,28],"wave retro","proposals","log","retrospective","trust","feedback","change","analysis",{"skillNo":132,"name":133,"nameZh":6,"description":134,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":135,"categories":136,"keywords":137,"publishedAt":33},"01M6E5TR8XB3Z10A9SD4ZYNV45","wave-kickoff","Automated wave planning — branch creation, label management, issue labeling, kickoff comments, and execution plan","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-kickoff\u002FSKILL.md",[],[138,115,139,140,141,28,142,97,143,29,92,31],"wave kickoff","execution plan","planning","execution","plan","management",{"skillNo":145,"name":146,"nameZh":6,"description":147,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":148,"categories":149,"keywords":150,"publishedAt":33},"01M6E5TMZAFB6MTVYT750FZDVY","wave-audit","Audit open issues against merged PRs — find and close orphaned issues with proper comments","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwave-audit\u002FSKILL.md",[],[151,152,153,154,155,156],"wave audit","audit","issues","open","find","close",{"skillNo":158,"name":159,"nameZh":6,"description":160,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":161,"categories":162,"keywords":168,"publishedAt":33},"01M6E5TG3MPXT1SVT4FTJZ4JNT","watch-deploy","Monitor (and, within bounds, remediate) a deploy triggered by a merge — staging automatically post-merge, production only after the owner approves the queued deploy. Polls the deploy run to a terminal state, classifies failures, attempts bounded fix-forward, and escalates with a precise diagnosis.","https:\u002F\u002Fgithub.com\u002Fparametrization\u002F2real-team-framework\u002Fblob\u002Fmain\u002Fintake\u002Fnoorinalabs-2026-06\u002Fcandidates\u002Fskills\u002Fwatch-deploy\u002FSKILL.md",[163],{"categoryNo":164,"categoryName":165,"taxonomyType":166,"taxonomyTypeDesc":167},"01JBSEEDCAT000000000000011","运维与部署","SCENARIO","使用场景",[169,170,171,22,172,173,174,175,176,177,178,179,180,181,182],"watch deploy","watch","deploy","monitor","run","state","Production","fix","only","env","id","diagnosis","post","terminal"]