forgedock 1.12.0 → 1.13.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 (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Lint: every scripts/*.test.sh must be referenced by a workflow in .github/workflows/*.yml.
6
+ # An unreferenced test never runs in CI, so regressions in the script under test merge silently.
7
+ # Lists EVERY unwired test before exiting non-zero. bash 3.2 portable (no bash-4 builtins, no PCRE grep).
8
+ #
9
+ # Usage: bash scripts/check-test-wiring.sh [<scripts_dir> [<workflows_dir>]]
10
+ # defaults: scripts .github/workflows (relative to the current directory)
11
+ # Exit: 0 all wired | 1 one or more unwired, or workflows dir missing/empty | 2 bad scripts dir
12
+
13
+ set -u
14
+ SCRIPTS_DIR="${1:-scripts}"
15
+ WORKFLOWS_DIR="${2:-.github/workflows}"
16
+
17
+ [ -d "$SCRIPTS_DIR" ] || { echo "check-test-wiring: scripts dir not found: $SCRIPTS_DIR" >&2; exit 2; }
18
+ [ -d "$WORKFLOWS_DIR" ] || { echo "check-test-wiring: workflows dir not found: $WORKFLOWS_DIR" >&2; exit 1; }
19
+
20
+ wf_files=""
21
+ for w in "$WORKFLOWS_DIR"/*.yml "$WORKFLOWS_DIR"/*.yaml; do
22
+ [ -f "$w" ] && wf_files="$wf_files
23
+ $w"
24
+ done
25
+ if [ -z "$wf_files" ]; then
26
+ echo "check-test-wiring: no workflow files in $WORKFLOWS_DIR" >&2
27
+ exit 1
28
+ fi
29
+
30
+ total=0
31
+ unwired=0
32
+ for t in "$SCRIPTS_DIR"/*.test.sh; do
33
+ [ -f "$t" ] || continue
34
+ total=$((total+1))
35
+ base=$(basename "$t")
36
+ # Escape '.' for ERE; names are [A-Za-z0-9._-]. Delimiter-aware: the name must not be a
37
+ # fragment of a longer filename (foo.test.sh must not be satisfied by barfoo.test.sh).
38
+ esc=$(printf '%s' "$base" | sed 's/\./\\./g')
39
+ found=0
40
+ while IFS= read -r w; do
41
+ [ -n "$w" ] || continue
42
+ if grep -Eq "(^|[^A-Za-z0-9._-])${esc}([^A-Za-z0-9._-]|\$)" "$w" 2>/dev/null; then found=1; break; fi
43
+ done <<EOF2
44
+ $wf_files
45
+ EOF2
46
+ if [ "$found" -eq 0 ]; then
47
+ echo "UNWIRED: $base is not referenced by any workflow in $WORKFLOWS_DIR"
48
+ unwired=$((unwired+1))
49
+ fi
50
+ done
51
+
52
+ if [ "$unwired" -gt 0 ]; then
53
+ echo "check-test-wiring: $unwired of $total test script(s) unwired — add each to a workflow (e.g. shell-compat.yml)" >&2
54
+ exit 1
55
+ fi
56
+ echo "check-test-wiring: all $total test script(s) wired"
57
+ exit 0
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/check-test-wiring.sh. Run: bash scripts/check-test-wiring.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-test-wiring.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-test-wiring-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+
14
+ # check <want_rc> <want_substring|-> <unwanted_substring|-> <desc> <scripts_dir> <workflows_dir>
15
+ check() {
16
+ want_rc="$1"; want="$2"; unwant="$3"; desc="$4"; sd="$5"; wd="$6"
17
+ out=$(bash "$S" "$sd" "$wd" 2>&1); rc=$?
18
+ ok=1
19
+ if [ "$want" != "-" ]; then case "$out" in *"$want"*) ;; *) ok=0 ;; esac; fi
20
+ if [ "$unwant" != "-" ]; then case "$out" in *"$unwant"*) ok=0 ;; esac; fi
21
+ if [ "$rc" = "$want_rc" ] && [ "$ok" = 1 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (rc=$rc)"; printf '%s\n' "$out" | sed 's/^/ /'; fi
22
+ }
23
+
24
+ mk() { mkdir -p "$T/$1/scripts" "$T/$1/wf"; }
25
+
26
+ # 1. all wired -> pass
27
+ mk ok
28
+ for n in a b c; do : > "$T/ok/scripts/$n.test.sh"; done
29
+ printf 'steps:\n - run: bash scripts/a.test.sh\n - run: |\n /bin/bash scripts/b.test.sh\n bash scripts/c.test.sh\n' > "$T/ok/wf/x.yml"
30
+ check 0 "all 3 test script(s) wired" - "all wired passes" "$T/ok/scripts" "$T/ok/wf"
31
+
32
+ # 2. two+ unwired -> fail, both listed, wired one not listed
33
+ mk bad
34
+ for n in a b c d; do : > "$T/bad/scripts/$n.test.sh"; done
35
+ printf 'run: bash scripts/a.test.sh\n' > "$T/bad/wf/x.yml"
36
+ check 1 "UNWIRED: b.test.sh" "UNWIRED: a.test.sh" "unwired b reported, wired a not" "$T/bad/scripts" "$T/bad/wf"
37
+ check 1 "UNWIRED: c.test.sh" - "unwired c reported too" "$T/bad/scripts" "$T/bad/wf"
38
+ check 1 "UNWIRED: d.test.sh" - "unwired d reported too" "$T/bad/scripts" "$T/bad/wf"
39
+ check 1 "3 of 4" - "summary counts every offender" "$T/bad/scripts" "$T/bad/wf"
40
+
41
+ # 3. substring of a longer name does not satisfy the match
42
+ mk sub
43
+ : > "$T/sub/scripts/foo.test.sh"
44
+ printf 'run: bash scripts/barfoo.test.sh\nrun: bash scripts/foo.test.sh.bak\nrun: bash scripts/foo-test-sh\n' > "$T/sub/wf/x.yml"
45
+ check 1 "UNWIRED: foo.test.sh" - "longer-name substring is not a match" "$T/sub/scripts" "$T/sub/wf"
46
+
47
+ # 4. '.' is literal, not a regex wildcard
48
+ mk dot
49
+ : > "$T/dot/scripts/foo.test.sh"
50
+ printf 'run: bash scripts/fooXtestYsh\n' > "$T/dot/wf/x.yml"
51
+ check 1 "UNWIRED: foo.test.sh" - "dot is not a wildcard" "$T/dot/scripts" "$T/dot/wf"
52
+
53
+ # 5. reference in a second workflow file counts
54
+ mk multi
55
+ : > "$T/multi/scripts/a.test.sh"
56
+ printf 'name: one\n' > "$T/multi/wf/one.yml"
57
+ printf 'run: bash scripts/a.test.sh\n' > "$T/multi/wf/two.yaml"
58
+ check 0 "wired" - "reference in any workflow counts" "$T/multi/scripts" "$T/multi/wf"
59
+
60
+ # 6. empty / missing workflows dir fails
61
+ mk empty
62
+ : > "$T/empty/scripts/a.test.sh"
63
+ check 1 "no workflow files" - "empty workflows dir fails" "$T/empty/scripts" "$T/empty/wf"
64
+ check 1 "workflows dir not found" - "missing workflows dir fails" "$T/empty/scripts" "$T/empty/nope"
65
+
66
+ # 7. missing scripts dir is a usage error
67
+ check 2 "scripts dir not found" - "missing scripts dir fails" "$T/nope" "$T/empty/wf"
68
+
69
+ echo "check-test-wiring.test.sh: passed=$pass failed=$fail"
70
+ [ "$fail" -eq 0 ]
@@ -10,6 +10,7 @@
10
10
  # Usage:
11
11
  # classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
12
12
  # [--lineage none|review-finding] [--text <T> | --text-file <F>]
13
+ # [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]
13
14
  #
14
15
  # --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
15
16
  # unparseable → ISSUE (fail toward filing, never drop).
@@ -27,14 +28,32 @@
27
28
  # classified INPR_FIX — fix it on the PR before merge instead of filing it.
28
29
  # Callers pass these only for the first fix round of an auto-merge review.
29
30
  #
31
+ # --contract-scope <F> --contract-open <F> --file <P> Contract-declared scope gate: F is the TSV
32
+ # from `check-contract-scope.sh list` (disposition<TAB>path<TAB>issue). A finding
33
+ # whose path equals, or sits under, a `deferred` or `accepted-risk` item becomes
34
+ # `NOTE contract-deferred #N` / `NOTE contract-accepted-risk`. `--contract-open`
35
+ # lists the deferred issue numbers confirmed open, one per line; a deferred item
36
+ # demotes only when its number is listed (closed, unreadable or absent: no demotion).
37
+ # The caller lists a number only after verifying it is an open issue (not a PR) whose
38
+ # body carries `FORGE:DEFERRED_FROM: #<merge issue>` and the path.
39
+ # `--merge-issue <N>` is the PR's linked issue: `deferred → #N` never demotes (that
40
+ # issue is always open during review, so it is no follow-up).
41
+ # Never demoted: CRITICAL/HIGH, `not-affected` items, safety-exempt findings
42
+ # (security/billing keywords or a dedicated domain agent) under either disposition, and
43
+ # findings whose path is in `--inpr-diff` or `--pr-files` (this PR touched it; the
44
+ # contract excluded the sibling path, not regressions the PR introduced in it).
45
+ # `--pr-files <F>` is the PR's changed paths, one per line, supplied for this guard
46
+ # alone (it never triggers INPR_FIX). The gate runs after the severity and in-PR gates. Without the flags output is unchanged.
47
+ #
30
48
  # Output (stdout, one line): `ISSUE <reason>`, `NOTE <reason>` or `INPR_FIX <reason>`.
31
49
  # Exit codes: 0 classified, 2 usage error.
32
50
  #
33
51
  # Rules (forge#3060, tightened after the 2026-10-08 cascade audit):
34
52
  # 1. HIGH/CRITICAL, or unparseable severity → ISSUE.
35
53
  # 2. Safety exemption is CONTENT-based: the text matches the security/billing
36
- # keyword set, or the finding came from a dedicated, signal-selected domain
37
- # agent (Auth, Billing, Concurrency, Database) and is MEDIUM+ or CONFIRMED. Origin from the always-on
54
+ # keyword set (a LOW finding additionally needs CONFIRMED confidence), or the
55
+ # finding came from a dedicated, signal-selected domain
56
+ # agent (Auth, Billing, Concurrency, Database) and is MEDIUM. Origin from the always-on
38
57
  # "General Security & Quality" agent alone NO LONGER exempts a finding —
39
58
  # that agent runs on every PR, so origin-based exemption filed nearly every
40
59
  # LOW note it raised (50 of 60 would-be notes in the audited batch).
@@ -57,9 +76,13 @@ LINEAGE="none"
57
76
  TEXT=""
58
77
  INPR_DIFF=""
59
78
  FINDING_FILE=""
79
+ CONTRACT_SCOPE=""
80
+ CONTRACT_OPEN=""
81
+ PR_FILES=""
82
+ MERGE_ISSUE=""
60
83
 
61
84
  usage() {
62
- echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>]" >&2
85
+ echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]" >&2
63
86
  exit 2
64
87
  }
65
88
 
@@ -72,6 +95,10 @@ while [ "$#" -gt 0 ]; do
72
95
  --text) [ "$#" -ge 2 ] || usage; TEXT="$2"; shift 2 ;;
73
96
  --inpr-diff) [ "$#" -ge 2 ] || usage; INPR_DIFF="$2"; shift 2 ;;
74
97
  --file) [ "$#" -ge 2 ] || usage; FINDING_FILE="$2"; shift 2 ;;
98
+ --contract-scope) [ "$#" -ge 2 ] || usage; CONTRACT_SCOPE="$2"; shift 2 ;;
99
+ --contract-open) [ "$#" -ge 2 ] || usage; CONTRACT_OPEN="$2"; shift 2 ;;
100
+ --pr-files) [ "$#" -ge 2 ] || usage; PR_FILES="$2"; shift 2 ;;
101
+ --merge-issue) [ "$#" -ge 2 ] || usage; MERGE_ISSUE="${2#\#}"; shift 2 ;;
75
102
  --text-file)
76
103
  [ "$#" -ge 2 ] || usage
77
104
  [ -r "$2" ] || { echo "ERROR: --text-file not readable: $2" >&2; exit 2; }
@@ -89,6 +116,17 @@ if [ -n "$INPR_DIFF" ] && [ ! -r "$INPR_DIFF" ]; then
89
116
  echo "ERROR: --inpr-diff not readable: $INPR_DIFF" >&2; exit 2
90
117
  fi
91
118
 
119
+ if [ -n "$CONTRACT_SCOPE" ] && [ ! -r "$CONTRACT_SCOPE" ]; then
120
+ echo "ERROR: --contract-scope not readable: $CONTRACT_SCOPE" >&2; exit 2
121
+ fi
122
+ if [ -n "$CONTRACT_OPEN" ] && [ ! -r "$CONTRACT_OPEN" ]; then
123
+ echo "ERROR: --contract-open not readable: $CONTRACT_OPEN" >&2; exit 2
124
+ fi
125
+
126
+ if [ -n "$PR_FILES" ] && [ ! -r "$PR_FILES" ]; then
127
+ echo "ERROR: --pr-files not readable: $PR_FILES" >&2; exit 2
128
+ fi
129
+
92
130
  upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
93
131
  SEV=$(upper "$SEVERITY")
94
132
  CONF=$(upper "$CONFIDENCE")
@@ -109,22 +147,48 @@ if [ -n "$INPR_DIFF" ] && [ -n "$FINDING_FILE" ] && [ "$SEV" = "MEDIUM" ] && [ "
109
147
  fi
110
148
  fi
111
149
 
112
-
113
150
  # Safety exemption. Keywords match whole words, where `_`, `-`, `/` and other
114
151
  # punctuation separate words (so `auth_service` matches, `author` does not).
115
152
  SAFETY=""
116
153
  KW='security|auth|authz|authn|billing|payment|stripe|charge|invoice|injection|xss|csrf|ssrf|idor|secret|secrets|credential|credentials|permission|permissions|sql|token|password|redact'
117
154
  if printf '%s' "$TEXT" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' ' ' | grep -Eqw "($KW)"; then
118
- SAFETY="keyword"
155
+ # A LOW finding needs the keyword AND CONFIRMED; MEDIUM keeps the keyword exemption at any confidence.
156
+ if [ "$SEV" != "LOW" ] || [ "$CONF" = "CONFIRMED" ]; then SAFETY="keyword"; fi
119
157
  fi
120
- # A dedicated domain agent rescues a finding only when it is MEDIUM+ or CONFIRMED:
121
- # a LOW/POSSIBLE finding stays a NOTE whichever reviewer raised it.
158
+ # A dedicated domain agent rescues a MEDIUM finding only: a LOW finding stays a NOTE
159
+ # whichever reviewer raised it, even when CONFIRMED.
122
160
  AGENT_LC=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
123
161
  case "$AGENT_LC" in
124
162
  auth*|billing*|concurrency*|database*)
125
- if [ "$SEV" = "MEDIUM" ] || [ "$CONF" = "CONFIRMED" ]; then SAFETY="${SAFETY:-domain-agent}"; fi ;;
163
+ if [ "$SEV" = "MEDIUM" ]; then SAFETY="${SAFETY:-domain-agent}"; fi ;;
126
164
  esac
127
165
 
166
+ # Contract-declared scope gate (#3447). Fails toward filing: any missing input, closed or unlisted
167
+ # deferred issue, a deferral to the PR's own issue, an in-PR path, or a safety-exempt finding keeps
168
+ # the classification below.
169
+ if [ -n "$CONTRACT_SCOPE" ] && [ -n "$FINDING_FILE" ]; then
170
+ _fpath="${FINDING_FILE#./}"; _fpath="${_fpath%%:*}"
171
+ _inpr=""
172
+ if [ -n "$INPR_DIFF" ] && grep -Fxq -- "$_fpath" "$INPR_DIFF"; then _inpr=1; fi
173
+ if [ -n "$PR_FILES" ] && grep -Fxq -- "$_fpath" "$PR_FILES"; then _inpr=1; fi
174
+ if [ -z "$_inpr" ]; then
175
+ while IFS="$(printf '\t')" read -r _disp _ipath _inum; do
176
+ [ -n "$_ipath" ] || continue
177
+ _ipath="${_ipath#./}"; _ipath="${_ipath%/}"
178
+ [ "$_fpath" = "$_ipath" ] || case "$_fpath" in "$_ipath"/*) ;; *) continue ;; esac
179
+ case "$_disp" in
180
+ deferred)
181
+ if [ -z "$SAFETY" ] && [ -n "$_inum" ] && [ "$_inum" != "$MERGE_ISSUE" ] \
182
+ && [ -n "$CONTRACT_OPEN" ] && grep -Fxq -- "$_inum" "$CONTRACT_OPEN"; then
183
+ echo "NOTE contract-deferred #$_inum"; exit 0
184
+ fi ;;
185
+ accepted-risk)
186
+ if [ -z "$SAFETY" ]; then echo "NOTE contract-accepted-risk"; exit 0; fi ;;
187
+ esac
188
+ done < "$CONTRACT_SCOPE"
189
+ fi
190
+ fi
191
+
128
192
  if [ "$LINEAGE" = "review-finding" ]; then
129
193
  if [ "$SEV" = "MEDIUM" ] && { [ "$CONF" = "CONFIRMED" ] || { [ -n "$SAFETY" ] && [ "$CONF" = "LIKELY" ]; }; }; then
130
194
  echo "ISSUE lineage-medium-${CONF:-UNKNOWN}"
@@ -38,19 +38,27 @@ expect NOTE "LOW from Security agent, no keyword, is a NOTE (audit fix)" -- --s
38
38
  expect NOTE "LOW from General Security & Quality, no keyword" -- --severity LOW --confidence LIKELY --agent "General Security & Quality" --text "duplicated predicate in NOTE count"
39
39
 
40
40
  # --- Safety exemption (content-based) ---
41
- expect ISSUE "LOW with injection keyword" -- --severity LOW --confidence POSSIBLE --text "shell injection via unquoted var"
42
- expect ISSUE "LOW with auth_service path" -- --severity LOW --confidence LIKELY --text "src/auth_service.py:12 missing check"
43
- expect ISSUE "LOW with Secrets (case)" -- --severity LOW --confidence LIKELY --text "Secrets leak to log"
44
- expect ISSUE "LOW with permission-check" -- --severity LOW --confidence LIKELY --text "permission-check skipped"
41
+ expect NOTE "LOW POSSIBLE injection keyword is a NOTE" -- --severity LOW --confidence POSSIBLE --text "shell injection via unquoted var"
42
+ expect NOTE "LOW LIKELY auth_service path is a NOTE" -- --severity LOW --confidence LIKELY --text "src/auth_service.py:12 missing check"
43
+ expect NOTE "LOW LIKELY Secrets (case) is a NOTE" -- --severity LOW --confidence LIKELY --text "Secrets leak to log"
44
+ expect NOTE "LOW LIKELY permission-check is a NOTE" -- --severity LOW --confidence LIKELY --text "permission-check skipped"
45
45
  expect NOTE "'author' is not 'auth'" -- --severity LOW --confidence LIKELY --text "author field unused"
46
46
  expect NOTE "'tokenizer' is not 'token'" -- --severity LOW --confidence LIKELY --text "tokenizer splits oddly"
47
- expect ISSUE "LOW CONFIRMED from Auth domain agent" -- --severity LOW --confidence CONFIRMED --agent "Auth" --text "nit"
47
+ expect NOTE "LOW CONFIRMED from Auth domain agent is a NOTE (no LOW rescue)" -- --severity LOW --confidence CONFIRMED --agent "Auth" --text "nit"
48
48
  expect ISSUE "MEDIUM POSSIBLE from Billing agent" -- --severity MEDIUM --confidence POSSIBLE --agent "billing" --text "nit"
49
49
  expect NOTE "LOW LIKELY from Auth domain agent is a NOTE" -- --severity LOW --confidence LIKELY --agent "Auth" --text "nit"
50
50
  expect NOTE "LOW POSSIBLE from Database agent is a NOTE" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "nit"
51
51
  expect NOTE "LOW POSSIBLE Concurrency reviewer is a NOTE (AlterLab #34671)" -- --severity LOW --confidence POSSIBLE --agent "Concurrency reviewer" --text "lock edge cases"
52
- expect ISSUE "LOW CONFIRMED Concurrency reviewer files" -- --severity LOW --confidence CONFIRMED --agent "Concurrency reviewer" --text "lock edge cases"
53
- expect ISSUE "keyword still rescues LOW POSSIBLE from domain agent" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "sql injection"
52
+ expect NOTE "LOW CONFIRMED Concurrency reviewer is a NOTE" -- --severity LOW --confidence CONFIRMED --agent "Concurrency reviewer" --text "lock edge cases"
53
+ expect NOTE "LOW POSSIBLE domain agent + keyword is a NOTE" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "sql injection"
54
+ # --- LOW needs keyword AND CONFIRMED; domain agents rescue MEDIUM only (#3639) ---
55
+ expect NOTE "LOW LIKELY + keyword is a NOTE" -- --severity LOW --confidence LIKELY --text "shell injection via unquoted var"
56
+ expect ISSUE "LOW CONFIRMED + keyword files" -- --severity LOW --confidence CONFIRMED --text "shell injection via unquoted var"
57
+ expect NOTE "LOW CONFIRMED Auth agent, no keyword" -- --severity LOW --confidence CONFIRMED --agent Auth --text "nit"
58
+ expect ISSUE "MEDIUM POSSIBLE + keyword still files" -- --severity MEDIUM --confidence POSSIBLE --text "sql injection"
59
+ expect ISSUE "MEDIUM POSSIBLE Billing agent still files" -- --severity MEDIUM --confidence POSSIBLE --agent Billing --text "nit"
60
+ expect ISSUE "HIGH POSSIBLE files" -- --severity HIGH --confidence POSSIBLE
61
+
54
62
 
55
63
  # --- Rule 3: review-finding lineage (fix for a finding) ---
56
64
  expect NOTE "lineage LOW CONFIRMED note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "nit"
@@ -63,7 +71,7 @@ expect NOTE "lineage MEDIUM POSSIBLE with keyword note" -- --severity MEDIUM --
63
71
  # --- text-file input ---
64
72
  TMPF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-test.XXXXXX")
65
73
  printf 'Title: unchecked xss sink\n' > "$TMPF"
66
- expect ISSUE "text-file keyword" -- --severity LOW --confidence LIKELY --text-file "$TMPF"
74
+ expect NOTE "text-file keyword, LOW LIKELY, is a NOTE" -- --severity LOW --confidence LIKELY --text-file "$TMPF"
67
75
  rm -f "$TMPF"
68
76
 
69
77
  # --- In-PR fix gate (#3387, narrowed): MEDIUM CONFIRMED in the PR diff ---
@@ -83,6 +91,67 @@ expect ISSUE "prefix of a diff path does not match" -- --severity MEDIUM --con
83
91
  rm -f "$DIFF"
84
92
  expect_exit 2 "unreadable --inpr-diff" -- --severity MEDIUM --inpr-diff /nonexistent/d --file x
85
93
 
94
+ # --- Contract-declared scope gate (#3447) ---
95
+ CSCOPE=$(mktemp "${TMPDIR:-/tmp}/classify-finding-scope.XXXXXX")
96
+ COPEN=$(mktemp "${TMPDIR:-/tmp}/classify-finding-open.XXXXXX")
97
+ TAB=$(printf '\t')
98
+ printf 'deferred%ssrc/sync.sh%s3446\naccepted-risk%ssrc/legacy%s\nnot-affected%ssrc/engine%s\ndeferred%ssrc/closed.py%s99\ndeferred%ssrc/a/b%s3446\n' \
99
+ "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" "$TAB" > "$CSCOPE"
100
+ printf '3446\n' > "$COPEN"
101
+ CS=(--contract-scope "$CSCOPE" --contract-open "$COPEN")
102
+ expect NOTE "deferred + open issue demotes" -- --severity MEDIUM --confidence CONFIRMED --text "sync gap" --file src/sync.sh "${CS[@]}"
103
+ got=$(bash "$S" --severity MEDIUM --confidence CONFIRMED --text "sync gap" --file src/sync.sh "${CS[@]}")
104
+ if [ "$got" = "NOTE contract-deferred #3446" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: deferred reason (got '$got')"; fi
105
+ expect NOTE "file:line form demotes" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/sync.sh:42 "${CS[@]}"
106
+ expect NOTE "./ prefix demotes" -- --severity MEDIUM --confidence LIKELY --text "gap" --file ./src/sync.sh "${CS[@]}"
107
+ expect NOTE "directory prefix demotes accepted-risk" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/legacy/old.py:9 "${CS[@]}"
108
+ got=$(bash "$S" --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/legacy "${CS[@]}")
109
+ if [ "$got" = "NOTE contract-accepted-risk" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: accepted-risk reason (got '$got')"; fi
110
+ expect NOTE "nested deferred path demotes" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/a/b/c.py "${CS[@]}"
111
+ expect ISSUE "sibling with shared name prefix is not under dir" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/legacy2/x.py "${CS[@]}"
112
+ expect ISSUE "unrelated path unchanged" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/other.py "${CS[@]}"
113
+ expect ISSUE "deferred issue closed (not listed) files" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/closed.py "${CS[@]}"
114
+ expect ISSUE "no --contract-open means no deferred demotion" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/sync.sh --contract-scope "$CSCOPE"
115
+ expect NOTE "accepted-risk needs no --contract-open" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/legacy/x.py --contract-scope "$CSCOPE"
116
+ expect ISSUE "not-affected never demotes" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/engine/x.py "${CS[@]}"
117
+ expect ISSUE "CRITICAL never demotes" -- --severity CRITICAL --confidence CONFIRMED --text "gap" --file src/sync.sh "${CS[@]}"
118
+ expect ISSUE "HIGH never demotes" -- --severity HIGH --confidence CONFIRMED --text "gap" --file src/legacy/x.py "${CS[@]}"
119
+ expect ISSUE "accepted-risk + security keyword files" -- --severity MEDIUM --confidence CONFIRMED --text "token leak in logs" --file src/legacy/x.py "${CS[@]}"
120
+ expect ISSUE "accepted-risk + domain agent files" -- --severity MEDIUM --confidence CONFIRMED --agent Billing --text "nit" --file src/legacy/x.py "${CS[@]}"
121
+ expect ISSUE "deferred + security keyword files" -- --severity MEDIUM --confidence CONFIRMED --text "token leak" --file src/sync.sh "${CS[@]}"
122
+ expect ISSUE "deferred + SQL injection keywords files" -- --severity MEDIUM --confidence CONFIRMED --text "SQL injection token password" --file src/sync.sh "${CS[@]}"
123
+ expect ISSUE "deferred + domain agent files" -- --severity MEDIUM --confidence CONFIRMED --agent Auth --text "nit" --file src/sync.sh "${CS[@]}"
124
+ expect ISSUE "deferred to own issue never demotes" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/sync.sh --merge-issue 3446 "${CS[@]}"
125
+ expect ISSUE "deferred to own issue (#N form) files" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/sync.sh --merge-issue "#3446" "${CS[@]}"
126
+ expect NOTE "deferred to another issue still demotes" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/sync.sh --merge-issue 3447 "${CS[@]}"
127
+ expect NOTE "LOW LIKELY keyword on deferred path demotes (#3639)" -- --severity LOW --confidence LIKELY --text "token leak" --file src/sync.sh "${CS[@]}"
128
+ got=$(bash "$S" --severity LOW --confidence LIKELY --text "token leak" --file src/sync.sh "${CS[@]}")
129
+ if [ "$got" = "NOTE contract-deferred #3446" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: LOW LIKELY keyword deferred reason (got '$got')"; fi
130
+ expect ISSUE "LOW CONFIRMED keyword on deferred path still files (#3639)" -- --severity LOW --confidence CONFIRMED --text "token leak" --file src/sync.sh "${CS[@]}"
131
+ expect NOTE "accepted-risk unaffected by --merge-issue" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/legacy/x.py --merge-issue 3446 "${CS[@]}"
132
+ DIFF2=$(mktemp "${TMPDIR:-/tmp}/classify-finding-diff.XXXXXX")
133
+ printf 'src/sync.sh\nsrc/legacy/touched.py\n' > "$DIFF2"
134
+ expect ISSUE "path in PR diff is not demoted (deferred)" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/sync.sh --inpr-diff "$DIFF2" "${CS[@]}"
135
+ expect ISSUE "path in PR diff is not demoted (accepted-risk)" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/legacy/touched.py --inpr-diff "$DIFF2" "${CS[@]}"
136
+ expect NOTE "untouched path under accepted dir demotes with diff given" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/legacy/other.py --inpr-diff "$DIFF2" "${CS[@]}"
137
+ rm -f "$DIFF2"
138
+ PRF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-prf.XXXXXX")
139
+ printf 'src/sync.sh\nsrc/legacy/touched.py\n' > "$PRF"
140
+ expect ISSUE "path in --pr-files is not demoted (deferred)" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/sync.sh --pr-files "$PRF" "${CS[@]}"
141
+ expect ISSUE "path in --pr-files is not demoted (accepted-risk)" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/legacy/touched.py --pr-files "$PRF" "${CS[@]}"
142
+ expect NOTE "untouched path demotes with --pr-files given" -- --severity MEDIUM --confidence LIKELY --text "gap" --file src/legacy/other.py --pr-files "$PRF" "${CS[@]}"
143
+ expect ISSUE "--pr-files alone never triggers INPR_FIX" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/other.py --pr-files "$PRF"
144
+ rm -f "$PRF"
145
+ expect_exit 2 "unreadable --pr-files" -- --severity MEDIUM --pr-files /nonexistent/p --file x
146
+ expect ISSUE "no --file: no contract demotion" -- --severity MEDIUM --confidence CONFIRMED --text "gap" "${CS[@]}"
147
+ expect ISSUE "no contract flags: unchanged" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/sync.sh
148
+ expect NOTE "LOW stays NOTE through the gate" -- --severity LOW --confidence CONFIRMED --text "gap" --file src/other.py "${CS[@]}"
149
+ : > "$CSCOPE"
150
+ expect ISSUE "empty scope file: unchanged" -- --severity MEDIUM --confidence CONFIRMED --text "gap" --file src/sync.sh "${CS[@]}"
151
+ rm -f "$CSCOPE" "$COPEN"
152
+ expect_exit 2 "unreadable --contract-scope" -- --severity MEDIUM --contract-scope /nonexistent/s --file x
153
+ expect_exit 2 "unreadable --contract-open" -- --severity MEDIUM --contract-open /nonexistent/o --file x
154
+
86
155
  # --- usage errors ---
87
156
  expect_exit 2 "unknown flag" -- --bogus x
88
157
  expect_exit 2 "bad lineage" -- --severity LOW --lineage P3
@@ -30,28 +30,28 @@ C17=$(sed -n '/^## Phase C1.7/,/^## Phase C1.5/p' "$CLOSE_MD")
30
30
  if printf '%s\n' "$C17" | grep -nE '^\s*cd "\$\{REPO_PATH\}"' >/dev/null; then
31
31
  bad "C1.7 changes directory into REPO_PATH"
32
32
  else ok "C1.7 does not cd into REPO_PATH"; fi
33
- printf '%s\n' "$C17" | grep -q 'Phase C1.7: skipped' && ok "C1.7 logs skip reasons" || bad "C1.7 missing 'Phase C1.7: skipped'"
34
- printf '%s\n' "$C17" | grep -q 'worktree add --detach' && ok "C1.7 uses a temporary detached worktree" || bad "C1.7 missing temp worktree"
35
- printf '%s\n' "$C17" | grep -q 'gh pr create' && ok "C1.7 opens a PR" || bad "C1.7 missing gh pr create"
33
+ grep -q 'Phase C1.7: skipped' <<< "$C17" && ok "C1.7 logs skip reasons" || bad "C1.7 missing 'Phase C1.7: skipped'"
34
+ grep -q 'worktree add --detach' <<< "$C17" && ok "C1.7 uses a temporary detached worktree" || bad "C1.7 missing temp worktree"
35
+ grep -q 'gh pr create' <<< "$C17" && ok "C1.7 opens a PR" || bad "C1.7 missing gh pr create"
36
36
  [ "$(printf '%s\n' "$C17" | grep -c '^```bash')" -eq 1 ] && ok "C1.7 is a single bash block (no cross-block shell state)" || bad "C1.7 must be exactly one bash block"
37
- printf '%s\n' "$C17" | grep -qE "^\s*trap .*EXIT" && ok "C1.7 registers an EXIT trap" || bad "C1.7 missing EXIT trap"
38
- printf '%s\n' "$C17" | grep -q 'worktree remove --force' && ok "C1.7 cleanup removes the worktree" || bad "C1.7 cleanup missing worktree remove"
37
+ grep -qE "^\s*trap .*EXIT" <<< "$C17" && ok "C1.7 registers an EXIT trap" || bad "C1.7 missing EXIT trap"
38
+ grep -q 'worktree remove --force' <<< "$C17" && ok "C1.7 cleanup removes the worktree" || bad "C1.7 cleanup missing worktree remove"
39
39
  ADD_LN=$(printf '%s\n' "$C17" | grep -n 'worktree add --detach' | head -1 | cut -d: -f1)
40
40
  TRAP_LN=$(printf '%s\n' "$C17" | grep -nE "^\s*trap .*EXIT" | head -1 | cut -d: -f1)
41
41
  [ -n "$ADD_LN" ] && [ -n "$TRAP_LN" ] && [ "$TRAP_LN" -gt "$ADD_LN" ] && ok "EXIT trap registered after worktree add" || bad "EXIT trap must follow worktree add"
42
- printf '%s\n' "$C17" | grep -q 'force-with-lease' && ok "C1.7 pushes with --force-with-lease" || bad "C1.7 missing force-with-lease"
43
- printf '%s\n' "$C17" | grep -qE 'push .*--force( |$)' && bad "C1.7 uses bare --force" || ok "C1.7 has no bare --force"
44
- printf '%s\n' "$C17" | grep -q 'gh pr list' && ok "C1.7 checks for an existing open PR" || bad "C1.7 missing gh pr list"
42
+ grep -q 'force-with-lease' <<< "$C17" && ok "C1.7 pushes with --force-with-lease" || bad "C1.7 missing force-with-lease"
43
+ grep -qE 'push .*--force( |$)' <<< "$C17" && bad "C1.7 uses bare --force" || ok "C1.7 has no bare --force"
44
+ grep -q 'gh pr list' <<< "$C17" && ok "C1.7 checks for an existing open PR" || bad "C1.7 missing gh pr list"
45
45
  grep -q 'FORGE:DOSSIER_UPDATED' "$CLOSE_MD" && ok "FORGE:DOSSIER_UPDATED marker kept" || bad "FORGE:DOSSIER_UPDATED marker missing"
46
46
 
47
47
  # --- Dynamic: execute the REAL C1.7 block extracted from close.md (gh stubbed) ---
48
48
  BLOCK=$(printf '%s\n' "$C17" | awk '/^```bash/{f=1;next} /^```/{f=0} f')
49
49
  if [ -z "$BLOCK" ]; then
50
50
  bad "could not extract the C1.7 bash block"
51
- elif ! command -v perl >/dev/null 2>&1 || ! yq --version 2>&1 | grep -q 'mikefarah'; then
51
+ elif ! command -v perl >/dev/null 2>&1 || ! grep -q 'mikefarah' <<< "$(yq --version 2>&1)"; then
52
52
  bad "dynamic section needs perl and mikefarah yq v4 (not silently skipped)"
53
53
  else
54
- printf '%s\n' "$BLOCK" | grep -q '^_dossier_run()' && printf '%s\n' "$BLOCK" | grep -q '^_dossier_cleanup()' \
54
+ grep -q '^_dossier_run()' <<< "$BLOCK" && grep -q '^_dossier_cleanup()' <<< "$BLOCK" \
55
55
  && ok "extracted block defines _dossier_run and _dossier_cleanup" || bad "extracted block missing _dossier_run/_dossier_cleanup"
56
56
 
57
57
  # Substitute only the exact {NAME} placeholders; leave ${NAME} shell expansions intact.
@@ -78,7 +78,7 @@ case "$1 $2" in
78
78
  printf '<!-- FORGE:BUILDER -->\n### Changes\n- `scripts/foo.sh` changed\n### Next\n' ;;
79
79
  "pr view") echo "Test PR title" ;;
80
80
  "pr list")
81
- echo "pr-list worktrees: $(git -C "$REPO_PATH" worktree list | wc -l)" >> "$GH_LOG"
81
+ echo "pr-list worktrees: $(git -C "$REPO_PATH" worktree list | wc -l | tr -d ' ')" >> "$GH_LOG"
82
82
  [ "${GH_KILL_ON_PR_LIST:-}" = 1 ] && kill -TERM "$(cat "$PIDF")" && sleep 1
83
83
  echo "${GH_PR_LIST_URL:-}" ;;
84
84
  "pr create") [ "${GH_FAIL_PR_CREATE:-}" = 1 ] && exit 1; echo "https://example.invalid/o/r/pull/9" ;;
@@ -107,7 +107,7 @@ run_block; RC=$?
107
107
  [ "$RC" -eq 0 ] && ok "A: real block exits 0" || bad "A: real block exit $RC"
108
108
  main_untouched && ok "A: main checkout untouched (HEAD, branch, clean tree)" || bad "A: main checkout modified"
109
109
  git -C "$TMP/origin.git" rev-parse -q --verify refs/heads/docs/dossier-1 >/dev/null && ok "A: dossier branch pushed to origin" || bad "A: dossier branch not pushed"
110
- git -C "$TMP/origin.git" show refs/heads/docs/dossier-1:devdocs/modules/m.md 2>/dev/null | grep -q '^## Entry' && ok "A: dossier entry appended on pushed branch" || bad "A: no entry on pushed branch"
110
+ grep -q '^## Entry' <<< "$(git -C "$TMP/origin.git" show refs/heads/docs/dossier-1:devdocs/modules/m.md 2>/dev/null)" && ok "A: dossier entry appended on pushed branch" || bad "A: no entry on pushed branch"
111
111
  grep -q '^gh pr create' "$GH_LOG" && ok "A: gh pr create called" || bad "A: gh pr create not called"
112
112
  grep -q 'FORGE:DOSSIER_UPDATED' "$GH_LOG" && ok "A: FORGE:DOSSIER_UPDATED comment posted" || bad "A: no FORGE:DOSSIER_UPDATED comment"
113
113
  no_leak && ok "A: temporary worktree removed" || bad "A: temporary worktree leaked"
@@ -108,8 +108,9 @@ OUT="$(bash "$S" --repo-path "$TMP/w" 2>/dev/null)"; rc=$?
108
108
  { [ "$rc" = 2 ] && [ -z "$OUT" ]; } && ok || bad "missing base (rc=$rc)"
109
109
 
110
110
  # SEC-1: commits landing on the base after the branch point are not counted
111
- reset; git checkout -q staging; gen 3000 > base-only.txt; git add base-only.txt; git commit -qm "base moves"
112
- git push -q origin staging 2>/dev/null; git checkout -q feat; gen 10 > a.txt; git add a.txt
111
+ reset; git checkout -q staging; git reset -q --hard origin/staging; gen 3000 > base-only.txt; git add base-only.txt; git commit -qm "base moves"
112
+ git push -q origin staging 2>/dev/null && ok || bad "base-moves push rejected"
113
+ git checkout -q feat; gen 10 > a.txt; git add a.txt
113
114
  OUT="$(run --threshold 100)"
114
115
  check "moving base lines" "$OUT" diff_lines 10; check "moving base over" "$OUT" over false
115
116
 
@@ -47,6 +47,8 @@
47
47
  # I7 Orphaned worktree branches (local branches fix/* with no open PR)
48
48
  # I8 FORGE-referenced gists with public visibility (KNOWLEDGE_GIST, MILESTONE_INDEX,
49
49
  # PRIOR_GIST annotations in recent issue comments)
50
+ # I9 Issues stuck in workflow:remediating beyond threshold (the state must
51
+ # never outlive its remediation run)
50
52
  #
51
53
  # /pipeline-health should consume this script's JSON output rather than
52
54
  # re-discovering state from scratch.
@@ -322,6 +324,39 @@ while IFS= read -r row; do
322
324
  fi
323
325
  done < <(echo "$IN_REVIEW" | jq -c '.[]')
324
326
 
327
+ # ---------------------------------------------------------------------------
328
+ # I9: Issues stuck in workflow:remediating
329
+ # ---------------------------------------------------------------------------
330
+ # The review handoff sets workflow:remediating until remediate Phase M1 swaps it
331
+ # for workflow:in-review; it must never outlive the run. Same fail-inconclusive
332
+ # pattern as I1-I3: a failed gh call is not treated as an empty list.
333
+ if ! REMEDIATING=$(gh issue list $GH_FLAG \
334
+ --state open --label "workflow:remediating" \
335
+ --limit 100 \
336
+ --json number,title,updatedAt,labels 2>"$GH_STDERR_TMP"); then
337
+ echo "WARNING: gh issue list failed for workflow:remediating — I9 check inconclusive, skipping (not treated as empty): $(cat "$GH_STDERR_TMP")" >&2
338
+ add_skip "I9" "all" "gh issue list failed: $(bounded_reason "$(cat "$GH_STDERR_TMP")")"
339
+ REMEDIATING="[]"
340
+ fi
341
+
342
+ while IFS= read -r row; do
343
+ num=$(echo "$row" | jq -r '.number')
344
+ labels=$(echo "$row" | jq -c '[.labels[].name]')
345
+ if has_terminal_label "$labels"; then
346
+ continue
347
+ fi
348
+ updated=$(echo "$row" | jq -r '.updatedAt')
349
+ hours=$(hours_since "$updated")
350
+ if [ "$hours" -ge "$STUCK_HOURS" ]; then
351
+ add_finding \
352
+ "stuck_remediating" "warning" \
353
+ "$num" "workflow:remediating" "$hours" \
354
+ "FORGE:REMEDIATION" \
355
+ "/work-on $num --resume" \
356
+ "Issue #$num stuck in workflow:remediating for ${hours}h (threshold: ${STUCK_HOURS}h). The state must not outlive its remediation run. Resume with /work-on $num --resume"
357
+ fi
358
+ done < <(echo "$REMEDIATING" | jq -c '.[]')
359
+
325
360
  # ---------------------------------------------------------------------------
326
361
  # I4: Issues in workflow:building with no FORGE:BUILDER annotation
327
362
  # ---------------------------------------------------------------------------
@@ -167,6 +167,9 @@ render_template() {
167
167
 
168
168
  <!-- INVESTIGATION:COMPLETE -->'
169
169
  ;;
170
+ # OUT_OF_SCOPE format (forge#3447): the value is `None.` (the default) or typed bullets, one per item:
171
+ # - `path` — deferred → #N: <why> | not-affected: <evidence> | accepted-risk: <reason>
172
+ # scripts/check-contract-scope.sh validates and lists them. The codec does not validate.
170
173
  CONTRACT)
171
174
  body='<!-- FORGE:CONTRACT -->
172
175
  ## Builder Contract