amsd-pipeline-shared 0.5.22 → 0.5.23

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.
@@ -241,3 +241,6 @@ a7d52fab41432240f1e2dbffd6710ea5 scripts/lib/project-prompt-builder.js
241
241
  dea5be1123db8ab5b054fef8cc1388fe scripts/mint-agents-step.js
242
242
  eb4979b0f88ed8449b2e4702d0dff7a5 scripts/lib/prompt-variant.sh
243
243
  d4992178558476af4c4c228e6aa63b2d scripts/lib/mint-and-spec.sh
244
+ fac1b26443bb52ba45ba072f4d654920 scripts/lib/story-guards.sh
245
+ ada237373615a26d924226184ed7614b scripts/lib/prd-change-review.sh
246
+ c2dfd2381db0242fdbc487a948fd7feb scripts/lib/agent-si.js
@@ -153,6 +153,7 @@ scripts/lib/agent-contract.js
153
153
  scripts/lib/agent-ladder.sh
154
154
  scripts/lib/agent-messages.js
155
155
  scripts/lib/agent-si.js
156
+ scripts/lib/agent-si.js
156
157
  scripts/lib/checkpoints.sh
157
158
  scripts/lib/codeline-lanes.sh
158
159
  scripts/lib/codeline-packages.js
@@ -222,6 +223,7 @@ scripts/lib/pipeline-tools.js
222
223
  scripts/lib/plan-hash.js
223
224
  scripts/lib/plan-of-record.sh
224
225
  scripts/lib/plan-scope.js
226
+ scripts/lib/prd-change-review.sh
225
227
  scripts/lib/preflight.sh
226
228
  scripts/lib/prelaunch.js
227
229
  scripts/lib/producers/fix-plan.js
@@ -244,6 +246,7 @@ scripts/lib/self-heal.js
244
246
  scripts/lib/stage-coverage-gate.sh
245
247
  scripts/lib/story-acs-block.sh
246
248
  scripts/lib/story-attempt.sh
249
+ scripts/lib/story-guards.sh
247
250
  scripts/lib/story-outputs.sh
248
251
  scripts/lib/story-retry-state.sh
249
252
  scripts/lib/tool-timeouts.js
