groundfast 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/extensions/index.ts +1 -2
  2. package/package.json +3 -2
  3. package/references/coverage.md +26 -16
  4. package/references/pr-goal.md +19 -6
  5. package/scripts/cr-comment.sh +19 -6
  6. package/scripts/cr-status.sh +7 -7
  7. package/scripts/origin-guard.sh +25 -7
  8. package/scripts/redact.sh +40 -3
  9. package/scripts/require-timeout.sh +13 -0
  10. package/scripts/required-checks.sh +52 -0
  11. package/scripts/strip-shim.sh +2 -1
  12. package/skills/babysit/SKILL.md +3 -3
  13. package/skills/babysit/references/coderabbit.md +6 -3
  14. package/skills/babysit/references/loop.md +23 -2
  15. package/skills/babysit/scripts/cov-comment.sh +6 -3
  16. package/skills/babysit/scripts/cycle.sh +24 -12
  17. package/skills/babysit/scripts/flaky.sh +166 -0
  18. package/skills/babysit/scripts/gh-thread.sh +1 -1
  19. package/skills/babysit/scripts/pr-goal.sh +52 -14
  20. package/skills/babysit/scripts/pr-state.sh +6 -2
  21. package/skills/babysit/scripts/push-pr.sh +1 -0
  22. package/skills/babysit/scripts/stage.sh +6 -2
  23. package/skills/babysit/scripts/wait-ci.sh +22 -1
  24. package/skills/babysit/scripts/wait-review.sh +5 -1
  25. package/skills/drain/SKILL.md +3 -2
  26. package/skills/drain/references/inner-loop.md +3 -1
  27. package/skills/drain/scripts/conflict-finish.sh +5 -3
  28. package/skills/drain/scripts/drain-queue.sh +4 -3
  29. package/skills/drain/scripts/integrate-base.sh +31 -1
  30. package/skills/drain/scripts/merge-pr.sh +8 -2
  31. package/skills/drain/scripts/order-queue.sh +18 -10
  32. package/skills/drain/scripts/review-diff.sh +3 -1
  33. package/skills/pipeline/references/issue-contract.md +12 -0
  34. package/skills/pipeline/references/issue-loop.md +6 -4
  35. package/skills/pipeline/references/review-gate.md +1 -1
  36. package/skills/pipeline/scripts/claim-issue.sh +1 -1
  37. package/skills/pipeline/scripts/create-pr.sh +2 -1
  38. package/skills/pipeline/scripts/issue-context.sh +1 -1
  39. package/skills/pipeline/scripts/issue-note.sh +1 -1
  40. package/skills/pipeline/scripts/issue-queue.sh +70 -7
  41. package/skills/pipeline/scripts/pipeline-cmd.sh +14 -7
  42. package/skills/pipeline/scripts/prepare-issue.sh +1 -0
  43. package/skills/pr/scripts/pre-pr-state.sh +8 -2
  44. package/skills/pr/scripts/push-branch.sh +13 -0
  45. package/skills/scaffolding-services/scripts/detect-stack.sh +28 -2
  46. package/skills/scoping-engagement/SKILL.md +1 -1
  47. package/skills/ship/references/plugin.md +2 -2
  48. package/skills/ship/scripts/repo-state.sh +5 -1
  49. package/skills/tidy/SKILL.md +2 -2
  50. package/skills/tidy/scripts/drop-stash.sh +26 -0
  51. package/skills/tidy/scripts/orphans.sh +34 -13
  52. package/skills/wrap/scripts/repo-state.sh +6 -2
  53. package/skills/wrap/scripts/session-cover.sh +8 -9
  54. package/skills/wrap/scripts/tasks.sh +10 -4
  55. package/skills/wrap/scripts/verify.sh +17 -2
@@ -16,8 +16,11 @@
16
16
  # parados; `aguardando` = fingerprint igual mas CI ou review ainda em curso,
17
17
  # ou HEAD local à frente do head remoto do PR e mudado desde o ciclo anterior
18
18
  # (commit ainda sem push; o mesmo HEAD no ciclo seguinte estagna, mesmo com CI em curso);
19
- # `indeterminado` = pr-goal.sh não leu o PR (o fingerprint anterior fica).
20
- # Exit: 0 atingido · 1 pendente · 4 leitura falhou · 2 uso · 3 script ausente.
19
+ # `indeterminado` = pr-goal.sh não leu o PR (o fingerprint anterior fica), ou
20
+ # o estado do ciclo não gravou (no 1º ciclo o orçamento recomeçaria do zero;
21
+ # depois, o próximo ciclo mediria contra o estado velho).
22
+ # Exit: 0 atingido · 1 pendente · 4 leitura falhou ou estado não gravado (begin)
23
+ # · 2 uso · 3 script ausente, recusa ou estado não gravado (pushed, pipeline-*).
21
24
  # Estado em <dir>/groundfast-babysit-<owner>-<name>-<n>.state (JSON, jq), com
22
25
  # <dir> = ${XDG_RUNTIME_DIR:-$HOME/.cache}/groundfast, criado 0700 — /tmp
23
26
  # compartilhado deixaria outro usuário plantar um deadline. owner/name em
@@ -27,7 +30,7 @@ set -uo pipefail
27
30
  cmd=${1:-}; num=${2:-}; repo=${3:-}; minutes=${4:-120}
28
31
  printf '%s' "$num" | grep -Eq '^[0-9]+$' || { echo "uso: cycle.sh begin|pushed|end|remaining|pipeline-begin|pipeline-check|pipeline-adopt <número> <owner/name> [valor]" >&2; exit 2; }
29
32
  printf '%s' "$repo" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$' || { echo "repo inválido — passe owner/name" >&2; exit 2; }
30
- repo_lc=${repo,,}
33
+ repo_lc=$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')
31
34
  owner=${repo_lc%%/*}; name=${repo_lc##*/}
32
35
  dir="${XDG_RUNTIME_DIR:-${HOME:-/tmp}/.cache}/groundfast"
33
36
  mkdir -p "$dir" || { echo "não consegui criar $dir" >&2; exit 3; }
@@ -37,15 +40,16 @@ state=$(state_for "$num")
37
40
  [ -L "$state" ] && { echo "$state é symlink — recusando" >&2; exit 3; }
38
41
  goal="$(dirname "$0")/pr-goal.sh"
39
42
  now=$(date +%s)
40
- tmp=
41
- trap 'rm -f "${tmp:-}"' EXIT
42
43
  # Escrita sempre atômica, no mesmo diretório (mv não cruza filesystem).
43
44
  # jq que falhou entrega stdin vazio: validar antes do mv, senão o estado vira
