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