@@ -303,6 +303,12 @@ _run_agent_mint() {
303
303
  fi
304
304
  fi
305
305
  [ "${EPAM_SKIP_AGENT_MINT:-0}" = "1" ] || {
306
+ # A FULL MINT STARTS FROM A CLEAN SLATE (B49; operator, 2026-10-22: "Fix critical"): the roster it replaces is cleared first, as
307
+ # the deferred decision's own clean slate does, so the mint's own seams take their base SI (lib/agent-si.js: the base is for the
308
+ # seams that run before the roster exists). A fresh install's committed roster has no agent-mint entry, and a full mint
309
+ # (EPAM_REGENERATE_CODELINE_ASSETS=1) was refused at the hub. The codeline's recorded roster (.prompt-cache/<codeline>/minted) is
310
+ # not touched.
311
+ codeline_minted_clear
306
312
  log "[mint] Minting project agents and assigning roles..."
307
313
  "$NODE_BIN" "$SCRIPT_DIR/mint-agents-step.js" \
308
314
  --prd "$_prd" \
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env bash
2
+ # prd-change-review.sh — moved verbatim out of claude.sh by tools/split-main-into-modules.py
3
+ # (7 functions). Sourced by claude.sh; SCRIPT_DIR and the globals it sets
4
+ # are in scope exactly as they were. A move, not an edit: every body is byte-identical to
5
+ # the golden recorded at the move (see the identity test).
6
+
7
+ # run_prd_change_reviewer <story_id> <change_type> <before_json> <after_json>
8
+ # Validates a proposed PRD AC/TC or profiles.json change using the gate model.
9
+ # change_type: ac_patch | tc_patch | skill_note | profile_addendum
10
+ # Echoes "pass" or "fail"; caller decides whether to revert on fail.
11
+ # Silently returns "pass" if gate model is not configured (non-blocking).
12
+ run_prd_change_reviewer() {
13
+ local story_id="$1"
14
+ local change_type="$2"
15
+ local before_json="$3"
16
+ local after_json="$4"
17
+
18
+ local gate_provider="${ORCH_GATE_PROVIDER:-}"
19
+ if [ -z "$gate_provider" ]; then
20
+ # A GATE THAT CANNOT JUDGE DOES NOT PASS. This emitted a literal "pass", so with no gate
21
+ # provider configured every KB/PRD/profile write was AUTO-APPROVED and the caller read a
22
+ # manufactured verdict as a real one — indistinguishable from a review that ran and found
23
+ # nothing wrong.
24
+ #
25
+ # 'unreviewed' is the honest answer and callers already understand it:
26
+ # reviewOutcomeKeepsChange() accepts only an explicit pass, so the change reverts rather
27
+ # than standing on a judgement nobody made.
28
+ error " [PRD-ChangeReviewer] no gate provider configured — NOT reviewing; returning 'unreviewed' so the change is not kept on an unmade judgement" >&2
29
+ echo "unreviewed"
30
+ return 0
31
+ fi
32
+ # KB/PRD/profile writes are persistent and must be reviewed by the highest-quality
33
+ # model available, not the cheap gate model. Use ESCALATION_MODEL_HIGH with high
34
+ # reasoning so every persisted write is agentic-quality-reviewed.
35
+ # THE REVIEWER'S OWN SEAM. This was a run-wide "high" model behind a run-wide pin behind a
36
+ # vendor literal — three sources, none of them the ladder, and the literal always answered so
37
+ # the ladder never had to.
38
+ local gate_model
39
+ gate_model=$(seam_model_or_fail "prd-change-reviewer" 2>/dev/null || printf '')
40
+
41
+ # Select profile based on change type — KB entries use the stricter kb-change-reviewer
42
+ local _profile_key="prd-change-reviewer"
43
+ [ "$change_type" = "kb_entry" ] && _profile_key="kb-change-reviewer"
44
+ local reviewer_profile=""
45
+ if [ -f "$profiles_file" ]; then
46
+ reviewer_profile=$(require_profile "$_profile_key" "$profiles_file" || true)
47
+ fi
48
+
49
+ local review_prompt
50
+ # RENDERED FROM THE TEMPLATE LAYER. Values go via a FILE, never argv: before/after carry
51
+ # whole PRD fragments, and a value past ARG_MAX exits 126 with an empty result — which is
52
+ # how the FailureAnalyst died silently earlier today.
53
+ local _rv_vals; _rv_vals=$(mktemp "${TMPDIR:-/tmp}/prd-review-vals-XXXXXX.json")
54
+ jq_vals --arg profile "$reviewer_profile" --arg story "$story_id" --arg ct "$change_type" \
55
+ --rawfile before <(printf '%s' "$before_json") --rawfile after <(printf '%s' "$after_json") \
56
+ '{"__REVIEWER_PROFILE__":$profile,"__STORY_ID__":$story,"__CHANGE_TYPE__":$ct,"__BEFORE__":$before,"__AFTER__":$after}' \
57
+ > "$_rv_vals" 2>/dev/null
58
+ if ! review_prompt=$(render_engine_prompt prd-change-reviewer "$_rv_vals"); then
59
+ # >&2 REQUIRED: this function returns its verdict on STDOUT, so any log written to
60
+ # stdout is read as part of the verdict. There is a test for exactly this.
61
+ error " [PRD-Reviewer] cannot render its prompt — refusing to review with no instructions" >&2
62
+ # The honest verdict, as with no gate provider: nobody judged it. Returning nothing let every caller that
63
+ # tested only for "fail" keep the change — a self-heal tool was written with a .reviewed marker this way.
64
+ rm -f "$_rv_vals"; echo "unreviewed"; return 1
65
+ fi
66
+ rm -f "$_rv_vals"
67
+
68
+ local review_raw=""
69
+ # Full agent audit, 2026-07-31 (same class as HEAL-BLIND): several of this
70
+ # reviewer's own rejection rules require checking a claim against the real
71
+ # codebase (stack/tech usage, TC-fact verifiability), but this call had no
72
+ # tool access at all — a live incident already occurred (rejected correct
73
+ # Contentstack advice for Metrolinx with no way to check the real stack).
74
+ # Reuses the same shared, read-only allowlist every other gate agent draws
75
+ # from, bounded the same way.
76
+ review_raw=$(echo "$review_prompt" | \
77
+ EPAM_AGENT_NAME="prd-change-reviewer" EPAM_STORY_ID="${story_id}" \
78
+ AI_PROVIDER="$gate_provider" \
79
+ AI_MODEL="$gate_model" \
80
+ EPAM_CLI="${EPAM_CLI:-}" \
81
+ EPAM_REASONING_EFFORT="high" \
82
+ EPAM_TEMPERATURE="0.7" \
83
+ AI_GATE_ALLOW_TOOLS=1 \
84
+ EPAM_ALLOWED_TOOLS="${ORCH_GATE_ALLOWED_TOOLS:-}" \
85
+ EPAM_MAX_TOOL_CALLS="${PRD_CHANGE_REVIEWER_MAX_TOOL_CALLS:-24}" \
86
+ bash "${AI_RUNNER_CMD:-$SCRIPT_DIR/ai-run.sh}" --provider "$gate_provider" \
87
+ ${gate_model:+--model "$gate_model"} \
88
+ 2>/dev/null || echo '{"verdict":"fail","issues":["the reviewer could not be reached — the change was NOT reviewed"],"reason":"reviewer unavailable"}')
89
+
90
+ # ASKED AND GOT NO ANSWER IS NOT APPROVAL.
91
+ #
92
+ # These defaulted to 'pass' in four places, including an explicit
93
+ # {"verdict":"pass","reason":"reviewer unavailable"} above — a reviewer that could not be
94
+ # reached approved the change and said so in the reason field. This gate covers ac_patch and
95
+ # tc_patch, so that silently accepted edits to acceptance criteria, which are supposed to be
96
+ # immutable. code-review-cycle.sh settled the same question on 2026-07-23: 'SAFE default =
97
+ # BLOCK, never silently approve an unreviewed change'.
98
+ #
99
+ # The documented opt-out above is untouched: with NO gate model configured the reviewer is
100
+ # disabled and returns pass, because it was never asked. That is a different state from
101
+ # asking and getting nothing back.
102
+ local verdict=""
103
+ verdict=$(echo "$review_raw" | python3 "$SCRIPT_DIR/lib/handlers/prd-change-verdict.py" 2>/dev/null || echo "fail")
104
+
105
+ local issues=""
106
+ issues=$(echo "$review_raw" | python3 "$SCRIPT_DIR/lib/handlers/prd-change-issues.py" 2>/dev/null || echo "")
107
+
108
+ # CRITICAL: this function's return value is captured via command substitution
109
+ # ($(run_prd_change_reviewer ...)). warning()/log() write to stdout as well as
110
+ # the progress log, so any call to them here would pollute the captured verdict
111
+ # with extra lines — making `[ "$verdict" = "fail" ]` at every call site always
112
+ # false (the string would be "warning text\nfail", not "fail"), silently treating
113
+ # every rejection as an approval. Redirect to stderr so only the final echo
114
+ # reaches the caller.
115
+ # Exposes the rejection reason to callers (e.g. run_change_with_reviewer_retry).
116
+ # Every caller invokes this function via $(...) command substitution, which
117
+ # forks a subshell — a plain variable assignment here (PRD_REVIEW_ISSUES=...)
118
+ # would never be visible to the caller's shell. A file survives the subshell
119
+ # exit, so use that instead. $$ scopes the file to this process (worktree
120
+ # primary/independent run as separate PIDs, so no cross-process collision).
121
+ printf '%s' "$issues" > "${TMPDIR:-/tmp}/.prd-review-issues-$$" 2>/dev/null || true
122
+
123
+ if [ "$verdict" = "fail" ]; then
124
+ warning " [PRD-Reviewer] REJECTED ${change_type} for ${story_id}: ${issues:-no details}" >&2
125
+ echo "fail"
126
+ else
127
+ log " [PRD-Reviewer] APPROVED ${change_type} for ${story_id}" >&2
128
+ echo "pass"
129
+ fi
130
+ }
131
+
132
+ # run_prd_change_summarizer <story_id> <change_type> <issues> <rejected_text>
133
+ # Rewrites rejected self-heal text to address the reviewer's stated issues instead
134
+ # of discarding it outright. Most kb_entry/skill_note rejections are FORMAT problems
135
+ # (over 200 chars, wrong verb tense, references a specific story ID, truncated
136
+ # mid-sentence) — the underlying lesson is usually sound, only its shape is wrong.
137
+ # Prints the reformatted text to stdout (falls back to the original text if the
138
+ # gate model is unavailable or returns nothing usable).
139
+ run_prd_change_summarizer() {
140
+ local story_id="$1"
141
+ local change_type="$2"
142
+ local issues="$3"
143
+ local rejected_text="$4"
144
+
145
+ # THE SAME CHAIN ITS FAMILY USES. ac-gate.js, codeline-discovery.js and cpa-inference.js all
146
+ # consult EPAM_ORCHESTRATION_PROVIDER before giving up; this one read ORCH_GATE_PROVIDER alone,
147
+ # so a run that set the orchestration provider and not the gate provider lost EVERY rewrite.
148
+ local gate_provider="${ORCH_GATE_PROVIDER:-${EPAM_ORCHESTRATION_PROVIDER:-}}"
149
+ if [ -z "$gate_provider" ]; then
150
+ # DEGRADING IS FINE; DEGRADING IN SILENCE IS NOT. This returned the rejected text with no
151
+ # diagnostic and exit 0, and the caller assigns the result as the rewritten value
152
+ # (current=$(run_prd_change_summarizer ...)) — so the content a reviewer had just rejected
153
+ # flowed onward as though it had been fixed. The text is still returned, because the caller
154
+ # must not be left with nothing; what changes is that the skip is now visible.
155
+ error " [PRD-Summarizer] no provider resolved (ORCH_GATE_PROVIDER and EPAM_ORCHESTRATION_PROVIDER are both unset) — skipping the rewrite; the REJECTED text is being returned unchanged" >&2
156
+ printf '%s' "$rejected_text"
157
+ return 0
158
+ fi
159
+ # Summarizer rewrites rejected KB/PRD/profile writes — must use the same
160
+ # high-quality model as the reviewer so the rewrite is meaningfully better.
161
+ # THE REVIEWER'S OWN SEAM. This was a run-wide "high" model behind a run-wide pin behind a
162
+ # vendor literal — three sources, none of them the ladder, and the literal always answered so
163
+ # the ladder never had to.
164
+ local gate_model
165
+ gate_model=$(seam_model_or_fail "prd-change-reviewer" 2>/dev/null || printf '')
166
+
167
+ # tool_creation rewrites a bash script, not a short prose rule — the
168
+ # kb_entry/skill_note constraints (single line, under 200 chars, imperative
169
+ # verb) would corrupt working code. Branch the prompt AND the post-processing
170
+ # (no `tr -d '\n'` — a script needs its newlines) by change type.
171
+ local summarize_prompt output_cap _sum_template
172
+ if [ "$change_type" = "tool_creation" ]; then
173
+ _sum_template="prd-change-summarizer-tool"
174
+ output_cap=4000
175
+ else
176
+ _sum_template="prd-change-summarizer-text"
177
+ output_cap=400
178
+ fi
179
+
180
+ # One renderer for both variants; the branch above chose WHICH prompt, not its text.
181
+ local _sum_vals; _sum_vals=$(mktemp "${TMPDIR:-/tmp}/prd-sum-vals-XXXXXX.json")
182
+ # Each variant gets exactly the values ITS template uses. The renderer rejects a value
183
+ # nobody uses, deliberately: an extra value means the caller believes it supplied something
184
+ # the prompt never mentions, which is the same defect as a missing one seen from the other
185
+ # side. The tool variant carries no change type — a bash script is a bash script.
186
+ if [ "$_sum_template" = "prd-change-summarizer-tool" ]; then
187
+ jq_vals --arg story "$story_id" \
188
+ --rawfile issues <(printf '%s' "${issues:-no details}") \
189
+ --rawfile rejected <(printf '%s' "$rejected_text") \
190
+ '{"__STORY_ID__":$story,"__ISSUES__":$issues,"__REJECTED_TEXT__":$rejected}' \
191
+ > "$_sum_vals" 2>/dev/null
192
+ else
193
+ jq_vals --arg story "$story_id" --arg ct "$change_type" \
194
+ --rawfile issues <(printf '%s' "${issues:-no details}") \
195
+ --rawfile rejected <(printf '%s' "$rejected_text") \
196
+ '{"__STORY_ID__":$story,"__CHANGE_TYPE__":$ct,"__ISSUES__":$issues,"__REJECTED_TEXT__":$rejected}' \
197
+ > "$_sum_vals" 2>/dev/null
198
+ fi
199
+ if ! summarize_prompt=$(render_engine_prompt "$_sum_template" "$_sum_vals"); then
200
+ # >&2 REQUIRED: the caller captures this function with command substitution
201
+ # (current=$(run_prd_change_summarizer ...)), so a log on stdout becomes the rewritten
202
+ # text. Same hazard as the reviewer above.
203
+ error " [PRD-Summarizer] cannot render '$_sum_template' — refusing to rewrite with no instructions" >&2
204
+ rm -f "$_sum_vals"; return 1
205
+ fi
206
+ rm -f "$_sum_vals"
207
+
208
+ local summarized=""
209
+ summarized=$(echo "$summarize_prompt" | \
210
+ EPAM_AGENT_NAME="prd-change-summarizer" EPAM_STORY_ID="${story_id}" \
211
+ AI_PROVIDER="$gate_provider" \
212
+ AI_MODEL="$gate_model" \
213
+ EPAM_CLI="${EPAM_CLI:-}" \
214
+ EPAM_REASONING_EFFORT="high" \
215
+ EPAM_TEMPERATURE="0.7" \
216
+ bash "${AI_RUNNER_CMD:-$SCRIPT_DIR/ai-run.sh}" --provider "$gate_provider" \
217
+ ${gate_model:+--model "$gate_model"} \
218
+ 2>/dev/null | head -c "$output_cap" || echo "")
219
+ if [ "$change_type" = "tool_creation" ]; then
220
+ summarized="$(echo "$summarized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
221
+ else
222
+ summarized="$(echo "$summarized" | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
223
+ fi
224
+
225
+ if [ -n "$summarized" ]; then
226
+ printf '%s' "$summarized"
227
+ else
228
+ printf '%s' "$rejected_text"
229
+ fi
230
+ }
231
+
232
+ # _skill_note_format_ok <note> <story_id> <existing_profile_text>
233
+ # Deterministic pre-check for the OBJECTIVELY verifiable skill_note/kb_entry
234
+ # format rules already stated in profiles.json's prd-change-reviewer /
235
+ # kb-change-reviewer profiles: length cap, imperative opener, no story-ID
236
+ # reference, not a verbatim duplicate of an existing line in the same
237
+ # profile. These are checkable facts, not subjective judgment calls -- yet a
238
+ # live run (2026-07-07) observed the LLM reviewer reject notes that already
239
+ # satisfied every one of these rules (e.g. "Always end TypeScript statements
240
+ # with semicolons...", well under 200 chars, no story ID), 3/3 times, purely
241
+ # on flaky format judgment. That burns a gate round-trip per rejection for
242
+ # zero benefit. When a candidate passes this check, skip the LLM review
243
+ # entirely for format -- genuinely subjective calls (does this CONTRADICT an
244
+ # existing rule, is the underlying lesson actually sound) are out of scope
245
+ # here and still need a real reviewer, so this only short-circuits the
246
+ # specific failure mode that was observed wasting cost.
247
+ # The declared skill-note length limit. One source (config/self-heal.json), read by the checker
248
+ # here and handed to the failure analyst so it writes within it in the first place.
249
+ _skill_note_max_chars() {
250
+ local _cfg="${AUTOMATION_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config/self-heal.json"
251
+ local _v=""
252
+ [ -f "$_cfg" ] && _v=$(jq -r '.skillNote.maxChars // empty' "$_cfg" 2>/dev/null)
253
+ case "$_v" in (''|*[!0-9]*)
254
+ echo "[skill-note] config/self-heal.json declares no numeric skillNote.maxChars — refusing to guess a limit" >&2
255
+ return 1 ;;
256
+ esac
257
+ printf '%s' "$_v"
258
+ }
259
+
260
+ _skill_note_format_ok() {
261
+ local note="$1"
262
+ local story_id="$2"
263
+ local existing_profile_text="${3:-}"
264
+ [ -z "$note" ] && return 1
265
+ # THE DECLARED LIMIT, NOT A NUMBER WRITTEN HERE. See config/self-heal.json: the producer is
266
+ # told the same value, so a note no longer has to be rejected and rewritten to discover it.
267
+ local _max; _max=$(_skill_note_max_chars)
268
+ [ "${#note}" -le "$_max" ] || return 1
269
+ echo "$note" | grep -Eiq "^(${SKILL_NOTE_IMPERATIVE_OPENERS})\\b" || return 1
270
+ if [ -n "$story_id" ] && echo "$note" | grep -qi "$story_id"; then
271
+ return 1
272
+ fi
273
+ if [ -n "$existing_profile_text" ] && echo "$existing_profile_text" | grep -qF "$note"; then
274
+ return 1
275
+ fi
276
+ return 0
277
+ }
278
+
279
+ # _ensure_imperative_opener <note>
280
+ # Deterministically normalizes a skill_note/kb_entry candidate to satisfy
281
+ # _skill_note_format_ok's imperative-opener check, WITHOUT ever needing an
282
+ # LLM rewrite round-trip. Closes a live gap (2026-07-12, tier3-travel-app
283
+ # run): SKY-002-impl's FailureAnalyst produced a genuinely correct, specific
284
+ # note -- "When converting an interface to Record<string, unknown>, ensure
285
+ # the interface has an index signature or use 'unknown' first to avoid
286
+ # TS2352 error." -- but it opens with a subordinate "When X, ..." clause,
287
+ # not an imperative, so it correctly failed the deterministic format check
288
+ # and went through the full LLM reviewer 3 times on this SAME fixable
289
+ # wording issue, then was persisted UNREVIEWED as a fallback. The lesson
290
+ # itself was fine; only the opening word was wrong -- a mechanically
291
+ # fixable defect, not a judgment call. Prepending
292
+ # SKILL_NOTE_NORMALIZATION_OPENER (a configurable var, checked against the
293
+ # SAME SKILL_NOTE_IMPERATIVE_OPENERS list _skill_note_format_ok uses -- no
294
+ # word list duplicated or hardcoded independently in either function) is a
295
+ # generic, content-preserving transform (not a rewrite) that reliably
296
+ # satisfies the imperative check for ANY note shape, so apply it BEFORE the
297
+ # note is ever handed to run_change_with_reviewer_retry, letting an
298
+ # otherwise-sound note skip the LLM reviewer entirely on the very first
299
+ # attempt.
300
+ _ensure_imperative_opener() {
301
+ local note="$1"
302
+ [ -z "$note" ] && { printf ''; return 0; }
303
+ if echo "$note" | grep -Eiq "^(${SKILL_NOTE_IMPERATIVE_OPENERS})\\b"; then
304
+ printf '%s' "$note"
305
+ return 0
306
+ fi
307
+ # Sanity-check the configured normalization opener is itself an accepted
308
+ # word (not assumed) -- if misconfigured, fall through without
309
+ # normalizing rather than prepend something that wouldn't pass the check
310
+ # anyway.
311
+ if ! echo "$SKILL_NOTE_NORMALIZATION_OPENER" | grep -Eiq "^(${SKILL_NOTE_IMPERATIVE_OPENERS})\\b"; then
312
+ printf '%s' "$note"
313
+ return 0
314
+ fi
315
+ # NO TRUNCATION. This function PREPENDS an opener, which makes the string LONGER;
316
+ # cutting the tail to compensate destroys the END of the instruction — where the fix
317
+ # lives. Live 2026-08-11, AMSD-2041/gotransit: the analyst correctly diagnosed a Jest
318
+ # ESM failure and this line delivered "...change the pattern to
319
+ # '/node_modules/(?!swiper|@azure|uu" to the writer. Told to change a regex, never told
320
+ # to what. Eight attempts, three ladder rungs, the run lost, on a one-line config fix.
321
+ # A severed instruction is not shorter guidance, it is confidently wrong guidance.
322
+ printf '%s' "${SKILL_NOTE_NORMALIZATION_OPENER}: ${note}"
323
+ }
324
+
325
+ run_change_with_reviewer_retry() {
326
+ local story_id="$1"
327
+ local change_type="$2"
328
+ local before="$3"
329
+ local candidate="$4"
330
+ local max_retries="${5:-3}"
331
+
332
+ # Same subshell-scope issue as PRD_REVIEW_ISSUES: this whole function is also
333
+ # invoked via $(...) by its callers, so a plain REVIEWER_RETRY_TEXT=... here
334
+ # would never reach them. File-based side channel again; callers read it
335
+ # right after the command substitution (see kb)/skill) cases in
336
+ # run_failure_analyst).
337
+ local _issues_file="${TMPDIR:-/tmp}/.prd-review-issues-$$"
338
+ local _retry_text_file="${TMPDIR:-/tmp}/.reviewer-retry-text-$$"
339
+
340
+ if { [ "$change_type" = "skill_note" ] || [ "$change_type" = "kb_entry" ]; } \
341
+ && _skill_note_format_ok "$candidate" "$story_id" "$before"; then
342
+ printf '%s' "$candidate" > "$_retry_text_file" 2>/dev/null || true
343
+ log " [PRD-Reviewer] Skipped LLM review for ${change_type} (${story_id}) -- deterministic format check passed" >&2
344
+ echo "pass"
345
+ return 0
346
+ fi
347
+
348
+ local attempt=1
349
+ local current="$candidate"
350
+ local verdict="" review_issues=""
351
+ while [ "$attempt" -le "$max_retries" ]; do
352
+ verdict=$(run_prd_change_reviewer "$story_id" "$change_type" "$before" "$current")
353
+ if _review_kept "$verdict"; then
354
+ printf '%s' "$current" > "$_retry_text_file" 2>/dev/null || true
355
+ echo "pass"
356
+ return 0
357
+ fi
358
+ # Not judged (no provider, no prompt): no rewrite can earn a review that cannot run.
359
+ if [ "$verdict" != "fail" ]; then
360
+ error " [PRD-Reviewer] ${change_type} for ${story_id} was NOT reviewed (verdict '${verdict:-none}') — not kept" >&2
361
+ printf '%s' "$current" > "$_retry_text_file" 2>/dev/null || true
362
+ echo "fail"
363
+ return 1
364
+ fi
365
+ review_issues=$(cat "$_issues_file" 2>/dev/null || echo "")
366
+ if [ "$attempt" -lt "$max_retries" ]; then
367
+ log " [PRD-Summarizer] Rewriting rejected ${change_type} for ${story_id} (attempt ${attempt}/${max_retries}): ${review_issues:-no details}" >&2
368
+ current=$(run_prd_change_summarizer "$story_id" "$change_type" "$review_issues" "$current")
369
+ fi
370
+ attempt=$((attempt + 1))
371
+ done
372
+ printf '%s' "$current" > "$_retry_text_file" 2>/dev/null || true
373
+ echo "fail"
374
+ return 1
375
+ }
376
+
377
+ # run_pre_phase_assessment: ONE definition, lib/phase-assessment.sh (B48; operator, 2026-10-22: "Fix critical"). The older copy
378
+ # that stood here loaded after it (gates-testing.sh → reviewed-acs.sh → this file) and was the one in effect at Step 3: it handed
379
+ # the agent tools and told it to edit the PRD and orchestrations/agents/profiles.json relative to its working folder — the client's
380
+ # codeline. The newer one returns a decision that assessment_apply.py applies, judged by the change reviewer.