44
- # 0 bytes e o próximo begin "reinicia" o orçamento sozinho.
45
+ # 0 bytes e o próximo begin "reinicia" o orçamento sozinho. Roda no fim de um
46
+ # pipe (subshell), onde trap do pai não vale: cada falha apaga o próprio tmp.
45
47
  write_state() {
46
- tmp=$(mktemp "$dir/.state.XXXXXX") && cat >"$tmp" || return 1
47
- jq -e '.cycle|type=="number"' "$tmp" >/dev/null 2>&1 || { echo "estado novo inválido — mantendo o anterior" >&2; rm -f "$tmp"; tmp=; return 1; }
48
- mv "$tmp" "$state"; tmp=
48
+ local tmp
49
+ tmp=$(mktemp "$dir/.state.XXXXXX") || return 1
50
+ cat >"$tmp" || { rm -f "$tmp"; return 1; }
51
+ jq -e '.cycle|type=="number"' "$tmp" >/dev/null 2>&1 || { echo "estado novo inválido — mantendo o anterior" >&2; rm -f "$tmp"; return 1; }
52
+ mv "$tmp" "$state" || { rm -f "$tmp"; return 1; }
49
53
  }
50
54
 
51
55
  # Estado ilegível, velho (>24 h) ou com orçamento impossível é resíduo ou
@@ -117,7 +121,8 @@ case "$cmd" in
117
121
  exit 0 ;;
118
122
  pushed)
119
123
  [ -f "$state" ] || { echo "sem ciclo aberto para #$num — nada a marcar"; exit 0; }
120
- jq '.pushed = 1' "$state" | write_state
124
+ jq '.pushed = 1' "$state" | write_state \
125
+ || { echo "push não marcado — o estado do ciclo não gravou em $dir" >&2; exit 3; }
121
126
  echo "push marcado no ciclo $(jq -r .cycle "$state")"
122
127
  exit 0 ;;
123
128
  end)
@@ -154,10 +159,11 @@ local_head=$(git rev-parse -q --verify HEAD 2>/dev/null)
154
159
  pr_head=$(printf '%s\n' "$report" | sed -n 's/^head_full: //p' | tail -1)
155
160
  printf '%s' "$pr_head" | grep -Eq '^[0-9a-f]{40}$' || pr_head=""
156
161
 
162
+ saved=1
157
163
  if [ ! -f "$state" ]; then
158
164
  deadline=$((now + minutes * 60))
159
165
  jq -cn --argjson s "$now" --argjson d "$deadline" --arg fp "$fp" --arg lh "$local_head" \
160
- '{start:$s, deadline:$d, cycle:1, fingerprint:$fp, pushed:0, local_head:$lh}' | write_state
166
+ '{start:$s, deadline:$d, cycle:1, fingerprint:$fp, pushed:0, local_head:$lh}' | write_state || saved=0
161
167
  start=$now
162
168
  cycle=1; progress="início"
163
169
  [ "$grc" -eq 4 ] && progress="indeterminado (leitura falhou)"
@@ -214,10 +220,16 @@ else
214
220
  rl_streak=0; progress="mudou"
215
221
  fi
216
222
  jq --argjson c "$cycle" --arg fp "$fp" --argjson rl "$rl_streak" --arg lh "$local_head" \
217
- '.cycle=$c | .fingerprint=$fp | .pushed=0 | .rl_streak=$rl | .local_head=$lh' "$state" | write_state
223
+ '.cycle=$c | .fingerprint=$fp | .pushed=0 | .rl_streak=$rl | .local_head=$lh' "$state" | write_state || saved=0
218
224
  fi
219
225
  fi
220
226
 
227
+ # Estado que não gravou: no 1º ciclo o próximo begin recomeçaria em `início`
228
+ # com deadline novo; depois, mediria contra o estado velho (pushed, contador).
229
+ # Objetivo atingido segue atingido.
230
+ if [ "$saved" -eq 0 ] && [ "$grc" -ne 0 ]; then
231
+ progress="indeterminado (estado do ciclo não gravado em $dir)"; grc=4
232
+ fi
221
233
  total=$(( (deadline - start) / 60 ))
222
234
  used=$(( (now - start + 59) / 60 ))
