amsd-pipeline-shared 0.5.21 → 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.
@@ -0,0 +1,970 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # _run_project_verification <project_root>
4
+ # The project's declared check (.epam/verification.json) via the verification plugin. The engine
5
+ # names no tool, extension, directory or runtime path. Undeclared -> non-zero with a reason.
6
+ # The baseline-delta implementation lives in ONE place. Sourced here rather than re-implemented:
7
+ # this file used to carry its own copy, complete with a tsc error regex and a node_modules
8
+ # literal, and so did claude.sh and eslint-baseline-gate.sh — four copies, four independent
9
+ # fail-open paths on any repo whose checker speaks a different dialect.
10
+ _SG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ # THE COST PARTITION HAS ONE HOME. Without this, ${LEDGER_BILLABLE_JQ} expands empty and the jq
12
+ # filter below is malformed — which fails silently into an empty cost, the worst of both errors.
13
+ # shellcheck source=ledger-tokens.sh
14
+ . "$_SG_LIB_DIR/ledger-tokens.sh"
15
+ # shellcheck source=/dev/null
16
+ [ -f "$_SG_LIB_DIR/tsc-baseline-gate.sh" ] && . "$_SG_LIB_DIR/tsc-baseline-gate.sh"
17
+
18
+ # _run_project_verification: ONE definition, lib/tsc-baseline-gate.sh (section-aware). A copy here
19
+ # overwrote it and dropped the section (regintel 2026-09-24).
20
+
21
+ # Shared per-story orchestration guards — single source of truth, sourced by
22
+ # BOTH run-agent-orchestration.sh (main lane, Step 1) and claude.sh
23
+ # (run_implementation(), worktree Step 3a/3b lanes).
24
+ #
25
+ # Root cause this fixes (found live, 2026-07-14, tier3-travel-app run): these
26
+ # guards used to be defined ONLY in run-agent-orchestration.sh and called
27
+ # ONLY from its Step 1 main-branch loop. Worktree lanes never pass through
28
+ # that loop at all — they run inside claude.sh's own run_implementation()
29
+ # loop, launched as a background subprocess (Step 2/3a/3b), so a worktree-
30
+ # lane story got none of: the cost-budget circuit breaker, pause/resume,
31
+ # operator redirects, the post-story TypeScript compile gate, per-story
32
+ # actualCost written back to prd.json, or mid-execution split validation.
33
+ # Per explicit instruction ("all lanes must have the same flow no
34
+ # deviations" / "ensure lanes gave no other differences in processing"),
35
+ # every lane now calls the exact same implementation of each guard.
36
+ #
37
+ # Requires from the caller's environment: PRD_FILE, LOG_DIR, SCRIPT_DIR,
38
+ # PROJECT_ROOT, PHASE (set as a plain global, not `local`, before calling —
39
+ # these guards read $PHASE directly, matching run-agent-orchestration.sh's
40
+ # own existing convention), ORCH_RUN_ID, and the log/warning/success/error
41
+ # helpers — all already defined identically in both sourcing scripts.
42
+
43
+ # Check actual phase spend against prd.json budget.
44
+ # If exceeded, writes a JSON PAUSED sentinel so wait_if_paused() blocks and
45
+ # the dashboard can display the reason. Operator resumes via dashboard Resume button.
46
+ # Bypass: SKIP_COST_GUARD=true
47
+ check_cost_budget() {
48
+ [ "${SKIP_COST_GUARD:-false}" = "true" ] && return
49
+ local cost_file="$LOG_DIR/phase-cost.jsonl"
50
+ [ -f "$cost_file" ] || return 0
51
+ local budget
52
+ budget=$(jq -r '.budget // empty' "$PRD_FILE" 2>/dev/null || true)
53
+ [ -z "$budget" ] || [ "$budget" = "null" ] && return
54
+ local actual
55
+ # THE COST THE LEDGER RECORDS (B44; operator, 2026-10-22: "Fix critical"): every ledger writer writes task_cost_usd
56
+ # (lib/cost-emitter.js appendLedgerRecord). This summed actual_cost_usd / cost_usd, which no writer writes, so the breaker
57
+ # could never trip (run 9: 0 of 4,009 rows). Billable rows only — a terminal row restates a call already counted — as
58
+ # record_story_actual_cost sums them; this phase's rows.
59
+ actual=$(jq -rs --arg phase "$PHASE" \
60
+ "[.[] | select(.phase_id == \$phase or .phase == \$phase) | ${LEDGER_BILLABLE_JQ} | .task_cost_usd // 0] | add // 0" \
61
+ "$cost_file" 2>/dev/null || echo "0")
62
+ [ -n "$actual" ] || actual=0
63
+ if python3 -c "import sys; sys.exit(0 if float('${actual}') >= float('${budget}') else 1)" 2>/dev/null; then
64
+ warning "Cost circuit breaker: actual=\$${actual} >= budget=\$${budget} for phase '$PHASE'"
65
+ warning "Orchestration paused — resume from dashboard after reviewing spend"
66
+ printf '%s' "$(jq -n \
67
+ --arg reason "budget_exceeded" \
68
+ --arg phase "$PHASE" \
69
+ --argjson actual "$actual" \
70
+ --argjson budget "$budget" \
71
+ '{reason:$reason, phase:$phase, actualCost:$actual, budget:$budget, pausedAt:(now|todate)}'
72
+ )" > "$LOG_DIR/PAUSED"
73
+ fi
74
+ }
75
+
76
+ # Block until the PAUSED sentinel is removed (operator resumes via dashboard).
77
+ # Checks every 5 seconds; logs a reminder every 60 seconds.
78
+ wait_if_paused() {
79
+ if [ ! -f "$LOG_DIR/PAUSED" ]; then
80
+ return
81
+ fi
82
+ local _waited=0
83
+ local _max="${EPAM_MAX_PAUSE_SECS:-300}"
84
+ warning "Orchestration PAUSED — waiting for resume signal (auto-resume in ${_max}s)..."
85
+ warning " Resume: POST http://localhost:${CONTROL_PLANE_PORT}/resume"
86
+ warning " Abort: DELETE $LOG_DIR/PAUSED then kill the orchestration process"
87
+ while [ -f "$LOG_DIR/PAUSED" ]; do
88
+ sleep 5
89
+ _waited=$(( _waited + 5 ))
90
+ if (( _waited % 60 == 0 )); then
91
+ warning "Still paused (${_waited}s / ${_max}s max). POST http://localhost:${CONTROL_PLANE_PORT}/resume to continue."
92
+ fi
93
+ # Hard ceiling — auto-resume after EPAM_MAX_PAUSE_SECS to prevent indefinite hangs
94
+ if [ "$_waited" -ge "$_max" ]; then
95
+ warning "Auto-resuming after ${_max}s pause ceiling — continuing past paused story."
96
+ rm -f "$LOG_DIR/PAUSED" 2>/dev/null || true
97
+ break
98
+ fi
99
+ done
100
+ success "Orchestration RESUMED after ${_waited}s pause."
101
+ }
102
+
103
+ # Usage: apply_redirect_if_any <story_id>
104
+ # Operator-initiated live re-route of a story to a different agentRole,
105
+ # signalled by dropping a redirect-<story_id>.json file in LOG_DIR.
106
+ apply_redirect_if_any() {
107
+ local story_id="$1"
108
+ local redirect_file="$LOG_DIR/redirect-${story_id}.json"
109
+ if [ -f "$redirect_file" ]; then
110
+ local target_agent
111
+ target_agent=$(jq -r '.targetAgent // empty' "$redirect_file" 2>/dev/null || true)
112
+ if [ -n "$target_agent" ]; then
113
+ warning "REDIRECT: story $story_id → $target_agent (operator override)"
114
+ rm -f "$redirect_file"
115
+ # Update prd.json agentRole for this story
116
+ local _tmp
117
+ _tmp="${PRD_FILE}.redirect.$$"
118
+ jq --arg id "$story_id" --arg role "$target_agent" \
119
+ '(.stories[] | select(.id == $id)).agentRole = $role' \
120
+ "$PRD_FILE" > "$_tmp" && mv "$_tmp" "$PRD_FILE"
121
+ info "prd.json updated: $story_id.agentRole = $target_agent"
122
+ fi
123
+ fi
124
+ }
125
+
126
+ # Speckit must review ALL splits, not only those proposed by openspec during
127
+ # the spec pass (Step 0). The pre-phase assessment agent (Step 0.5) may write
128
+ # new stories directly to the PRD, and (for worktree lanes) an agent can
129
+ # register a mid-execution split while its own worktree story is running.
130
+ # Validate those before the NEXT story in this lane executes.
131
+ validate_mid_execution_splits() {
132
+ local _phase_id="$1"
133
+ local _spec_runner="$SCRIPT_DIR/spec-mode-runner.js"
134
+ local _node_cmd="${NODE_CMD:-${HOME}/.nvm/versions/node/v20.20.0/bin/node}"
135
+ [ ! -x "$_node_cmd" ] && _node_cmd="$(command -v node 2>/dev/null || echo 'node')"
136
+
137
+ if [ ! -f "$_spec_runner" ] || ! command -v "$_node_cmd" >/dev/null 2>&1; then
138
+ return 0
139
+ fi
140
+
141
+ # Find stories in implementationOrder for this phase that have createdFrom set
142
+ # but have NOT been speckit-validated (i.e., mid-execution splits)
143
+ local _new_split_ids
144
+ _new_split_ids=$(jq -r \
145
+ --arg phase "$_phase_id" \
146
+ '(.implementationOrder[$phase] // []) as $order |
147
+ .stories[] |
148
+ select(
149
+ (.id as $id | $order | index($id) != null) and
150
+ (.specification.createdFrom != null) and
151
+ (.specification.speckitValidated != true) and
152
+ (.specification.splitRejected != true)
153
+ ) | .id' \
154
+ "$PRD_FILE" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
155
+
156
+ if [ -z "$_new_split_ids" ]; then
157
+ log " [split-gate] No unvalidated mid-execution splits for phase '$_phase_id'"
158
+ return 0
159
+ fi
160
+
161
+ log " [split-gate] Running speckit on mid-execution splits: $_new_split_ids"
162
+ set +e
163
+ # Every line here forwards a value to the child process, and each expansion deliberately reads
164
+ # the OUTER value — which IS the value being forwarded. shellcheck is right about the shape and
165
+ # wrong about the intent; rewriting it risks silently dropping a credential the child needs.
166
+ # shellcheck disable=SC2097,SC2098
167
+ PRD_FILE="$PRD_FILE" OUTPUT_DIR="$LOG_DIR" \
168
+ AI_RUNNER_CMD="$AI_RUNNER_CMD" \
169
+ EPAM_ORCHESTRATION_PROVIDER="${ORCH_GATE_PROVIDER:-${EPAM_ORCHESTRATION_PROVIDER:-}}" \
170
+ SPEC_MODE_PROVIDER="${SPEC_MODE_PROVIDER:-}" \
171
+ SPEC_MODE_SPECKIT_MODEL="${SPEC_MODE_SPECKIT_MODEL:-}" \
172
+ PHASE="$_phase_id" ORCH_RUN_ID="$ORCH_RUN_ID" \
173
+ "$_node_cmd" "$_spec_runner" --validate-splits "$PRD_FILE" "$_new_split_ids" \
174
+ 2>&1 | tee "$LOG_DIR/split-validate-${_phase_id}.log"
175
+ local _sv_exit=${PIPESTATUS[0]}
176
+ set -e
177
+ if [ "$_sv_exit" -ne 0 ]; then
178
+ warning " [split-gate] Split validation found hard violations — check $LOG_DIR/split-validate-${_phase_id}.log"
179
+ else
180
+ success " [split-gate] Mid-execution splits validated for phase '$_phase_id'"
181
+ fi
182
+ }
183
+
184
+ # record_story_actual_cost <story_id> [log_file]
185
+ # Extracts cost_usd from the story's JSONL log output and writes it back to
186
+ # prd.json as .actualCost so estimates-vs-actuals can be compared per story.
187
+ #
188
+ # log_file is OPTIONAL (2026-07-14): worktree-lane stories run implement_story
189
+ # in-process inside claude.sh's own loop, not via a `timeout ... | tee
190
+ # per-story.log` subprocess wrapper the way main-lane stories do via
191
+ # run_story_with_watchdog — there is no per-story log file to grep. When
192
+ # log_file is omitted or doesn't exist, this falls straight to the
193
+ # phase-cost.jsonl aggregation (claude.sh's own cost-tracking append,
194
+ # append_task_cost_record()-equivalent, already writes task_cost_usd there
195
+ # for EVERY story regardless of lane) instead of returning early.
196
+ record_story_actual_cost() {
197
+ local story_id="$1"
198
+ local log_file="${2:-}"
199
+ local actual_cost=""
200
+ if [ -n "$log_file" ] && [ -f "$log_file" ]; then
201
+ # Extract cost from JSONL lines: epam run --json emits lines with cost_usd field
202
+ actual_cost=$(grep -o '"cost_usd":[0-9.]*\|"total_cost_usd":[0-9.]*' "$log_file" 2>/dev/null \
203
+ | tail -1 | grep -o '[0-9.]*$' || echo "")
204
+ fi
205
+ # Fallback: sum all task_cost_usd records for this story from phase-cost.jsonl
206
+ if [ -z "$actual_cost" ] || [ "$actual_cost" = "0" ]; then
207
+ local cost_file="${PHASE_COST_FILE:-$LOG_DIR/phase-cost.jsonl}"
208
+ if [ -f "$cost_file" ]; then
209
+ # Billable rows only — a terminal row restates a call already counted here.
210
+ actual_cost=$(jq -rs --arg sid "$story_id" \
211
+ "[.[] | select(.story_id == \$sid) | ${LEDGER_BILLABLE_JQ} | .task_cost_usd // 0] | add // 0" \
212
+ "$cost_file" 2>/dev/null || echo "")
213
+ fi
214
+ fi
215
+ [ -z "$actual_cost" ] && return 0
216
+ [ "$actual_cost" = "0" ] && return 0
217
+ # Write actualCost back to prd.json for this story
218
+ local prd="${MAIN_PRD_FILE:-$PRD_FILE}"
219
+ if [ -f "$prd" ]; then
220
+ local tmp
221
+ tmp=$(mktemp)
222
+ # mktemp defaults to mode 0600; mv preserves that onto the final PRD
223
+ # file, breaking anything not running as this user (e.g. the monitor
224
+ # dashboard's nginx worker).
225
+ chmod 644 "$tmp" 2>/dev/null
226
+ jq --arg sid "$story_id" --argjson cost "$actual_cost" \
227
+ '(.stories[] | select(.id == $sid)) |= (.actualCost = $cost)' \
228
+ "$prd" > "$tmp" && mv "$tmp" "$prd" || rm -f "$tmp"
229
+ fi
230
+ }
231
+
232
+ # Per-story TypeScript compile gate — runs tsc --noEmit after each story
233
+ # succeeds. Catches TS errors at the responsible story rather than at phase
234
+ # level. Bypassed when: tsconfig.json not yet present, SKIP_STORY_TSC_GATE=1,
235
+ # or test-only stories.
236
+ story_tsc_gate() {
237
+ local _sid="$1"
238
+ [ "${SKIP_STORY_TSC_GATE:-0}" = "1" ] && return 0
239
+ # No stack precondition. runVerification reports UNKNOWN for a project that has declared
240
+ # no check, and every caller treats non-zero as failure — so an undeclared repo is
241
+ # refused rather than skipped. Counting a language's files here meant "skip", which
242
+ # callers read as PASS: the fail-open the verification plugin exists to remove, moved
243
+ # from the invocation to the condition.
244
+ # Skip tsc gate for test-only stories (they extend existing files, not create TS modules)
245
+ local _role
246
+ _role=$(jq -r --arg id "$_sid" '.stories[] | select(.id==$id) | .agentRole // ""' "$PRD_FILE" 2>/dev/null || echo "")
247
+ [ "$_role" = "test-engineer" ] && return 0
248
+
249
+ local _node_cmd="${NODE_CMD:-${HOME}/.nvm/versions/node/v20.20.0/bin/node}"
250
+ [ ! -x "$_node_cmd" ] && _node_cmd="$(command -v node 2>/dev/null || echo 'node')"
251
+ local _tsc_log="$LOG_DIR/tsc-gate-${_sid}.log"
252
+
253
+ set +e
254
+ _run_project_verification "$PROJECT_ROOT" 2>&1 | tee "$_tsc_log"
255
+ local _tsc_exit=${PIPESTATUS[0]}
256
+ set -e
257
+
258
+ if [ "$_tsc_exit" -ne 0 ]; then
259
+ # Brownfield: this is the SAME whole-project-scope bug fixed in
260
+ # claude.sh's run_tsc_verification() (live, AMSD-1820, 2026-07-22) —
261
+ # a large brownfield repo can have pre-existing tsc errors in files no
262
+ # story ever touches. Without the baseline diff, this OUTER gate
263
+ # flipped a genuinely-completed story back to failed on the exact same
264
+ # pre-existing Redis/Stripe/OTel/jwt errors, AFTER run_tsc_verification
265
+ # had already correctly passed it inside the retry loop — the run
266
+ # showed "Story AMSD-1820 marked as completed" immediately followed by
267
+ # "Story AMSD-1820 marked as failed" a few lines later. Same fix:
268
+ # diff against JIRA_BASELINE_BRANCH via a git worktree (symlinking
269
+ # node_modules in, since worktree checkouts don't include gitignored
270
+ # dirs), only failing on errors the story's own commit introduced.
271
+ # ONE IMPLEMENTATION, in lib/tsc-baseline-gate.sh. This was a fourth copy of the same
272
+ # baseline-delta logic, each carrying its own tsc error regex and its own `node_modules`
273
+ # literal — so each one failed open independently on a repo whose checker speaks a
274
+ # different dialect: the grep matches nothing, the baseline set is empty, there is
275
+ # nothing to subtract, and the gate reports PASS having verified nothing.
276
+ #
277
+ # The output is passed in because it has already been captured; re-running the check
278
+ # here would double the cost of the most expensive gate in the run.
279
+ local _new_errors
280
+ _new_errors="$(cat "$_tsc_log")"
281
+ if command -v baseline_new_failures >/dev/null 2>&1; then
282
+ local _delta_out _delta_rc=0
283
+ _delta_out=$(baseline_new_failures "$PROJECT_ROOT" "${NODE_CMD:-${NODE_BIN:-node}}" \
284
+ "$LOG_DIR" typecheck "$_tsc_log") || _delta_rc=$?
285
+ [ "$_delta_rc" -eq 0 ] && _new_errors="" || _new_errors="$_delta_out"
286
+ fi
287
+
288
+ if [ -z "$(echo "$_new_errors" | tr -d '[:space:]')" ]; then
289
+ success " [tsc-gate] $_sid: the type check has only pre-existing baseline errors — none introduced by this story"
290
+ record_brownfield_verified_baseline
291
+ return 0
292
+ fi
293
+
294
+ error " [tsc-gate] $_sid: TypeScript errors after story completed — story marked failed"
295
+ error " [tsc-gate] Fix required before next story runs. Log: ${_tsc_log##*/}"
296
+ reset_brownfield_story_commit "$_sid"
297
+ return 1
298
+ fi
299
+ success " [tsc-gate] $_sid: the project's type check passed"
300
+ record_brownfield_verified_baseline
301
+ return 0
302
+ }
303
+
304
+ # record_brownfield_verified_baseline
305
+ #
306
+ # Durable (cross-run) half of the predictable-teardown mandate. Writes the
307
+ # current HEAD SHA to a marker OUTSIDE the codeline entirely — never inside
308
+ # $PROJECT_ROOT itself, so it can never be swept into a `git add -A` /
309
+ # committed, and modifying it is never a surprising change to the client's
310
+ # own repo (no .gitignore edits, no stray files in their tree). Keyed by an
311
+ # md5 of the absolute PROJECT_ROOT path so multiple codelines never collide.
312
+ #
313
+ # Survives a `git reset --hard` unconditionally (it isn't part of the git
314
+ # working tree at all) and persists across runs regardless of how the
315
+ # previous run ended (clean success, clean failure via
316
+ # reset_brownfield_story_commit, or a hard kill/crash mid-story).
317
+ #
318
+ # This is the run-START backstop's source of truth: a run that gets killed
319
+ # before story_tsc_gate ever runs leaves no updated marker, so the NEXT run's
320
+ # start-of-run check (see run-brownfield-preflight-reset.sh) knows to reset
321
+ # back to the last point a story was ACTUALLY verified complete — not just
322
+ # "the last commit whose message happened to say complete."
323
+ #
324
+ # Brownfield-only; a no-op elsewhere (the file simply never gets created).
325
+ record_brownfield_verified_baseline() {
326
+ [ "${EPAM_BROWNFIELD:-0}" = "1" ] || return 0
327
+ [ -e "${PROJECT_ROOT:-}/.git" ] || return 0
328
+ local _sha=""
329
+ # When a fixed baseline branch is configured (JIRA_BASELINE_BRANCH, e.g.
330
+ # "develop"), the "verified baseline" is that BRANCH — never a story's own
331
+ # fix commit sitting on top of it. Recording HEAD here (which becomes the fix
332
+ # commit once a story completes) is exactly what made a re-run of the same
333
+ # story reset to the PREVIOUS fix and build on top of it, so the detective saw
334
+ # already-fixed code and the run was invalid (found live 2026-07-24,
335
+ # AMSD-1820). Pin the marker to the baseline branch so it can never advance
336
+ # onto a fix — repeat runs of the same story always start from the clean
337
+ # original state.
338
+ if [ -n "${JIRA_BASELINE_BRANCH:-}" ]; then
339
+ _sha=$(git -C "$PROJECT_ROOT" rev-parse --verify --quiet "origin/${JIRA_BASELINE_BRANCH}" 2>/dev/null \
340
+ || git -C "$PROJECT_ROOT" rev-parse --verify --quiet "${JIRA_BASELINE_BRANCH}" 2>/dev/null || echo "")
341
+ fi
342
+ [ -n "$_sha" ] || _sha=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null) || return 0
343
+ local _state_dir="${EPAM_BROWNFIELD_STATE_DIR:-$HOME/.epam/brownfield-baselines}"
344
+ mkdir -p "$_state_dir" 2>/dev/null || true
345
+ local _key
346
+ _key=$(printf '%s' "$PROJECT_ROOT" | md5sum 2>/dev/null | cut -d' ' -f1)
347
+ [ -n "$_key" ] || return 0
348
+ echo "$_sha" > "$_state_dir/${_key}.sha" 2>/dev/null || true
349
+ }
350
+
351
+ # reset_brownfield_story_commit <story_id>
352
+ #
353
+ # Standing mandate (6+ months, not new): the pipeline must be able to tear
354
+ # down to a predictable pre-run state every time — a run may be repeated
355
+ # 200+ times until it succeeds, and contamination from a failed attempt must
356
+ # never persist into the next one. Brownfield's own semantics for this
357
+ # (confirmed 2026-07-22, not the same as reset-to-baseline.sh's greenfield
358
+ # "last <id>: story complete commit" model): hard-reset the codeline to
359
+ # phase-baseline-sha.txt — the SHA captured once, at the very start of this
360
+ # run's phase, BEFORE any story in it touched anything (see
361
+ # run-agent-orchestration.sh Step 8's capture just before the main-story
362
+ # loop). Every commit made during this run is provisional until a story
363
+ # passes ALL of its gates; a story failing story_tsc_gate has NOT done so.
364
+ #
365
+ # Live bug this closes (AMSD-1820, 2026-07-22): commit_completed_story()
366
+ # commits BEFORE story_tsc_gate runs. When the gate then failed, the commit
367
+ # ("AMSD-1820: story complete (7 file(s))") was left sitting on develop
368
+ # permanently — nothing ever reverted it. It went on to actively poison
369
+ # later Semble semantic-search results (its prose matched the bug title
370
+ # better than the real fix file, outranking the actual code needing the fix)
371
+ # for every subsequent run, compounding the damage rather than staying inert.
372
+ #
373
+ # Brownfield-only (EPAM_BROWNFIELD=1) — greenfield worktree lanes have their
374
+ # own, different teardown model and are unaffected.
375
+ # No-op (not an error) when: not brownfield, no phase-baseline-sha.txt yet
376
+ # (can't reset to an unknown target), or the working tree isn't a git repo.
377
+ reset_brownfield_story_commit() {
378
+ local _sid="$1"
379
+ [ "${EPAM_BROWNFIELD:-0}" = "1" ] || return 0
380
+ [ -e "${PROJECT_ROOT:-}/.git" ] || return 0
381
+ local _baseline_file="${LOG_DIR:-}/phase-baseline-sha.txt"
382
+ [ -f "$_baseline_file" ] || return 0
383
+ local _baseline_sha
384
+ _baseline_sha=$(tr -d '[:space:]' < "$_baseline_file")
385
+ [ -n "$_baseline_sha" ] || return 0
386
+
387
+ local _current_sha
388
+ _current_sha=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null || echo "")
389
+ if [ "$_current_sha" = "$_baseline_sha" ]; then
390
+ # Nothing was ever committed for this story (or it's already clean) —
391
+ # no reset needed, and resetting to HEAD would be a harmless no-op
392
+ # anyway, but skip it entirely to avoid an unnecessary git operation.
393
+ return 0
394
+ fi
395
+
396
+ # THE BRANCH IS CLEANED; THE WORK IS KEPT. `reset --hard` alone made the story's commits
397
+ # unreachable — everything the agent wrote, including a diagnosis that was most of the way
398
+ # there and the only starting point the next attempt had. Keeping the branch clean and keeping
399
+ # the work were never in conflict: a ref costs nothing, and this pipeline never discards an
400
+ # agent's code. The commits move to a branch named for the story; the branch the run works on
401
+ # goes back to the baseline exactly as before.
402
+ local _keep_ref; _keep_ref="kept/${_sid}-$(date -u +%Y%m%dT%H%M%SZ)"
403
+ if git -C "$PROJECT_ROOT" branch -f "$_keep_ref" "$_current_sha" >/dev/null 2>&1; then
404
+ success " [teardown] $_sid: its commit(s) are KEPT on branch $_keep_ref — nothing was discarded"
405
+ else
406
+ warning " [teardown] $_sid: could not create a keep-branch — REFUSING to reset, because that would destroy this story's work"
407
+ return 0
408
+ fi
409
+ warning " [teardown] $_sid: returning $PROJECT_ROOT to pre-run baseline $_baseline_sha (the work is on $_keep_ref)"
410
+ git -C "$PROJECT_ROOT" reset --hard "$_baseline_sha" >/dev/null 2>&1 \
411
+ && success " [teardown] $_sid: reset complete — repo is back to the exact state before this run started"
412
+ }
413
+
414
+ # ── _text_violates_anti_pattern moved here 2026-08-02 ──────────────────────
415
+ # Moved from claude.sh so BOTH claude.sh (FailureAnalyst's skill-note path)
416
+ # and run-agent-orchestration.sh (review-rejection skill-note path, below)
417
+ # can call it — the same "shared, single source of truth" reasoning already
418
+ # applied to lib/git-ops.sh.
419
+ # _text_violates_anti_pattern <text>
420
+ # A SEPARATE, purpose-built pattern per rule — `textMatchPattern`, distinct
421
+ # from `matchPattern` — applied to an arbitrary piece of TEXT: specifically, a
422
+ # self-heal skill note about to be persisted into profiles.json for every
423
+ # future run to inherit. A rule with no `textMatchPattern` simply does not
424
+ # apply here (silently skipped) — most anti-pattern rules are code-shape-only
425
+ # and have no prose equivalent worth gating.
426
+ #
427
+ # Root cause this closes (found live, 2026-08-02, Writer Retest): FailureAnalyst
428
+ # diagnosed a TypeScript compile error from an SDK's installed .d.ts and wrote
429
+ # a skill note prescribing a config key from it. A type declaration can
430
+ # disagree with what the package's runtime actually reads, in either direction,
431
+ # and FailureAnalyst has no way to know that — nor does its reviewer-retry gate,
432
+ # an LLM judging another LLM's note, since the same blind spot applies to both.
433
+ # Vetting the note here stops a wrong belief being persisted into a profile for
434
+ # every future run to inherit.
435
+ #
436
+ # WHAT THIS MUST NOT BECOME, learned the hard way 2026-08-04: the original
437
+ # version of this vetting leaned on a hand-written anti-patterns.json rule
438
+ # asserting which SDK key was correct — a VENDOR API FACT written from memory
439
+ # after watching one ticket fail. Discovery against the INSTALLED package (the
440
+ # dependency_contract plugin) later showed the assertion was backwards: the
441
+ # "wrong" key is the one the runtime reads, and the "prescribed" one appears
442
+ # nowhere in the package. The rule would have refused correct guidance and
443
+ # forced writers toward a key that silently does nothing.
444
+ #
445
+ # So a rule vetted here must be one that could have been written BEFORE any
446
+ # failure was observed. What a third-party package consumes is DETERMINABLE —
447
+ # have the agent call dependency_contract; never transcribe the answer into a
448
+ # rule file. See test/unit/orchestration/no-hand-authored-vendor-rules.test.ts.
449
+ #
450
+ # `textMatchPattern` is DELIBERATELY separate from `matchPattern` (found live,
451
+ # 2026-08-02, same day: the two were briefly the same shared, loosened pattern
452
+ # — broadening it to catch prose in a skill note ALSO made
453
+ # run_anti_pattern_check(), which scans real CODE FILES with `matchPattern`,
454
+ # false-positive on a correct, review-approved comment explaining the exact
455
+ # same contrast in prose (a comment naming one SDK key "not" another).
456
+ # That false positive blocked a real test run and cascaded into a
457
+ # HealingBroken escalation — a genuine run regression traced back to sharing
458
+ # one pattern across two consumers with different strictness needs. Never
459
+ # reuse one regex across a code-scanner and a free-text scanner again — see
460
+ # [[feedback_regex_100_percent_coverage]].
461
+ #
462
+ # Prints the matched rule's message on stdout when it matches; prints nothing
463
+ # and returns 0 when clean (matches run_anti_pattern_check's "OK"/malformed
464
+ # config = never block" posture).
465
+ _text_violates_anti_pattern() {
466
+ local _text="$1"
467
+ local _rules_file="${EPAM_PROJECT_CONFIG_DIR:+$EPAM_PROJECT_CONFIG_DIR/anti-patterns.json}"
468
+ [ -f "$_rules_file" ] || return 0
469
+
470
+ python3 "$SCRIPT_DIR/lib/handlers/story-text-rule-check.py" "$_rules_file" "$_text"
471
+ }
472
+
473
+
474
+ # _persist_skill_note_simple <profiles_file> <role> <raw_text>
475
+ # Lightweight skill-note persister for call sites that don't have access to
476
+ # claude.sh's full LLM-invocation infrastructure (run_change_with_reviewer_retry,
477
+ # which validates a note's WORDING via an LLM review pass before persisting) —
478
+ # specifically, run-agent-orchestration.sh's review-rejection path (Step 3.6,
479
+ # below). Applies the SAME deterministic anti-pattern gate and exact-duplicate
480
+ # guard claude.sh's own FailureAnalyst skill-note persister uses, then writes
481
+ # directly with no LLM wording-review sub-step — justified because the text
482
+ # here is already reviewer-vetted content (a real code review's own blocker
483
+ # description), unlike FailureAnalyst's raw, unvalidated model diagnosis text.
484
+ #
485
+ # Root cause this closes (found live, 2026-08-02): a story that repeatedly
486
+ # fails REVIEW for the same reason (e.g. AMSD-2041's live_preview never
487
+ # forwarded through public query functions) had NO mechanism to persist that
488
+ # lesson for the writer across runs — only FailureAnalyst's tsc/test-failure
489
+ # diagnoses fed the skill-note pipeline. The exact same defect could recur
490
+ # indefinitely across runs with nothing ever accumulating for the writer to
491
+ # learn from, unlike self-heal-diagnosed failures.
492
+ #
493
+ # Best-effort: never fails the caller (always returns 0), same posture as
494
+ # run_anti_pattern_check()/_text_violates_anti_pattern().
495
+ _persist_skill_note_simple() {
496
+ local _profiles_file="$1"
497
+ local _role="$2"
498
+ local _raw_text="$3"
499
+ [ -f "$_profiles_file" ] || return 0
500
+ [ -n "$_raw_text" ] || return 0
501
+
502
+ local _anti_pattern_msg
503
+ _anti_pattern_msg=$(_text_violates_anti_pattern "$_raw_text")
504
+ if [ -n "$_anti_pattern_msg" ]; then
505
+ warning " [skill-note] Refusing to persist for [${_role}] — contradicts a known anti-pattern: $_anti_pattern_msg"
506
+ return 0
507
+ fi
508
+
509
+ local _current_role_profile
510
+ _current_role_profile=$(jq -c --arg role "$_role" '.[$role] // ""' "$_profiles_file" 2>/dev/null)
511
+ if echo "$_current_role_profile" | grep -qF -- "$_raw_text"; then
512
+ log " [skill-note] Exact duplicate already present in [${_role}] — not persisting again"
513
+ return 0
514
+ fi
515
+
516
+ ( flock -w 10 200 || { error " [skill-note] Could not acquire lock on $_profiles_file"; return 1; }
517
+ python3 "$SCRIPT_DIR/lib/handlers/skill-note-append.py" "$_raw_text" "$_profiles_file" "$_role" 2>&1 | while IFS= read -r line; do log " [skill-note] $line"; done
518
+ ) 200>"${_profiles_file}.lock"
519
+ return 0
520
+ }
521
+
522
+
523
+ # _load_timeout_config
524
+ # Loads timeout-related fallback defaults from EPAM_PROJECT_CONFIG_DIR/
525
+ # llm-settings.json — same "only export if the env var isn't already set"
526
+ # posture as claude.sh's load_llm_settings_json(), and reads the SAME
527
+ # storyTimeoutSecs/gateTimeoutSecs keys that function already reads, plus
528
+ # three new ones (storyEffortTimeoutSecs, roleTimeoutMultipliers,
529
+ # watchdogRetryMultiplier — see llm-settings.schema.json).
530
+ #
531
+ # Root cause this closes (found live, 2026-08-02, gotransit/upexpress digging):
532
+ # run_story_with_watchdog() (run-agent-orchestration.sh) computes its timeout
533
+ # from a hardcoded case statement, completely disconnected from
534
+ # llm-settings.json — load_llm_settings_json() DOES export
535
+ # EPAM_STORY_TIMEOUT_SECS, but run_story_with_watchdog() reads the unprefixed
536
+ # STORY_TIMEOUT_SECS, so the two never connected (a naming mismatch, not a
537
+ # missing feature). Worse: load_llm_settings_json() only runs INSIDE
538
+ # claude.sh, which run_story_with_watchdog() itself invokes AS A SUBPROCESS —
539
+ # by the time claude.sh could export anything, the watchdog's `timeout`
540
+ # wrapper around that exact invocation has already been computed and applied.
541
+ # A child process's exported env vars never propagate back to its parent, so
542
+ # that channel could never have worked no matter what the var was named.
543
+ # The fix is loading config in run-agent-orchestration.sh itself, BEFORE the
544
+ # first call to run_story_with_watchdog() — this function, called once near
545
+ # the top of that script, right after EPAM_PROJECT_CONFIG_DIR is available.
546
+ #
547
+ # Deliberately does NOT special-case gotransit's specific hung-API-call
548
+ # failure (0 tokens, $0 cost, 11+ min, recurring 6 times) — that failure mode
549
+ # is a dead/stuck connection, not "needs more time to finish work", and
550
+ # raising its timeout would only make a hung run take longer to fail the same
551
+ # way. This function makes the timeout MECHANISM configurable; it does not
552
+ # and should not paper over a specific hang.
553
+ #
554
+ # Best-effort: malformed/absent config is silently skipped, same posture as
555
+ # every other loader in this file.
556
+ _load_timeout_config() {
557
+ # :+ not :- — an unset config dir must yield an EMPTY path, never one at the filesystem root.
558
+ local _settings_file="${EPAM_PROJECT_CONFIG_DIR:+$EPAM_PROJECT_CONFIG_DIR/llm-settings.json}"
559
+ [ -f "$_settings_file" ] || return 0
560
+
561
+ # `|| true` is load-bearing (found live, 2026-08-02): this runs under
562
+ # `set -e` (run-agent-orchestration.sh:12) — a malformed llm-settings.json
563
+ # makes jq exit non-zero on the PARSE error itself (`// empty` only
564
+ # covers a valid-but-absent VALUE, not a parse failure), and every call
565
+ # site is `_v=$(_lt_get ...)`, a bare simple command whose failing exit
566
+ # status would otherwise kill the whole orchestration script — see the
567
+ # identical fix and rationale in claude.sh's load_llm_settings_json().
568
+ _lt_get() { jq -r "$1 // empty" "$_settings_file" 2>/dev/null || true; }
569
+ local _v
570
+
571
+ _v=$(_lt_get '.timeouts.storyTimeoutSecs'); [ -z "${EPAM_STORY_TIMEOUT_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_TIMEOUT_SECS="$_v"
572
+ _v=$(_lt_get '.timeouts.gateTimeoutSecs'); [ -z "${EPAM_GATE_TIMEOUT_SECS:-}" ] && [ -n "$_v" ] && export EPAM_GATE_TIMEOUT_SECS="$_v"
573
+
574
+ # Loaded HERE, in the parent, because the watchdog that consumes them runs in this process
575
+ # — claude.sh's own loader is a subprocess and too late by construction (the reason this
576
+ # function exists at all). secondsPerIteration x the attempt's iteration budget derives a
577
+ # wall that can actually accommodate the work; storyTimeoutMaxSecs caps it.
578
+ _v=$(_lt_get '.timeouts.secondsPerIteration'); [ -z "${EPAM_SECONDS_PER_ITERATION:-}" ] && [ -n "$_v" ] && export EPAM_SECONDS_PER_ITERATION="$_v"
579
+ _v=$(_lt_get '.timeouts.storyTimeoutMaxSecs'); [ -z "${EPAM_STORY_TIMEOUT_MAX_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_TIMEOUT_MAX_SECS="$_v"
580
+ # TWO SCOPES, TWO WALLS. The knobs above bound ONE ATTEMPT. A story runs up to
581
+ # maxRetries+1 attempts inside a single watchdog window, and each attempt also pays
582
+ # planning, gates, verification and the analyst — none of which are iterations. Measured
583
+ # 2026-08-12: two attempts consumed 1780s of an 1800s wall that also claimed to bound eight.
584
+ _v=$(_lt_get '.timeouts.perAttemptOverheadSecs'); [ -z "${EPAM_PER_ATTEMPT_OVERHEAD_SECS:-}" ] && [ -n "$_v" ] && export EPAM_PER_ATTEMPT_OVERHEAD_SECS="$_v"
585
+ _v=$(_lt_get '.timeouts.storyWallMaxSecs'); [ -z "${EPAM_STORY_WALL_MAX_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_WALL_MAX_SECS="$_v"
586
+
587
+ # HOW MANY ATTEMPTS RUN INSIDE ONE STORY WALL — needed HERE, in the parent.
588
+ #
589
+ # The watchdog multiplies the per-attempt wall by maxRetries+1 to size the story wall.
590
+ # EPAM_MAX_RETRIES is loaded by CLAUDE.SH from .retries.maxRetries, in claude.sh's process,
591
+ # and the watchdog runs in this one. So the multiplier defaulted to 0+1 = 1 and the story
592
+ # wall collapsed back to exactly one attempt — the two-scope fix was INERT on its first
593
+ # live run, which printed the floor branch:
594
+ #
595
+ # [orch] story timeout 1800s (floor — no iteration budget granted yet for AMSD-2041)
596
+ #
597
+ # The tests passed because they SET EPAM_MAX_RETRIES in their own harness, supplying the
598
+ # one input reality never did: the caller tested instead of the receiver, the same error
599
+ # that left the 2026-08-10 wall derivation dead for two days.
600
+ _v=$(_lt_get '.retries.maxRetries'); [ -z "${EPAM_MAX_RETRIES:-}" ] && [ -n "$_v" ] && export EPAM_MAX_RETRIES="$_v"
601
+
602
+ # THE MODEL LADDERS, LOADED WHERE THE ORCHESTRATOR CAN SEE THEM.
603
+ #
604
+ # A LADDER A SEAM CANNOT REACH IS NOT A LADDER ASSIGNMENT.
605
+ #
606
+ # agents/invocation-profiles.json assigns a ladder to 17 seams, and lib/seam-invocation.js
607
+ # turns that into a MODEL — it exports EPAM_MODEL (the first rung) and EPAM_MODEL_LADDER —
608
+ # but only if EPAM_MODEL_LADDER_<TIER> exists in the CALLING process. Otherwise it warns and
609
+ # returns effort and temperature alone, and the seam keeps whatever fixed model its script
610
+ # hardcoded.
611
+ #
612
+ # The project declares its ladders in llm-settings.json. Until now ONLY claude.sh's loader
613
+ # read them, exporting _HIGH and _MEDIUM inside claude.sh's own process; config.env sets
614
+ # _HIGHEST and deliberately not the others ("they live in one place"). Seam scripts are
615
+ # children of the ORCHESTRATOR, not of claude.sh, so they inherited _HIGHEST and nothing
616
+ # else — and every seam declaring "high" or "medium" resolved to no model at all:
617
+ #
618
+ # [seam-invocation] seam 'impl-failure-analyst' asks for ladder 'high' but
619
+ # EPAM_MODEL_LADDER_HIGH is unset — using the run's default ladder
620
+ #
621
+ # 17 ladders were assigned on 2026-08-11 and reported as "every agent can escalate". The
622
+ # field was present and selected a model for none of them.
623
+ #
624
+ # Loaded HERE for the same reason _load_timeout_config exists at all: the parent runs the
625
+ # watchdog and spawns the seams, and anything exported inside claude.sh is invisible to
626
+ # both. Existing values are never overwritten, so an explicit override still wins.
627
+ local _ladder_tier _ladder_var _ladder_chain
628
+ for _ladder_tier in high medium highest; do
629
+ _ladder_var="EPAM_MODEL_LADDER_$(printf '%s' "$_ladder_tier" | tr '[:lower:]' '[:upper:]')"
630
+ [ -n "${!_ladder_var:-}" ] && continue
631
+ _ladder_chain=$(_lt_get "[.ladders.${_ladder_tier}.modelLadder[]? | \"\\(.from)=\\(.to)\"] | join(\"|\")")
632
+ [ -n "$_ladder_chain" ] && export "$_ladder_var=$_ladder_chain"
633
+ done
634
+
635
+ _v=$(_lt_get '.timeouts.storyEffortTimeoutSecs.low'); [ -z "${EPAM_STORY_EFFORT_TIMEOUT_LOW_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_EFFORT_TIMEOUT_LOW_SECS="$_v"
636
+ _v=$(_lt_get '.timeouts.storyEffortTimeoutSecs.medium'); [ -z "${EPAM_STORY_EFFORT_TIMEOUT_MEDIUM_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_EFFORT_TIMEOUT_MEDIUM_SECS="$_v"
637
+ _v=$(_lt_get '.timeouts.storyEffortTimeoutSecs.high'); [ -z "${EPAM_STORY_EFFORT_TIMEOUT_HIGH_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_EFFORT_TIMEOUT_HIGH_SECS="$_v"
638
+ _v=$(_lt_get '.timeouts.storyEffortTimeoutSecs.default'); [ -z "${EPAM_STORY_EFFORT_TIMEOUT_DEFAULT_SECS:-}" ] && [ -n "$_v" ] && export EPAM_STORY_EFFORT_TIMEOUT_DEFAULT_SECS="$_v"
639
+
640
+ # roleTimeoutMultipliers is an arbitrary-keys object (unlike the fixed
641
+ # low/medium/high/default tiers above) — serialize it the same way
642
+ # load_llm_settings_json() serializes modelLadder[], into the
643
+ # "role=mult|role2=mult2" string resolve_role_timeout_multiplier() already
644
+ # parses via EPAM_ROLE_TIMEOUT_MULTIPLIER_MAP.
645
+ _v=$(_lt_get '(.timeouts.roleTimeoutMultipliers // {}) | to_entries | map("\(.key)=\(.value)") | join("|")')
646
+ [ -z "${EPAM_ROLE_TIMEOUT_MULTIPLIER_MAP:-}" ] && [ -n "$_v" ] && export EPAM_ROLE_TIMEOUT_MULTIPLIER_MAP="$_v"
647
+
648
+ _v=$(_lt_get '.timeouts.watchdogRetryMultiplier'); [ -z "${EPAM_WATCHDOG_RETRY_MULTIPLIER:-}" ] && [ -n "$_v" ] && export EPAM_WATCHDOG_RETRY_MULTIPLIER="$_v"
649
+
650
+ unset -f _lt_get
651
+ return 0
652
+ }
653
+
654
+ # spec_review_gate <prd-file>
655
+ #
656
+ # A REVIEW THAT CANNOT STOP ANYTHING IS NOT A GATE.
657
+ #
658
+ # Live 2026-08-04 (run 20260804T035435Z): the coordinator reviewed all three lanes, used
659
+ # list_files to check each manifest against the repository, and returned needs_review on
660
+ # every one — qualityScore 0.45 on the worst. Two of those lanes had a manifest naming a
661
+ # file that does not exist, the condition that sent a writer into a 120-iteration,
662
+ # ~2M-token loop. The verdict was written to story.specification.coordinatorReview,
663
+ # and then ignored: the only verdict the code branched on was 'fail', which the review
664
+ # schema (approved|needs_review) never emits. The reviewer knew, on every lane, and had
665
+ # no word that stopped anything.
666
+ #
667
+ # Enforced here, deterministically, from the PRD the spec pass wrote — at the pre-writer
668
+ # boundary, before a single token is spent on implementation.
669
+ #
670
+ # CONFIGURABLE, never hardcoded:
671
+ # SPEC_REVIEW_ENFORCE=0 turn the gate off deliberately (default: on)
672
+ # SPEC_REVIEW_MIN_QUALITY=0.7 the bar a score must clear (default: 0.7)
673
+ #
674
+ # Deliberately NOT blocking: a story with no coordinatorReview at all. A resumed run skips the
675
+ # spec pass, so an absent review is expected and must not halt it. Absent is not zero —
676
+ # a null qualityScore does not block either, or a reviewer that omitted the field would
677
+ # fail every story.
678
+ spec_review_gate() {
679
+ local _prd="${1:-${PRD_FILE:-}}"
680
+ if [ "${SPEC_REVIEW_ENFORCE:-1}" = "0" ]; then
681
+ return 0
682
+ fi
683
+ if [ -z "$_prd" ] || [ ! -f "$_prd" ]; then
684
+ warning "[spec-review-gate] no PRD to check (${_prd:-unset}) — cannot verify the spec review"
685
+ return 1
686
+ fi
687
+ if ! jq -e . "$_prd" >/dev/null 2>&1; then
688
+ error "[spec-review-gate] PRD is not valid JSON — refusing to pass a gate it cannot read"
689
+ return 1
690
+ fi
691
+
692
+ # qualityScore IS TELEMETRY. It never gates. (Operator decision, 2026-08-07.)
693
+ #
694
+ # It is a bare number the reviewer invents: nothing constrains how it is derived and
695
+ # nothing can check it. Unlike a flag, a verdict or a missing manifest path, "0.68" is not
696
+ # a claim about anything, so it can be neither structurally constrained nor independently
697
+ # re-checked — which is how every other model assertion in this pipeline is now handled.
698
+ #
699
+ # It was the DEFAULT blocker at 0.7 while the specific, enumerable signal defaulted to
700
+ # empty, so the only thing that could stop a run was the one thing nobody could
701
+ # interrogate. Live 2026-08-07: a lane halted at 0.68 — a 0.02 margin — while two lanes
702
+ # cleared, and nothing in the artefacts can say whether that spec was materially worse or
703
+ # simply drew a lower number. The comments above record the same instability from earlier
704
+ # runs: lanes stopped at 0.78 and 0.72, and elsewhere every lane sailing through at 0.45.
705
+ #
706
+ # Reported every time so a degrading reviewer is visible; never enforced.
707
+
708
+ local _blocking_flags="${SPEC_REVIEW_BLOCKING_FLAGS-}"
709
+ local _blockers _advisories _manifest_blockers
710
+ # THE PATH IS THE PRODUCER'S, NOT AN INVENTED ONE. This gate originally queried
711
+ # `.specReview` — a field nothing in the pipeline has ever written. spec-mode-runner.js
712
+ # persists the coordinator's verdict to `.specification.coordinatorReview`, so the gate
713
+ # read null on every story and passed everything. Live run 20260804T130402Z: all three
714
+ # lanes returned needs_review at qualityScore 0.45 and every one sailed through.
715
+ # gate-reads-what-the-producer-writes.test.ts now derives this path from the producer's
716
+ # own assignment, so the two cannot drift apart again.
717
+ local _jq_common='
718
+ ($bf | ascii_downcase | split(",") | map(gsub("^[[:space:]]+|[[:space:]]+$";""))
719
+ | map(select(length > 0))) as $blocking
720
+ | [ .stories[]?
721
+ | select(.status != "deprecated")
722
+ | . as $s
723
+ | (.specification.coordinatorReview) as $r
724
+ | select($r != null)
725
+ # Flags may be bare strings (legacy: always advisory) or objects carrying severity.
726
+ | (($r.flags // [])
727
+ | map(if type == "string" then {flag: ascii_downcase, severity: "advisory"}
728
+ elif type == "object" then {flag: ((.flag // "") | ascii_downcase),
729
+ severity: ((.severity // "advisory") | ascii_downcase)}
730
+ else empty end)
731
+ | map(select(.flag != ""))) as $fo
732
+ | ($fo | map(.flag)) as $f
733
+ # A REVIEW THAT OBJECTS MUST SAY WHAT IT OBJECTS TO.
734
+ #
735
+ # Blocking requires a needs_review verdict AND at least one flag. Enumerable and
736
+ # specific: it satisfies "a review must be able to stop things" without resting on
737
+ # a scalar, and a reviewer returning needs_review with no flags — exactly what
738
+ # halted a lane on 2026-08-07 — no longer blocks, because it named nothing anyone
739
+ # can act on. Blocking on the verdict alone was tried and abandoned: it stopped
740
+ # three lanes whose flags were advisory nits.
741
+ #
742
+ # With no SPEC_REVIEW_BLOCKING_FLAGS declared, ANY flag counts. A project narrows
743
+ # it by declaring the ones it cares about; it is never silently inert.
744
+ # BLOCKING REQUIRES CORROBORATION OR A HUMAN DECISION — never self-assessment.
745
+ #
746
+ # The reviewer own severity does NOT grant blocking. It is the same class of
747
+ # signal as qualityScore: something the model asserts about its own output, which
748
+ # nothing can check. This reviewer hallucinates a missing-manifest-path flag in 1 of
749
+ # 4 samples; letting it mark that flag blocking would halt valid runs on that word
750
+ # alone, which is the failure the computed manifest check exists to prevent.
751
+ #
752
+ # What blocks: a flag the PROJECT declared blocking (a human decided it matters),
753
+ # and the deterministic computed checks below. Severity is carried through as
754
+ # advisory metadata so a human reading the report can rank what to look at first.
755
+ | [ $fo[] | select(.flag as $x | $blocking | index($x)) | .flag ] as $hit
756
+ | ((($r.verdict // "approved") != "approved") and (($hit | length) > 0)) as $blocked
757
+ | {s: $s, r: $r, hit: $hit, fo: $fo, lowq: $blocked} # lowq is the BLOCKED flag now
758
+ ]'
759
+
760
+ _blockers=$(jq -r --arg bf "$_blocking_flags" "
761
+ $_jq_common
762
+ | .[] | select(.lowq)
763
+ | \"\(.s.id)\tverdict=\(.r.verdict // \"?\")\tquality=\(.r.qualityScore // \"n/a\")\treason=\(if (.hit | length) > 0 then \"blocking flag: \" + (.hit | join(\",\")) else \"needs_review with blocking flag(s): \" + (.hit | join(\",\")) end)\"
764
+ " "$_prd" 2>/dev/null)
765
+
766
+ # A needs_review that does NOT block is still the reviewer telling us something. It is
767
+ # reported every time — an advisory nobody sees is a silent failure.
768
+ _advisories=$(jq -r --arg bf "$_blocking_flags" "
769
+ $_jq_common
770
+ | .[] | select(.lowq | not)
771
+ | select((.r.verdict // \"approved\") != \"approved\")
772
+ | \"\(.s.id)\tverdict=\(.r.verdict)\tquality=\(.r.qualityScore // \"n/a\")\tflags=\(.fo | map(.flag + \"[\" + .severity + \"]\") | join(\",\"))\"
773
+ " "$_prd" 2>/dev/null)
774
+
775
+ # The score, always reported, never enforced.
776
+ _low_quality=$(jq -r '
777
+ [ .stories[]? | select(.status != "deprecated")
778
+ | . as $s | (.specification.coordinatorReview) as $r
779
+ | select($r != null and $r.qualityScore != null and $r.qualityScore < 0.7)
780
+ | "\($s.id)\tquality=\($r.qualityScore)" ] | .[]' "$_prd" 2>/dev/null)
781
+ if [ -n "$_low_quality" ]; then
782
+ warning "[spec-review-gate] low reviewer qualityScore (telemetry — never blocks):"
783
+ printf '%s\n' "$_low_quality" | while IFS= read -r _q; do
784
+ [ -n "$_q" ] && warning "[spec-review-gate] $_q"
785
+ done
786
+ warning "[spec-review-gate] What blocks: a needs_review verdict carrying at least one flag, and missing manifest paths."
787
+ fi
788
+
789
+ if [ -n "$_advisories" ]; then
790
+ warning "[spec-review-gate] the reviewer flagged these for human attention (advisory — not blocking):"
791
+ printf '%s\n' "$_advisories" | while IFS= read -r _a; do
792
+ [ -n "$_a" ] && warning "[spec-review-gate] $_a"
793
+ done
794
+ warning "[spec-review-gate] None carries a flag the reviewer marked blocking (or the project narrowed the set), so none halts the run."
795
+ warning "[spec-review-gate] Blocking flags: ${_blocking_flags:-(none)} — set SPEC_REVIEW_BLOCKING_FLAGS to change."
796
+ fi
797
+
798
+ # The computed missing-path list — the one manifest fact worth halting for. Absent
799
+ # manifestCheck does not block: a resumed run skips the spec pass, and absent is not
800
+ # zero (same rule the quality score already follows).
801
+ _manifest_blockers=$(jq -r '
802
+ .stories[]?
803
+ | select(.status != "deprecated")
804
+ | . as $s
805
+ | (.specification.manifestCheck.missing // []) as $m
806
+ | select(($m | length) > 0)
807
+ | $m[]
808
+ | "\($s.id)\tMISSING declared path: \(.file // .)"
809
+ ' "$_prd" 2>/dev/null)
810
+
811
+ if [ -n "$_manifest_blockers" ]; then
812
+ error "[spec-review-gate] the manifest names files that are NOT in the repository:"
813
+ printf '%s\n' "$_manifest_blockers" | while IFS= read -r _m; do
814
+ [ -n "$_m" ] && error "[spec-review-gate] $_m"
815
+ done
816
+ error "[spec-review-gate] A writer sent to edit a file that is not there cannot succeed,"
817
+ error "[spec-review-gate] and every retry reproduces it. Fix the manifest before implementing."
818
+ return 1
819
+ fi
820
+
821
+ if [ -z "$_blockers" ]; then
822
+ return 0
823
+ fi
824
+
825
+ error "[spec-review-gate] the specification review did not clear these stories:"
826
+ printf '%s\n' "$_blockers" | while IFS= read -r _b; do
827
+ [ -n "$_b" ] && error "[spec-review-gate] $_b"
828
+ done
829
+ error "[spec-review-gate] The reviewer examined the repository and was not satisfied."
830
+ error "[spec-review-gate] Implementing anyway spends the writer's budget on a spec the"
831
+ error "[spec-review-gate] reviewer already flagged. Fix the spec, or set"
832
+ error "[spec-review-gate] SPEC_REVIEW_ENFORCE=0 to proceed deliberately."
833
+ return 1
834
+ }
835
+
836
+ # ── Brownfield story selection: two questions, two names ────────────────────
837
+ #
838
+ # These were ONE jq snippet, copy-pasted into two loops that ask different
839
+ # questions. fe5d6cb (2026-07-29) narrowed it with `storyKind != "novel"` to fix
840
+ # the GATE — correctly, a novel story has no bug to reproduce — and the same
841
+ # filter landed on the test WRITER, which never needed it. Step 10's TC writer is
842
+ # skipped for all brownfield, so the writer at Step 3.54 is the ONLY step that
843
+ # authors a test in this path; excluding novel left those stories with no test
844
+ # author at all, while team-lead-review.sh still enforces test coverage. Live run
845
+ # 20260804T202338Z deadlocked all three lanes on exactly that.
846
+ #
847
+ # Two names so a future copy-paste cannot silently re-conflate them.
848
+
849
+ # Every story in this phase. Who gets a test written for them (Step 3.54), and
850
+ # whose VC coverage is reported (Step 3.55). Test authoring is not conditional on
851
+ # there having been a bug: the writer works from the committed fix diff and the
852
+ # story's verificationCriteria.
853
+ phase_stories_brownfield_scope() {
854
+ local _prd="${1:-${PRD_FILE:-}}" _phase="${2:-${PHASE:-}}"
855
+ [ -n "$_prd" ] && [ -f "$_prd" ] || return 0
856
+ jq -r --arg phase "$_phase" \
857
+ '(.implementationOrder[$phase] // []) as $ids |
858
+ .stories[]? | select(.id != null)
859
+ | select(.id as $id | $ids | index($id) != null)
860
+ | .id' "$_prd" 2>/dev/null || true
861
+ return 0
862
+ }
863
+
864
+ # repro_writer_baseline — lib/repro-baseline.sh (one definition; the review loop loads it too).
865
+ # shellcheck source=repro-baseline.sh
866
+ . "$(dirname "${BASH_SOURCE[0]}")/repro-baseline.sh"
867
+
868
+
869
+ # Strictly narrower, and narrower ON PURPOSE: only stories that have a bug to
870
+ # reproduce. The repro gate proves RED→GREEN against a pre-fix baseline, which a
871
+ # story with no prior bug can never satisfy — it was blocking AMSD-2041 on an
872
+ # unsatisfiable bar. An UNCLASSIFIED story is still gated: absent classification
873
+ # defaults to the safe side.
874
+ # phase_stories_for_tc_writer — the EXACT COMPLEMENT of the repro gate.
875
+ #
876
+ # The bug-reproduction gate excludes storyKind "novel", and rightly: a new capability has no
877
+ # prior bug to reproduce, so RED→GREEN against a pre-fix baseline is unsatisfiable. Step 10
878
+ # then skipped the TC writer for ALL brownfield work, on the stated grounds that the
879
+ # reproduction gate covers it.
880
+ #
881
+ # For a novel brownfield story neither is true. Live 2026-08-07 (AMSD-2041, storyKind novel):
882
+ # Step 10 skipped, Step 3.55 "passed for all phase stories" because it had none to check, and
883
+ # NO step in the pipeline was responsible for tests. The team-lead reviewer asked for them as
884
+ # an opinion on seven review cycles across two runs; the writer, under a minimal-fix
885
+ # instruction, never wrote any; the reviewer never approved; the phase halted every time.
886
+ # Two mechanisms handing off to each other, and novel work falling between them.
887
+ #
888
+ # So: defects are proven by reproduction, novel work is proven by test criteria, and this
889
+ # selector is the other half of that split.
890
+ phase_stories_for_tc_writer() {
891
+ local _prd="${1:-${PRD_FILE:-}}" _phase="${2:-${PHASE:-}}"
892
+ [ -n "$_prd" ] && [ -f "$_prd" ] || return 0
893
+ jq -r --arg phase "$_phase" \
894
+ '(.implementationOrder[$phase] // []) as $ids |
895
+ .stories[]? | select(.id != null)
896
+ | select(.id as $id | $ids | index($id) != null)
897
+ | select((.storyKind // "") == "novel")
898
+ | .id' "$_prd" 2>/dev/null || true
899
+ return 0
900
+ }
901
+
902
+ phase_stories_for_repro_gate() {
903
+ local _prd="${1:-${PRD_FILE:-}}" _phase="${2:-${PHASE:-}}"
904
+ [ -n "$_prd" ] && [ -f "$_prd" ] || return 0
905
+ jq -r --arg phase "$_phase" \
906
+ '(.implementationOrder[$phase] // []) as $ids |
907
+ .stories[]? | select(.id != null)
908
+ | select(.id as $id | $ids | index($id) != null)
909
+ | select((.storyKind // "") != "novel")
910
+ | .id' "$_prd" 2>/dev/null || true
911
+ return 0
912
+ }
913
+
914
+ # assert_phase_stories_have_roles — refuse to run a phase whose stories have no agent.
915
+ #
916
+ # agentRole is deliberately null between synthesis and assignment: at synthesis nothing has
917
+ # analysed the codeline, so there is no roster to choose from, and synthesize-prd-from-jira.js
918
+ # no longer invents one (it used to hardcode a single role, which is how every client ticket
919
+ # ended up on an agent briefed on THIS repo's CLI). assignAgentRoles() fills it after the
920
+ # project's roles are minted.
921
+ #
922
+ # Past that point a null is a defect, and it is a SILENT one: fifteen consumers read the field
923
+ # as `.agentRole // "unknown"`, so an unassigned story is handed to the writer with an empty
924
+ # system prompt and the run completes with nobody the wiser. "unknown" is rejected for the same
925
+ # reason — it is the substituted value, never a role anyone assigned.
926
+ #
927
+ # One guard here rather than fifteen patched call sites.
928
+ assert_phase_stories_have_roles() {
929
+ local _prd="${1:-${PRD_FILE:-}}" _phase="${2:-${PHASE:-}}"
930
+ [ -n "$_prd" ] && [ -f "$_prd" ] || return 0
931
+ local _bad
932
+ _bad=$(jq -r --arg phase "$_phase" \
933
+ '(.implementationOrder[$phase] // []) as $ids |
934
+ .stories[]? | select(.id != null)
935
+ | select(.id as $id | $ids | index($id) != null)
936
+ | select((.agentRole // "") == "" or (.agentRole // "") == "unknown")
937
+ | .id' "$_prd" 2>/dev/null || true)
938
+ [ -z "$_bad" ] && return 0
939
+ printf '[story-guards] no agent role assigned for: %s\n' "$(printf '%s' "$_bad" | tr '\n' ' ')" >&2
940
+ printf '[story-guards] a story with no role runs with an empty system prompt and is read as "unknown" by every consumer downstream. Refusing to run the phase.\n' >&2
941
+ return 1
942
+ }
943
+
944
+ # story_introduced_deps <repo>
945
+ #
946
+ # The dependency names this story ADDED to the manifest, comma-separated, or empty when it added
947
+ # none. Two gates depend on this answer -- SAST (a CVE in pre-existing debt is advisory, in a
948
+ # package the story added it blocks) and lockfile-sync (drift the story caused blocks; drift it
949
+ # inherited does not) -- so it is computed ONCE, here, and read from here.
950
+ #
951
+ # It names no manifest file: the handler asks lib/ecosystem-registry.js. The first version was an inline
952
+ # `grep` over package.json that returned the empty answer on every non-Node codeline, and the empty
953
+ # answer disarms both gates silently.
954
+ story_introduced_deps() {
955
+ local _repo="${1:-}"
956
+ local _handler="${SCRIPT_DIR:-}/lib/handlers/introduced-deps.js"
957
+ local _node="${NODE_CMD:-node}"
958
+ local _ref=""
959
+
960
+ [ -n "$_repo" ] && [ -d "$_repo" ] && [ -f "$_handler" ] || return 0
961
+
962
+ # The phase baseline is where the story's own change begins.
963
+ [ -f "${LOG_DIR:-}/phase-baseline-sha.txt" ] && \
964
+ _ref=$(tr -d '[:space:]' < "${LOG_DIR}/phase-baseline-sha.txt" 2>/dev/null)
965
+ [ -n "$_ref" ] || _ref="$(_resolved_baseline_ref 2>/dev/null || echo '')"
966
+ [ -n "$_ref" ] || return 0
967
+ git -C "$_repo" rev-parse --verify "$_ref" >/dev/null 2>&1 || return 0
968
+
969
+ "$_node" "$_handler" "$_repo" "$_ref" 2>/dev/null || return 0
970
+ }