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.
- package/docs/gateway-baseline-2.0.80.md5 +8 -0
- package/docs/gateway-files.txt +8 -0
- package/orchestrations/scripts/lib/codeline-lanes.sh +20 -0
- package/orchestrations/scripts/lib/mint-and-spec.sh +859 -0
- package/orchestrations/scripts/lib/prd-change-review.sh +380 -0
- package/orchestrations/scripts/lib/prelaunch.js +21 -1
- package/orchestrations/scripts/lib/project-prompt-builder.js +1413 -0
- package/orchestrations/scripts/lib/prompt-library.js +298 -0
- package/orchestrations/scripts/lib/prompt-variant.sh +123 -0
- package/orchestrations/scripts/lib/story-guards.sh +970 -0
- package/orchestrations/scripts/mint-agents-step.js +1417 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|