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,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
|
+
}
|