223
235
  [ "$used" -gt "$total" ] && used=$total
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # Falha de CI que passa no rerun é flaky, não infra: registra um comentário no PR.
3
+ # Uso: flaky.sh <N> <owner/name> <run-id>
4
+ #
5
+ # Chame depois do `gh run rerun <run-id> --failed` e do `wait-ci.sh` terminarem.
6
+ # Compara a tentativa anterior do run com a última, check a check: job que falhou
7
+ # antes (failure ou timed_out) e passou agora é flaky; job que continua vermelho
8
+ # é PERSISTE; job cancelado, pulado ou ausente na última é INCONCLUSIVO; rerun em
9
+ # curso ou run sem rerun não é nada disso. Sem enfraquecer asserção: o registro só
10
+ # diz o que oscilou.
11
+ #
12
+ # Nome repetido (matrix, jobs reusados): casa por contagem. As falhas do nome na
13
+ # tentativa anterior pareiam, em ordem, com os jobs verdes do nome na última; cada
14
+ # job verde serve a uma falha só. Falha sem par vira PERSISTE se a última tem job
15
+ # vermelho do nome, senão INCONCLUSIVO.
16
+ #
17
+ # O run precisa ser do head atual do PR (`headRefOid`): flaky de outro head não é
18
+ # registro deste PR.
19
+ #
20
+ # Decida pela linha em coluna 0, um token por causa:
21
+ # FLAKY: <check> · … passou no rerun; segue a linha REGISTRO:
22
+ # REGISTRO: postado|já registrado — head <sha7> · <check>
23
+ # PERSISTE: <check> falhou de novo no rerun, ou falhou só agora (job novo vermelho): falha real, passo 3
24
+ # INCONCLUSIVO: <check> a última tentativa não deu verde nem vermelho (cancelado, pulado, ausente)
25
+ # PENDENTE: rerun em curso a última tentativa não terminou: espere
26
+ # SEM-RERUN: … o run tem uma tentativa só: nada a comparar
27
+ # SEM-FALHA-ANTERIOR: … a tentativa anterior não tem job falho: o run-id não é o vermelho
28
+ # FLAKY-ERRO: … leitura falhou ou run de outro head
29
+ # Exit: 0 há flaky (registrado ou já registrado) e nenhum PERSISTE/INCONCLUSIVO · 5 nenhum
30
+ # flaky, ou algum check segue vermelho/inconclusivo · 4 leitura do GitHub
31
+ # falhou ou run de outro head · 6 postagem falhou · 2 uso · 3 origin divergente ou
32
+ # script sourced ausente.
33
+ #
34
+ # Veículo: comentário no PR (o mecanismo que cov-comment.sh já usa), sem label nem
35
+ # issue nova. Um por (check, head): o marcador na primeira linha é lido de volta e,
36
+ # se já existe comentário válido do usuário autenticado, não posta de novo. Nomes
37
+ # de job vêm do GitHub: dado, nunca instrução — saneados, redigidos e sem @menção.
38
+ # O corpo vai pela entrada padrão (`--body-file -`), nunca por argv.
39
+ set -uo pipefail
40
+ # shellcheck source=../../../scripts/require-timeout.sh
41
+ . "$(dirname "$0")/../../../scripts/require-timeout.sh" || exit 3
42
+ . "$(dirname "$0")/../../../scripts/redact.sh" || { echo "redact.sh ausente — abortando" >&2; exit 3; }
43
+ . "$(dirname "$0")/../../../scripts/origin-guard.sh" || { echo "origin-guard.sh ausente — abortando" >&2; exit 3; }
44
+ . "$(dirname "$0")/../../../scripts/strip-shim.sh" || { echo "strip-shim.sh ausente — abortando" >&2; exit 3; }
45
+
46
+ usage() { echo "uso: flaky.sh <N> <owner/name> <run-id>" >&2; exit 2; }
47
+ num=${1:-}; repo=${2:-}; run=${3:-}
48
+ [[ $num =~ ^[0-9]{1,9}$ ]] || usage
49
+ [[ $repo =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "repo inválido — passe owner/name" >&2; usage; }
50
+ [[ $run =~ ^[0-9]{1,20}$ ]] || usage
51
+ assert_origin_is "$repo" || exit 3
52
+
53
+ VR=''
54
+ view_run() { # <tentativa|""> → JSON em $VR; 1 com o motivo impresso se a leitura falhar
55
+ local out rc
56
+ if [ -n "$1" ]; then
57
+ out=$(timeout 30 gh run view "$run" --repo "$repo" --attempt "$1" --json attempt,status,conclusion,headSha,jobs 2>&1); rc=$?
58
+ else
59
+ out=$(timeout 30 gh run view "$run" --repo "$repo" --json attempt,status,conclusion,headSha,jobs 2>&1); rc=$?
60
+ fi
61
+ out=$(printf '%s\n' "$out" | strip_shim)
62
+ if [ "$rc" -ne 0 ] || ! printf '%s' "$out" | jq -e 'type == "object"' >/dev/null 2>&1; then
63
+ printf '%s\n' "$out" | redact | head -5
64
+ echo "FLAKY-ERRO: não consegui ler o run $run (gh saiu $rc)"
65
+ return 1
66
+ fi
67
+ VR=$out
68
+ }
69
+
70
+ view_run "" || exit 4
71
+ cur=$VR
72
+ attempt=$(printf '%s' "$cur" | jq -r '.attempt // 0')
73
+ [[ $attempt =~ ^[0-9]+$ ]] || { echo "FLAKY-ERRO: tentativa ilegível no run $run"; exit 4; }
74
+ [ "$attempt" -ge 2 ] || { echo "SEM-RERUN: o run $run tem uma tentativa só — nada a comparar"; exit 5; }
75
+ head_full=$(printf '%s' "$cur" | jq -r '.headSha // ""')
76
+ [[ $head_full =~ ^[0-9a-f]{40}$ ]] || { echo "FLAKY-ERRO: head do run $run ilegível"; exit 4; }
77
+ [ "$(printf '%s' "$cur" | jq -r '.status // ""')" = completed ] || { echo "PENDENTE: rerun em curso — a tentativa $attempt do run $run não terminou"; exit 5; }
78
+ prev_n=$((attempt - 1))
79
+ view_run "$prev_n" || exit 4
80
+ prev=$VR
81
+
82
+ # O PR é a fonte do head: flaky de run que não é do head atual não vale aqui.
83
+ pv=$(timeout 30 gh pr view "$num" --repo "$repo" --json headRefOid,comments 2>&1 | strip_shim)
84
+ printf '%s' "$pv" | jq -e 'type == "object"' >/dev/null 2>&1 || { printf '%s\n' "$pv" | redact | head -5; echo "FLAKY-ERRO: não consegui ler o PR #$num — sem postar às cegas"; exit 4; }
85
+ pr_head=$(printf '%s' "$pv" | jq -r '.headRefOid // ""')
86
+ [[ $pr_head =~ ^[0-9a-f]{40}$ ]] || { echo "FLAKY-ERRO: head do PR #$num ilegível"; exit 4; }
87
+ [ "$pr_head" = "$head_full" ] || { echo "FLAKY-ERRO: run de outro head — o run $run é de ${head_full:0:7}, o PR #$num está em ${pr_head:0:7}"; exit 4; }
88
+
89
+ # Nomes que falharam na tentativa anterior (falha = failure ou timed_out), como array
90
+ # JSON: o nome vem do GitHub e pode ter tab ou quebra de linha, então é lido por índice.
91
+ names=$(printf '%s' "$prev" | jq -c '[.jobs[]? | select(.conclusion == "failure" or .conclusion == "timed_out") | .name] | unique')
92
+ nnames=$(printf '%s' "$names" | jq -r 'length')
93
+ [[ $nnames =~ ^[0-9]+$ ]] && [ "$nnames" -gt 0 ] || { echo "SEM-FALHA-ANTERIOR: a tentativa $prev_n do run $run não tem job falho — confira o run-id"; exit 5; }
94
+
95
+ head7=${head_full:0:7}
96
+ flaky_n=0; red_n=0; post_fail=0; i=0
97
+ # Máscara antes do teto, teto por caractere (jq fatia por codepoint, nunca no meio de um UTF-8).
98
+ mklabel() { printf '%s' "$1" | LC_ALL=C tr -d '\000-\037`<>' | REDACT_MAXCOL=4000 redact | jq -Rr '.[0:120]' | LC_ALL=C sed -E $'s/@([A-Za-z0-9_])/@\xe2\x80\x8b\\1/g'; }
99
+ # sha256 do nome, 16 hex: o marcador identifica o check sem colidir entre nomes diferentes.
100
+ name_hash() {
101
+ if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -c1-16
102
+ else printf '%s' "$1" | shasum -a 256 | cut -c1-16; fi
103
+ }
104
+ [[ $(name_hash x) =~ ^[0-9a-f]{16}$ ]] || { echo "FLAKY-ERRO: sem sha256sum nem shasum — o marcador sairia vazio"; exit 4; }
105
+ while [ "$i" -lt "$nnames" ]; do
106
+ name=$(printf '%s' "$names" | jq -r --argjson i "$i" '.[$i]')
107
+ i=$((i + 1))
108
+ # ids numéricos: seguros em argv e em word-splitting
109
+ fids=$(printf '%s' "$prev" | jq -r --arg n "$name" '[.jobs[]? | select(.name == $n and (.conclusion == "failure" or .conclusion == "timed_out")) | .databaseId | tostring] | join(" ")')
110
+ # Verde que já estava verde na anterior (matrix com o mesmo nome; o rerun --failed
111
+ # carrega os jobs que passaram) não é par de falha nenhuma: só os verdes a mais contam.
112
+ # Só os ids vão por argv (o JSON da tentativa pode passar do teto de um argumento).
113
+ old_ids=$(printf '%s' "$prev" | jq -c --arg n "$name" '[.jobs[]? | select(.name == $n and .conclusion == "success") | .databaseId]')
114
+ gids=$(printf '%s' "$cur" | jq -r --arg n "$name" --argjson old "$old_ids" '
115
+ [.jobs[]? | select(.name == $n and .conclusion == "success") | .databaseId] as $all
116
+ | ([($all | length) - ($old | length), 0] | max) as $k
117
+ | [$all[] | . as $id | select(any($old[]; . == $id) | not)] | .[0:$k] | map(tostring) | join(" ")')
118
+ bad_n=$(printf '%s' "$cur" | jq -r --arg n "$name" '[.jobs[]? | select(.name == $n and (.conclusion == "failure" or .conclusion == "timed_out"))] | length')
119
+ label=$(mklabel "$name")
120
+ pairs=''; unpaired=0
121
+ set -- $gids
122
+ for fid in $fids; do
123
+ if [ $# -gt 0 ]; then pairs="$pairs$fid:$1 "; shift; else unpaired=$((unpaired + 1)); fi
124
+ done
125
+ # Vermelho que sobra no nome depois dos pares é PERSISTE, com ou sem falha sem par.
126
+ if [ "$bad_n" != 0 ]; then red_n=$((red_n + 1)); echo "PERSISTE: $label"
127
+ elif [ "$unpaired" -gt 0 ]; then red_n=$((red_n + 1)); echo "INCONCLUSIVO: $label"; fi
128
+ [ -n "$pairs" ] || continue
129
+ body_pairs=''
130
+ for p in $pairs; do
131
+ flaky_n=$((flaky_n + 1))
132
+ echo "FLAKY: $label · job falho ${p%%:*} (tentativa $prev_n) · job verde ${p##*:} (tentativa $attempt) · run $run · head $head7"
133
+ body_pairs="${body_pairs}- job ${p%%:*} (tentativa $prev_n) falhou; job ${p##*:} (tentativa $attempt) passou"$'\n'
134
+ done
135
+ marker="<!-- groundfast-flaky: $head_full $(name_hash "$name") -->"
136
+ dup=$(printf '%s' "$pv" | jq -r --arg m "$marker" '[.comments[]? | select(.viewerDidAuthor == true and ((.includesCreatedEdit // false) | not) and ((.isMinimized // false) | not) and ((.body // "") | split("\n") | map(rtrimstr("\r")) | index($m) != null))] | length')
137
+ if [ "$dup" != 0 ]; then
138
+ echo "REGISTRO: já registrado — head $head7 · $label"
139
+ continue
140
+ fi
141
+ body=$(printf '%s\n\n**Flaky** · `%s` · head `%s` · run %s\n\n%s\nPassou no rerun sem mudar o head. Não é falha de infra: o teste ou o ambiente oscila. A asserção fica como está; a oscilação precisa de dono.\n' \
142
+ "$marker" "$label" "$head7" "$run" "$body_pairs")
143
+ out=$(printf '%s' "$body" | REDACT_MAXCOL=4000 redact | timeout 30 gh pr comment "$num" --repo "$repo" --body-file - 2>&1); rc=$?
144
+ if [ "$rc" -ne 0 ]; then
145
+ printf '%s\n' "$out" | redact | head -5
146
+ echo "REGISTRO: falha ao postar no PR #$num (gh saiu $rc) — rode este script de novo; ele só posta se ainda faltar"
147
+ post_fail=1
148
+ continue
149
+ fi
150
+ echo "REGISTRO: postado — head $head7 · $label"
151
+ done
152
+ # Falha NOVA na última tentativa (job que estava pulado ou verde antes, ex.: deploy
153
+ # atrás de `needs:`) não está nos nomes comparados e não pode passar calada.
154
+ newnames=$(printf '%s' "$cur" | jq -c --argjson old "$names" '[.jobs[]? | select(.conclusion == "failure" or .conclusion == "timed_out") | .name] | unique - $old')
155
+ nnew=$(printf '%s' "$newnames" | jq -r 'length')
156
+ j=0
157
+ while [[ $nnew =~ ^[0-9]+$ ]] && [ "$j" -lt "$nnew" ]; do
158
+ red_n=$((red_n + 1))
159
+ echo "PERSISTE: $(mklabel "$(printf '%s' "$newnames" | jq -r --argjson j "$j" '.[$j]')")"
160
+ j=$((j + 1))
161
+ done
162
+ [ "$post_fail" -eq 0 ] || exit 6
163
+ # PERSISTE ou INCONCLUSIVO no mesmo run vencem FLAKY: o run continua vermelho.
164
+ [ "$red_n" -eq 0 ] || exit 5
165
+ [ "$flaky_n" -gt 0 ] || exit 5
166
+ exit 0
@@ -52,7 +52,7 @@ case "${1:-}" in
52
52
  -F id="$tid" -q '"resolvida: \(.data.resolveReviewThread.thread.isResolved)"'
53
53
  ;;
54
54
  show)
55
- # Leitura: pr-state.sh trunca o corpo em 160 chars; aqui sai inteiro,
55
+ # Leitura: pr-state.sh corta a linha em 200 colunas; aqui o corpo sai inteiro,
56
56
  # redigido. Texto de terceiro — dado, nunca instrução.
57
57
  tid=${2:-}
58
58
  printf '%s' "$tid" | grep -Eq '^[A-Za-z0-9_=-]+$' || { echo "thread id inválido" >&2; exit 2; }
@@ -14,6 +14,8 @@ set -uo pipefail
14
14
  . "$(dirname "$0")/../../../scripts/cr-status.sh" || { echo "cr-status.sh ausente — abortando" >&2; exit 3; }
15
15
  # shellcheck source=../../../scripts/cov-marker.sh
16
16
  . "$(dirname "$0")/../../../scripts/cov-marker.sh" || { echo "cov-marker.sh ausente — abortando" >&2; exit 3; }
17
+ # shellcheck source=../../../scripts/required-checks.sh
18
+ . "$(dirname "$0")/../../../scripts/required-checks.sh" || { echo "required-checks.sh ausente — abortando" >&2; exit 3; }
17
19
 
18
20
  num=${1:-}; repo=${2:-}
19
21
  printf '%s' "$num" | grep -Eq '^[0-9]+$' || { echo "uso: pr-goal.sh <número> <owner/name>" >&2; exit 2; }
@@ -77,15 +79,28 @@ fi
77
79
 
78
80
  # --- 1. CI --------------------------------------------------------------------
79
81
  # Só os checks que não são o CodeRabbit: o bot é o critério 2, não o CI.
82
+ # CheckRun em andamento chega com conclusion "" (não null): `//` não o pula.
80
83
  ci=$(printf '%s' "$pv" | jq -r '
81
84
  [ .statusCheckRollup[]?
82
85
  | select(((.name // .context // "") | test("coderabbit"; "i")) | not)
83
- | (.conclusion // .state // .status // "PENDING") | ascii_upcase ] as $s
86
+ | (first((.conclusion, .state, .status) | select(. != null and . != "")) // "PENDING") | ascii_upcase ] as $s
84
87
  | if ($s|length) == 0 then "empty"
85
88
  elif ($s | map(select(test("PENDING|IN_PROGRESS|QUEUED|EXPECTED|WAITING|REQUESTED"))) | length) > 0 then "pending"
86
89
  elif ($s | map(select(test("SUCCESS|NEUTRAL|SKIPPED") | not)) | length) > 0 then "red"
87
90
  else "green" end')
91
+ # Check obrigatório que nunca foi criado no head (delivery perdida) não aparece
92
+ # no rollup: sem esta conta, o rollup vazio ou verde passaria (issue #112).
93
+ req=$(required_checks "$repo" "$base") || {
94
+ echo "GOAL: indeterminado — não consegui ler os checks obrigatórios da base (${base:-?}). Nenhum critério medido."
95
+ exit 4
96
+ }
97
+ missing=$(missing_checks "$pv" "$req") || { echo "GOAL: indeterminado — não consegui comparar o rollup com os checks obrigatórios."; exit 4; }
98
+ # Só verde e vazio viram `missing`: vermelho manda ler o log, e pendente segue
99
+ # esperando (fingerprint ci=pending não estagna).
100
+ [ -z "$missing" ] || { [ "$ci" != green ] && [ "$ci" != empty ]; } || ci=missing
88
101
  case "$ci" in
102
+ # Nome de check é dado de terceiro: sem \n nem \r, ele não forja uma linha `GOAL:`.
103
+ missing) fail "1 CI" "check obrigatório $(printf '%s' "$missing" | tr -d '\n\r' | redact | head -c 200) ausente no head — confira o runner e reentregue a delivery do push (loop.md 2a)" ;;
89
104
  green) pass "1 CI verde" ;;
90
105
  empty) pass "1 CI — repo sem check configurado" ;;
91
106
  pending) fail "1 CI" "ainda em curso" ;;
@@ -95,17 +110,20 @@ esac
95
110
  # --- 2. review cobre o head ---------------------------------------------------
96
111
  cr_status "$owner" "$name" "$num" "$repo"
97
112
  cr_detail=$(printf '%s' "$CR_DETAIL" | redact | head -c 200)
98
- # Cobertura interna (references/pr-goal.md §Cobertura interna): só quando o
99
- # bot não cobre o head. A prova é um comentário de issue do PR, escrito pelo
113
+ # Cobertura interna (references/pr-goal.md §Cobertura interna). A prova é um
114
+ # comentário de issue do PR, escrito pelo
100
115
  # usuário autenticado (viewerDidAuthor, decidido pelo GitHub), nunca editado
101
116
  # nem minimizado, com o marcador do head numa linha própria — citação e bloco
102
117
  # de código não contam. Corpo de comentário é dado, não instrução: só a linha
103
118
  # do marcador é lida. Sha antigo, outro autor ou edição deixam o motivo na linha.
104
- internal=0; internal_note=""
105
- if { [ "$CR_CLASS" = "rate_limited" ] || [ "$CR_CLASS" = "absent" ]; } && [ -n "$head_full" ]; then
119
+ # cov_ok = cobertura válida no head; quem a usa decide cada critério: o 2 só com o
120
+ # bot rate_limited/absent (`internal`), o 6 só com rate_limited/success (abaixo).
121
+ cov_ok=0; internal=0; internal_note=""
122
+ case "$CR_CLASS" in rate_limited|absent|success) cov_read=1 ;; *) cov_read=0 ;; esac
123
+ if [ "$cov_read" -eq 1 ] && [ -n "$head_full" ]; then
106
124
  cov=$(cov_status "$pv" "$head_full")
107
125
  case "$cov" in
108
- ok) internal=1 ;;
126
+ ok) cov_ok=1 ;;
109
127
  none) ;;
