forgedock 1.16.2 → 1.17.0

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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env bash
2
+ # review-prepare.sh — deterministic review-pr preparation (Phases 1-3B plus the 3C delta-mode and diff-slice steps).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # One self-contained shell: it re-derives every value it uses (no helper or variable is defined in one place and consumed in
7
+ # another), resolves ForgeDock's own helpers through forge_resolve (FORGEDOCK_HOME / plugin root / FORGE_ROOT / plugin cache; the
8
+ # working directory and the PR checkout are NEVER a script source), and treats everything author-controlled (PR title/body, comments,
9
+ # the PR checkout, a SPEC_CLI persona file, forge.yaml paths.root) as data.
10
+ #
11
+ # Usage: review-prepare.sh --pr N --repo owner/name [--merge-issue N] [--thorough true|false] [--out FILE] [--out-dir DIR]
12
+ # [--checkout-pr]
13
+ # --pr PR number (numeric, required) --repo owner/name (required)
14
+ # --merge-issue issue the PR closes, from --issue (numeric; non-numeric is ignored)
15
+ # --thorough true restores the full-union roster --out write the context JSON here (default: stdout)
16
+ # --out-dir directory for per-domain diff slices (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-<pr>)
17
+ # --checkout-pr `git checkout -f --detach <review sha>` in the CURRENT repo (must be a throwaway review checkout, never the
18
+ # main checkout); the recorded original ref is restored by restore_ref on every exit path
19
+ # Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
20
+ # REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5).
21
+ # Exit: 0 context emitted (status OK) | 3 context emitted with status BLOCKED (the caller must stop, never skip) | 2 usage error
22
+ #
23
+ # Moved from the spec and still in the spec (the caller keeps running them): Phase 2 lint/typecheck/test/build EXECUTION (long-running,
24
+ # needs the throwaway worktree), Phase 3B.5 provenance trust escalation, the FORGE:REVIEW_TIER comment and the carry-record post.
25
+ #
26
+ # Behavior changes vs the prose fences (intentional, documented in the issue): (1) an empty trusted typescript.build / python.format on a
27
+ # full-build lane (staging->main or milestone->staging) is BLOCKED, not SKIPPED; (2) forge.yaml is loaded BEFORE the roster, so
28
+ # review.domains.scraping gates scraping on the first pass; (3) an absent yq key is empty, never the literal string "null"; (4) the
29
+ # contract comment is read through scripts/trusted-comments.sh; (5) the ISSUE_NUM extraction is numeric-validated (lib/pr-issue-number.sh).
30
+ #
31
+ # Context JSON on stdout / --out (schema_version 1):
32
+ # schema_version 1
33
+ # status "OK" | "BLOCKED"; blockers: [string] (non-empty iff BLOCKED)
34
+ # pr {number, repo, title, author, head_ref, base_ref, review_sha, review_sha_short, additions, deletions,
35
+ # changed_files, first_time_contributor}
36
+ # lane {is_staging_to_main, is_milestone_to_staging, requires_full_build, thorough}
37
+ # merge_health {mergeable: MERGEABLE|CONFLICTING|UNKNOWN, state: CLEAN|DIRTY|BLOCKED|UNSTABLE|UNKNOWN, retries}
38
+ # classification {services: [..], domains: [..]}
39
+ # phase2 {build_config: {typescript_build, python_format, status}, findings: [string]} (2D env, 2E secrets, 2F sql)
40
+ # broken_assumptions [string] (Phase 2.5 verify-*.sh BLOCKING/WARNING lines and the sibling-workflow drift check)
41
+ # risk_signals [string]; churn_context: string; churn_escalation: bool; danger_zone: bool
42
+ # scores {AUTH, BILLING, CONCURRENCY, DATABASE, INFRA, SECURITY, SCRAPING, FRONTEND, API}
43
+ # contract {issue: N|null, flags: string}
44
+ # roster {tier, reason, agents: [canonical marker domains]} (the effective roster; [] for BASE_SYNC_ONLY)
45
+ # delta {mode: FULL|DELTA|BASE_SYNC_ONLY, from, carried_from, full_rounds, files: [string]}
46
+ # config {project_name, project_context, domains: {auth, billing, ...}, scraping_configured, repo_path}
47
+ # templates {source, base, monolithic_catalog}
48
+ # index_slices {auth, billing, database, api, frontend, infrastructure}: "Domain files (...): ..." strings
49
+ # slices {<canonical domain>: {paths: [string], diff_file: path}} (diffs live in files, never inline; capped at 100K chars)
50
+ # full_files [string] the whole PR file list (context under DELTA)
51
+ # Bash 3.2 portable (no mapfile, associative arrays, grep -P).
52
+
53
+ set -u
54
+
55
+ PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"
56
+ while [ $# -gt 0 ]; do
57
+ case "$1" in
58
+ --pr|--repo|--merge-issue|--thorough|--out|--out-dir)
59
+ [ $# -ge 2 ] || { echo "review-prepare: $1 requires a value" >&2; exit 2; } ;;
60
+ esac
61
+ case "$1" in
62
+ --pr) PR_NUMBER="${2:-}"; shift 2 ;;
63
+ --repo) REPO="${2:-}"; shift 2 ;;
64
+ --merge-issue) MERGE_ISSUE="${2:-}"; shift 2 ;;
65
+ --thorough) THOROUGH="${2:-false}"; shift 2 ;;
66
+ --out) OUT="${2:-}"; shift 2 ;;
67
+ --out-dir) OUT_DIR="${2:-}"; shift 2 ;;
68
+ --checkout-pr) CHECKOUT_PR="true"; shift ;;
69
+ *) echo "review-prepare: unknown argument: $1" >&2; exit 2 ;;
70
+ esac
71
+ done
72
+ case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-prepare: --pr must be numeric" >&2; exit 2 ;; esac
73
+ case "$REPO" in */*) ;; *) echo "review-prepare: --repo must be owner/name" >&2; exit 2 ;; esac
74
+ case "$REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/) echo "review-prepare: invalid --repo" >&2; exit 2 ;; esac
75
+ case "$MERGE_ISSUE" in *[!0-9]*) MERGE_ISSUE="" ;; esac
76
+ [ "$THOROUGH" = "true" ] || THOROUGH="false"
77
+
78
+ # --- trusted helper resolution (install roots only; never cwd) ---
79
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
80
+
81
+ ROSTER_SCRIPT=$(forge_resolve review-roster.sh)
82
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
83
+ REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
84
+ CODE_INDEX_SCRIPT=$(forge_resolve code-index.sh)
85
+ _l=$(forge_resolve lib/pr-issue-number.sh); if [ -n "$_l" ]; then . "$_l"; else pr_issue_number() { return 0; }; fi
86
+ _l=$(forge_resolve lib/review-template-resolve.sh); if [ -n "$_l" ]; then . "$_l"; else review_template_resolve() { TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""; }; fi
87
+ _l=$(forge_resolve lib/review-delta-mode.sh); if [ -n "$_l" ]; then . "$_l"; else review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; fi
88
+
89
+ # --- state, scratch and the ref-restore guard ---
90
+ BLOCKERS=""
91
+ block() { BLOCKERS="${BLOCKERS}${1}"$'\n'; }
92
+ [ -n "$OUT_DIR" ] || OUT_DIR="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-${PR_NUMBER}"
93
+ mkdir -p "$OUT_DIR" 2>/dev/null || { echo "review-prepare: cannot create $OUT_DIR" >&2; exit 2; }
94
+ TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/review-prepare.XXXXXX") || exit 2
95
+ ORIG_REF=""; CHECKED_OUT=""
96
+ # restore_ref: put the checkout back exactly where it was. -f so a dirty review checkout can never wedge the restore.
97
+ restore_ref() {
98
+ if [ -n "$CHECKED_OUT" ] && [ -n "$ORIG_REF" ]; then
99
+ git checkout -f "$ORIG_REF" >/dev/null 2>&1 || echo "review-prepare: WARNING could not restore $ORIG_REF" >&2
100
+ fi
101
+ CHECKED_OUT=""
102
+ }
103
+ cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
104
+ trap cleanup EXIT
105
+
106
+ # --- Phase 1A: fetch ---
107
+ PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
108
+ printf '%s' "$PR_INFO" | jq -e . >/dev/null 2>&1 || PR_INFO=""
109
+ if [ -z "$PR_INFO" ]; then block "gh pr view failed for #${PR_NUMBER}"; fi
110
+ pj() { printf '%s' "$PR_INFO" | jq -r "$1 // empty" 2>/dev/null; }
111
+ TITLE=$(pj .title); PR_AUTHOR=$(pj .author.login); HEAD=$(pj .headRefName); BASE=$(pj .baseRefName)
112
+ REVIEW_SHA=$(pj .headRefOid); REVIEW_SHA_SHORT=$(printf '%s' "$REVIEW_SHA" | cut -c1-7)
113
+ ADDITIONS=$(pj .additions); DELETIONS=$(pj .deletions); NFILES=$(pj .changedFiles)
114
+ case "$ADDITIONS" in ''|*[!0-9]*) ADDITIONS=0 ;; esac; case "$DELETIONS" in ''|*[!0-9]*) DELETIONS=0 ;; esac
115
+ case "$NFILES" in ''|*[!0-9]*) NFILES="" ;; esac
116
+ LINES=$((ADDITIONS + DELETIONS))
117
+ [ -z "$PR_INFO" ] || [ -n "$REVIEW_SHA" ] || block "could not read the PR head SHA"
118
+
119
+ FILES=""; DIFF=""
120
+ if [ -z "$BLOCKERS" ]; then
121
+ FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || block "gh pr diff --name-only failed"
122
+ DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || block "gh pr diff failed"
123
+ [ -n "$FILES" ] || [ -n "$BLOCKERS" ] || block "PR #${PR_NUMBER} has an empty file list"
124
+ fi
125
+
126
+ # Mergeability: GitHub computes it asynchronously; retry up to 3x on UNKNOWN.
127
+ merge_health() { gh pr view "$PR_NUMBER" -R "$REPO" --json mergeable,mergeStateStatus --jq '"\(.mergeable)|\(.mergeStateStatus)"' 2>/dev/null || echo "UNKNOWN|UNKNOWN"; }
128
+ MH=$(merge_health); MERGE_HEALTH=${MH%%|*}; MERGE_HEALTH_STATE=${MH##*|}; MERGE_RETRY=0
129
+ while [ "$MERGE_HEALTH" = "UNKNOWN" ] && [ "$MERGE_RETRY" -lt 3 ]; do
130
+ MERGE_RETRY=$((MERGE_RETRY + 1)); sleep "${REVIEW_PREPARE_SLEEP:-5}"
131
+ MH=$(merge_health); MERGE_HEALTH=${MH%%|*}; MERGE_HEALTH_STATE=${MH##*|}
132
+ done
133
+ case "$MERGE_HEALTH" in MERGEABLE|CONFLICTING|UNKNOWN) ;; *) MERGE_HEALTH="UNKNOWN" ;; esac
134
+ case "$MERGE_HEALTH_STATE" in CLEAN|DIRTY|BLOCKED|UNSTABLE|HAS_HOOKS|BEHIND|DRAFT|UNKNOWN) ;; *) MERGE_HEALTH_STATE="UNKNOWN" ;; esac
135
+
136
+ # First-time contributor (consumed by Phase 9): no prior merged PR by this author.
137
+ IS_FIRST_TIME_CONTRIBUTOR="false"
138
+ if [ -n "$PR_AUTHOR" ]; then
139
+ PRIOR_MERGED_COUNT=$(gh pr list -R "$REPO" --author "$PR_AUTHOR" --state merged --limit 1 --json number --jq 'length' 2>/dev/null || echo "1")
140
+ case "$PRIOR_MERGED_COUNT" in ''|*[!0-9]*) PRIOR_MERGED_COUNT=1 ;; esac
141
+ [ "$PRIOR_MERGED_COUNT" -eq 0 ] && IS_FIRST_TIME_CONTRIBUTOR="true"
142
+ fi
143
+
144
+ # Lane (the router already diverted staging->main; these still gate the full-build check and the roster).
145
+ IS_STAGING_TO_MAIN="false"; [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ] && IS_STAGING_TO_MAIN="true"
146
+ IS_MILESTONE_TO_STAGING="false"; case "$HEAD" in milestone/*) [ "$BASE" = "staging" ] && IS_MILESTONE_TO_STAGING="true" ;; esac
147
+ REQUIRES_FULL_BUILD="false"
148
+ { [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && REQUIRES_FULL_BUILD="true"
149
+
150
+ # --- optional PR checkout (throwaway review checkout only) ---
151
+ if [ "$CHECKOUT_PR" = "true" ] && [ -z "$BLOCKERS" ]; then
152
+ ORIG_REF=$(git symbolic-ref -q --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || echo "")
153
+ git fetch origin "pull/${PR_NUMBER}/head" >/dev/null 2>&1
154
+ if git checkout -f --detach "$REVIEW_SHA" >/dev/null 2>&1; then CHECKED_OUT="1"; else block "could not check out review SHA ${REVIEW_SHA_SHORT}"; fi
155
+ fi
156
+
157
+ # --- config load (BEFORE scoring and the roster) ---
158
+ TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
159
+ _gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "")
160
+ MAIN_ROOT=""; [ -n "$_gcd" ] && MAIN_ROOT=$(cd "$_gcd/.." 2>/dev/null && pwd || echo "")
161
+ CONFIG_FILE="${FORGE_CONFIG:-}"
162
+ if [ -z "$CONFIG_FILE" ]; then
163
+ for _c in "$MAIN_ROOT/forge.yaml" "$TOP/forge.yaml"; do [ -f "$_c" ] && { CONFIG_FILE="$_c"; break; }; done
164
+ fi
165
+ CONFIG_READABLE="false"; [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ] && CONFIG_READABLE="true"
166
+ cfg() { [ "$CONFIG_READABLE" = "true" ] || return 0; yq "$1 // \"\"" "$CONFIG_FILE" 2>/dev/null || true; }
167
+ PROJECT_NAME=$(cfg .project.name); [ -n "$PROJECT_NAME" ] || PROJECT_NAME="this project"
168
+ PROJECT_CONTEXT=$(cfg .review.context)
169
+ DOMAIN_CONTEXT_AUTH=$(cfg .review.domains.auth); DOMAIN_CONTEXT_BILLING=$(cfg .review.domains.billing)
170
+ DOMAIN_CONTEXT_CONCURRENCY=$(cfg .review.domains.concurrency); DOMAIN_CONTEXT_INFRA=$(cfg .review.domains.infra)
171
+ DOMAIN_CONTEXT_DATABASE=$(cfg .review.domains.database); DOMAIN_CONTEXT_FRONTEND=$(cfg .review.domains.frontend)
172
+ DOMAIN_CONTEXT_SECURITY=$(cfg .review.domains.security); DOMAIN_CONTEXT_API=$(cfg .review.domains.api)
173
+ DOMAIN_CONTEXT_SCRAPING=$(cfg .review.domains.scraping) # also gates spawning the scraping agent
174
+ REPO_PATH=$(cfg .paths.root); [ -n "$REPO_PATH" ] || REPO_PATH="${TOP:-$PWD}" # data only: the index target, never a script source
175
+
176
+ # --- Phase 1B: classify ---
177
+ SERVICES=""; DOMAINS=""
178
+ svc() { printf '%s\n' "$FILES" | grep -qE "$2" && SERVICES="$SERVICES $1"; }
179
+ dom_f() { printf '%s\n' "$FILES" | grep -qE "$2" && DOMAINS="$DOMAINS $1"; }
180
+ dom_d() { printf '%s\n' "$DIFF" | grep -qE "$2" && DOMAINS="$DOMAINS $1"; }
181
+ svc API "(^services/api/|/api/|/backend/)"; svc WORKER "(^services/worker/|/worker/|/jobs/|/tasks/)"
182
+ svc WEB "(^web/|/frontend/|/app/|\.tsx?$|\.jsx?$)"; svc SHARED "(^shared/|/shared/|/common/|/lib/)"
183
+ svc INFRA "^(docker|infra/|\.github|Makefile|traefik|k8s/|terraform/)"
184
+ dom_d AUTH "get_current_user|jwt|oauth|login|logout|require_auth|authenticated_user|x.forwarded.for|x_forwarded_for|forwarded_for|rate.limit.*ip|ip.*rate.limit|algorithm.*HS256|algorithm.*RS256|NEXTAUTH_SECRET|JWT_SECRET"
185
+ dom_d BILLING "credit|balance|debit|reconcil|pricing|charge|refund|stripe|subscription|payment"
186
+ dom_d SCRAPING "scrape|tier.*escalat|anti_bot|stealth|playwright.*scrape|scrape.*playwright|playbook_min_tier|browser.*pool|proxy.*scrape|web.*scrape"
187
+ dom_d CONCURRENCY "FOR UPDATE|atomic|transaction|pipeline|MULTI|distributed_lock|acquire_lock|reserved_by|promo.*claim|voucher.*redeem"
188
+ dom_f DATABASE "migration|\.sql$"
189
+ dom_d DB_CONFIG "create_async_engine|AsyncSession|connect_args|pool_size|prepared_statement|engine_from_config|sessionmaker"
190
+ dom_f API_DESIGN "router|routes"
191
+
192
+ # --- Phase 2: read-only checks + the trusted full-build config gate ---
193
+ P2_FINDINGS=""
194
+ p2() { P2_FINDINGS="${P2_FINDINGS}${1}"$'\n'; }
195
+ HAS_TS=$(printf '%s\n' "$FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
196
+ HAS_PY=$(printf '%s\n' "$FILES" | grep -E '\.py$' | head -1)
197
+ TS_BUILD=$(cfg .verification.commands.typescript.build); PY_FORMAT=$(cfg .verification.commands.python.format)
198
+ BUILD_CONFIG_STATUS="not_required"
199
+ if [ "$REQUIRES_FULL_BUILD" = "true" ]; then
200
+ BUILD_CONFIG_STATUS="ok"
201
+ if [ "$CONFIG_READABLE" != "true" ]; then
202
+ BUILD_CONFIG_STATUS="blocked"; block "full-build lane but no readable trusted forge.yaml (checked FORGE_CONFIG, the main checkout and the git top-level)"
203
+ else
204
+ if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: typescript.build is empty in verification.commands on a full-build lane (typecheck alone misses prerender failures)"; fi
205
+ if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: python.format is empty in verification.commands on a full-build lane"; fi
206
+ fi
207
+ fi
208
+ # 2D env vars, 2E secrets (CRITICAL), 2F SQL, over the diff text
209
+ while IFS= read -r _l; do [ -n "$_l" ] && p2 "2D env: $_l"; done <<EOF2
210
+ $(printf '%s\n' "$DIFF" | grep -E "os\.getenv|os\.environ|process\.env" | head -30)
211
+ EOF2
212
+ while IFS= read -r _l; do [ -n "$_l" ] && p2 "2E secret?: $_l"; done <<EOF2
213
+ $(printf '%s\n' "$DIFF" | grep -iE "(api[_-]?key|secret[_-]?key|password|token|credential|private[_-]?key)" | grep -vE "(#|//|\.example|placeholder|PLACEHOLDER|YOUR_|<|>)" | head -20)
214
+ EOF2
215
+ while IFS= read -r _l; do [ -n "$_l" ] && p2 "2E secret?: high-entropy literal ${_l}"; done <<EOF2
216
+ $(printf '%s\n' "$DIFF" | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10)
217
+ EOF2
218
+ file_at_head() { git show "${REVIEW_SHA}:${1}" 2>/dev/null || cat "$1" 2>/dev/null || true; }
219
+ for f in $(printf '%s\n' "$FILES" | grep '\.sql$'); do
220
+ _c=$(file_at_head "$f"); [ -n "$_c" ] || continue
221
+ printf '%s\n' "$_c" | grep -E "FOR UPDATE" | grep -qE "(SUM|COUNT|AVG|MIN|MAX)\s*\(" && p2 "2F ERROR: FOR UPDATE with aggregate in $f"
222
+ printf '%s\n' "$_c" | grep -qE "DROP (TABLE|COLUMN|INDEX)" && ! printf '%s\n' "$_c" | grep -qE "IF EXISTS" && p2 "2F WARNING: DROP without IF EXISTS in $f"
223
+ printf '%s\n' "$_c" | grep -qE "ALTER TABLE.*ADD COLUMN.*NOT NULL" && ! printf '%s\n' "$_c" | grep -qE "DEFAULT" && p2 "2F WARNING: NOT NULL without DEFAULT in $f"
224
+ done
225
+
226
+ # --- Phase 2.5: integration assumptions (verify-*.sh via the trusted resolver) ---
227
+ ASSUMPTIONS=""
228
+ asm() { ASSUMPTIONS="${ASSUMPTIONS}${1}"$'\n'; }
229
+ printf '%s\n' "$FILES" > "$TMP_DIR/changed.txt"; printf '%s\n' "$DIFF" > "$TMP_DIR/diff.txt"
230
+ LAYOUT_PAGES=$(cfg .review.layout.pages); LAYOUT_ROUTERS=$(cfg .review.layout.api_routers_dir); LAYOUT_MAIN=$(cfg .review.layout.api_main); LAYOUT_MW=$(cfg .review.layout.api_middleware_dir)
231
+ [ -n "$LAYOUT_PAGES" ] && export FORGE_PAGES_ROOT="$LAYOUT_PAGES"; [ -n "$LAYOUT_ROUTERS" ] && export FORGE_API_ROUTERS_DIR="$LAYOUT_ROUTERS"
232
+ [ -n "$LAYOUT_MAIN" ] && export FORGE_API_MAIN="$LAYOUT_MAIN"; [ -n "$LAYOUT_MW" ] && export FORGE_API_MIDDLEWARE_DIR="$LAYOUT_MW"
233
+ FORGE_INTERNAL_PATTERNS=""
234
+ if [ "$CONFIG_READABLE" = "true" ]; then
235
+ FORGE_INTERNAL_PATTERNS=$(grep -A 999 'internal_service_patterns:' "$CONFIG_FILE" 2>/dev/null | grep -E '^\s*-\s+' | sed 's/^\s*-\s*//' | tr -d "\"'" | awk 'NR>1{printf "|"}{printf "%s", $0}END{print ""}')
236
+ fi
237
+ export FORGE_INTERNAL_PATTERNS
238
+ run_verify() { # <script name> <args...>; BLOCKING/WARNING lines become broken assumptions
239
+ local name="$1" path out; shift
240
+ path=$(forge_resolve "$name")
241
+ [ -n "$path" ] || { asm "WARNING: ${name} unresolved (trusted install root not found); integration check not run"; return 0; }
242
+ out=$(bash "$path" "$@" 2>&1) || true
243
+ while IFS= read -r _l; do case "$_l" in BLOCKING:*|WARNING:*) asm "${name}: ${_l}" ;; esac; done <<EOF2
244
+ $out
245
+ EOF2
246
+ }
247
+ run_verify verify-route-registration.sh "$TMP_DIR/changed.txt" "."
248
+ run_verify verify-env-vars.sh "$TMP_DIR/diff.txt" "."
249
+ run_verify verify-host-headers.sh "$TMP_DIR/changed.txt" "."
250
+ run_verify verify-sops-chain.sh "$TMP_DIR/diff.txt" "$TMP_DIR/changed.txt" "."
251
+ # Sibling workflow drift (always for staging->main; otherwise when a workflow file changed)
252
+ if { [ "$IS_STAGING_TO_MAIN" = "true" ] || printf '%s\n' "$FILES" | grep -qE "^\.github/workflows/.*\.yml$"; } && [ -f .github/workflows/ci.yml ] && [ -f .github/workflows/deploy-production.yml ]; then
253
+ for JOB in test-api test-web; do
254
+ job_env() { awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$1" 2>/dev/null | grep "PYTHONPATH" | sed 's/^ *//' | sort; }
255
+ CI_PYPATH=$(job_env .github/workflows/ci.yml); DEPLOY_PYPATH=$(job_env .github/workflows/deploy-production.yml)
256
+ [ "$CI_PYPATH" = "$DEPLOY_PYPATH" ] || asm "BLOCKING: PYTHONPATH differs between ci.yml and deploy-production.yml for job '${JOB}'"
257
+ done
258
+ fi
259
+
260
+ # --- Phase 3A: risk signals + churn ---
261
+ RISK=""
262
+ sig_d() { printf '%s\n' "$DIFF" | grep -qE "$2" && RISK="$RISK $1"; }
263
+ sig_d UNTRUSTED_INPUT_PROCESSING "subprocess|exec|eval|system\(|popen|heredoc"; sig_d SHELL_SCRIPT "\.sh$|bash|shell|cron"
264
+ sig_d FINANCIAL "credit|balance|debit|charge|refund|stripe"
265
+ sig_d AUTH_SENSITIVE "jwt|oauth|password|token|secret|x.forwarded.for|x_forwarded_for|forwarded_for|rate.limit.*ip|ip.*rate.limit|algorithm.*HS256|algorithm.*RS256|NEXTAUTH_SECRET|JWT_SECRET|get_current_user|require_auth"
266
+ sig_d DATABASE_MUTATION "\.sql$|migration|DROP|ALTER|DELETE FROM"; sig_d INFRASTRUCTURE "docker|deploy|traefik|nginx|\.yml.*service"
267
+ sig_d DATABASE_CONTAINER "docker-compose.*postgres|docker-compose.*redis|postgres.*command:|redis.*command:|image:.*postgres|image:.*redis"
268
+ sig_d DB_CONFIG "create_async_engine|AsyncSession|connect_args|pool_size|prepared_statement|engine_from_config|sessionmaker"
269
+ sig_d CODE_EXECUTION "subprocess|os\.system|eval\(|exec\(|pickle|yaml\.load[^_]"
270
+ printf '%s\n' "$FILES" | grep -qE "^sdk/|openapi.*\.json$|openapi-versions/" && RISK="$RISK SDK_OPENAPI"
271
+
272
+ CHURN_WINDOW="90 days ago" # must match architect.md Phase A5
273
+ CHURN_CONTEXT=""
274
+ DANGER_ZONES_INDEX="${HOME}/.forge/index/danger-zones.json"; DZ_DATA=""
275
+ [ -f "$DANGER_ZONES_INDEX" ] && DZ_DATA=$(cat "$DANGER_ZONES_INDEX" 2>/dev/null || true)
276
+ while IFS= read -r FILE; do
277
+ [ -z "$FILE" ] && continue
278
+ COMMITS=$(git log --oneline --since="$CHURN_WINDOW" -- "$FILE" 2>/dev/null | wc -l | tr -d ' ')
279
+ COMMIT_TAG=""; [ "${COMMITS:-0}" -ge 15 ] && COMMIT_TAG=" — HOT (${COMMITS} commits/90d)"
280
+ FINDING_TAG=""
281
+ if [ -n "$DZ_DATA" ]; then
282
+ FINDING_COUNT=$(printf '%s' "$DZ_DATA" | jq -r --arg f "$FILE" '.files[$f].findingCount90d // 0' 2>/dev/null || echo 0)
283
+ case "$FINDING_COUNT" in ''|*[!0-9]*) FINDING_COUNT=0 ;; esac
284
+ [ "$FINDING_COUNT" -ge 3 ] && FINDING_TAG=" — HOT-FINDINGS (${FINDING_COUNT} findings/90d)"
285
+ fi
286
+ if [ -n "$COMMIT_TAG" ] || [ -n "$FINDING_TAG" ]; then CHURN_CONTEXT="${CHURN_CONTEXT}${FILE}${COMMIT_TAG}${FINDING_TAG}"$'\n'; fi
287
+ done <<< "$FILES"
288
+ [ -n "$CHURN_CONTEXT" ] || CHURN_CONTEXT="No hot-spot files detected (all changed files under 15 commits and under 3 findings in the last 90 days)."
289
+ CHURN_ESCALATION=false; printf '%s\n' "$CHURN_CONTEXT" | grep -q "HOT" && CHURN_ESCALATION=true
290
+ DANGER=false; printf '%s\n' "$CHURN_CONTEXT" | grep -q "HOT-FINDINGS" && DANGER=true
291
+
292
+ # --- Phase 3B step 1: domain scores ---
293
+ SCORE_AUTH=0; SCORE_BILLING=0; SCORE_CONCURRENCY=0; SCORE_DATABASE=0; SCORE_INFRA=0; SCORE_SECURITY=1; SCORE_SCRAPING=0; SCORE_FRONTEND=0; SCORE_API=0
294
+ sc_d() { printf '%s\n' "$DIFF" | grep -qE "$2" && eval "$1=\$(( $1 + $3 ))"; }
295
+ sc_f() { printf '%s\n' "$FILES" | grep -qE "$2" && eval "$1=\$(( $1 + $3 ))"; }
296
+ sc_d SCORE_AUTH "jwt|oauth|login|logout|password|NEXTAUTH_SECRET|JWT_SECRET|auth_token|access_token|refresh_token" 3
297
+ sc_d SCORE_AUTH "get_current_user|Depends\(get_|x.forwarded.for|forwarded_for|rate.limit.*ip|ip.*rate.limit" 2
298
+ sc_d SCORE_AUTH "algorithm.*HS256|algorithm.*RS256|admin.proxy" 2
299
+ sc_d SCORE_BILLING "credit|balance|debit|charge|refund|stripe|subscription" 3
300
+ sc_d SCORE_BILLING "pricing|tier_cost|reconcil" 2
301
+ sc_d SCORE_CONCURRENCY "FOR UPDATE|atomic|transaction|pipeline|MULTI|distributed_lock|acquire_lock" 3
302
+ sc_d SCORE_CONCURRENCY "reserved_by|promo.*claim|voucher.*redeem" 2
303
+ sc_f SCORE_DATABASE "migration|\.sql$" 3
304
+ sc_d SCORE_DATABASE "DROP|ALTER|DELETE FROM" 2
305
+ sc_d SCORE_DATABASE "create_async_engine|AsyncSession|pool_size|prepared_statement|sessionmaker" 2
306
+ sc_d SCORE_INFRA "docker-compose.*postgres|docker-compose.*redis|postgres.*command:|redis.*command:" 3
307
+ sc_d SCORE_INFRA "docker|deploy|traefik|nginx" 1
308
+ sc_f SCORE_INFRA "^\.github/workflows/" 2
309
+ sc_f SCORE_INFRA "Dockerfile|docker-compose" 2
310
+ sc_d SCORE_SECURITY "subprocess|exec|eval|system\(|popen" 3
311
+ sc_d SCORE_SECURITY "os\.system|eval\(|exec\(|pickle|yaml\.load[^_]" 3
312
+ sc_d SCORE_SECURITY "\.sh$|bash|shell|cron" 2
313
+ sc_d SCORE_SCRAPING "scrape|tier.*escalat|anti_bot|stealth|playwright.*scrape|scrape.*playwright|playbook_min_tier|browser.*pool|web.*scrape" 3
314
+ sc_f SCORE_FRONTEND "^web/src/" 1
315
+ sc_f SCORE_FRONTEND "^web/src/app/|^web/src/components/" 2
316
+ sc_f SCORE_API "router|routes" 2
317
+ sc_f SCORE_API "^sdk/|openapi.*\.json$|openapi-versions/" 3
318
+
319
+ # --- Phase 3B step 2: contract risk flags (trusted authors only) ---
320
+ CONTRACT_RISK_FLAGS=""
321
+ ISSUE_NUM=$(gh pr view "$PR_NUMBER" -R "$REPO" --json body --jq '.body // ""' 2>/dev/null | pr_issue_number)
322
+ case "$ISSUE_NUM" in ''|*[!0-9]*) ISSUE_NUM="" ;; esac
323
+ if [ -n "$ISSUE_NUM" ]; then
324
+ CONTRACT_BODY=""; CONTRACT_OK="false"
325
+ if [ -n "$TRUSTED_SCRIPT" ]; then
326
+ # Each stage is checked on its own: in a pipeline `jq -r .` on empty stdin exits 0, so a failed read looked like success.
327
+ # A failed or empty comment read, or a failing trusted-comments run, stays CONTRACT_OK=false (widen, never narrow).
328
+ # A successful read with no trusted contract comment is the deliberate "no contract" case (empty body, no flags).
329
+ if _cc=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUM}/comments" 2>/dev/null) && [ -n "$_cc" ] \
330
+ && _cb=$(printf '%s' "$_cc" | bash "$TRUSTED_SCRIPT" bodies 'FORGE:CONTRACT' 2>/dev/null); then
331
+ CONTRACT_OK="true"
332
+ [ -z "$_cb" ] || CONTRACT_BODY=$(printf '%s\n' "$_cb" | head -n 1 | jq -r . 2>/dev/null) || CONTRACT_OK="false"
333
+ fi
334
+ fi
335
+ if [ "$CONTRACT_OK" != "true" ]; then
336
+ CONTRACT_RISK_FLAGS="HIGH_RISK" # cannot read the contract trust-filtered: widen, never narrow
337
+ else
338
+ printf '%s\n' "$CONTRACT_BODY" | grep -qiE "\*\*Risk\*\*.*HIGH|\*\*Risk\*\*.*CRITICAL|Risk.*:\s*(HIGH|CRITICAL)" && CONTRACT_RISK_FLAGS="HIGH_RISK"
339
+ printf '%s\n' "$CONTRACT_BODY" | grep -qiE "auth|session|jwt" && CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS} CONTRACT_AUTH"
340
+ printf '%s\n' "$CONTRACT_BODY" | grep -qiE "billing|credit|payment" && CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS} CONTRACT_BILLING"
341
+ printf '%s\n' "$CONTRACT_BODY" | grep -qiE "migration|schema|database" && CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS} CONTRACT_DATABASE"
342
+ fi
343
+ fi
344
+ CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS# }"
345
+
346
+ # --- Phase 3B step 4: roster (review-roster.sh; unresolved or failing means the full union) ---
347
+ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
348
+ FIX_PR=false; SRC_SEV=""; SRC_DOM=""
349
+ if [ -n "$MERGE_ISSUE" ]; then
350
+ gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels --jq '[.labels[].name] | any(. == "review-finding")' 2>/dev/null | grep -qx true && FIX_PR=true
351
+ if [ "$FIX_PR" = "true" ]; then
352
+ SRC_BODY=$(gh issue view "$MERGE_ISSUE" -R "$REPO" --json body --jq '.body' 2>/dev/null)
353
+ SRC_SEV=$(printf '%s\n' "$SRC_BODY" | sed -n 's/^\*\*Severity\*\*: *\([A-Za-z]*\).*/\1/p' | head -n 1)
354
+ SRC_DOM=$(printf '%s\n' "$SRC_BODY" | sed -n 's/^\*\*Agent\*\*: *//p' | head -n 1 | tr -c 'A-Za-z\n' ' ' | tr ' ' '\n' | grep -viE '^security$' | grep -v '^$' | head -n 1)
355
+ fi
356
+ fi
357
+ SC="AUTH=$SCORE_AUTH BILLING=$SCORE_BILLING CONCURRENCY=$SCORE_CONCURRENCY DATABASE=$SCORE_DATABASE INFRA=$SCORE_INFRA SECURITY=$SCORE_SECURITY SCRAPING=$SCORE_SCRAPING FRONTEND=$SCORE_FRONTEND API=$SCORE_API"
358
+ SPEC_CLI=false; [ -n "$TOP" ] && [ -f "$TOP/commands/review-pr-agents/spec-cli.md" ] && SPEC_CLI=true # existence only
359
+ SCRAPING_ON=false; [ -n "$DOMAIN_CONTEXT_SCRAPING" ] && SCRAPING_ON=true
360
+ DBC=false; printf '%s\n' "$DIFF" | grep -qE "docker-compose.*postgres|docker-compose.*redis|postgres.*command:|redis.*command:" && DBC=true
361
+ ROSTER_OUT=""
362
+ [ -n "$ROSTER_SCRIPT" ] && ROSTER_OUT=$(printf '%s\n' "$FILES" | bash "$ROSTER_SCRIPT" --files-file - --scores "$SC" --changed-lines "$LINES" --changed-files "$NFILES" \
363
+ --fix-pr "$FIX_PR" --source-severity "$SRC_SEV" --source-domain "$SRC_DOM" --contract-flags "$CONTRACT_RISK_FLAGS" --churn "$CHURN_ESCALATION" --danger-zone "$DANGER" \
364
+ --thorough "$THOROUGH" --milestone "$IS_MILESTONE_TO_STAGING" --scraping-configured "$SCRAPING_ON" --database-container "$DBC" --spec-cli "$SPEC_CLI" 2>/dev/null)
365
+ SELECTED_AGENTS=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^SELECTED_AGENTS=//p')
366
+ ROSTER_TIER=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^TIER=//p')
367
+ ROSTER_REASON=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^REASON=//p')
368
+ FULL_UNION="false"
369
+ if [ -z "$SELECTED_AGENTS" ]; then
370
+ FULL_UNION="true"; THOROUGH=true; ROSTER_TIER=full; ROSTER_REASON="review-roster.sh unavailable or failed: full union"
371
+ fi
372
+ if [ "$THOROUGH" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; then FULL_UNION="true"; fi
373
+ if [ "$FULL_UNION" = "true" ]; then
374
+ SELECTED_AGENTS="Security"
375
+ [ "$SCORE_AUTH" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Auth"
376
+ [ "$SCORE_BILLING" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Billing Concurrency"
377
+ [ "$SCORE_DATABASE" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Database"
378
+ [ "$SCORE_INFRA" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Infrastructure"
379
+ [ "$SCORE_SCRAPING" -gt 0 ] && [ "$SCRAPING_ON" = "true" ] && SELECTED_AGENTS="$SELECTED_AGENTS Scraping"
380
+ [ "$SCORE_FRONTEND" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS Frontend"
381
+ [ "$SCORE_API" -gt 0 ] && SELECTED_AGENTS="$SELECTED_AGENTS API"
382
+ [ "$SPEC_CLI" = "true" ] && printf '%s\n' "$FILES" | grep -qE "^(commands|bin)/" && SELECTED_AGENTS="$SELECTED_AGENTS spec-cli"
383
+ fi
384
+ # Roster canonicalization: one canonical marker domain per persona.
385
+ canonical_roster() { local out="" a d; for a in $1; do d=$(agent_domain "$a"); case " $out " in *" $d "*) ;; *) out="$out $d" ;; esac; done; printf '%s' "${out# }"; }
386
+ SELECTED_AGENTS=$(canonical_roster "$SELECTED_AGENTS")
387
+
388
+ # --- Phase 3C: review mode (FULL | DELTA | BASE_SYNC_ONLY) ---
389
+ BASE_BRANCH="$BASE"
390
+ COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
391
+ delta_roster() {
392
+ local rounds="${1}" full="${2}" files="${3}" out="security" root
393
+ root="${REPO_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
394
+ case "$rounds" in ''|*[!0-9]*) printf '%s' "$full"; return 0 ;; esac
395
+ if [ "$rounds" -lt 2 ]; then printf '%s' "$full"; return 0; fi
396
+ printf '%s\n' "$files" | grep -qiE "auth|session|login|logout|jwt|oauth|token|user|permission|middleware" && out="$out auth"
397
+ printf '%s\n' "$files" | grep -qiE "billing|credit|payment|charge|refund|subscription|pricing|tier" && out="$out billing"
398
+ printf '%s\n' "$files" | grep -qiE "lock|queue|atomic|transaction|worker|job|task|celery|redis|claim|lease|engine-state" && out="$out concurrency"
399
+ printf '%s\n' "$files" | grep -qiE "migration|\.sql$|model|schema|db|database|alembic" && out="$out database"
400
+ printf '%s\n' "$files" | grep -qiE "docker|nginx|traefik|\.github|Makefile|deploy|infra" && out="$out infra"
401
+ printf '%s\n' "$files" | grep -qiE "scrape|crawler|stealth|browser.*pool|headless|anti.bot|detection.*keyword|captcha" && out="$out scraper"
402
+ printf '%s\n' "$files" | grep -qiE "^web/|\.tsx?$|\.jsx?$|component|page|layout|style|css" && out="$out frontend"
403
+ printf '%s\n' "$files" | grep -qiE "router|route|endpoint|openapi|sdk|api" && out="$out api"
404
+ printf '%s\n' "$files" | grep -qE "^(commands|bin)/" && [ -f "$root/commands/review-pr-agents/spec-cli.md" ] && out="$out spec-cli"
405
+ printf '%s' "$out"
406
+ }
407
+ REVIEW_MODE="FULL"; DELTA_FROM=""; CARRIED_FROM=""; FULL_ROUNDS=0; DELTA_FILES=""
408
+ if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ]; then
409
+ read -r RD_KIND RD_ARG RD_ROUNDS <<< "$(review_delta_mode)"
410
+ FULL_ROUNDS="${RD_ROUNDS:-0}"
411
+ case "$RD_KIND" in
412
+ DELTA) REVIEW_MODE="DELTA"; DELTA_FROM="$RD_ARG" ;;
413
+ BASE_SYNC_ONLY) REVIEW_MODE="BASE_SYNC_ONLY"; CARRIED_FROM="$RD_ARG" ;;
414
+ esac
415
+ fi
416
+ if [ "$REVIEW_MODE" = "DELTA" ]; then
417
+ DELTA_FILES=$(git -C "${TOP:-.}" -c core.quotepath=off diff --name-only --no-renames "${DELTA_FROM}" "${REVIEW_SHA}" 2>/dev/null) || DELTA_FILES=""
418
+ if [ -z "$DELTA_FILES" ]; then REVIEW_MODE="FULL"; DELTA_FROM=""
419
+ else SELECTED_AGENTS=$(canonical_roster "$(delta_roster "$FULL_ROUNDS" "$SELECTED_AGENTS" "$DELTA_FILES")"); fi
420
+ fi
421
+ [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ] && SELECTED_AGENTS=""
422
+
423
+ # --- templates, code index, per-domain slices (skipped for BASE_SYNC_ONLY: zero reviewers) ---
424
+ TEMPLATE_SOURCE="none"; TEMPLATE_BASE=""; MONOLITHIC_CATALOG=""
425
+ IDX_AUTH=""; IDX_BILLING=""; IDX_DATABASE=""; IDX_API=""; IDX_FRONTEND=""; IDX_INFRA=""
426
+ SLICES_JSON="{}"
427
+ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
428
+ review_template_resolve "$REPO_PATH"
429
+ [ "$TEMPLATE_SOURCE" = "none" ] && block "no review-pr-agents template source resolved: run \`npx forgedock update\`; NEVER review inline"
430
+ if [ -n "$CODE_INDEX_SCRIPT" ]; then bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1 || true; fi
431
+ build_index_slice() {
432
+ local domain="${1}" files
433
+ if [ -n "$CODE_INDEX_SCRIPT" ]; then
434
+ files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $1}' | head -30 | tr '\n' ' ')
435
+ echo "Domain files (${domain}): ${files:-none}"
436
+ else echo "Code index not available — agent will use grep exploration"; fi
437
+ }
438
+ IDX_AUTH=$(build_index_slice auth); IDX_BILLING=$(build_index_slice billing); IDX_DATABASE=$(build_index_slice database)
439
+ IDX_API=$(build_index_slice api); IDX_FRONTEND=$(build_index_slice frontend); IDX_INFRA=$(build_index_slice infrastructure)
440
+
441
+ FULL_DIFF="$DIFF"
442
+ if [ "$REVIEW_MODE" = "DELTA" ]; then
443
+ DELTA_DIFF=$(git -C "${TOP:-.}" -c core.quotepath=off diff --no-color --no-ext-diff "${DELTA_FROM}..${REVIEW_SHA}" 2>/dev/null) || DELTA_DIFF=""
444
+ [ -n "$DELTA_DIFF" ] && FULL_DIFF="$DELTA_DIFF"
445
+ fi
446
+ SLICE_FILES_SRC="$FILES"; [ "$REVIEW_MODE" = "DELTA" ] && SLICE_FILES_SRC="$DELTA_FILES"
447
+ # slice <domain> <diff-header regex|ALL> <file-path regex|ALL>
448
+ slice() {
449
+ local dom="$1" dre="$2" fre="$3" body paths df="$OUT_DIR/slice-$1.diff"
450
+ if [ "$dre" = "ALL" ]; then body="$FULL_DIFF"
451
+ else
452
+ body=$(printf '%s\n' "$FULL_DIFF" | awk -v re="$dre" '/^diff --git/ { in_block=0 } $0 ~ re { in_block=1 } in_block { print }')
453
+ [ -n "$body" ] || body="$FULL_DIFF"
454
+ fi
455
+ if [ "$fre" = "ALL" ]; then paths="$SLICE_FILES_SRC"
456
+ else paths=$(printf '%s\n' "$SLICE_FILES_SRC" | grep -iE "$fre" || echo ""); [ -n "$paths" ] || paths="$SLICE_FILES_SRC"; fi
457
+ # 100K-char cap, same as the runner's tool-result cap
458
+ if [ "${#body}" -gt 102400 ]; then body="$(printf '%s' "$body" | head -c 102400)"$'\n'"... [TRUNCATED — diff exceeded 100K chars; focus on the files listed]"; fi
459
+ printf '%s\n' "$body" > "$df"
460
+ SLICES_JSON=$(printf '%s' "$SLICES_JSON" | jq -c --arg d "$dom" --arg df "$df" --arg p "$paths" '. + {($d): {paths: ($p | split("\n") | map(select(length > 0))), diff_file: $df}}')
461
+ }
462
+ slice security ALL ALL
463
+ slice auth '^diff --git.*/(auth|session|jwt|oauth|permission|middleware|login|token)' "auth|session|login|logout|jwt|oauth|token|user|permission|middleware"
464
+ slice billing '^diff --git.*/(billing|payment|credit|pricing|stripe|subscription|invoice|charge|refund)' "billing|credit|payment|charge|refund|subscription|pricing|tier"
465
+ slice concurrency '^diff --git.*/(worker|queue|task|async|lock|transaction|pipeline|job)' "lock|queue|atomic|transaction|worker|job|task|celery|redis|claim|lease|engine-state"
466
+ slice database '^diff --git.*/(migration|model|schema|\.sql$|orm|database|db)' "migration|\.sql$|model|schema|db|database|alembic"
467
+ slice frontend '^diff --git.*(web/src|components|pages|styles|\.tsx|\.jsx|\.css|\.scss)' "^web/|\.tsx?$|\.jsx?$|component|page|layout|style|css"
468
+ slice api '^diff --git.*/(router|route|endpoint|openapi|serializer|schema|api)' "router|route|endpoint|openapi|sdk|api"
469
+ slice infra '^diff --git.*/(docker|nginx|infra|\.github|deploy|ci|traefik|k8s|helm)' "docker|nginx|traefik|\.github|Makefile|deploy|infra"
470
+ slice scraper '^diff --git.*/(scrape|scraper|stealth|browser_pool|headless|anti_bot|captcha|crawl)' "scrape|crawler|stealth|browser.*pool|headless|anti.bot|detection.*keyword|captcha"
471
+ slice spec-cli '^diff --git a/(commands|bin)/' "^(commands|bin)/"
472
+ fi
473
+
474
+ # --- emit ---
475
+ STATUS="OK"; [ -n "$BLOCKERS" ] && STATUS="BLOCKED"
476
+ j_lines() { printf '%s' "${1}" | jq -R -s -c 'split("\n") | map(select(length > 0))'; }
477
+ j_words() { printf '%s' "${1}" | jq -R -s -c 'split(" ") | map(select(length > 0))'; }
478
+ CTX=$(jq -n \
479
+ --arg status "$STATUS" --argjson blockers "$(j_lines "$BLOCKERS")" \
480
+ --argjson pr_number "$PR_NUMBER" --arg repo "$REPO" --arg title "$TITLE" --arg author "$PR_AUTHOR" --arg head "$HEAD" --arg base "$BASE" \
481
+ --arg sha "$REVIEW_SHA" --arg sha7 "$REVIEW_SHA_SHORT" --argjson adds "$ADDITIONS" --argjson dels "$DELETIONS" --arg nfiles "${NFILES:-}" --arg first "$IS_FIRST_TIME_CONTRIBUTOR" \
482
+ --arg s2m "$IS_STAGING_TO_MAIN" --arg m2s "$IS_MILESTONE_TO_STAGING" --arg rfb "$REQUIRES_FULL_BUILD" --arg thorough "$THOROUGH" \
483
+ --arg mh "$MERGE_HEALTH" --arg mhs "$MERGE_HEALTH_STATE" --argjson mhr "$MERGE_RETRY" \
484
+ --argjson services "$(j_words "$SERVICES")" --argjson domains "$(j_words "$DOMAINS")" \
485
+ --arg tsb "$TS_BUILD" --arg pyf "$PY_FORMAT" --arg bcs "$BUILD_CONFIG_STATUS" --argjson p2 "$(j_lines "$P2_FINDINGS")" \
486
+ --argjson asm "$(j_lines "$ASSUMPTIONS")" --argjson risk "$(j_words "$RISK")" --arg churn "$CHURN_CONTEXT" --arg churn_esc "$CHURN_ESCALATION" --arg danger "$DANGER" \
487
+ --argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
488
+ --argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
489
+ --arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
490
+ --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
491
+ --arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --argjson dfiles "$(j_lines "$DELTA_FILES")" \
492
+ --arg pname "$PROJECT_NAME" --arg pctx "$PROJECT_CONTEXT" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
493
+ --arg d_auth "$DOMAIN_CONTEXT_AUTH" --arg d_billing "$DOMAIN_CONTEXT_BILLING" --arg d_conc "$DOMAIN_CONTEXT_CONCURRENCY" --arg d_infra "$DOMAIN_CONTEXT_INFRA" \
494
+ --arg d_db "$DOMAIN_CONTEXT_DATABASE" --arg d_fe "$DOMAIN_CONTEXT_FRONTEND" --arg d_sec "$DOMAIN_CONTEXT_SECURITY" --arg d_api "$DOMAIN_CONTEXT_API" --arg d_scr "$DOMAIN_CONTEXT_SCRAPING" \
495
+ --arg tsrc "$TEMPLATE_SOURCE" --arg tbase "$TEMPLATE_BASE" --arg tmono "$MONOLITHIC_CATALOG" \
496
+ --arg i_auth "$IDX_AUTH" --arg i_billing "$IDX_BILLING" --arg i_db "$IDX_DATABASE" --arg i_api "$IDX_API" --arg i_fe "$IDX_FRONTEND" --arg i_infra "$IDX_INFRA" \
497
+ --argjson slices "$SLICES_JSON" --argjson full_files "$(j_lines "$FILES")" '
498
+ {schema_version: 1, status: $status, blockers: $blockers,
499
+ pr: {number: $pr_number, repo: $repo, title: $title, author: $author, head_ref: $head, base_ref: $base, review_sha: $sha, review_sha_short: $sha7,
500
+ additions: $adds, deletions: $dels, changed_files: ($nfiles | tonumber? // null), first_time_contributor: ($first == "true")},
501
+ lane: {is_staging_to_main: ($s2m == "true"), is_milestone_to_staging: ($m2s == "true"), requires_full_build: ($rfb == "true"), thorough: ($thorough == "true")},
502
+ merge_health: {mergeable: $mh, state: $mhs, retries: $mhr},
503
+ classification: {services: $services, domains: $domains},
504
+ phase2: {build_config: {typescript_build: $tsb, python_format: $pyf, status: $bcs}, findings: $p2},
505
+ broken_assumptions: $asm, risk_signals: $risk, churn_context: $churn, churn_escalation: ($churn_esc == "true"), danger_zone: ($danger == "true"),
506
+ scores: {AUTH: $sa, BILLING: $sb, CONCURRENCY: $sc, DATABASE: $sd, INFRA: $si, SECURITY: $ss, SCRAPING: $ssc, FRONTEND: $sf, API: $sap},
507
+ contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
508
+ roster: {tier: $rtier, reason: $rreason, agents: $agents},
509
+ delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles},
510
+ config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
511
+ domains: {auth: $d_auth, billing: $d_billing, concurrency: $d_conc, infra: $d_infra, database: $d_db, frontend: $d_fe, security: $d_sec, api: $d_api, scraping: $d_scr}},
512
+ templates: {source: $tsrc, base: $tbase, monolithic_catalog: $tmono},
513
+ index_slices: {auth: $i_auth, billing: $i_billing, database: $i_db, api: $i_api, frontend: $i_fe, infrastructure: $i_infra},
514
+ slices: $slices, full_files: $full_files}') || { echo "review-prepare: could not assemble the context JSON" >&2; exit 2; }
515
+ if [ -n "$OUT" ]; then printf '%s\n' "$CTX" > "$OUT" || { echo "review-prepare: cannot write $OUT" >&2; exit 2; }; else printf '%s\n' "$CTX"; fi
516
+ [ "$STATUS" = "OK" ] && exit 0
517
+ exit 3