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,859 @@
1
+ #!/usr/bin/env bash
2
+ # mint-and-spec.sh — moved verbatim out of run-agent-orchestration.sh by tools/split-main-into-modules.py
3
+ # (12 functions). Sourced by run-agent-orchestration.sh; SCRIPT_DIR and the globals it sets
4
+ # are in scope exactly as they were. A move, not an edit: every body is byte-identical to
5
+ # the golden recorded at the move (see the identity test).
6
+
7
+ resolve_prompt_provider() {
8
+ if [ -n "${EPAM_ORCHESTRATION_PROVIDER:-}" ]; then
9
+ echo "$EPAM_ORCHESTRATION_PROVIDER"
10
+ return
11
+ fi
12
+ case "$(basename "$CLAUDE_CMD")" in
13
+ codex|openai|openrouter|cursor|copilot|codemie-claude) echo "$(basename "$CLAUDE_CMD")" ;;
14
+ *) echo "claude" ;;
15
+ esac
16
+ }
17
+
18
+ # GAP-P22: emit a cost record for a pipeline agent invocation.
19
+ # Args: agent_type, story_id, model, started_at, cost_usd, tokens_in, tokens_out, turns
20
+ # _spec_pass_usage <phase_id>
21
+ # Prints "<cost> <tokens_in> <tokens_out> <turns>" for the spec runner's own
22
+ # LLM calls in this phase.
23
+ #
24
+ # spec-mode-runner wires emitCostSnapshot through runClaude — the funnel for the
25
+ # detective, openspec, speckit, the spec coordinator, the VC reviewer and the
26
+ # PRD change reviewer — so every one of those calls already lands in
27
+ # agent-activity.jsonl tagged source="spec-mode-runner". The spec-pass row in
28
+ # phase-cost.jsonl was nevertheless written with literal zeros, so the ledger
29
+ # every reader sums (dashboard, run report, validate-dashboards.sh) understated
30
+ # the run by $0.1077 on gotransit (22%) and $0.0755 on upexpress, measured
31
+ # 2026-07-30. Cost tracking that silently reports zero is worse than none: a
32
+ # reader cannot tell "free" from "unmeasured".
33
+ #
34
+ # Filtered on source, NOT on agent name: typescript-engineer and team-lead-agent
35
+ # write their own phase-cost rows, so summing them here would double-count the
36
+ # run. Overstating is no more true than understating.
37
+ #
38
+ # Fails soft to zeros — a cost record must never be the thing that breaks a run.
39
+ _spec_pass_usage() {
40
+ local _phase="${1:-}"
41
+ local _act="${ACTIVITY_FILE:-$LOG_DIR/agent-activity.jsonl}"
42
+ if [ ! -f "$_act" ]; then printf '0 0 0 0\n'; return 0; fi
43
+ # -R (raw) + fromjson? so ONE malformed line cannot void the whole file.
44
+ # Lanes append concurrently, so a torn write is realistic; jq -s would abort
45
+ # on it and silently report zero — the exact failure this function exists to
46
+ # end.
47
+ jq -rRs --arg ph "$_phase" '
48
+ [ (split("\n") | .[] | select(length > 0) | fromjson? // empty)
49
+ | select(type == "object")
50
+ | select(.type == "cost_snapshot")
51
+ | select((.detail.source // "") == "spec-mode-runner")
52
+ | select(($ph == "") or ((.phase // "") == $ph))
53
+ | .detail ]
54
+ | [ (map(.costUsd // 0) | add // 0),
55
+ (map(.tokensIn // 0) | add // 0),
56
+ (map(.tokensOut // 0) | add // 0),
57
+ (map(.turns // 0) | add // 0) ]
58
+ | @tsv' "$_act" 2>/dev/null | tr '\t' ' ' || printf '0 0 0 0\n'
59
+ }
60
+
61
+ append_pipeline_cost_record() {
62
+ local agent_type="${1:-pipeline}" story_id="${2:-pipeline}"
63
+ local model="${3:-}" started_at="${4:-}" ended_at
64
+ ended_at=$(date -Iseconds)
65
+ local cost="${5:-0}" tokens_in="${6:-0}" tokens_out="${7:-0}" turns="${8:-0}"
66
+ local cost_file="${PHASE_COST_FILE:-$LOG_DIR/phase-cost.jsonl}"
67
+ local lock_file="${cost_file}.lock"
68
+ local phase_id="${CURRENT_PHASE:-${PHASE:-unknown}}"
69
+ (
70
+ flock -w 5 200 2>/dev/null || true
71
+ jq -cn \
72
+ --arg pid "$phase_id" \
73
+ --arg sid "$story_id" \
74
+ --arg at "$agent_type" \
75
+ --arg rm "$model" \
76
+ --arg sa "$started_at" \
77
+ --arg ea "$ended_at" \
78
+ --argjson cu "${cost:-0}" \
79
+ --argjson ti "${tokens_in:-0}" \
80
+ --argjson to "${tokens_out:-0}" \
81
+ --argjson tt "${turns:-0}" \
82
+ '{phase_id:$pid, story_id:$sid, agent_type:$at, resolvedModel:$rm,
83
+ started_at:$sa, ended_at:$ea, task_cost_usd:$cu,
84
+ task_tokens_in:$ti, task_tokens_out:$to, task_turns:$tt,
85
+ status:"completed", invokeMode:"cli"}' >> "$cost_file"
86
+ ) 200>"$lock_file"
87
+ }
88
+
89
+ # ── The agent mint ────────────────────────────────────────────────────────────
90
+ # EVERY PROJECT MINTS ITS OWN AGENTS, however its PRD arrived.
91
+ #
92
+ # This lived inside _run_jira_pipeline, so a project whose PRD is authored never reached it and ran
93
+ # on the canonical base roster — epam-cli's own first-commit agents, which is the exact failure the
94
+ # mint was built to end. It also meant no role assignment, no project prompt library and no
95
+ # prompt-agent-link, so a project declaring EPAM_PROMPT_PROVISION_MODE=generate exercised none of
96
+ # that path.
97
+ #
98
+ # Same shape as codeline discovery, which was also reachable only from the Jira branch: a
99
+ # capability every project needs cannot live behind the branch only some projects take.
100
+ #
101
+ # $1 — the PRD to mint against
102
+ # $2 — the log file to tee into
103
+ _run_agent_mint() {
104
+ local _prd="$1" _log="${2:-/dev/null}"
105
+
106
+ # THE SCAFFOLD PHASE, first. It carries no implementation stories; its only job is to make the
107
+ # phase loop fire the pre-phase skill assessment over every story, so each agent's profile gains
108
+ # this project's skills before any implementation begins. Without it that assessment never runs
109
+ # and every agent works without them.
110
+ #
111
+ # It lived inside the Jira branch, so a project with an authored PRD never got one — the same
112
+ # shape as the mint itself and as codeline discovery. Paired with the mint here because both
113
+ # prepare the roster's working conditions and both are needed by every project.
114
+ # Guarded so an unloaded library is skipped rather than fatal: an undefined function
115
+ # returns 127, and `|| exit 1` on that silently killed the enclosing block wherever a
116
+ # harness runs this code with the gate library absent. The orchestrator sources it at the
117
+ # top, and pre-flight is what actually gates a run.
118
+ declare -f require_stage_coverage >/dev/null && { require_stage_coverage ingest || exit 1; }
119
+ if "$NODE_BIN" "$SCRIPT_DIR/lib/handlers/run-jira-pipeline.js" "$_prd"; then
120
+ log "[mint] Scaffold phase present for the pre-phase skill assessment"
121
+ else
122
+ error "[mint] could not inject the scaffold phase into ${_prd} — the pre-phase skill"
123
+ error "[mint] assessment would not run, so every agent would work without this project's skills."
124
+ return 1
125
+ fi
126
+
127
+ # A CODELINE THAT IS ALREADY PROVISIONED IS NOT RE-MINTED.
128
+ #
129
+ # Operator, 2026-09-06: "mint should not run nor prompt builder".
130
+ #
131
+ # THE DECISION IS HERE, IN BASH, and that is the whole point of this block sitting where it does.
132
+ # A first attempt set EPAM_SKIP_AGENT_MINT inside mint-agents-step.js — a CHILD of the gate
133
+ # below, spawned only after that gate has already decided and logged "Minting project agents".
134
+ # A child cannot change the mind of the parent that spawned it. It was covered by twelve green
135
+ # tests that lifted the child's guard and ran it directly: they proved a value is computed and
136
+ # could not observe the decision. The live run said MINTING=1.
137
+ #
138
+ # The signal is the one pre-run-reset and the prompt builder already trust:
139
+ # .prompt-cache/.complete-<codeline>, written only after every prompt installed, named for the
140
+ # codeline, and cleared whenever provisioning restarts. Prompts must also actually be on disk —
141
+ # a marker beside an empty directory would skip the mint and leave the run with no prompts.
142
+ # THE CODELINE IS DETECTED, NEVER PRESET.
143
+ #
144
+ # Operator, 2026-09-06: "code line is detected in a live run ... this var will never be preset in
145
+ # a live run." EPAM_CODELINE_ID is READ in six places in this repo and SET in none — no launcher,
146
+ # config file or env file exports it. Gated on it alone, this test built the path
147
+ # `.complete-` on every live run, matched nothing, and the mint ran and was PAID FOR every time
148
+ # while the reuse machinery reported itself as working. A gate that cannot fire is worse than no
149
+ # gate: it is a saving that appears in the log and never in the bill.
150
+ #
151
+ # The PRD holds the run's resolved scope by the time the mint is reached — synthesize-prd-from-
152
+ # jira.js writes project.outputDirs and project.outputDir, and the mint is invoked with that PRD
153
+ # ($1). Those are the fields lib/codeline-scope.sh already treats as the run's scope, so both
154
+ # sides of the engine answer "which codeline?" from the same place.
155
+ #
156
+ # EXACTLY ONE, or nothing: a run scoped to two codelines produces assets specialised for both,
157
+ # and claiming either would hand the other's run a set built for a repository it is not in.
158
+ local _detected_cl="${EPAM_CODELINE_ID:-}"
159
+ if [ -z "$_detected_cl" ] && [ -n "${1:-}" ] && [ -f "${1:-}" ] && command -v jq >/dev/null 2>&1; then
160
+ _detected_cl=$(jq -r '
161
+ [ (.project.outputDirs // [])[]?.path,
162
+ (.project.outputDir // empty) ]
163
+ | map(select(type == "string" and . != "") | sub("/+$"; "") | split("/") | last)
164
+ | unique
165
+ | if length == 1 then .[0] else empty end
166
+ ' "$1" 2>/dev/null || true)
167
+ fi
168
+ # A STORY OF SEVERAL CODELINES (operator, 2026-10-22: "prompts/profiles BY codeline"): its own codeline when EVERY codeline of the
169
+ # story has its complete recorded set (lib/prompt-variant.sh scope_codeline_of_prd), so the reuse below decides for it; else empty
170
+ # — provisioned again, and the build records the set for every codeline of the story.
171
+ if [ -z "$_detected_cl" ] && [ -n "${1:-}" ] && [ -f "${1:-}" ]; then
172
+ _detected_cl=$(scope_codeline_of_prd "$1")
173
+ fi
174
+
175
+ # SETTLE WHAT pre-run-reset COULD NOT DECIDE.
176
+ #
177
+ # The reset runs from the launcher, before discovery, so it cannot know which codeline the run is
178
+ # for. Rather than guess — and destroy a completed codeline's roster, registries and prompts on
179
+ # every live run — it defers and leaves `.prompt-cache/.reset-pending` behind. This is the first
180
+ # point the decision can be taken correctly: discovery has persisted the scope, both call paths
181
+ # reach here, and no profile has been generated yet.
182
+ #
183
+ # THE CLEAN SLATE IS NOT WEAKENED, only correctly timed. Anything unproven still clears: another
184
+ # codeline's marker, no codeline resolved, two codelines resolved, or the override. And exactly
185
+ # what pre-run-reset clears is cleared here — never more, or this becomes a second, divergent
186
+ # clean-slate policy that nothing reconciles.
187
+ local _pending="${EPAM_PROJECT_CONFIG_DIR:+$EPAM_PROJECT_CONFIG_DIR/.prompt-cache/.reset-pending}"
188
+ if [ -n "$_pending" ] && [ -f "$_pending" ]; then
189
+ if [ "${EPAM_REGENERATE_CODELINE_ASSETS:-0}" != "1" ] \
190
+ && [ -n "$_detected_cl" ] \
191
+ && codeline_prompts_complete "$_detected_cl" \
192
+ && codeline_minted_restore "$_detected_cl"; then
193
+ log "[mint] deferred decision settled: ${_detected_cl} completed its agents and prompts — kept (its own recorded agents restored)"
194
+ elif [ "${EPAM_REGENERATE_CODELINE_ASSETS:-0}" != "1" ] \
195
+ && [ -n "$_detected_cl" ] \
196
+ && [ -f "$EPAM_PROJECT_CONFIG_DIR/.prompt-cache/$(prompt_marker_key "$_detected_cl")" ] \
197
+ && { declare -F resume_preserves >/dev/null 2>&1 || . "$(dirname "${BASH_SOURCE[0]}")/resume-semantics.sh"; } \
198
+ && resume_preserves prompts; then
199
+ # A RESUME KEEPS THE PROMPTS IT BUILT, even when an engine release since the pause changed
200
+ # their inputs (config/resume-preserves.json: "prompts … reused, not rebuilt"). This branch
201
+ # used to fall through to the rebuild below on a resume too — a completed step run twice after
202
+ # every release (found 2026-09-25 by the £0 mocking agent). The drift is said, not hidden;
203
+ # the next NEW launch rebuilds from the new inputs.
204
+ log "[mint] deferred decision settled: resuming ${EPAM_RESUME_RUN} — ${_detected_cl}'s prompts were built by this run; their inputs changed since (an engine release) — kept for this run, each judged again by the current reviewer (only a rejected one is regenerated); rebuilt on the next new launch"
205
+ EPAM_SKIP_AGENT_MINT=1
206
+ elif [ "${EPAM_REGENERATE_CODELINE_ASSETS:-0}" != "1" ] \
207
+ && [ -n "$_detected_cl" ] \
208
+ && [ -f "$EPAM_PROJECT_CONFIG_DIR/.prompt-cache/$(prompt_marker_key "$_detected_cl")" ] \
209
+ && codeline_minted_restore "$_detected_cl"; then
210
+ # STALE, NOT FOREIGN. The marker names this codeline, so the roster is this codeline's own
211
+ # (the mint's paid work) and is kept; only the prompts are built from inputs that have since
212
+ # changed, and the prompt builder rebuilds exactly those (regintel 20260920T232518Z, $5.73).
213
+ log "[mint] deferred decision settled: ${_detected_cl} is this run's codeline but its prompt inputs changed — keeping the roster, rebuilding the prompts"
214
+ rm -rf "$EPAM_PROJECT_CONFIG_DIR/prompts" 2>/dev/null || true
215
+ mkdir -p "$EPAM_PROJECT_CONFIG_DIR/prompts" 2>/dev/null || true
216
+ EPAM_SKIP_AGENT_MINT=1
217
+ EPAM_REBUILD_PROMPTS=1
218
+ export EPAM_REBUILD_PROMPTS
219
+ elif [ "${EPAM_REGENERATE_CODELINE_ASSETS:-0}" != "1" ] \
220
+ && { declare -F resume_preserves >/dev/null 2>&1 || . "$(dirname "${BASH_SOURCE[0]}")/resume-semantics.sh"; } \
221
+ && resume_preserves roster; then
222
+ # A RESUME NEVER CLEARS WHAT THE RUN BUILT. With no completion marker — a prompt build that
223
+ # did not finish — this fell through to the clearing below ON A RESUME: the reviewed roster,
224
+ # the project roles and every prompt were deleted, and the roster was re-derived (regintel,
225
+ # 2026-09-25). The roster and agents are this run's and are kept; the prompt set is COMPLETED
226
+ # by the mint step (every installed prompt kept, only the missing ones built).
227
+ log "[mint] deferred decision settled: resuming ${EPAM_RESUME_RUN} — no completion marker (an earlier prompt build did not finish); keeping this run's roster, agents and prompts, completing the missing prompts"
228
+ EPAM_SKIP_AGENT_MINT=1
229
+ elif [ "${EPAM_REGENERATE_CODELINE_ASSETS:-0}" != "1" ] \
230
+ && "$NODE_BIN" "$SCRIPT_DIR/lib/project-roster.js" --kept-for "$1" "$EPAM_PROJECT_CONFIG_DIR" "${LOG_DIR:-}/codeline-discovery.json" 2>/dev/null; then
231
+ # THE PREVIOUS RUN'S AGENTS ARE THIS RUN'S. A run that died before its prompt set completed wrote no completion
232
+ # marker, and every fresh launch over the same codelines threw its reviewed roster away and paid to mint it again
233
+ # (AMSD-2974 run 6 -> 7, 2026-10-09). The roster is kept when it is current for today's seam bases and the
234
+ # investigators it minted cover exactly this run's codelines (project-roster.js keptRosterFor). The installed
235
+ # prompt copies are cleared so the builder serves the reviewed cache — a kept copy would be re-reviewed, paid —
236
+ # and only prompts the cache does not hold are generated.
237
+ log "[mint] deferred decision settled: the previous run's agents are current and were minted for exactly this run's codelines — kept, nothing minted; prompts come from the reviewed cache"
238
+ rm -rf "$EPAM_PROJECT_CONFIG_DIR/prompts" 2>/dev/null || true
239
+ mkdir -p "$EPAM_PROJECT_CONFIG_DIR/prompts" 2>/dev/null || true
240
+ EPAM_SKIP_AGENT_MINT=1
241
+ else
242
+ log "[mint] deferred decision settled: this run is not ${_detected_cl:-<unresolved>}'s completed codeline — clearing the previous run's agents and prompts"
243
+ codeline_minted_clear
244
+ rm -rf "$EPAM_PROJECT_CONFIG_DIR/prompts" 2>/dev/null || true
245
+ mkdir -p "$EPAM_PROJECT_CONFIG_DIR/prompts" 2>/dev/null || true
246
+ fi
247
+ rm -f "$_pending" 2>/dev/null || true
248
+ fi
249
+
250
+ # PROVISIONED FOR THE CURRENT INPUTS, not merely provisioned once: codeline_prompts_complete
251
+ # compares the marker's digest with the template layer now. A changed template layer keeps the
252
+ # roster (the mint's LLM work) and rebuilds the prompts (regintel 20260920T232518Z, $5.73).
253
+ if [ "${EPAM_SKIP_AGENT_MINT:-0}" != "1" ] \
254
+ && [ "${EPAM_REGENERATE_CODELINE_ASSETS:-0}" != "1" ] \
255
+ && [ -n "$_detected_cl" ] && [ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] \
256
+ && ls "$EPAM_PROJECT_CONFIG_DIR/prompts/"*.json >/dev/null 2>&1; then
257
+ if codeline_prompts_complete "$_detected_cl" && codeline_minted_restore "$_detected_cl"; then
258
+ log "[mint] codeline ${_detected_cl} is already provisioned from the current templates — the mint is skipped; EPAM_REGENERATE_CODELINE_ASSETS=1 forces a re-mint"
259
+ EPAM_SKIP_AGENT_MINT=1
260
+ elif [ -f "$EPAM_PROJECT_CONFIG_DIR/.prompt-cache/$(prompt_marker_key "$_detected_cl")" ] \
261
+ && codeline_minted_restore "$_detected_cl"; then
262
+ log "[mint] codeline ${_detected_cl}: the prompt inputs changed since its prompts were built — keeping the roster, rebuilding the prompts"
263
+ EPAM_SKIP_AGENT_MINT=1
264
+ EPAM_REBUILD_PROMPTS=1
265
+ export EPAM_REBUILD_PROMPTS
266
+ fi
267
+ fi
268
+
269
+ # SKIPPING THE MINT REQUIRES A ROSTER TO SKIP TO. Travels with the mint rather than sitting in
270
+ # one caller, so the other path cannot skip into nothing.
271
+ if [ "${EPAM_SKIP_AGENT_MINT:-0}" = "1" ]; then
272
+ local _roster_file="${EPAM_AGENTS_DIR}/profiles.json" _roster_n=0
273
+ [ -s "$_roster_file" ] && _roster_n=$("$NODE_BIN" "$SCRIPT_DIR/lib/handlers/roster-size.js" "$_roster_file" 2>/dev/null || echo 0)
274
+ if [ "${_roster_n:-0}" -lt 1 ]; then
275
+ error "[mint] EPAM_SKIP_AGENT_MINT=1 but no minted roster exists at ${_roster_file}."
276
+ error "[mint] Skipping the mint now would hand every story to an agent that was never defined."
277
+ return 1
278
+ fi
279
+ log "[mint] Agent mint skipped (EPAM_SKIP_AGENT_MINT=1) — using the roster on disk (${_roster_n} agent(s))"
280
+
281
+ # BUT THE PROJECT ROSTER IS STILL DERIVED. Skipping the mint means "invent no new agents"; it
282
+ # has never meant "run with no identities". Every launch resets, and a writer-style resume
283
+ # skips the mint on purpose, so without this a resumed run would reach its first seam with no
284
+ # persona for it — and there is no engine roster to fall back to any more.
285
+ #
286
+ # Roster-only: no survey, no proposals, no role assignment. Just canonical -> this project.
287
+ # Guarded so an unloaded library is skipped rather than fatal: an undefined function
288
+ # returns 127, and `|| exit 1` on that silently killed the enclosing block wherever a
289
+ # harness runs this code with the gate library absent. The orchestrator sources it at the
290
+ # top, and pre-flight is what actually gates a run.
291
+ declare -f require_stage_coverage >/dev/null && { require_stage_coverage mint || exit 1; }
292
+ log "[mint] Deriving this project's roster (roster-only — nothing is minted)..."
293
+ EPAM_ROSTER_ONLY=1 EPAM_SKIP_AGENT_MINT=1 "$NODE_BIN" "$SCRIPT_DIR/mint-agents-step.js" \
294
+ --prd "$_prd" \
295
+ --agents-dir "$EPAM_AGENTS_DIR" \
296
+ --log-dir "$LOG_DIR" \
297
+ --codeline-root "${PROJECT_ROOT:-}" 2>&1 | tee -a "$_log"
298
+ # PIPESTATUS[0], not the pipeline status: without pipefail this reads tee's, and a roster the
299
+ # agent never produced would pass as one it did.
300
+ if [ "${PIPESTATUS[0]}" -ne 0 ]; then
301
+ error "[mint] roster derivation FAILED — refusing to continue with agents that have no identity."
302
+ return 1
303
+ fi
304
+ fi
305
+ [ "${EPAM_SKIP_AGENT_MINT:-0}" = "1" ] || {
306
+ # A FULL MINT STARTS FROM A CLEAN SLATE (B49; operator, 2026-10-22: "Fix critical"): the roster it replaces is cleared first, as
307
+ # the deferred decision's own clean slate does, so the mint's own seams take their base SI (lib/agent-si.js: the base is for the
308
+ # seams that run before the roster exists). A fresh install's committed roster has no agent-mint entry, and a full mint
309
+ # (EPAM_REGENERATE_CODELINE_ASSETS=1) was refused at the hub. The codeline's recorded roster (.prompt-cache/<codeline>/minted) is
310
+ # not touched.
311
+ codeline_minted_clear
312
+ log "[mint] Minting project agents and assigning roles..."
313
+ "$NODE_BIN" "$SCRIPT_DIR/mint-agents-step.js" \
314
+ --prd "$_prd" \
315
+ --agents-dir "$EPAM_AGENTS_DIR" \
316
+ --log-dir "$LOG_DIR" \
317
+ --codeline-root "${PROJECT_ROOT:-}" 2>&1 | tee -a "$_log"
318
+ # PIPESTATUS[0], not the pipeline status: without pipefail the status here is tee's, so a
319
+ # failed mint would read as a success and every story would run with no assigned agent.
320
+ if [ "${PIPESTATUS[0]}" != "0" ]; then
321
+ error "[mint] Agent mint/assignment failed — refusing to run stories with no assigned agent."
322
+ return 1
323
+ fi
324
+ }
325
+ return 0
326
+ }
327
+
328
+ # PAUSE 1 of 2 — A HUMAN REVIEW POINT ON EVERY PATH, NOT JUST THE INGESTING ONE.
329
+ #
330
+ # This lived inline inside _run_jira_pipeline, so it was reachable only by a project that
331
+ # ingests from a tracker. A project that AUTHORS its PRD takes the other _run_agent_mint call
332
+ # site and ran straight past it into the spec phase: on 2026-08-27 mock3 was launched with
333
+ # EPAM_PAUSE_AFTER_AGENT_MINT=1 correctly set and present in the process environment, and never
334
+ # stopped — the operator was promised a review point that the shape of their project could not
335
+ # reach. The pause is about the roster and the assignments, which every path produces.
336
+ #
337
+ # Defined ONCE and called from both sites: the review point and the checkpoint it writes are one
338
+ # behaviour, and two copies is how the two paths drifted apart in the first place.
339
+ _pause_after_agent_mint() {
340
+ # PAUSE 1 of 2 — the roster is minted and every story assigned, and nothing has been
341
+ # specified or written yet. Which roles exist, how they are briefed, and which story each
342
+ # owns shape every later stage, and they are cheap to correct here and expensive to correct
343
+ # after the spec pass has built on them.
344
+ if command -v should_pause_after_agent_mint >/dev/null 2>&1 && should_pause_after_agent_mint; then
345
+ local _rckpt=""
346
+ if _rckpt=$(save_run_checkpoint "${PHASE:-core}" post-roster 2>&1); then
347
+ info "[orch] post-roster checkpoint saved: ${_rckpt}"
348
+ else
349
+ warning "[orch] could not save the post-roster checkpoint: ${_rckpt}"
350
+ fi
351
+ echo ""
352
+ echo -e "${GREEN}╔════════════════════════════════════════════════════════════════════╗${NC}"
353
+ echo -e "${GREEN}║ PAUSED — agents minted and assigned, spec NOT started ║${NC}"
354
+ echo -e "${GREEN}╚════════════════════════════════════════════════════════════════════╝${NC}"
355
+ echo ""
356
+ echo -e " RUN NUMBER: ${GREEN}${ORCH_RUN_ID:-unknown}${NC}"
357
+ echo -e " Roster: ${EPAM_AGENTS_DIR}/profiles.json"
358
+ echo -e " Implementers: ${EPAM_PROJECT_CONFIG_DIR:-${EPAM_AGENTS_DIR}}/project-roles.json"
359
+ echo -e " Investigators: ${EPAM_PROJECT_CONFIG_DIR:-${EPAM_AGENTS_DIR}}/project-investigators.json"
360
+ echo -e " Minted: ${LOG_DIR}/agent-mint.json"
361
+ echo -e " Assignments: ${LOG_DIR}/role-assignments.json"
362
+ echo -e " ${GREEN}WHAT WAS GENERATED (vs canonical): ${LOG_DIR}/roster-diff.md${NC}"
363
+ echo -e " What the mint could SEE: ${LOG_DIR}/mint-inputs.json"
364
+ echo -e " ${GREEN}ROSTER REVIEW: ${LOG_DIR}/roster-review.json${NC}"
365
+ if [ -f "${LOG_DIR}/roster-review.json" ] && command -v jq >/dev/null 2>&1; then
366
+ _rv=$(jq -r '.verdict // "?"' "${LOG_DIR}/roster-review.json" 2>/dev/null)
367
+ _rn=$(jq -r '.findings | length' "${LOG_DIR}/roster-review.json" 2>/dev/null)
368
+ _rb=$(jq -r '[.findings[]? | select(.severity=="blocking")] | length' "${LOG_DIR}/roster-review.json" 2>/dev/null)
369
+ if [ "${_rb:-0}" != "0" ]; then
370
+ echo -e " ${RED}verdict: ${_rv} — ${_rn} finding(s), ${_rb} BLOCKING${NC}"
371
+ else
372
+ echo -e " verdict: ${_rv} — ${_rn} finding(s)"
373
+ fi
374
+ jq -r '.findings[]? | " [\(.severity)] \(.agent): \(.found)"' "${LOG_DIR}/roster-review.json" 2>/dev/null | head -8
375
+ fi
376
+ if [ -f "${LOG_DIR}/mint-inputs.json" ] && command -v jq >/dev/null 2>&1; then
377
+ _mi_repo=$(jq -r '.codelineRepo // "NONE"' "${LOG_DIR}/mint-inputs.json" 2>/dev/null)
378
+ _mi_deps=$(jq -r '.declaredDependencies // 0' "${LOG_DIR}/mint-inputs.json" 2>/dev/null)
379
+ _mi_df=$(jq -r '.documentsFetched // 0' "${LOG_DIR}/mint-inputs.json" 2>/dev/null)
380
+ _mi_dl=$(jq -r '.documentsLinked // 0' "${LOG_DIR}/mint-inputs.json" 2>/dev/null)
381
+ echo -e " codeline repo: ${_mi_repo}"
382
+ echo -e " declared deps: ${_mi_deps}"
383
+ if [ "${_mi_df}" != "${_mi_dl}" ]; then
384
+ echo -e " ${RED}documents: ${_mi_df} of ${_mi_dl} fetched — the roster was derived WITHOUT them${NC}"
385
+ else
386
+ echo -e " documents: ${_mi_df} of ${_mi_dl} fetched"
387
+ fi
388
+ fi
389
+ echo ""
390
+ echo -e " Inspect and EDIT if needed:"
391
+ # The set is declared by operator_reviewable_inputs and kept by save_run_checkpoint. Printing a
392
+ # second hand-kept list here is how the banner came to offer files the checkpoint never saved.
393
+ while IFS=$'\t' read -r _ri_path _ri_what; do
394
+ [ -n "$_ri_path" ] || continue
395
+ echo -e " ${_ri_path} (${_ri_what})"
396
+ done < <(operator_reviewable_inputs "${_synth_prd}")
397
+ echo ""
398
+ echo -e " Then CONTINUE into the spec phase with:"
399
+ echo -e " ${GREEN}EPAM_RESUME_RUN=${ORCH_RUN_ID:-<run-id>} ${TIER3_LAUNCHER:-<your launcher>} --yes${NC}"
400
+ echo ""
401
+ echo -e " Resume re-reads those files and VALIDATES your edits (every story assigned, every"
402
+ echo -e " role real and not a canonical process role). It does not re-mint and does not"
403
+ echo -e " re-assign over your changes."
404
+ echo ""
405
+ # END THE RUN — exit, not return.
406
+ #
407
+ # This said `return 0` under this same comment. `return` leaves the FUNCTION; the caller carries
408
+ # on. On the ingesting path the call happened to be the last thing before an exit, so it halted
409
+ # by accident and looked correct for as long as nobody called it from anywhere else.
410
+ #
411
+ # Live 2026-08-28 on a PAID run: the banner printed with its resume instructions, the operator
412
+ # was told the run had stopped, and it went straight into the spec pass and was making model
413
+ # calls when it was killed by hand. Pause 2, three thousand lines below, has always used exit.
414
+ #
415
+ # The operator restarts with the command above; resume validates the roster rather than
416
+ # regenerating it, so hand edits survive.
417
+ record_run_pause post-roster
418
+ exit 0
419
+ fi
420
+ }
421
+
422
+ # THE ESTIMATION APPLICATION'S ASSESSMENT OF EACH TICKET, FOR THE AGENTS THAT WORK ON IT.
423
+ #
424
+ # Operator, 2026-09-28: estimation manifests will commonly inform the brownfield pipeline —
425
+ # EPAM_ESTIMATION_MANIFEST names one (a file or a directory; the format is declared in
426
+ # config/estimation-manifest.json). Each ingested ticket's entry becomes one record under
427
+ # $LOG_DIR/estimation/, exported as EPAM_ESTIMATION_DIR for every stage after this one; six agents
428
+ # render it through lib/handlers/estimation-manifest.js (the spec agent, the detective, CPA, the
429
+ # writer, the repro-test writer, the reviewer). It is evidence for them, never an edit to the PRD.
430
+ #
431
+ # UNSET IS NOTHING: no output, no directory, no export — a run without a manifest is the run it
432
+ # was. Runs on a resume too (ingest is skipped there, the records are not: each run's logs are its
433
+ # own). A manifest the operator named but that cannot be read stops the run here, before any agent
434
+ # is spent on it; a ticket the manifest does not cover is said aloud and the run goes on.
435
+ _read_estimation_manifest() {
436
+ local _prd="$1" _log_file="$2"
437
+ [ -n "${EPAM_ESTIMATION_MANIFEST:-}" ] || return 0
438
+ if [ ! -r "$EPAM_ESTIMATION_MANIFEST" ]; then
439
+ error "[estimation] EPAM_ESTIMATION_MANIFEST=$EPAM_ESTIMATION_MANIFEST cannot be read — refusing to run without the assessment the run was told to use"
440
+ return 1
441
+ fi
442
+ local _dir="${LOG_DIR:?LOG_DIR must be set}/estimation" _key _rc _found=0 _missing=()
443
+ mkdir -p "$_dir" || return 1
444
+ export EPAM_ESTIMATION_DIR="$_dir"
445
+ while IFS= read -r _key; do
446
+ [ -n "$_key" ] || continue
447
+ "${NODE_BIN:-node}" "$SCRIPT_DIR/lib/handlers/estimation-manifest.js" extract \
448
+ "$EPAM_ESTIMATION_MANIFEST" "$_key" "$_dir/${_key//[^A-Za-z0-9._-]/_}.json" 2>&1 | tee -a "$_log_file"
449
+ _rc="${PIPESTATUS[0]}"
450
+ case "$_rc" in
451
+ 0) _found=$((_found + 1)) ;;
452
+ 3) _missing+=("$_key") ;;
453
+ *) error "[estimation] the manifest could not be read for $_key (exit $_rc) — see the line above"; return 1 ;;
454
+ esac
455
+ done < <(jq -r '[.stories[]? | (.jiraKey // empty)] | unique | .[]' "$_prd" 2>/dev/null)
456
+ log "[estimation] $_found ticket(s) assessed from $EPAM_ESTIMATION_MANIFEST → $_dir"
457
+ [ "${#_missing[@]}" -eq 0 ] || warning "[estimation] the manifest holds NOTHING for: ${_missing[*]} — those tickets run without an assessment"
458
+ return 0
459
+ }
460
+
461
+ _run_jira_pipeline() {
462
+ local _jira_dir="$AUTOMATION_DIR/jira"
463
+ local _log_file
464
+ _log_file="/tmp/orch-$(date +%Y%m%dT%H%M%S).log"
465
+
466
+ local _missing=()
467
+ [ -z "${JIRA_URL:-}" ] && _missing+=("JIRA_URL")
468
+ [ -z "${JIRA_EMAIL:-}" ] && _missing+=("JIRA_EMAIL")
469
+ [ -z "${JIRA_TOKEN:-}" ] && _missing+=("JIRA_TOKEN")
470
+ [ -z "${JIRA_PROJECT_KEY:-}" ] && _missing+=("JIRA_PROJECT_KEY")
471
+ # Worktree validation uses bash indirection to avoid relying on `env | grep`.
472
+ # Brownfield mode: JIRA_CODELINE_ROOT is required; worktree paths are discovered
473
+ # at runtime by codeline-discovery.js (ingest step 1.5) — no JIRA_WORKTREE_* needed.
474
+ # Greenfield mode: JIRA_CODELINES + matching JIRA_WORKTREE_* must be pre-declared.
475
+ local _found_wt=0
476
+ if [ "${EPAM_BROWNFIELD:-0}" = "1" ]; then
477
+ [ -z "${JIRA_CODELINE_ROOT:-}" ] && _missing+=("JIRA_CODELINE_ROOT")
478
+ [ -n "${JIRA_CODELINE_ROOT:-}" ] && _found_wt=1
479
+ elif [ -n "${JIRA_CODELINES:-}" ]; then
480
+ IFS=',' read -ra _wt_cls <<< "$JIRA_CODELINES"
481
+ for _wt_cl in "${_wt_cls[@]}"; do
482
+ local _wt_var="JIRA_WORKTREE_${_wt_cl^^}"
483
+ if [ -n "${!_wt_var:-}" ]; then _found_wt=1; else _missing+=("$_wt_var"); fi
484
+ done
485
+ else
486
+ [ -n "${JIRA_WORKTREE_BE:-}" ] && _found_wt=1
487
+ [ "$_found_wt" = "0" ] && _missing+=("JIRA_WORKTREE_<CODELINE>")
488
+ fi
489
+ if [ ${#_missing[@]} -gt 0 ]; then
490
+ error "[jira] Missing required env vars: ${_missing[*]}"
491
+ error "[jira] Run: source orchestrations/jira/.env"
492
+ return 1
493
+ fi
494
+
495
+ log "[jira] ${JIRA_URL} (project: ${JIRA_PROJECT_KEY}) → ${_log_file}"
496
+
497
+ # Overridable so a test (or any concurrent, isolated Jira-pipeline run) can
498
+ # point the synthesized PRD at its own disposable path instead of colliding
499
+ # with whatever real project last used the shared default location.
500
+ # Follows the run's own PRD_FILE. There is NO built-in default: a default that names
501
+ # one project does not fail when it is wrong, it succeeds against that project's data —
502
+ # which is exactly how every Jira-driven run once synthesized into one shared PRD
503
+ # (2026-07-25 clobber). Absent is an error, not a substitution.
504
+ local _synth_prd="${JIRA_SYNTH_PRD_PATH:-${PRD_FILE:-}}"
505
+ if [ -z "$_synth_prd" ]; then
506
+ error "[jira] no PRD path: set JIRA_SYNTH_PRD_PATH or PRD_FILE. Refusing to guess — the engine names no project."
507
+ return 1
508
+ fi
509
+ local _ingest_exit=0
510
+ # IMPORTANT: do NOT use `|| _ingest_exit=${PIPESTATUS[0]}` here.
511
+ # Without pipefail, the pipeline exit code is tee's exit code (almost always 0),
512
+ # so the || never fires even when ingest-jira-tickets.sh exits 1.
513
+ # PIPESTATUS[0] captures bash ingest's exit code REGARDLESS of tee's success.
514
+ # A RESUME MUST NOT RE-INGEST. ingest writes --out-prd over PRD_FILE, and Jira carries the
515
+ # story text only: no verification criteria, no fix-site analysis, no per-codeline maps. On a
516
+ # resume the PRD on disk is strictly richer than anything ingest can synthesize, so re-running
517
+ # it silently deletes the spec output the resume exists to preserve.
518
+ #
519
+ # Live 2026-08-09: restore_run_checkpoint correctly KEPT the merged canonical (27 spec items,
520
+ # "KEEPING the PRD on disk"), and this call emptied it three steps later. The lane PRDs are
521
+ # filtered FROM canonical at lane start, so gotransit ran its writer against 0 criteria and the
522
+ # end-of-lane merge wrote that emptiness back, taking the other two codelines' entries with it.
523
+ # The restore guard was necessary and not sufficient — it protected one writer, not the file.
524
+ if [ "${EPAM_SKIP_JIRA_INGEST:-0}" = "1" ]; then
525
+ if [ ! -s "$_synth_prd" ]; then
526
+ error "[jira] resume asked to skip ingest but no PRD exists at $_synth_prd — refusing to continue with no stories"
527
+ return 1
528
+ fi
529
+ log "[jira] ⊘ Ingest skipped (resume) — using the PRD on disk: $(jq '[.stories[]?] | length' "$_synth_prd" 2>/dev/null || echo '?') story(ies), $(jq '[.stories[]? | ((.verificationCriteria // []) | length) + ((.fixSiteAnalysis // []) | length)] | add // 0' "$_synth_prd" 2>/dev/null || echo '?') spec item(s)"
530
+ else
531
+ bash "$SCRIPT_DIR/ingest-jira-tickets.sh" \
532
+ --project "$JIRA_PROJECT_KEY" \
533
+ --status "${JIRA_STATUS_FILTER:-To Do}" \
534
+ --out-prd "$_synth_prd" \
535
+ 2>&1 | tee -a "$_log_file"
536
+ _ingest_exit="${PIPESTATUS[0]}"
537
+ fi
538
+
539
+ if [ "$_ingest_exit" = "2" ]; then
540
+ error "[jira] Pipeline halted: insufficient ACs. Review Jira tickets and re-trigger."
541
+ return 2
542
+ elif [ "$_ingest_exit" != "0" ]; then
543
+ error "[jira] Ingestion failed (exit $_ingest_exit)."
544
+ return 1
545
+ fi
546
+
547
+ # Inject an empty scaffold phase into implementationOrder so the tier3 launcher's
548
+ # run_phase "scaffold" call fires the pre-phase assessment agent. The scaffold phase
549
+ # has 0 implementation stories but runs Step 3 (skill assessment) over ALL synthesized
550
+ # Jira stories — this assesses and injects project-specific skills into each agent's
551
+ # profile before any core implementation begins. Agent SKILLS are assessed per project
552
+ # here; agent IDENTITIES are minted per project immediately below — they used to be kept
553
+ # wholesale from the canonical, which is how a client codeline ran epam-cli's own roster.
554
+
555
+ # ── Mint this project's agents, then assign every story one ────────────────
556
+ #
557
+ # Ordering (operator direction, 2026-08-07): after ingest, before spec. The inputs that make
558
+ # a proposed role project-specific rather than a restatement of the canonical core are the
559
+ # tickets and the documents linked on them, and both exist only once ingest has run.
560
+ #
561
+ # Until now the roster was inherited wholesale: a client codeline ran with epam-cli's OWN
562
+ # first-commit agents, and synthesize-prd-from-jira.js assigned every ticket to one of them
563
+ # with a hardcoded literal. Nothing errored — it was simply always the wrong agent.
564
+ # THE SKIP IS HONOURED, INCLUDING ON A RESUME.
565
+ #
566
+ # This used to read `|| [ -n "${EPAM_RESUME_RUN:-}" ]`, which forced the mint back ON for every
567
+ # checkpoint-based resume — exactly contradicting the instruction the checkpoint had just
568
+ # issued, and for the reason it states: the merge is additive, so each resume accumulated
569
+ # near-duplicate roles in a roster the operator had already settled.
570
+ #
571
+ # The danger the clause was reaching for is still real and is now handled properly: skipping the
572
+ # mint when nothing was ever minted would hand stories to agents that do not exist. That is a
573
+ # refusal, not a silent re-mint — the same rule as every other guard here, which is to stop
574
+ # rather than proceed on unknown state.
575
+ # The skip guard travels with the mint now — see _run_agent_mint — so neither path can skip
576
+ # into a roster that was never minted.
577
+ _read_estimation_manifest "$_synth_prd" "$_log_file" || return 1
578
+
579
+ _run_agent_mint "$_synth_prd" "$_log_file" || return 1
580
+
581
+ _pause_after_agent_mint
582
+
583
+ _run_codeline_loop "$_synth_prd" "$_log_file"
584
+ }
585
+
586
+ # ──────────────────────────────────────────────
587
+ # resolve_orch_mode <phase_id>
588
+ # Precedence: prd.json phasesConfig[phase].orchestrationMode
589
+ # > ORCH_MODE env var > default "bash"
590
+ # ──────────────────────────────────────────────
591
+ resolve_orch_mode() {
592
+ local phase_id="$1"
593
+ local phase_mode
594
+ phase_mode=$(jq -r \
595
+ --arg p "$phase_id" \
596
+ '.phasesConfig[$p].orchestrationMode // empty' \
597
+ "$PRD_FILE" 2>/dev/null || true)
598
+ if [ -n "$phase_mode" ] && [ "$phase_mode" != "null" ]; then
599
+ echo "$phase_mode"
600
+ else
601
+ echo "${ORCH_MODE:-bash}"
602
+ fi
603
+ }
604
+
605
+ # ── Step 0: Specification pre-pass (OpenSpec/Speckit) ─────────────────────────
606
+ run_specification_pass() {
607
+ local phase_id="$1"
608
+ # Guarded so an unloaded library is skipped rather than fatal: an undefined function
609
+ # returns 127, and `|| exit 1` on that silently killed the enclosing block wherever a
610
+ # harness runs this code with the gate library absent. The orchestrator sources it at the
611
+ # top, and pre-flight is what actually gates a run.
612
+ declare -f require_stage_coverage >/dev/null && { require_stage_coverage spec || return 1; }
613
+ local spec_runner="$SCRIPT_DIR/spec-mode-runner.js"
614
+ if [ ! -f "$spec_runner" ]; then
615
+ info "Step 1: Specification runner not found (${spec_runner##*/}) — skipping"
616
+ return 0
617
+ fi
618
+ local node_cmd="${NODE_CMD:-${HOME}/.nvm/versions/node/v20.20.0/bin/node}"
619
+ if [ ! -x "$node_cmd" ]; then
620
+ node_cmd="$(command -v node 2>/dev/null || echo 'node')"
621
+ fi
622
+ if ! command -v "$node_cmd" >/dev/null 2>&1; then
623
+ warning "Step 1: Node.js is required for specification mode but was not found"
624
+ return 0
625
+ fi
626
+ step_emit "1" "running" "Step 1: Specification pass"
627
+ log "Step 1: Running specification pass for phase '$phase_id'..."
628
+ local _spec_started; _spec_started=$(date -Iseconds)
629
+ set +e
630
+ PRD_FILE="$PRD_FILE" OUTPUT_DIR="$LOG_DIR" CLAUDE_CMD="${CLAUDE_CMD}" \
631
+ AI_RUNNER_CMD="$AI_RUNNER_CMD" EPAM_ORCHESTRATION_PROVIDER="${ORCH_GATE_PROVIDER:-${EPAM_ORCHESTRATION_PROVIDER:-}}" \
632
+ "$node_cmd" "$spec_runner" --phase "$phase_id" 2>&1 | tee "$LOG_DIR/spec-${phase_id}.log"
633
+ local spec_rc=${PIPESTATUS[0]}
634
+ set -e
635
+ # GAP-P22: emit spec runner cost record (token/cost estimated — spec runner
636
+ # doesn't expose per-call usage; a future improvement can parse spec logs)
637
+ # Real usage, measured from the records spec-mode-runner already emits —
638
+ # see _spec_pass_usage for why this used to be four literal zeros.
639
+ local _spec_usage _spec_cost _spec_tin _spec_tout _spec_turns
640
+ _spec_usage=$(_spec_pass_usage "$phase_id")
641
+ read -r _spec_cost _spec_tin _spec_tout _spec_turns <<< "${_spec_usage:-0 0 0 0}"
642
+ append_pipeline_cost_record "spec-pass" "$phase_id" \
643
+ "$(seam_model_or_fail "spec-agent")" "$_spec_started" \
644
+ "${_spec_cost:-0}" "${_spec_tin:-0}" "${_spec_tout:-0}" "${_spec_turns:-0}" 2>/dev/null || true
645
+ # Surface openspec/speckit as visible checklist sub-steps instead of only
646
+ # showing as "spec-mode: fast-path ..." log lines buried inside Step 0's
647
+ # own log — parses the summary spec-mode-runner.js already writes
648
+ # (summary.stats.agents: {agentName: invocationCount}) for a real story
649
+ # count per agent; model comes from the same env vars the runner itself
650
+ # uses (SPEC_MODE_OPENSPEC_MODEL/SPEC_MODE_SPECKIT_MODEL).
651
+ local _spec_summary="$LOG_DIR/spec-summary.json"
652
+ local _openspec_model
653
+ _openspec_model="$(seam_model_or_fail "spec-agent")"
654
+ local _speckit_model
655
+ _speckit_model="$(seam_model_or_fail "spec-agent")"
656
+ local _openspec_count=0 _speckit_count=0
657
+ if [ -f "$_spec_summary" ]; then
658
+ _openspec_count=$(jq -r '.stats.agents.openspec // 0' "$_spec_summary" 2>/dev/null || echo 0)
659
+ _speckit_count=$(jq -r '.stats.agents.speckit // 0' "$_spec_summary" 2>/dev/null || echo 0)
660
+ fi
661
+
662
+ if [ $spec_rc -eq 0 ]; then
663
+ step_emit "1" "pass" "Step 1: Specification pass"
664
+ step_emit "1a" "pass" " openspec (elaboration)" "${_openspec_model}, ${_openspec_count} stor(y/ies)"
665
+ step_emit "1b" "pass" " speckit (verification)" "${_speckit_model}, ${_speckit_count} stor(y/ies)"
666
+ success "Step 1: Specification pass completed for '$phase_id'"
667
+ "$SCRIPT_DIR/update-monitor.sh" event "specification_pass" \
668
+ "Specification agents completed (OpenSpec/Speckit)" "" "main" "spec-coordinator" 2>/dev/null || true
669
+ # Block execution when spec-pass failed on stories that still need splitting
670
+ # and the caller has opted in to hard blocking (SPEC_PASS_BLOCK_ON_TIMEOUT=true).
671
+ if [ "${SPEC_PASS_BLOCK_ON_TIMEOUT:-false}" = "true" ]; then
672
+ _failed_untuned=$(jq -r \
673
+ '[.stories[] | select(.specification.specPassFailed == true)] | length' \
674
+ "$PRD_FILE" 2>/dev/null || echo 0)
675
+ if [ "${_failed_untuned:-0}" -gt 0 ]; then
676
+ _failed_ids=$(jq -r \
677
+ '[.stories[] | select(.specification.specPassFailed == true) | .id] | join(", ")' \
678
+ "$PRD_FILE" 2>/dev/null || echo "unknown")
679
+ error "Spec pass FAILED for untuned stories: $_failed_ids"
680
+ error " Execution blocked (SPEC_PASS_BLOCK_ON_TIMEOUT=true). Set to false to override."
681
+ exit 1
682
+ fi
683
+ fi
684
+ # SUFFICIENCY GATE (always on, NOT overridable): a brownfield story where
685
+ # the detective found no fix site AND the ticket context is thin cannot be
686
+ # implemented or verified — fail early with a clear reason rather than
687
+ # burning a doomed run. Autonomous (no human halt); the flag is set by the
688
+ # spec pass (spec-mode-runner.js sufficiency gate).
689
+ _insufficient=$(jq -r \
690
+ '[.stories[] | select(.specification.insufficientContext == true)] | length' \
691
+ "$PRD_FILE" 2>/dev/null || echo 0)
692
+ if [ "${_insufficient:-0}" -gt 0 ]; then
693
+ _insufficient_ids=$(jq -r \
694
+ '[.stories[] | select(.specification.insufficientContext == true) | .id] | join(", ")' \
695
+ "$PRD_FILE" 2>/dev/null || echo "unknown")
696
+ error "Step 1: INSUFFICIENT CONTEXT — $_insufficient_ids: the code-graph-detective located no fix site and the ticket's ACs + description are too thin to implement or to write a reproducing test."
697
+ error " Failing early rather than proceeding to a doomed run. Enrich the ticket (ACs or description) and re-run."
698
+ exit 2
699
+ fi
700
+ else
701
+ step_emit "1" "fail" "Step 1: Specification pass"
702
+ step_emit "1a" "fail" " openspec (elaboration)" "${_openspec_model}"
703
+ step_emit "1b" "fail" " speckit (verification)" "${_speckit_model}"
704
+ error "Step 1: Specification pass FAILED for '$phase_id' — all agent invocations failed."
705
+ error " Check EPAM_ORCHESTRATION_PROVIDER is set and supported by ai-run.sh."
706
+ error " See: $LOG_DIR/spec-${phase_id}.log"
707
+ exit 1
708
+ fi
709
+ }
710
+
711
+ # ──────────────────────────────────────────────
712
+ # Step 0.6 (hybrid only): Pre-phase coordination
713
+ # Seeds the MCP message bus with guidance messages
714
+ # and identifies any stories requiring plan mode.
715
+ # ──────────────────────────────────────────────
716
+ run_hybrid_precoordination() {
717
+ local phase_id="$1"
718
+ local coord_log="$LOG_DIR/hybrid-coord-${phase_id}.log"
719
+ local coord_prompt
720
+ touch "$MESSAGES_JSONL"
721
+
722
+ _cp_vals=$(mktemp "${TMPDIR:-/tmp}/hybrid-prephase-coordinator-vals-XXXXXX.json")
723
+ jq_vals \
724
+ --arg phase_id "$phase_id" \
725
+ --arg prd_rel "${PRD_REL}" \
726
+ '{"__PHASE_ID__":$phase_id,"__PRD_REL__":$prd_rel}' > "$_cp_vals"
727
+ coord_prompt="$(render_engine_prompt hybrid-prephase-coordinator "$_cp_vals")"
728
+ rm -f "$_cp_vals"
729
+
730
+ cd "$PROJECT_ROOT"
731
+ # run_orch_prompt_with_tools (not plain run_orch_prompt): the prompt above
732
+ # instructs reading the PRD and flock-appending real JSONL messages — same
733
+ # class of bug already fixed for the assessment agents above.
734
+ local _hpc_attempt=0 _hpc_ok=0
735
+ while [ "$_hpc_attempt" -lt 2 ] && [ "$_hpc_ok" = "0" ]; do
736
+ local _hpc_prompt="$coord_prompt"
737
+ if [ "$_hpc_attempt" -ge 1 ]; then
738
+ _rp_vals=$(mktemp "${TMPDIR:-/tmp}/retry-vals-XXXXXX.json")
739
+ jq_vals \
740
+ --arg coord_prompt "$coord_prompt" \
741
+ '{"__COORD_PROMPT__":$coord_prompt}' > "$_rp_vals"
742
+ _hpc_prompt="$(render_engine_prompt agent-retry-prefix "$_rp_vals" hybrid_prephase_coordinator)"
743
+ rm -f "$_rp_vals"
744
+ fi
745
+ # No story_id — phase-level coordination call, not tied to a single story.
746
+ # PIPESTATUS, not `[ -s "$coord_log" ]` alone. This is a PIPELINE and its exit status is
747
+ # tee's — always 0 — so `set -e` cannot help either (no `set -o pipefail`). Judging
748
+ # success by "the log is non-empty" meant an agent that errored after writing a single
749
+ # line counted as having coordinated the phase. The same trap is already documented at
750
+ # the assessment call sites in this file; this one was missed.
751
+ #
752
+ # BOTH conditions are required: a clean exit that produced nothing did no work, and
753
+ # output from a failed run is not a result.
754
+ run_orch_prompt_with_tools "$_hpc_prompt" "spec-coordinator" 2>&1 | tee "$coord_log"
755
+ local _hpc_rc="${PIPESTATUS[0]}"
756
+ if [ "$_hpc_rc" -ne 0 ]; then
757
+ warning "Hybrid pre-phase coordination attempt $((_hpc_attempt + 1)) FAILED (exit ${_hpc_rc}) — its output is not a result"
758
+ elif [ -s "$coord_log" ]; then
759
+ _hpc_ok=1
760
+ else
761
+ [ "$_hpc_attempt" -lt 1 ] && warning "Hybrid pre-phase coordination attempt 1 produced no output — retrying" || warning "Hybrid pre-phase coordination had issues — continuing with bash fallback"
762
+ fi
763
+ _hpc_attempt=$(( _hpc_attempt + 1 ))
764
+ done
765
+ if [ "$_hpc_ok" = "1" ]; then
766
+ step_emit "4" "pass" "Step 4: Hybrid pre-coord"
767
+ success "Hybrid pre-phase coordination completed for '$phase_id'"
768
+ "$SCRIPT_DIR/update-monitor.sh" event "hybrid_precoord" \
769
+ "Hybrid pre-phase coordination completed" "" "main" "coordination-agent" 2>/dev/null || true
770
+ else
771
+ warning "Hybrid pre-phase coordination had issues — continuing with bash fallback"
772
+ fi
773
+ }
774
+
775
+ # ──────────────────────────────────────────────
776
+ # Step 11: Skills coordinator audit
777
+ #
778
+ # Root cause this addresses (found live, 2026-07-10, tier3-travel-app run): a
779
+ # self-heal skill note ("Do not use 'as' keyword for type assertions... use
780
+ # 'value as Type'...") was persisted TWICE, verbatim, into typescript-
781
+ # engineer's profile during a single story's retry loop — the note is also
782
+ # internally self-contradictory (it recommends the exact syntax it says not
783
+ # to use). Nothing in the pipeline ever looks at the ACCUMULATED set of
784
+ # skill notes as a whole; FailureAnalyst only ever appends. This step audits
785
+ # profiles.json once per phase, after Step 1's healing activity has had a
786
+ # chance to add new notes:
787
+ # 1. Deterministic pass (run_skills_audit_scan.py): collapses exact-
788
+ # duplicate [Self-Heal] paragraphs within each role's profile, and
789
+ # flags (via a narrow regex heuristic) any note that says "do not use
790
+ # 'X'" while also recommending "use ... 'X'" elsewhere in the same
791
+ # note — exactly the shape of bug that motivated this step.
792
+ # 2. Only if step 1 flags a suspected contradiction: invoke the
793
+ # skills-coordinator agent (Bash+WriteFile tool access, same pattern as
794
+ # run_pre_phase_assessment) to rewrite JUST that flagged note into
795
+ # something internally coherent. The LLM is only ever invoked when the
796
+ # deterministic scan found something to fix — most phases will run the
797
+ # free, instant scan and skip the LLM call entirely.
798
+ # Bypass: SKIP_SKILLS_AUDIT=1
799
+ run_skills_audit_scan() {
800
+ local profiles_file="$1"
801
+ # Locked for the whole scan+conditional-write: fast, in-memory text
802
+ # processing only (no LLM call inside), so holding the lock this long
803
+ # never risks stalling a parallel worktree story on model latency.
804
+ ( flock -w 10 200 || { error " [SkillsAudit] Could not acquire lock on $profiles_file"; return 1; }
805
+ python3 "$SCRIPT_DIR/lib/handlers/skill-note-duplicates.py" "$profiles_file"
806
+ ) 200>"${profiles_file}.lock"
807
+ }
808
+
809
+ # ──────────────────────────────────────────────
810
+ # Step 12: Tools coordinator audit
811
+ #
812
+ # Same rationale and shape as Step 11, applied to the dynamic-tools
813
+ # mechanism instead of skill notes: FailureAnalyst can write a tool script to
814
+ # .epam/dynamic-tools/<name>.sh (target=tool), and run_dynamic_tools_in_
815
+ # unlocked_window() (claude.sh) runs every reviewed, syntax-valid tool on
816
+ # every retry unconditionally — but nothing ever checks whether a tool
817
+ # actually WORKS, or whether two tools solve the same problem. Observed live
818
+ # this session: "[dynamic-tools] mock-fetch-in-test.sh exited non-zero
819
+ # (continuing)" — a tool got created, was broken, and the pipeline just
820
+ # logged a warning and moved on, paying its cost on every subsequent retry
821
+ # with no mechanism to ever fix or remove it.
822
+ # 1. Deterministic scan (run_tools_audit_scan): for each reviewed tool,
823
+ # (a) a free bash -n syntax check, (b) counts "<tool>.sh exited
824
+ # non-zero" occurrences across this phase's main-*.log files (a REAL
825
+ # observed-failure signal, not a synthetic re-execution — tools aren't
826
+ # re-run here to avoid side effects outside their sanctioned window),
827
+ # (c) flags near-duplicate tools via purpose-comment similarity.
828
+ # 2. Only when something is flagged, invoke the tools-coordinator LLM
829
+ # (Bash+WriteFile access) to fix the broken tool or consolidate a
830
+ # duplicate pair.
831
+ # Bypass: SKIP_TOOLS_AUDIT=1
832
+ run_tools_audit_scan() {
833
+ local tools_dir="$1"
834
+ local log_dir="$2"
835
+ python3 "$SCRIPT_DIR/lib/handlers/tool-scripts-health.py" "$tools_dir" "$log_dir"
836
+ }
837
+
838
+ # _build_skill_domain_guidance <project_root>
839
+ # Generic, project-supplied mapping of tech-stack keywords to agentRole
840
+ # names for the phase-assessment agent's skill-domain-mismatch correction
841
+ # step (see run_phase_assessment's prompt). Never hardcoded in this engine
842
+ # -- a project may not use TypeScript/React/Docker/Vitest at all (found
843
+ # 2026-07-12 while fixing the Step 6 real-output-gate bug in the same
844
+ # function: the prompt hardcoded exactly that mapping directly in the
845
+ # engine). Reads .epam/skill-domain-map.json's "skillDomains" array:
846
+ # [{"role":"...", "keywords":["...", ...]}, ...] -- same opt-in convention
847
+ # as dependency-check.json's vendorDirs: no config file or no
848
+ # "skillDomains" key means no guidance; callers must supply their own
849
+ # generic fallback instruction.
850
+ _build_skill_domain_guidance() {
851
+ local project_root="$1"
852
+ local config_file="${project_root}/.epam/skill-domain-map.json"
853
+ [ -f "$config_file" ] || return 0
854
+ jq -r '
855
+ (.skillDomains // [])
856
+ | map("\"" + (.keywords | join("\" / \"")) + "\" → " + .role)
857
+ | join(",\n ")
858
+ ' "$config_file" 2>/dev/null
859
+ }