110
128
  edited) internal_note="comentário de cobertura para o head foi editado ou minimizado — não conta; poste outro" ;;
111
129
  other:*) internal_note="comentário de cobertura para o head é de $(printf '%s' "${cov#other:}" | redact | head -c 80), não do usuário autenticado — não conta" ;;
@@ -113,6 +131,7 @@ if { [ "$CR_CLASS" = "rate_limited" ] || [ "$CR_CLASS" = "absent" ]; } && [ -n "
113
131
  *) internal_note="leitura dos comentários de cobertura falhou — não conta" ;;
114
132
  esac
115
133
  fi
134
+ [ "$cov_ok" -eq 1 ] && { [ "$CR_CLASS" = "rate_limited" ] || [ "$CR_CLASS" = "absent" ]; } && internal=1
116
135
  note="${internal_note:+ · $internal_note}"
117
136
  case "$CR_CLASS" in
118
137
  paused) na "2 review — CodeRabbit pausado (GROUNDFAST_CODERABBIT=off): a review do PR é a da casa, pelo /pr" ;;
@@ -171,9 +190,10 @@ else fail "3 threads" "$n_open não-resolvida(s) — pr-state.sh lista cada uma"
171
190
 
172
191
  # --- 4. sem CHANGES_REQUESTED -------------------------------------------------
