amsd-pipeline-shared 0.5.0 → 0.5.2

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.
Files changed (50) hide show
  1. package/docs/gateway-baseline-2.0.80.md5 +47 -0
  2. package/docs/gateway-files.txt +47 -0
  3. package/orchestrations/config/repo-artifacts.json +23 -0
  4. package/orchestrations/config/resume-preserves.json +25 -0
  5. package/orchestrations/config/run-modes.json +47 -0
  6. package/orchestrations/scripts/lib/checkpoints.sh +175 -0
  7. package/orchestrations/scripts/lib/codeline-lanes.sh +1664 -0
  8. package/orchestrations/scripts/lib/codeline-packages.js +67 -0
  9. package/orchestrations/scripts/lib/codeline-write-perimeter.sh +318 -0
  10. package/orchestrations/scripts/lib/common.sh +66 -0
  11. package/orchestrations/scripts/lib/control-plane.sh +506 -0
  12. package/orchestrations/scripts/lib/ecosystem-registry.js +148 -0
  13. package/orchestrations/scripts/lib/engine-paths.sh +52 -0
  14. package/orchestrations/scripts/lib/flags.sh +38 -0
  15. package/orchestrations/scripts/lib/git-ops.sh +975 -0
  16. package/orchestrations/scripts/lib/halt-recovery.sh +56 -0
  17. package/orchestrations/scripts/lib/handlers/_read-input.js +88 -0
  18. package/orchestrations/scripts/lib/handlers/carry-plan.js +25 -0
  19. package/orchestrations/scripts/lib/handlers/cl-entries.js +10 -0
  20. package/orchestrations/scripts/lib/handlers/codeline-ecosystem.js +164 -0
  21. package/orchestrations/scripts/lib/handlers/codeline-manifests.js +148 -0
  22. package/orchestrations/scripts/lib/handlers/filtered-prd.js +85 -0
  23. package/orchestrations/scripts/lib/handlers/merge-lane-into-canonical.js +39 -0
  24. package/orchestrations/scripts/lib/handlers/package-engines-node.js +25 -0
  25. package/orchestrations/scripts/lib/handlers/prd-phases.js +46 -0
  26. package/orchestrations/scripts/lib/handlers/rebuild-cross-contract.js +7 -0
  27. package/orchestrations/scripts/lib/handlers/record-declared-pass.js +23 -0
  28. package/orchestrations/scripts/lib/handlers/release-line-prd.js +31 -0
  29. package/orchestrations/scripts/lib/handlers/release-lines.js +31 -0
  30. package/orchestrations/scripts/lib/handlers/repo-exclude-patterns.js +108 -0
  31. package/orchestrations/scripts/lib/handlers/resume-spec-output-present.js +16 -0
  32. package/orchestrations/scripts/lib/handlers/sibling-providers.js +43 -0
  33. package/orchestrations/scripts/lib/handlers/spanning-stories-incomplete.js +50 -0
  34. package/orchestrations/scripts/lib/handlers/spec-inputs-digest.js +48 -0
  35. package/orchestrations/scripts/lib/handlers/spec-predates-plan.js +40 -0
  36. package/orchestrations/scripts/lib/handlers/stories-lacking-spec.js +64 -0
  37. package/orchestrations/scripts/lib/handlers/write-missing-plans.js +92 -0
  38. package/orchestrations/scripts/lib/orchestration-resume.sh +74 -0
  39. package/orchestrations/scripts/lib/phase-exit.sh +90 -0
  40. package/orchestrations/scripts/lib/pipeline-commit.sh +19 -0
  41. package/orchestrations/scripts/lib/plan-hash.js +19 -0
  42. package/orchestrations/scripts/lib/plan-scope.js +73 -0
  43. package/orchestrations/scripts/lib/qa-gate-evidence.sh +235 -0
  44. package/orchestrations/scripts/lib/release-lines.sh +166 -0
  45. package/orchestrations/scripts/lib/resume-semantics.sh +31 -0
  46. package/orchestrations/scripts/lib/run-checkpoint.sh +842 -0
  47. package/orchestrations/scripts/lib/run-modes.sh +93 -0
  48. package/orchestrations/scripts/lib/story-outputs.sh +322 -0
  49. package/orchestrations/scripts/lib/story-retry-state.sh +355 -0
  50. package/package.json +1 -1
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env bash
2
+ # control-plane.sh — moved verbatim out of run-agent-orchestration.sh by tools/split-main-into-modules.py
3
+ # (15 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
+ # is_parent — true in the top-level orchestrator process. Guard project-wide work with this:
8
+ # anything that allocates a run-global resource, mutates shared state, or must happen exactly
9
+ # once per run regardless of how many codelines are in scope.
10
+ is_parent() { [ "$(orch_role)" = 'parent' ]; }
11
+
12
+ # REGISTERED HERE, NOT IN cleanup() — BECAUSE bash INSTALLS A HANDLER WHEN IT REACHES IT.
13
+ #
14
+ # The export used to ride along inside cleanup(), which is registered at the foot of this file.
15
+ # A run only ever had a cassette handler installed if it survived that far. The first `exit` in
16
+ # this script is thousands of lines earlier, so ingest, codeline discovery, spec-mode, the mint
17
+ # and roster derivation all failed into a trap that did not yet include the export.
18
+ #
19
+ # Measured 2026-09-11 by the free mockserver rehearsal: roster derivation failed, the write
20
+ # perimeter released (registered at the top of this file, so it was installed), cleanup never
21
+ # ran, and the run left nothing to replay. An early structural failure is exactly the run worth
22
+ # replaying, and it was the one guaranteed to leave no recording.
23
+ #
24
+ # Registered beside the recorder it uses: reaching the source line is the only precondition.
25
+ _epam_export_cassette() {
26
+ declare -F export_run_cassette >/dev/null 2>&1 || return 0
27
+ local _cas_run_id="${ORCH_RUN_ID:-${EPAM_RUN_ID:-${RUN_NUMBER:-}}}"
28
+ [ -n "$_cas_run_id" ] || return 0
29
+ export_run_cassette "$_cas_run_id" \
30
+ "$(basename "${EPAM_PROJECT_CONFIG_DIR:-project}")" \
31
+ "${EPAM_CASSETTE_DIR:-${AUTOMATION_DIR:-$SCRIPT_DIR/..}/cassettes}" || true
32
+ }
33
+
34
+ _resolve_control_plane_port() {
35
+ # Reads only the environment and the PRD — plus is_parent(), the single place the
36
+ # parent/lane role is derived. A harness testing this in isolation must carry those
37
+ # helpers with it; duplicating the role check here would be the very thing they exist
38
+ # to prevent.
39
+ local _base="${CONTROL_PLANE_BASE_PORT:-8094}"
40
+ # An explicit override always wins — the operator can pin a port.
41
+ if [ -n "${CONTROL_PLANE_PORT:-}" ]; then
42
+ printf '%s' "$CONTROL_PLANE_PORT"; return 0
43
+ fi
44
+ # THE PARENT AND ITS LANES ARE THE SAME SCRIPT, SO THEY MUST NOT DERIVE THE SAME PORT.
45
+ #
46
+ # This script is both the parent orchestrator and, re-invoked with JIRA_CODELINE_RUN=1,
47
+ # each lane — so every per-run resource is allocated twice. The lane is derived from the
48
+ # codeline whose outputDirs[].path matches project.outputDir, and the synthesizer sets
49
+ # project.outputDir = outputDirs[0].path, so the PARENT resolved to codeline index 0 —
50
+ # exactly what the FIRST LANE resolves to from its own filtered PRD.
51
+ #
52
+ # That collision is not benign: start_control_plane kills whatever already holds the port
53
+ # ("a stale process from a previous run") before binding. The first lane therefore killed
54
+ # the parent's control plane and took the port, and when the lane finished its cleanup
55
+ # stopped the control plane entirely — leaving the port dead while the parent still held a
56
+ # PID it believed was live.
57
+ #
58
+ # The parent reserves the base port. Lanes are offset past it, so no lane can ever land on
59
+ # it. Lanes keep their own control plane — each has its own LOG_DIR to serve.
60
+ if is_parent; then
61
+ printf '%s' "$_base"; return 0
62
+ fi
63
+
64
+ # EPAM_CODELINE is what the lane invocation actually exports; CODELINE_NAME is kept as the
65
+ # documented override. Reading only the latter meant every lane fell through to the PRD
66
+ # lookup, since nothing in this script has ever set it.
67
+ local _lane="${CODELINE_NAME:-${EPAM_CODELINE:-}}"
68
+ if [ -z "$_lane" ] && [ -n "${PRD_FILE:-}" ] && [ -f "${PRD_FILE}" ]; then
69
+ _lane=$(jq -r '.project as $p | (($p.outputDirs // []) | map(select(.path == $p.outputDir)) | .[0].codeline) // empty' \
70
+ "$PRD_FILE" 2>/dev/null)
71
+ fi
72
+ if [ -z "$_lane" ]; then
73
+ printf '%s' "$_base"; return 0
74
+ fi
75
+ local _idx=""
76
+ if [ -n "${PRD_FILE:-}" ] && [ -f "${PRD_FILE}" ]; then
77
+ _idx=$(jq -r --arg cl "$_lane" '((.project.outputDirs // []) | map(.codeline) | index($cl)) // empty' \
78
+ "$PRD_FILE" 2>/dev/null)
79
+ fi
80
+ if [ -z "$_idx" ] || [ "$_idx" = "null" ]; then
81
+ _idx=$(printf '%s' "$_lane" | cksum | awk '{print $1 % 64}')
82
+ fi
83
+ # +1 so lane 0 clears the parent's reserved base port.
84
+ printf '%s' "$(( _base + 1 + _idx ))"
85
+ }
86
+
87
+ step_emit() {
88
+ local step_id="$1"
89
+ local status="$2"
90
+ local label="$3"
91
+ local reason="${4:-}"
92
+ local ts
93
+ ts=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S)
94
+
95
+ _STEP_LABELS["$step_id"]="$label"
96
+ _STEP_STATUS["$step_id"]="$status"
97
+ # Only overwrite the stored reason when this call actually supplied one —
98
+ # many terminal-state calls (e.g. the final "pass") are emitted right
99
+ # after a "running" call with no reason, and would otherwise blank out
100
+ # a real detail a caller set moments earlier (found while wiring real
101
+ # per-step detail through to the dashboard, 2026-07-13).
102
+ [ -n "$reason" ] && _STEP_REASON["$step_id"]="$reason"
103
+
104
+ local icon
105
+ case "$status" in
106
+ pass) icon="${GREEN} ✓${NC}" ;;
107
+ skip) icon="${YELLOW} ⊘${NC}" ;;
108
+ fail) icon="${RED} ✗${NC}" ;;
109
+ warn) icon="${YELLOW} ⚠${NC}" ;;
110
+ running) icon="${CYAN} ▶${NC}" ;;
111
+ *) icon=" " ;;
112
+ esac
113
+
114
+ local reason_str=""
115
+ [ -n "$reason" ] && reason_str=" ${YELLOW}[${reason}]${NC}"
116
+ echo -e "${icon} ${label}${reason_str}"
117
+
118
+ # Write JSON snapshot (atomic via tmp file)
119
+ local tmp_file="${STEP_STATUS_FILE}.tmp.$$"
120
+ {
121
+ echo "{"
122
+ echo " \"phase\": \"${PHASE:-unknown}\","
123
+ echo " \"updatedAt\": \"${ts}\","
124
+ echo " \"steps\": ["
125
+ local first=true
126
+ local _sid _slabel _sstatus _sreason
127
+ for _sid in \
128
+ "1:spec" "1a:openspec" "1b:speckit" "2:cpa" "3:skill-pre" "4:hybrid-coord" "5:regression" \
129
+ "6:mkdir" "7:model-coord" "8:main-stories" "9:auto-commit" "10:tc-writer" \
130
+ "11:skills-audit" "12:tools-audit" "13:worktrees" "14:primary" "15:independent" "16:wt-health" \
131
+ "17:wt-merge" "18:skill-post" "19:pre-review" "20:lint-gate" \
132
+ "21:review-stories" "22a:sast" "22b:spec-val" \
133
+ "22c:review-ranger" "22d:mutant-hunter" \
134
+ "22e:fuzz-weaver" "22f:perf-sentinel" "23:e2e"; do
135
+ local _key="${_sid%%:*}"
136
+ _slabel="$(_json_escape_str "${_STEP_LABELS[$_key]:-${_sid#*:}}")"
137
+ _sstatus="${_STEP_STATUS[$_key]:-pending}"
138
+ _sreason="$(_json_escape_str "${_STEP_REASON[$_key]:-}")"
139
+ [ "$first" = "true" ] && first=false || echo ","
140
+ printf ' {"id":"%s","label":"%s","status":"%s","detail":"%s"}' \
141
+ "$_key" "$_slabel" "$_sstatus" "$_sreason"
142
+ done
143
+ echo ""
144
+ echo " ]"
145
+ echo "}"
146
+ } > "$tmp_file" && mv "$tmp_file" "$STEP_STATUS_FILE" 2>/dev/null || true
147
+ }
148
+
149
+ # Print full step checklist (called once at run start, after skip detection)
150
+ print_step_checklist() {
151
+ echo ""
152
+ echo -e "${MAGENTA}━━━ Pipeline Step Checklist ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
153
+ printf " %-6s %-32s %s\n" "Step" "Name" "Planned"
154
+ printf " %-6s %-32s %s\n" "------" "--------------------------------" "--------"
155
+
156
+ _checklist_row() {
157
+ local step="$1" name="$2" planned="$3" reason="${4:-}"
158
+ local color
159
+ case "$planned" in
160
+ ACTIVE) color="$GREEN" ;;
161
+ SKIP) color="$YELLOW" ;;
162
+ COND) color="$CYAN" ;;
163
+ *) color="$NC" ;;
164
+ esac
165
+ # A SWITCH ONLY MEANS SOMETHING ON A ROW THAT IS ACTUALLY OFF.
166
+ #
167
+ # The reason was printed unconditionally, and for the skip-toggle rows it is the variable
168
+ # that DISABLES the step — so a running gate rendered as
169
+ # 5 Regression guard ACTIVE (SKIP_REGRESSION_GUARD=true)
170
+ # which reads as "skipped, here is the proof". Eleven rows did this on every run: the
171
+ # status was computed and correct, and the text beside it contradicted the status.
172
+ #
173
+ # By SHAPE, not by a list of rows: several rows pass the resolved MODEL here, which is the
174
+ # most useful thing on the line and must survive. `NAME=value` is a switch; a model name
175
+ # never looks like one. So a switch is shown only when the row is not ACTIVE.
176
+ local reason_str=""
177
+ if [ -n "$reason" ] \
178
+ && { [ "$planned" != "ACTIVE" ] || ! printf '%s' "$reason" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*='; }; then
179
+ reason_str=" (${reason})"
180
+ fi
181
+ printf " %-6s %-32s " "$step" "$name"
182
+ echo -e "${color}${planned}${reason_str}${NC}"
183
+ }
184
+
185
+ if [ -n "${RESOLVED_RUN_MODE:-}" ]; then
186
+ echo -e " ${CYAN}RUN MODE: ${RESOLVED_RUN_MODE}${NC} — the SKIP rows below are what it turns off"
187
+ fi
188
+ _checklist_row "1" "Specification pass" "$([ "${EPAM_SPEC_MODE:-1}" = "0" ] && echo SKIP || echo ACTIVE)" "EPAM_SPEC_MODE=0"
189
+ _checklist_row "1a" " openspec (elaboration)" "$([ "${EPAM_SPEC_MODE:-1}" = "0" ] && echo SKIP || echo ACTIVE)" "$(seam_model_or_fail "spec-agent" 2>/dev/null || printf '<unresolved>')"
190
+ _checklist_row "1b" " speckit (verification)" "$([ "${EPAM_SPEC_MODE:-1}" = "0" ] && echo SKIP || echo ACTIVE)" "$(seam_model_or_fail "spec-agent" 2>/dev/null || printf '<unresolved>')"
191
+ _checklist_row "2" "CPA pre-pass" "$(is_truthy "${SKIP_CPA:-}" && echo SKIP || echo ACTIVE)" "SKIP_CPA=1"
192
+ _checklist_row "3" "Pre-phase skill assess" "$(is_truthy "${SKIP_SKILL_ASSESSMENT:-}" && echo SKIP || echo ACTIVE)" "$(is_truthy "${SKIP_SKILL_ASSESSMENT:-}" && echo SKIP_SKILL_ASSESSMENT=1 || true)"
193
+ _checklist_row "4" "Hybrid pre-coord" "$([ "${RESOLVED_ORCH_MODE:-bash}" = "hybrid" ] && echo ACTIVE || echo SKIP)" "ORCH_MODE≠hybrid"
194
+ _checklist_row "5" "Regression guard" "$(is_truthy "${SKIP_REGRESSION_GUARD:-}" && echo SKIP || echo ACTIVE)" "SKIP_REGRESSION_GUARD=true"
195
+ _checklist_row "6" "mkdir src/ dirs" "ACTIVE"
196
+ _checklist_row "7" "PRD model coordinator" "$(is_truthy "${SKIP_PRD_MODEL_COORDINATOR:-}" && echo SKIP || echo ACTIVE)" "$(is_truthy "${SKIP_PRD_MODEL_COORDINATOR:-}" && echo SKIP_PRD_MODEL_COORDINATOR=1 || true)"
197
+ _checklist_row "8" "Main-branch stories" "ACTIVE"
198
+ _checklist_row "9" "Auto-commit" "COND" "if uncommitted changes"
199
+ _checklist_row "10" "TC writer gate" "$(is_truthy "${SKIP_TC_WRITER:-}" && echo SKIP || echo COND)" "SKIP_TC_WRITER=1 or no test stories"
200
+ _checklist_row "11" "Skills coordinator audit" "$(is_truthy "${SKIP_SKILLS_AUDIT:-}" && echo SKIP || echo ACTIVE)" "SKIP_SKILLS_AUDIT=1"
201
+ _checklist_row "12" "Tools coordinator audit" "$(is_truthy "${SKIP_TOOLS_AUDIT:-}" && echo SKIP || echo ACTIVE)" "SKIP_TOOLS_AUDIT=1"
202
+ _checklist_row "13" "Create worktrees" "COND" "if parallel stories exist"
203
+ _checklist_row "14" "Primary agent" "COND" "if primary stories"
204
+ _checklist_row "15" "Independent agent" "COND" "if independent stories"
205
+ _checklist_row "16" "Worktree health check" "COND" "if worktrees created"
206
+ _checklist_row "17" "Merge worktrees" "COND" "if worktrees created"
207
+ _checklist_row "18" "Post-parallel assessment" "$(is_truthy "${SKIP_SKILL_ASSESSMENT:-}" && echo SKIP || echo ACTIVE)" "$(is_truthy "${SKIP_SKILL_ASSESSMENT:-}" && echo SKIP_SKILL_ASSESSMENT=1 || true)"
208
+ _checklist_row "19" "Pre-review gate" "$(is_truthy "${SKIP_PRE_REVIEW_GATE:-}" && echo SKIP || echo ACTIVE)" "SKIP_PRE_REVIEW_GATE=true"
209
+ _checklist_row "20" "Lint gate" "$(is_truthy "${SKIP_LINT_GATE:-}" && echo SKIP || echo ACTIVE)" "SKIP_LINT_GATE=true"
210
+ _checklist_row "21" "Review stories" "COND" "if review stories exist"
211
+ _checklist_row "22a" "SAST sentinel" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
212
+ _checklist_row "22b" "Spec validator" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
213
+ _checklist_row "22c" "Review ranger" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
214
+ _checklist_row "22d" "Mutant hunter" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
215
+ _checklist_row "22e" "Fuzz-weaver" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
216
+ _checklist_row "22f" "Perf sentinel" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
217
+ _checklist_row "22g" "Runtime boundary" "$(is_truthy "${SKIP_TESTING_GATES:-}" && echo SKIP || echo ACTIVE)" "SKIP_TESTING_GATES=true"
218
+ _checklist_row "23" "Browser E2E routing" "$([ "${SKIP_BROWSER_E2E_ROUTING:-false}" = "true" ] && echo SKIP || echo COND)" "SKIP_BROWSER_E2E_ROUTING=true"
219
+
220
+ local skips=0
221
+ for key in "1" "2" "3" "4" "5" "6" "7" "8" "9" "10" "11" "12" "13" "14" "15" "16" "17" "18" "19" "21" "22a" "22b" "22c" "22d" "22e" "22f" "23"; do
222
+ [ "${_STEP_STATUS[$key]:-}" = "skip" ] && skips=$((skips + 1))
223
+ done
224
+ echo ""
225
+ echo -e " ${YELLOW}SKIP bypass env vars active: SKIP_TESTING_GATES=${SKIP_TESTING_GATES:-false} SKIP_CPA=${SKIP_CPA:-0} SKIP_TC_WRITER=${SKIP_TC_WRITER:-0} SKIP_REGRESSION_GUARD=${SKIP_REGRESSION_GUARD:-false}${NC}"
226
+ echo -e "${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
227
+ echo ""
228
+ }
229
+
230
+ seed_runtime_logs() {
231
+ mkdir -p "$LOG_DIR" "$LOG_DIR/phase-improvements"
232
+ local files=(
233
+ "agent-activity.jsonl"
234
+ "agent-messages.jsonl"
235
+ "code-reviews.jsonl"
236
+ "cpa-review.jsonl"
237
+ "phase-cost.jsonl"
238
+ "phase-gates.jsonl"
239
+ "profiles-audit.jsonl"
240
+ "testing-gates.jsonl"
241
+ )
242
+ local f
243
+ for f in "${files[@]}"; do
244
+ [ -f "$LOG_DIR/$f" ] || : > "$LOG_DIR/$f"
245
+ done
246
+ }
247
+
248
+ # Kill a process and every descendant, leaves first. `pkill -P` reaches only direct children:
249
+ # `npx @11ty/eleventy` runs eleventy as ITS child, so killing npx's children and npx left eleventy
250
+ # alive after every run — five of them, 4GB resident, measured 2026-09-14 after five finished
251
+ # harness runs (one of which exited 0).
252
+ _epam_kill_tree() {
253
+ local _pid="$1" _child
254
+ for _child in $(pgrep -P "$_pid" 2>/dev/null); do
255
+ _epam_kill_tree "$_child"
256
+ done
257
+ kill "$_pid" 2>/dev/null || true
258
+ }
259
+
260
+ stop_dashboards_watch() {
261
+ if [ "$DASHBOARD_WATCH_OWNED" != "true" ] || [ -z "$DASHBOARD_WATCH_PID" ]; then
262
+ return
263
+ fi
264
+ if ps -p "$DASHBOARD_WATCH_PID" > /dev/null 2>&1; then
265
+ info "Stopping dashboards watcher (PID $DASHBOARD_WATCH_PID)..."
266
+ _epam_kill_tree "$DASHBOARD_WATCH_PID"
267
+ wait "$DASHBOARD_WATCH_PID" 2>/dev/null || true
268
+ fi
269
+ rm -f "$DASHBOARD_WATCH_PID_FILE"
270
+ DASHBOARD_WATCH_PID=""
271
+ DASHBOARD_WATCH_OWNED=false
272
+ }
273
+
274
+ start_control_plane() {
275
+ if [ "${EPAM_CONTROL_PLANE:-1}" != "1" ]; then
276
+ info "Control plane disabled (EPAM_CONTROL_PLANE=0)."
277
+ return
278
+ fi
279
+ local _node_bin
280
+ _node_bin=$(detect_node 2>/dev/null || true)
281
+ if [ -z "$_node_bin" ]; then
282
+ warning "Control plane: node binary not found — skipping."
283
+ return
284
+ fi
285
+ local cp_script="$SCRIPT_DIR/control-plane.js"
286
+ if [ ! -f "$cp_script" ]; then
287
+ warning "Control plane script not found at $cp_script — skipping."
288
+ return
289
+ fi
290
+ # Remove stale PAUSED sentinel from a previous run
291
+ rm -f "$LOG_DIR/PAUSED"
292
+ # Kill any stale process holding the control plane port from a previous run
293
+ local _stale_pid
294
+ _stale_pid=$(lsof -ti "tcp:${CONTROL_PLANE_PORT}" 2>/dev/null || true)
295
+ if [ -n "$_stale_pid" ]; then
296
+ warning "Killing stale process on port ${CONTROL_PLANE_PORT} (PID $_stale_pid)"
297
+ kill "$_stale_pid" 2>/dev/null || true
298
+ sleep 0.3
299
+ fi
300
+ CONTROL_PLANE_PORT="${CONTROL_PLANE_PORT}" \
301
+ LOG_DIR="$LOG_DIR" \
302
+ "$_node_bin" "$cp_script" >> "$CONTROL_PLANE_LOG" 2>&1 &
303
+ CONTROL_PLANE_PID=$!
304
+ sleep 0.5
305
+ if ! ps -p "$CONTROL_PLANE_PID" > /dev/null 2>&1; then
306
+ warning "Control plane exited immediately; see $CONTROL_PLANE_LOG"
307
+ CONTROL_PLANE_PID=""
308
+ return
309
+ fi
310
+ info "Control plane started (PID $CONTROL_PLANE_PID, port $CONTROL_PLANE_PORT)"
311
+ }
312
+
313
+ stop_control_plane() {
314
+ if [ -z "$CONTROL_PLANE_PID" ]; then
315
+ return
316
+ fi
317
+ if ps -p "$CONTROL_PLANE_PID" > /dev/null 2>&1; then
318
+ kill "$CONTROL_PLANE_PID" 2>/dev/null || true
319
+ wait "$CONTROL_PLANE_PID" 2>/dev/null || true
320
+ fi
321
+ CONTROL_PLANE_PID=""
322
+ }
323
+
324
+ start_dashboards_watch() {
325
+ local dashboards_dir="$AUTOMATION_DIR/dashboards"
326
+ local config_path="$dashboards_dir/.eleventy.js"
327
+ local local_eleventy_bin="$dashboards_dir/node_modules/.bin/eleventy"
328
+ # THE INSTALL'S OWN ELEVENTY. The installer puts it in the install root's node_modules (it is a
329
+ # root dependency); dashboards/ has none, so every run fell to npx — a registry fetch, not the
330
+ # installed build.
331
+ [ -x "$local_eleventy_bin" ] || local_eleventy_bin="$(dirname "$AUTOMATION_DIR")/node_modules/.bin/eleventy"
332
+
333
+ if [ "${EPAM_DASH_AUTO_SERVE:-1}" != "1" ]; then
334
+ info "Dashboard auto-serve disabled (EPAM_DASH_AUTO_SERVE=0)."
335
+ return
336
+ fi
337
+ if [ ! -f "$config_path" ]; then
338
+ warning "Dashboards config not found at $config_path; skipping auto-serve."
339
+ return
340
+ fi
341
+ # A lane re-exec must never start its own watcher. The parent run already has
342
+ # one, and the pid-file check below is NOT atomic: three lanes launched
343
+ # together all read the file before any of them wrote it, so live metrolinx
344
+ # 2026-07-29 ran three Eleventy stacks rebuilding the same dashboard from the
345
+ # same LOG_DIR — ~120% CPU competing with the lanes for identical output.
346
+ # Sequential lanes hid this by never overlapping. A lock would also close the
347
+ # race; not starting it at all is simpler and strictly correct, because the
348
+ # lane has nothing to serve that the parent is not already serving.
349
+ if is_lane; then
350
+ return
351
+ fi
352
+ if [ -n "$DASHBOARD_WATCH_PID" ]; then
353
+ return
354
+ fi
355
+ if [ -f "$DASHBOARD_WATCH_PID_FILE" ]; then
356
+ local existing_pid
357
+ existing_pid="$(cat "$DASHBOARD_WATCH_PID_FILE" 2>/dev/null || true)"
358
+ if [ -n "$existing_pid" ] && ps -p "$existing_pid" > /dev/null 2>&1; then
359
+ info "Eleventy dashboards watcher already running (PID $existing_pid)."
360
+ DASHBOARD_WATCH_PID="$existing_pid"
361
+ return
362
+ fi
363
+ fi
364
+
365
+ # NOTE: EPAM_PROJECT_OUTPUT_DIR is intentionally NOT exported to the Eleventy
366
+ # subprocess here. snapshot.js's agentActivity/agentStatus/phaseCost paths use
367
+ # process.env.EPAM_PROJECT_OUTPUT_DIR directly (not via resolveProjectOutputDir),
368
+ # and those files live in orchestrations/logs — which the dashboard's logs/ symlink
369
+ # already resolves correctly. Exporting EPAM_PROJECT_OUTPUT_DIR=OUTPUT_DIR would
370
+ # redirect those reads to the project output dir (wrong place). The .active-output-dir
371
+ # pointer written by pre-run-reset.sh is sufficient for resolveProjectOutputDir(),
372
+ # which only feeds healingEvents/storyFailures/guardedStepRetries (files in OUTPUT_DIR).
373
+
374
+ if [ -x "$local_eleventy_bin" ]; then
375
+ info "Starting Eleventy dashboards watcher (local binary)..."
376
+ (
377
+ cd "$PROJECT_ROOT" || exit 1
378
+ exec "$local_eleventy_bin" \
379
+ "--config=$config_path" \
380
+ "--input=$dashboards_dir" \
381
+ "--output=$dashboards_dir/live" \
382
+ --serve >> "$DASHBOARD_WATCH_LOG" 2>&1
383
+ ) &
384
+ elif command -v npx >/dev/null 2>&1; then
385
+ info "Starting Eleventy dashboards watcher (npx --prefix)..."
386
+ (
387
+ cd "$PROJECT_ROOT" || exit 1
388
+ exec npx --prefix "$dashboards_dir" @11ty/eleventy \
389
+ "--config=$config_path" \
390
+ "--input=$dashboards_dir" \
391
+ "--output=$dashboards_dir/live" \
392
+ --serve >> "$DASHBOARD_WATCH_LOG" 2>&1
393
+ ) &
394
+ else
395
+ warning "Neither local Eleventy binary nor npx is available; skipping dashboard auto-serve."
396
+ return
397
+ fi
398
+
399
+ DASHBOARD_WATCH_PID=$!
400
+ DASHBOARD_WATCH_OWNED=true
401
+ echo "$DASHBOARD_WATCH_PID" > "$DASHBOARD_WATCH_PID_FILE"
402
+ sleep 1
403
+ if ! ps -p "$DASHBOARD_WATCH_PID" > /dev/null 2>&1; then
404
+ warning "Dashboards watcher exited immediately; see $DASHBOARD_WATCH_LOG"
405
+ rm -f "$DASHBOARD_WATCH_PID_FILE"
406
+ DASHBOARD_WATCH_PID=""
407
+ DASHBOARD_WATCH_OWNED=false
408
+ fi
409
+ }
410
+
411
+ # Cleanup on exit
412
+ cleanup() {
413
+ local exit_code=$?
414
+ # THE RUN LEAVES A CASSETTE HOWEVER IT ENDS — and this is the only place that can promise it.
415
+ #
416
+ # Export-on-completion sat at the end of _run_codeline_loop(), which a run reaches only by
417
+ # running to the end. pause-before-writer ends the process with `exit 0` hundreds of lines
418
+ # earlier, and every failure path is a bare `exit 1`/`2`/`3`. pipeline-tests-48 is the one
419
+ # install that carried that hook and has no cassettes directory at all: the run paused, so the
420
+ # hook never ran, while Langfuse held all 286 traces the whole time. Langfuse records each call
421
+ # as it happens, so the recording was never pending — waiting for completion to harvest it was
422
+ # the defect.
423
+ #
424
+ # `trap ... EXIT` fires on every exit path bash controls, including ones added later, so this
425
+ # cannot be forgotten by whoever writes the next `exit`. FIRST in the trap, before the control
426
+ # plane is torn down, because the export reads a service that teardown is about to stop. It
427
+ # cannot change what the run reports: $? is already captured above, and export_run_cassette
428
+ # never fails the caller.
429
+ #
430
+ # NOT COVERED HERE: `kill -9` and an OOM kill bypass every trap. Only a harvest that does not
431
+ # depend on this process can cover those.
432
+ # The cassette export is registered beside lib/cassette-archive.sh, near the top of this
433
+ # file, so that a run failing before cleanup() is even registered still leaves a recording.
434
+ stop_control_plane
435
+ stop_dashboards_watch
436
+ if [ "$SKIP_CLEANUP" = "true" ]; then
437
+ warning "Skipping worktree cleanup (--skip-cleanup)"
438
+ return
439
+ fi
440
+ if [ $exit_code -ne 0 ]; then
441
+ error "Execution failed with exit code $exit_code"
442
+ fi
443
+ log "Cleaning up worktrees..."
444
+ "$CLAUDE_SH" --cleanup-worktrees 2>/dev/null || true
445
+ }
446
+
447
+ # PUBLISH THE PLAN THIS PROCESS WILL WORK FROM — outside the spec-pass branch, deliberately.
448
+ #
449
+ # The detective renders its own answer (lib/producers/fix-plan.js) and it is published once, from
450
+ # THIS process's PRD, so consumers stop reading story.fixSiteAnalysis and inventing their own
451
+ # wording of it. Publishing from this PRD is what keeps a lane's writer on its own plan: a lane
452
+ # runs with its own scoped PRD, while the canonical one holds the UNION of every codeline — on
453
+ # AMSD-2041 that is 13 sites where gotransit has 4, including three conflicting prescriptions for
454
+ # one file.
455
+ #
456
+ # It sits OUTSIDE the spec-pass branch because a resume routinely skips the spec pass. Inside it,
457
+ # a resumed run would publish nothing, the plan would simply be absent, and the writer would go in
458
+ # blind — which looks exactly like a run that never had a plan.
459
+ _publish_agent_outputs() {
460
+ local _node="${NODE_CMD:-${HOME}/.nvm/versions/node/v20.20.0/bin/node}"
461
+ [ -x "$_node" ] || _node="$(command -v node 2>/dev/null || echo node)"
462
+ [ -f "${PRD_FILE:-}" ] || return 0
463
+ "$_node" "$SCRIPT_DIR/lib/producers/fix-plan.js" --publish "$PRD_FILE" \
464
+ || warning "fix-plan publication failed — consumers will find no published plan"
465
+ }
466
+
467
+ # ── Periodic checklist heartbeat ─────────────────────────────────────────────
468
+ # Prints a compact step-status summary every 60s so long-running phases stay
469
+ # visible in the log without requiring tail.
470
+ _checklist_heartbeat() {
471
+ while true; do
472
+ sleep 60
473
+ echo ""
474
+ echo -e "${MAGENTA}━━━ Step Status @ $(date +%H:%M:%S) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
475
+ for _sid in \
476
+ "1:Specification pass" "1a:openspec" "1b:speckit" "2:CPA pre-pass" \
477
+ "3:Skill assessment" "4:Hybrid pre-coord" "5:Regression guard" \
478
+ "6:mkdir src/ dirs" "7:PRD model coordinator" "8:Main-branch stories" \
479
+ "9:Auto-commit" "10:TC writer gate" "11:Skills coordinator audit" \
480
+ "12:Tools coordinator audit" "13:Create worktrees" "14:Primary agent" \
481
+ "15:Independent agent" "16:Worktree health" "17:Merge worktrees" \
482
+ "18:Post-parallel assessment" "19:Pre-review gate" "20:Lint gate" \
483
+ "21:Review stories" "22a:SAST sentinel" "22b:Spec validator" \
484
+ "22c:Review ranger" "22d:Mutant hunter" \
485
+ "22e:Fuzz-weaver" "22f:Perf sentinel" "23:Browser E2E"; do
486
+ local _key="${_sid%%:*}"
487
+ local _st="${_STEP_STATUS[$_key]:-pending}"
488
+ local _lbl="${_STEP_LABELS[$_key]:-${_sid#*:}}"
489
+ local _icon
490
+ case "$_st" in
491
+ pass) _icon="${GREEN}✓${NC}" ;;
492
+ skip) _icon="${YELLOW}⊘${NC}" ;;
493
+ fail) _icon="${RED}✗${NC}" ;;
494
+ warn) _icon="${YELLOW}⚠${NC}" ;;
495
+ running) _icon="${CYAN}▶${NC}" ;;
496
+ *) _icon="${WHITE}○${NC}" ;;
497
+ esac
498
+ printf " %b %-6s %s\n" "${_icon}" "$_key" "$_lbl"
499
+ done
500
+ echo -e "${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
501
+ echo ""
502
+ done
503
+ }
504
+
505
+ # Kill heartbeat on exit — ADDED, never replacing the handlers registered above.
506
+ _epam_kill_heartbeat() { kill "${_HEARTBEAT_PID:-}" 2>/dev/null || true; }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * ECOSYSTEM REGISTRY — resolves providers at RUN TIME. The engine names no stack.
3
+ *
4
+ * REPLACES lib/ecosystem-registry.js, which was a table of "the ecosystems this engine knows". Six stacks
5
+ * were enumerated in engine source, so onboarding a seventh meant editing the pipeline — and every
6
+ * guard that consulted the table was generic only across the stacks already listed. That is the
7
+ * shape of a fact the pipeline must not hold.
8
+ *
9
+ * A provider is a module describing ONE ecosystem: the manifest file that identifies it, what it
10
+ * vendors, what it leaves behind, what a writer must not touch, and how to read its manifest.
11
+ * Those last parts are real parsers — JSON for a package manifest, section matching for TOML, line
12
+ * rules for a requirements file — so a provider is code, not a config row. It is loaded from a
13
+ * DIRECTORY, discovered by listing, never by a name written here.
14
+ *
15
+ * Adding an ecosystem is a new file in a provider directory. There is no engine change, no case
16
+ * statement, and no list to append to.
17
+ *
18
+ * Provider directories, in order:
19
+ * 1. orchestrations/ecosystems — the ones that ship
20
+ * 2. $EPAM_ECOSYSTEM_PROVIDERS — colon-separated, injected per run
21
+ *
22
+ * A later directory may REPLACE an earlier provider by declaring the same `file`, so a codeline
23
+ * that reads its manifest differently supplies its own provider without forking the engine.
24
+ *
25
+ * EPAM_CODELINE_MANIFESTS still extends the set for an ecosystem with no provider at all
26
+ * ("manifest:installDir,manifest:") — the minimum a scan needs to stop calling a repository
27
+ * `unknown`.
28
+ *
29
+ * NO FALLBACK TABLE. If no provider directory resolves, this returns an empty set and the callers
30
+ * that need an ecosystem must say they could not determine one. A built-in default would be a
31
+ * second definition of the same fact, and the second definition is the one that drifts.
32
+ */
33
+ 'use strict';
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+
37
+ /** Where providers ship. Resolved from THIS file, so it holds no absolute path. */
38
+ const BUILTIN_DIR = path.resolve(__dirname, '../../ecosystems');
39
+
40
+ /** Provider directories, earliest first. Later entries win on a duplicate `file`. */
41
+ function providerDirs(env = process.env) {
42
+ const extra = String((env && env.EPAM_ECOSYSTEM_PROVIDERS) || '')
43
+ .split(':').map((s) => s.trim()).filter(Boolean);
44
+ return [BUILTIN_DIR, ...extra];
45
+ }
46
+
47
+ /**
48
+ * Every provider, discovered by listing the directories.
49
+ *
50
+ * A provider that throws on load is SKIPPED LOUDLY on stderr rather than silently dropped: an
51
+ * ecosystem that vanishes because its file has a syntax error would otherwise present as "this
52
+ * repository is not that stack", which is a wrong answer wearing the shape of a right one.
53
+ */
54
+ function loadProviders(env = process.env) {
55
+ const byFile = new Map();
56
+ for (const dir of providerDirs(env)) {
57
+ let names = [];
58
+ try { names = fs.readdirSync(dir); } catch { continue; }
59
+ for (const n of names.filter((x) => x.endsWith('.js')).sort()) {
60
+ const p = path.join(dir, n);
61
+ let mod;
62
+ try { mod = require(p); } catch (e) {
63
+ process.stderr.write(`[ecosystem-registry] provider ${p} did not load: ${e.message}\n`);
64
+ continue;
65
+ }
66
+ if (!mod || typeof mod.file !== 'string' || !mod.file) {
67
+ process.stderr.write(`[ecosystem-registry] provider ${p} declares no manifest file — ignored\n`);
68
+ continue;
69
+ }
70
+ byFile.set(mod.file, mod);
71
+ }
72
+ }
73
+ // ORDERED BY DECLARED PRECEDENCE. Consumers resolve a repository's ecosystem with a
74
+ // first-match-wins `find`, so load order is behaviour: a Node repository that also carries a
75
+ // Gemfile must not resolve as ruby because of how a directory listing sorted. A provider that
76
+ // declares none sorts last, never silently ahead of one that did.
77
+ return [...byFile.values()].sort(
78
+ (a, b) => (a.precedence ?? Number.MAX_SAFE_INTEGER) - (b.precedence ?? Number.MAX_SAFE_INTEGER),
79
+ );
80
+ }
81
+
82
+ /** Extra ecosystems without a provider: "manifest:installDir,manifest:" */
83
+ function extraManifests(env) {
84
+ const raw = (env && env.EPAM_CODELINE_MANIFESTS) || '';
85
+ return raw.split(',').map((s) => s.trim()).filter(Boolean).map((pair) => {
86
+ const [file, installDir] = pair.split(':');
87
+ // No `stack` is declared for a runtime addition, so the scan reports the manifest filename
88
+ // itself. Saying which file was found beats saying `unknown`.
89
+ return { file, stack: file, installDir: installDir || null, artifactDirs: installDir ? [installDir] : [], deps: () => [] };
90
+ });
91
+ }
92
+
93
+ function allManifests(env = process.env) {
94
+ const providers = loadProviders(env);
95
+ const extra = extraManifests(env).filter((e) => !providers.some((p) => p.file === e.file));
96
+ return [...providers, ...extra];
97
+ }
98
+
99
+ /**
100
+ * EVERY DIRECTORY ANY RESOLVED ECOSYSTEM LEAVES BEHIND, deduped.
101
+ *
102
+ * The union, not the detected ecosystem's — a monorepo mixes stacks, and excluding a directory a
103
+ * repository does not have costs nothing.
104
+ */
105
+ function allArtifactDirs(env = process.env) {
106
+ const seen = new Set();
107
+ for (const eco of allManifests(env)) for (const d of eco.artifactDirs || []) seen.add(d);
108
+ return [...seen];
109
+ }
110
+
111
+ /**
112
+ * WHICH LOCKFILE THIS REPOSITORY CARRIES, first match wins — the same order that decides the
113
+ * package manager, so the two can never disagree. Takes an `exists` predicate rather than
114
+ * touching the filesystem.
115
+ */
116
+ const lockfileFor = (eco, exists) => Object.keys((eco && eco.lockfiles) || {}).find((f) => exists(f)) || '';
117
+
118
+ /**
119
+ * THE ECOSYSTEM A PRD DECLARES BEFORE ITS CODELINE EXISTS. A greenfield output directory carries no
120
+ * manifest until the scaffold story writes one; the PRD names the files that story creates, and
121
+ * the provider whose manifest is among them — in the providers' own precedence — is the codeline's
122
+ * ecosystem. One rule, read by the stack facts and by the rehearsal's stand-in writer, so the two
123
+ * cannot disagree about which stack a project is on.
124
+ */
125
+ function declaredByStories(stories, env = process.env) {
126
+ const named = new Set();
127
+ for (const st of (Array.isArray(stories) ? stories : [])) {
128
+ const tn = st && st.technicalNotes;
129
+ for (const f of (tn && Array.isArray(tn.files) ? tn.files : [])) named.add(path.basename(String(f)));
130
+ }
131
+ if (!named.size) return null;
132
+ return allManifests(env).find((e) => [e.file, ...(Array.isArray(e.alsoMatches) ? e.alsoMatches : [])]
133
+ .some((n) => named.has(n))) || null;
134
+ }
135
+
136
+ module.exports = {
137
+ lockfileFor,
138
+ declaredByStories,
139
+ allManifests,
140
+ extraManifests,
141
+ allArtifactDirs,
142
+ loadProviders,
143
+ providerDirs,
144
+ BUILTIN_DIR,
145
+ // Compatibility for importers that read a static list. Resolved on ACCESS, so it reflects the
146
+ // providers present at the moment it is read rather than a snapshot taken at require() time.
147
+ get MANIFESTS() { return allManifests(); },
148
+ };