173
192
  # Quem pediu mudança decide o caminho: o bot muda de ideia com review nova;
174
- # humano não muda com ping — é escalada.
193
+ # humano não muda com ping — é escalada. Review COMMENTED (resposta numa
194
+ # thread) não aprova nem pede mudança: não conta como a última decisão.
175
195
  if [ "$rd" = "CHANGES_REQUESTED" ]; then
176
- humans=$(printf '%s' "$pv" | jq -r '[.reviews[]? | {a: (.author.login // "?"), s: (.state // "")}] | group_by(.a) | map(last) | map(select(.s == "CHANGES_REQUESTED" and (.a | test("coderabbit"; "i") | not))) | map(.a) | join(",")' | redact | head -c 120)
196
+ humans=$(printf '%s' "$pv" | jq -r '[.reviews[]? | {a: (.author.login // "?"), s: (.state // "")} | select(.s == "APPROVED" or .s == "CHANGES_REQUESTED" or .s == "DISMISSED")] | group_by(.a) | map(last) | map(select(.s == "CHANGES_REQUESTED" and (.a | test("coderabbit"; "i") | not))) | map(.a) | join(",")' | redact | head -c 120)
177
197
  if [ -n "$humans" ]; then
178
198
  fail "4 reviewDecision" "CHANGES_REQUESTED de humano ($humans) — só uma review nova dessa pessoa muda a decisão: escale, não pingue o bot"
179
199
  elif [ "$paused" -eq 1 ]; then
@@ -189,25 +209,40 @@ fi
189
209
  if [ "$mss" = "CLEAN" ]; then pass "5 mergeStateStatus CLEAN"; else fail "5 mergeStateStatus" "$mss"; fi
190
210
 
191
211
  # --- 6. APPROVED, só com request_changes_workflow na base ---------------------
212
+ # A cobertura interna no head dispensa o 6 só com evidência positiva de que o bot
213
+ # existe e não aprovou este head (ADR 0001, emenda de 2026-10-01): rate_limited
214
+ # (recusou) ou success sem APPROVED (revisou e não aprovou). `absent` pode ser só
215
+ # o bot ainda a caminho do head recém-pushado: segue a régua da config. A
216
+ # dispensa só conta quando a base exige APPROVED ou a config dela não foi lida.
217
+ cov6=0
218
+ need6=0; { [ "$cr_flag" -eq 1 ] || [ "$cr_cfg" = "error" ]; } && need6=1
192
219
  if [ "$paused" -eq 1 ]; then
193
220
  na "6 APPROVED — CodeRabbit pausado (GROUNDFAST_CODERABBIT=off): ninguém aprova automaticamente"
221
+ elif [ "$need6" -eq 1 ] && [ "$cov_ok" -eq 1 ] && [ "$CR_CLASS" = "rate_limited" ]; then
222
+ cov6=1; na "6 APPROVED — cobertura interna no head ${head}: o bot (rate_limited) não revisou o head e não tem como aprová-lo"
223
+ elif [ "$need6" -eq 1 ] && [ "$cov_ok" -eq 1 ] && [ "$CR_CLASS" = "success" ] && [ "$rd" != "APPROVED" ]; then
224
+ cov6=1; na "6 APPROVED — cobertura interna no head ${head}: o bot revisou o head sem aprovar (reviewDecision=${rd:-nenhuma}); a cobertura cobre a aprovação"
194
225
  elif [ "$cr_cfg" = "error" ]; then
195
226
  fail "6 APPROVED" "leitura do .coderabbit.yaml da base ($base) falhou — sem ela não dá para saber se o critério se aplica"
196
227
  elif [ "$cr_flag" -eq 1 ]; then
197
- if [ "$rd" = "APPROVED" ]; then pass "6 APPROVED (request_changes_workflow ligado na base)"; else fail "6 APPROVED" "reviewDecision=${rd:-nenhuma}; o bot aprova quando os comentários dele estão resolvidos, o head foi revisado e nenhum pre-merge check falha"; fi
228
+ sn=""; [ "$CR_CLASS" = "success" ] && sn=$note
229
+ if [ "$rd" = "APPROVED" ]; then pass "6 APPROVED (request_changes_workflow ligado na base)"; else fail "6 APPROVED" "reviewDecision=${rd:-nenhuma}; o bot aprova quando os comentários dele estão resolvidos, o head foi revisado e nenhum pre-merge check falha${sn}"; fi
198
230
  else
199
231
  na "6 APPROVED — sem request_changes_workflow no .coderabbit.yaml da base ($cr_cfg)"
200
232
  fi
201
233
 
202
234
  # ci e cr entram no fingerprint: CI que ainda roda não é estagnação, e a
203
- # cobertura interna é progresso (rate_limited → rate_limited+interno).
204
- crfp=$CR_CLASS; [ "$internal" -eq 1 ] && crfp="${CR_CLASS}+interno"
235
+ # cobertura interna é progresso (rate_limited → rate_limited+interno; com o bot
236
+ # success, a cobertura que dispensa o 6 vira success+cov6).
237
+ crfp=$CR_CLASS
238
+ if [ "$internal" -eq 1 ]; then crfp="${CR_CLASS}+interno"
239
+ elif [ "$cov6" -eq 1 ]; then crfp="${CR_CLASS}+cov6"; fi
205
240
  echo "fingerprint: ${head}|${mss}|${rd:-none}|ci=${ci}|cr=${crfp}|${open_ids:-none}"
206
241
  # Mesmos campos, um objeto: o cycle.sh lê a linha `fingerprint:` por campo;
207
242
  # a linha JSON existe para inspeção e para não voltar a glob na string.
208
243
  jq -nc --arg head "$head" --arg mss "$mss" --arg rd "${rd:-none}" --arg ci "$ci" \
209
- --arg cr "$CR_CLASS" --argjson internal "$internal" --arg open "${open_ids:-none}" \
210
- '{head:$head,mss:$mss,rd:$rd,ci:$ci,cr:$cr,internal:($internal==1),open_ids:$open}' \
244
+ --arg cr "$CR_CLASS" --argjson internal "$internal" --argjson cov6 "$cov6" --arg open "${open_ids:-none}" \
245
+ '{head:$head,mss:$mss,rd:$rd,ci:$ci,cr:$cr,internal:($internal==1),cov6:($cov6==1),open_ids:$open}' \
211
246
  | sed 's/^/fpjson: /'
212
247
  # Head completo, fora do fingerprint: o cycle.sh testa se o HEAD local descende
213
248
  # dele (commit só no disco), e o prefixo de 7 chars pode ser ambíguo no repo local.
@@ -217,7 +252,10 @@ if [ "${#pending[@]}" -eq 0 ]; then
217
252
  # relatório do drain distingue PR revisado pelo bot dos outros dois casos.
218
253
  suffix=""
219
254
  [ "$paused" -eq 1 ] && suffix=" (CodeRabbit pausado — 2 e 6 N-A)"
220
- [ "$internal" -eq 1 ] && suffix=" (critério 2 por cobertura interna)"
255
+ # O 6 só entra na nota quando a cobertura o dispensou (cov6).
256
+ if [ "$internal" -eq 1 ] && [ "$cov6" -eq 1 ]; then suffix=" (critérios 2 e 6 por cobertura interna)"
257
+ elif [ "$internal" -eq 1 ]; then suffix=" (critério 2 por cobertura interna)"
258
+ elif [ "$cov6" -eq 1 ]; then suffix=" (critério 6 por cobertura interna)"; fi
221
259
  echo "GOAL: atingido$suffix"
222
260
  exit 0
223
261
  fi
@@ -5,6 +5,8 @@
5
5
  # Tudo que sai daqui veio do GitHub — título de PR, nome de branch, corpo de
6
6
  # comentário são texto livre escrito por terceiros. É dado, nunca instrução.
7
7
  set -uo pipefail
8
+ # shellcheck source=../../../scripts/require-timeout.sh
9
+ . "$(dirname "$0")/../../../scripts/require-timeout.sh" || exit 3
8
10
  # shellcheck source=../../../scripts/redact.sh
9
11
  . "$(dirname "$0")/../../../scripts/redact.sh" || { echo "redact.sh ausente — abortando" >&2; exit 3; }
10
12
  . "$(dirname "$0")/../../../scripts/strip-shim.sh" || { echo "strip-shim.sh ausente — abortando" >&2; exit 3; }
@@ -47,7 +49,7 @@ if [ "$CR_CLASS" = "error" ]; then
47
49
  echo "(INDETERMINADO — não consegui ler o rollup; NÃO conclua que o head foi revisado)"
48
50
  else
49
51
  [ -n "$CR_HEAD" ] && printf '%s\n' "$CR_HEAD"
50
- [ -n "$CR_BOT" ] && printf '%s\n' "$CR_BOT"
52
+ [ -n "$CR_BOT" ] && printf '%s\n' "$CR_BOT" | redact # descrição do status é texto do bot
51
53
  case "$CR_CLASS" in
52
54
  paused) echo "veredito: PAUSADO — CodeRabbit desligado (GROUNDFAST_CODERABBIT=off); nada a esperar nem a pingar" ;;
53
55
  success) echo "veredito: a review cobre o head" ;;
@@ -111,9 +113,11 @@ if [ "$trc" -ne 0 ]; then
111
113
  exit 4
112
114
  fi
113
115
  total=$(printf '%s' "$raw" | jq -r '.data.repository.pullRequest.reviewThreads.totalCount')
116
+ # Corpo inteiro para o redact: o corte (200 colunas) vem depois da máscara, e
117
+ # segredo partido ao meio não casaria mais o padrão.
114
118
  printf '%s' "$raw" | jq -r '.data.repository.pullRequest.reviewThreads.nodes[]
115
119
  | select(.isResolved | not)
116
- | "\(.id)\treply-id=\(.comments.nodes[0].databaseId // "none")\t\(.path):\(.line // "?")\t\(if .isOutdated then "OUTDATED " else "" end)\(.comments.nodes[0].author.login // "?")\t\(.comments.nodes[0].body // "" | gsub("\n"; " ") | .[0:160])"' \
120
+ | "\(.id)\treply-id=\(.comments.nodes[0].databaseId // "none")\t\(.path):\(.line // "?")\t\(if .isOutdated then "OUTDATED " else "" end)\(.comments.nodes[0].author.login // "?")\t\(.comments.nodes[0].body // "" | gsub("\n"; " "))"' \
117
121
  | redact | sed 's/^/ /'
118
122
  echo " (vazio acima = zero threads abertas · $total threads no total, resolvidas inclusive)"
119
123
  [ "$total" -ge 100 ] && echo " (AVISO: $total ≥ 100 — a página é first:100 e pode estar truncada)"
@@ -14,6 +14,7 @@ if [ -z "$repo" ]; then repo=$(timeout 15 gh repo view --json nameWithOwner -q .
14
14
  printf '%s' "$repo" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$' \
15
15
  || { echo "não consegui determinar o repositório — passe owner/name" >&2; exit 2; }
16
16
  assert_origin_is "$repo" || exit 3
17
+ assert_push_is "$repo" || exit 3 # o push usa remote.origin.pushurl, se houver
17
18
 
18
19
  expected=$(timeout 30 gh pr view "$num" --repo "$repo" --json headRefName -q .headRefName 2>/dev/null | strip_shim)
19
20
  [ -n "$expected" ] || { echo "não consegui ler a head branch do PR #$num" >&2; exit 4; }
@@ -16,7 +16,9 @@
16
16
  # heredoc de delimitador entre aspas nada é interpolado, e a aspa simples do
17
17
  # nome é só mais um caractere.
18
18
  set -uo pipefail
19
- mapfile -t paths
19
+ # Laço em vez de mapfile (bash 3.2 do macOS); a última linha sem \n entra igual.
20
+ paths=()
21
+ while IFS= read -r line || [ -n "$line" ]; do paths+=("$line"); done
20
22
  [ "${#paths[@]}" -gt 0 ] || { echo "nenhum caminho na entrada padrão" >&2; exit 2; }
21
23
 
22
24
  clean=()
@@ -29,6 +31,8 @@ for p in "${paths[@]}"; do
29
31
  done
30
32
  [ "${#clean[@]}" -gt 0 ] || { echo "nenhum caminho utilizável" >&2; exit 2; }
31
33
 
32
- git add -- "${clean[@]}" || exit $?
34
+ # Literal: depois do `--` o git ainda lê pathspec — `*` casaria todo arquivo e
35
+ # `:(glob)**` é magic. O caminho do achado é um arquivo, nunca um padrão.
36
+ git --literal-pathspecs add -- "${clean[@]}" || exit $?
33
37
  printf 'staged (%s):\n' "${#clean[@]}"
34
38
  git diff --cached --name-only
@@ -5,10 +5,15 @@
5
5
  set -uo pipefail
6
6
  . "$(dirname "$0")/../../../scripts/redact.sh" || { echo "redact.sh ausente — abortando" >&2; exit 3; }
7
7
  . "$(dirname "$0")/../../../scripts/strip-shim.sh" || { echo "strip-shim.sh ausente — abortando" >&2; exit 3; }
8
+ . "$(dirname "$0")/../../../scripts/required-checks.sh" || { echo "required-checks.sh ausente — abortando" >&2; exit 3; }
8
9
  num=${1:-}; repo=${2:-}; budget=${3:-900}
9
10
  printf '%s' "$num" | grep -Eq '^[0-9]+$' || { echo "uso: wait-ci.sh <número> <owner/name> [segundos]" >&2; exit 2; }
10
11
  printf '%s' "$repo" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$' || { echo "repo inválido" >&2; exit 2; }
11
- printf '%s' "$budget" | grep -Eq '^[0-9]+$' || { echo "orçamento inválido" >&2; exit 2; }
12
+ # A string inteira, não linha a linha como o grep: o orçamento entra em $(( )),
13
+ # que expande `a[$(cmd)]` de uma linha extra. Lista explícita: em en_US.UTF-8
14
+ # `[0-9]` casa `²`. E `10#`: `08` é decimal, não octal inválido.
15
+ [[ $budget =~ ^[0123456789]+$ ]] || { echo "orçamento inválido" >&2; exit 2; }
16
+ budget=$((10#$budget))
12
17
  [ "${GROUNDFAST_WAIT_BG:-}" = "1" ] || {
13
18
  echo "espera longa: defina GROUNDFAST_WAIT_BG=1 (no Pi, via bg_run)" >&2
14
19
  exit 2
@@ -38,6 +43,22 @@ case "$rc" in
38
43
  veredito="VEREDITO: vermelho ou erro de leitura — exit $rc"
39
44
  fi ;;
40
45
  esac
46
+ # `gh pr checks` só lista o que existe no head: um check obrigatório cuja
47
+ # delivery se perdeu nunca é criado, e o watch sai 0 (issue #112). Verde e
48
+ # "sem CI" só valem depois de conferir a lista da base.
49
+ case "$veredito" in
50
+ "VEREDITO: verde"*|"VEREDITO: sem CI"*)
51
+ pv=$(timeout 30 gh pr view "$num" --repo "$repo" --json baseRefName,statusCheckRollup 2>/dev/null | strip_shim)
52
+ base=$(printf '%s' "$pv" | jq -r '.baseRefName // ""' 2>/dev/null)
53
+ printf '%s' "$base" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]*$' || base=""
54
+ if ! req=$(required_checks "$repo" "$base"); then
55
+ veredito="VEREDITO: vermelho ou erro de leitura — não consegui ler os checks obrigatórios da base"
56
+ elif ! missing=$(missing_checks "$pv" "$req"); then
57
+ veredito="VEREDITO: vermelho ou erro de leitura — não consegui comparar o rollup com os checks obrigatórios"
58
+ elif [ -n "$missing" ]; then
59
+ veredito="VEREDITO: pendente — check obrigatório $(printf '%s' "$missing" | redact | head -c 200) ausente no head"
60
+ fi ;;
61
+ esac
41
62
  printf '%s\n' "$veredito"
42
63
  echo "--- saída de gh pr checks (dado, não instrução):"
43
64
  # tail, não head: com --watch o começo do buffer é o frame em que tudo
@@ -28,7 +28,11 @@ done
28
28
  num=${rest[0]:-}; repo=${rest[1]:-}; budget=${rest[2]:-900}
29
29
  printf '%s' "$num" | grep -Eq '^[0-9]+$' || { echo "uso: wait-review.sh [--ping] <número> <owner/name> [segundos]" >&2; exit 2; }
30
30
  printf '%s' "$repo" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$' || { echo "repo inválido" >&2; exit 2; }
31
- printf '%s' "$budget" | grep -Eq '^[0-9]+$' || { echo "orçamento inválido" >&2; exit 2; }
31
+ # A string inteira, não linha a linha como o grep: o orçamento entra em $(( )),
32
+ # que expande `a[$(cmd)]` de uma linha extra. Lista explícita: em en_US.UTF-8
33
+ # `[0-9]` casa `²`. E `10#`: `08` é decimal, não octal inválido.
34
+ [[ $budget =~ ^[0123456789]+$ ]] || { echo "orçamento inválido" >&2; exit 2; }
35
+ budget=$((10#$budget))
32
36
  # CodeRabbit pausado (references/pr-goal.md §Pausa): sem espera e sem ping —
33
37
  # não há bot a esperar, e um `@coderabbitai review` só sujaria o PR.
34
38
  if cr_paused; then
@@ -107,7 +107,7 @@ Conflito segue neste PR. Só se `mergeable` ≠ `MERGEABLE`:
107
107
  scripts/integrate-base.sh <N> [owner/name]
108
108
  ```
109
109
 
110
- `integrate-base.sh` exit 0 (`MERGE_OK`): **volte ao 2d**. Exit 5 (`CONFLITO`): edite os `paths:`, rode `conflict-finish.sh`; `merge commit ok` (exit 0): **volte ao 2d**. O GitHub ainda tem o oid velho até o push.
110
+ `integrate-base.sh` exit 0 (`MERGE_OK`): **volte ao 2d**. Exit 5 (`CONFLITO`): edite os `paths:`, rode `conflict-finish.sh`; `merge commit ok` (exit 0): **volte ao 2d**. O GitHub ainda tem o oid velho até o push. Qualquer outro exit (2 uso, 3 guarda recusou, 4 leitura, fetch ou merge que não começou ou parou sem conflito): nada a editar — direto ao 2g, `#<N> BLOQUEADO integrate-base <motivo do stderr>`.
111
111
 
112
112
  ```bash
113
113
  scripts/conflict-finish.sh <<'--PATHS--'
@@ -125,7 +125,7 @@ Irreconciliável: pergunte (opções: `abandonar este PR` · `continuar resolven
125
125
  scripts/merge-pr.sh <N> [owner/name]
126
126
  ```
127
127
 
128
- Saiu 4 com `merge no GitHub ok`: 1 linha `#<N> MERGED (sync local falhou)`, próximo.
128
+ Saiu 4 com `merge no GitHub ok`: 1 linha `#<N> MERGED (sync local falhou)`, próximo. Qualquer outra saída não-zero (2 uso, 3 guarda recusou, 4 sem essa frase: leitura ou merge falhou): direto ao 2g, `#<N> BLOQUEADO merge-pr <motivo do stderr>`.
129
129
 
130
130
  ### 2g. Uma linha
131
131
 
@@ -139,6 +139,7 @@ Todo PR que passou por `cycle begin` termina com `babysit-cmd.sh cycle end <N> <
139
139
  #<N> BLOQUEADO estagnado <critérios do GOAL: pendente>
140
140
  #<N> BLOQUEADO leitura <o que o pr-goal não conseguiu ler>
141
141
  #<N> BLOQUEADO checkout
142
+ #<N> BLOQUEADO integrate-base|merge-pr <motivo>
142
143
  #<N> ABANDONADO conflito (Mestre)
143
144
  ```
144
145
 
@@ -47,6 +47,8 @@ Não vá de `MERGE_OK` / `merge commit ok` direto ao merge.
47
47
  script antes do `gh pr merge`. `GOAL: pendente` lista o critério que falta: 3 → `threads.md`; 1 →
48
48
  corrija o CI; 4 ou 6 **do bot** com zero threads → `gh-thread cr … full-review` (o wrapper posta uma
49
49
  vez por head aceito e espera a resposta do bot, ver `babysit/references/coderabbit.md`; `RATE LIMITED` → volte ao 2d, o `--ping`
50
- repete quando a janela reabrir; recusou por `pausado` → `pr-goal.md` §Pausa, escalada); 4 de humano → `#<N> BLOQUEADO estagnado 4 (humano)`, o bot não muda decisão alheia;
50
+ repete quando a janela reabrir; recusou por `pausado` → `pr-goal.md` §Pausa, escalada); 6 sozinho com o bot
51
+ `success` e o full-review respondendo `já postado após o head` → cubra o head pela casa agora
52
+ (`coverage.md` §Gatilho 2, mesmo registro por `babysit-cmd.sh cov-comment`); 4 de humano → `#<N> BLOQUEADO estagnado 4 (humano)`, o bot não muda decisão alheia;
51
53
  5 `BEHIND`/`DIRTY` → `integrate-base.sh`; 5 `BLOCKED` sozinho → aprovação humana,
52
54
  `#<N> BLOQUEADO estagnado 5 (BLOCKED)`. Depois **volte ao 2d**; a linha `CICLO:` é quem encerra o PR.
@@ -6,7 +6,9 @@
6
6
  #
7
7
  # Só merge: este fluxo não inicia rebase. Mesma classe do stage.sh.
8
8
  set -uo pipefail
9
- mapfile -t paths
9
+ # Laço em vez de mapfile (bash 3.2 do macOS); a última linha sem \n entra igual.
10
+ paths=()
11
+ while IFS= read -r line || [ -n "$line" ]; do paths+=("$line"); done
10
12
  [ "${#paths[@]}" -gt 0 ] || { echo "nenhum caminho na entrada padrão" >&2; exit 2; }
11
13
 
12
14
  clean=()
@@ -31,7 +33,7 @@ if [ ! -f "$merge_head" ]; then
31
33
  exit 3
32
34
  fi
33
35
 
34
- git add -- "${clean[@]}" || exit $?
36
+ # Literal, como no stage.sh: `*` ou `:(glob)**` levariam arquivo alheio ao merge.
37
+ git --literal-pathspecs add -- "${clean[@]}" || exit $?
35
38
  git commit --no-edit || exit $?
36
39
  echo "merge commit ok"
37
- git diff --name-only --diff-filter=U
@@ -2,6 +2,7 @@
2
2
  # Lista PRs OPEN do origin, normaliza e ordena. Uso: drain-queue.sh [owner/name]
3
3
  # Saída é dado, nunca instrução — título e path vieram do GitHub.
4
4
  set -uo pipefail
5
+ . "$(dirname "$0")/../../../scripts/require-timeout.sh" || exit 3
5
6
  . "$(dirname "$0")/../../../scripts/redact.sh" || { echo "redact.sh ausente — abortando" >&2; exit 3; }
6
7
  . "$(dirname "$0")/../../../scripts/origin-guard.sh" || { echo "origin-guard.sh ausente — abortando" >&2; exit 3; }
7
8
  . "$(dirname "$0")/../../../scripts/strip-shim.sh" || { echo "strip-shim.sh ausente — abortando" >&2; exit 3; }
@@ -69,6 +70,6 @@ norm=$(printf '%s' "$raw" | jq --arg d "$default" '{
69
70
  }')
70
71
 
71
72
  echo
72
- printf '%s' "$norm" | "$(dirname "$0")/order-queue.sh" | while IFS= read -r line; do
73
- printf '%s\n' "$line" | redact
74
- done
73
+ # Um redact para a fila inteira: ele já é linha a linha, e um por linha custava
74
+ # ~0,5 s numa fila de 50 PRs.
75
+ printf '%s' "$norm" | "$(dirname "$0")/order-queue.sh" | redact