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,975 @@
1
+ #!/usr/bin/env bash
2
+ # Shared git-mutation primitives — single source of truth, sourced by
3
+ # run-agent-orchestration.sh, claude.sh, and codemie-claude.sh.
4
+ #
5
+ # Root cause this fixes (found live, 2026-08-02, git-surface audit): before
6
+ # this file existed, setup_worktrees()/cleanup_worktrees() were independently
7
+ # copy-pasted into claude.sh AND codemie-claude.sh. claude.sh's copy got a
8
+ # real bug fix (2026-07-06: stale-worktree validation, branch deletion on
9
+ # cleanup, prune-before-delete ordering, a final zero-worktrees-remain
10
+ # verification) that codemie-claude.sh's copy never received — a silent,
11
+ # untested drift that would have reproduced the exact "fatal: a branch named
12
+ # 'wt-primary' already exists" live failure the first time codemie-claude.sh
13
+ # was used for a real run. Per the "all lanes must have the same flow, no
14
+ # deviations" principle already applied to lib/story-guards.sh, every caller
15
+ # now shares the exact same function bodies instead of re-implementing them.
16
+ #
17
+ # Requires from the caller's environment: the log/warning/success/error
18
+ # helpers — already defined identically in all three sourcing scripts (same
19
+ # convention lib/story-guards.sh already relies on).
20
+
21
+ # ensure_story_branch <codeline_root> <story_id> [baseline_branch]
22
+ # Every story commits to its OWN dedicated branch ("AI-<story_id>", derived
23
+ # entirely from the story ID in flight — never a hardcoded name), freshly
24
+ # created off origin/<baseline_branch> every single time via `checkout -B`
25
+ # (which resets the branch to that start-point if it already exists,
26
+ # discarding any prior half-done local attempt — same "clean slate every
27
+ # run" principle already applied elsewhere in this pipeline).
28
+ #
29
+ # Live bug this eliminates (2026-07-22): the previous design committed
30
+ # directly onto the shared baseline branch (develop) and relied on a durable
31
+ # local marker (record_brownfield_verified_baseline / brownfield-preflight-
32
+ # reset.sh) to know what "last known good" state to reset back to before each
33
+ # run. That marker only updates when a story genuinely passes story_tsc_gate
34
+ # — a manual correction to the branch (e.g. a human running `git reset --hard
35
+ # origin/develop`) doesn't update it, so it can point at an already-discarded,
36
+ # orphaned commit. The next run then trusts that stale marker and resets the
37
+ # shared branch BACKWARD onto abandoned history, and a new story commit lands
38
+ # on top of the wrong base — confirmed live: a discarded commit
39
+ # (.epam/setup-deps.sh cruft) got silently reintroduced this way, and the
40
+ # story commit built on top of it.
41
+ #
42
+ # Branching per story removes the entire problem class: there is no shared
43
+ # mutable branch state to protect via a local marker. Every story always
44
+ # starts from the ACTUAL current origin/<baseline_branch> tip, live, via
45
+ # `git fetch` — never a cached/remembered SHA that can go stale.
46
+ # _provision_epam_plugin_config <project_root>
47
+ # Writes .epam/settings.json (plugins) and .epam/codeline-facts.json for
48
+ # THIS project_root. Shared so every git-mutating reset that wipes .epam/
49
+ # (untracked, no .gitignore guarantee on a real client repo — confirmed live,
50
+ # none of Metrolinx's 3 codelines ignore it) has exactly one place to restore
51
+ # it from, instead of independently drifting copies.
52
+ #
53
+ # CodeGraph's query tool (orchestrations/plugins/codegraph-plugin.js) ships
54
+ # with epam-cli itself — provisioned into .epam/settings.json's "tools"
55
+ # array for EVERY codeline unconditionally (mirroring run-agent-
56
+ # orchestration.sh's own per-codeline provisioning step), merged with
57
+ # whatever the project's own EPAM_PROJECT_CONFIG_DIR/plugins.json adds on
58
+ # top. codeline-facts.json remains purely project-config-driven and is a
59
+ # silent no-op when no project config dir is set or the codeline can't be
60
+ # matched against project.outputDirs.
61
+
62
+ # _epam_write_verification_manifest <project_root>
63
+ # Generates .epam/verification.json by asking the verification plugin to detect how THIS repo
64
+ # checks itself (its own package scripts, its own lockfile). Never invents a command: an
65
+ # unrecognised stack writes no manifest, and the plugin then reports UNKNOWN rather than a pass.
66
+ _epam_write_verification_manifest() {
67
+ local _root="$1"
68
+ local _plugin="${AUTOMATION_DIR:-$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)")}/plugins/verification-plugin.js"
69
+ [ -f "$_plugin" ] || return 0
70
+ local _node="${NODE_CMD:-${NODE_BIN:-node}}"
71
+ command -v "$_node" >/dev/null 2>&1 || return 0
72
+ "$_node" -e '
73
+ const p = require(process.argv[1]);
74
+ const root = process.argv[2];
75
+ // BOTH sections. This wrote only `typecheck`, so the `test` section never existed and
76
+ // every reader of it fell back to an engine-side guess. Detection is independent: a repo
77
+ // may declare one and not the other, and writing a partial manifest is correct — the
78
+ // reader reports UNKNOWN for whichever half is absent, never a pass.
79
+ const d = { ...(p.detectVerification(root) || {}), ...(p.detectTests(root) || {}) };
80
+ if (!Object.keys(d).length) process.exit(3);
81
+ const fs = require("node:fs"), path = require("node:path");
82
+ // PRESERVE WHAT THE PROJECT ALREADY DECLARED. Detection is a default, not an authority:
83
+ // an operator who hand-tuned a command must not have it silently overwritten on the next
84
+ // provisioning pass.
85
+ const out = path.join(root, ".epam", "verification.json");
86
+ let existing = {};
87
+ try { existing = JSON.parse(fs.readFileSync(out, "utf8")) || {}; } catch { existing = {}; }
88
+ // MERGE PER SECTION, NOT PER FILE.
89
+ //
90
+ // This was a flat { ...d, ...existing }, so a codeline that already had a `test` section kept
91
+ // it WHOLE — and every real codeline already has one. A newly detected key could therefore
92
+ // never reach any of them: next.gotransit.com carried a test section with no scopedCommand,
93
+ // so story-scoped verification stayed off and a one-line change ran all 746 suites (live
94
+ // 2026-09-02, AMSD-1919: 10,731MB of an 11,264MB cap, a 70-second suite past 10 minutes).
95
+ //
96
+ // Per-key precedence keeps the original intent: a hand-tuned command still wins over
97
+ // detection, while keys nobody set are filled in.
98
+ // WHAT THE PROJECT DECLARES SURVIVES THE RESET; WHAT THE CODELINE HOLDS DOES NOT.
99
+ //
100
+ // .epam/ is untracked and the brownfield reset runs `git clean -fd`, so this whole file is
101
+ // destroyed before every run and rebuilt by detection. Anything hand-written here lives
102
+ // exactly one run — which is how a declared clock pin vanished on 2026-09-07 and AMSD-1919
103
+ // met the same wall-clock failure the pin exists to remove.
104
+ //
105
+ // A project declares its overrides in ITS OWN config dir, which no codeline reset can touch.
106
+ // Precedence, lowest to highest: detected < project-declared < hand-tuned in the codeline.
107
+ // Detection still fills in what nobody declared, and an operator editing this file for one
108
+ // run still wins for that run.
109
+ let declared = {};
110
+ const declFile = process.env.EPAM_PROJECT_CONFIG_DIR
111
+ ? path.join(process.env.EPAM_PROJECT_CONFIG_DIR, "verification.json") : "";
112
+ if (declFile && fs.existsSync(declFile)) {
113
+ try { declared = JSON.parse(fs.readFileSync(declFile, "utf8")) || {}; }
114
+ catch { declared = {}; } // an unreadable declaration changes nothing, and says nothing
115
+ }
116
+ const layer = (lo, hi) => {
117
+ const out = { ...lo, ...hi };
118
+ for (const k of Object.keys(lo)) {
119
+ const a = lo[k]; const b = hi[k];
120
+ if (a && b && typeof a === "object" && typeof b === "object"
121
+ && !Array.isArray(a) && !Array.isArray(b)) {
122
+ out[k] = { ...a, ...b };
123
+ }
124
+ }
125
+ return out;
126
+ };
127
+ // A VALUE DETECTION WROTE IS NOT A HAND-TUNING. `existing` wins so an operator can tune a
128
+ // command for one run -- but the file FIRST content was written by DETECTION, and it then
129
+ // outranked detection forever. Live 2026-09-23: this file had said test.command "pytest"
130
+ // since the codeline was created; the ecosystem command was corrected to
131
+ // `python3 -m pytest` (without which the suite cannot import what it tests, and every
132
+ // verification exited 2 with zero tests collected) and the fix could not reach the
133
+ // codeline. The comment above assumes the file is destroyed before every run -- true of
134
+ // brownfield `git clean -fd`, false of a greenfield RESUME, which keeps its codeline.
135
+ //
136
+ // Detection stamps each section it writes with `detected`. A section carrying that stamp
137
+ // belongs to detection and yields to fresh detection; a section without it was authored by
138
+ // a human or a project declaration and still wins. Nothing is guessed: the file records
139
+ // which half wrote each section.
140
+ //
141
+ // NO APOSTROPHES IN THIS BLOCK. It lives inside a single-quoted shell string, so one
142
+ // closes it: on 2026-09-23 an apostrophe here broke the quoting, the mangled shell ran
143
+ // fragments of its own source, and it deleted the test file that was proving this fix.
144
+ const minusOwnDetections = Object.fromEntries(
145
+ Object.entries(existing).filter(([k, v]) => !(v && typeof v === "object" && !Array.isArray(v) && v.detected)),
146
+ );
147
+ const merged = layer(layer(d, declared), minusOwnDetections);
148
+ fs.mkdirSync(path.join(root, ".epam"), { recursive: true });
149
+ fs.writeFileSync(out, JSON.stringify(merged, null, 2) + "\n");
150
+ ' "$_plugin" "$_root" 2>/dev/null || true
151
+ }
152
+
153
+ _provision_epam_plugin_config() {
154
+ local _project_root="$1"
155
+
156
+ local _project_tools_json="[]"
157
+ if [ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] && [ -f "${EPAM_PROJECT_CONFIG_DIR}/plugins.json" ]; then
158
+ _project_tools_json=$(jq -c '.tools // []' "${EPAM_PROJECT_CONFIG_DIR}/plugins.json" 2>/dev/null || echo "[]")
159
+ fi
160
+
161
+ local _codegraph_plugin_abs=""
162
+ local _codegraph_plugin_src="${SCRIPT_DIR:-}/../plugins/codegraph-plugin.js"
163
+ if [ -n "${SCRIPT_DIR:-}" ] && [ -f "$_codegraph_plugin_src" ]; then
164
+ _codegraph_plugin_abs="$(cd "$(dirname "$_codegraph_plugin_src")" 2>/dev/null && pwd)/$(basename "$_codegraph_plugin_src")"
165
+ fi
166
+
167
+ if [ -n "$_codegraph_plugin_abs" ] || [ "$_project_tools_json" != "[]" ]; then
168
+ mkdir -p "${_project_root}/.epam"
169
+ # Verification manifest, generated from the repo's OWN scripts (never a tool name baked
170
+ # into the engine). Written per codeline at provisioning time so every writer, gate and
171
+ # worktree has it. detect + emit live in the plugin; this only places the file.
172
+ _epam_write_verification_manifest "${_project_root}"
173
+ jq -n --argjson project "$_project_tools_json" --arg cg "$_codegraph_plugin_abs" \
174
+ '{tools: (((if $cg != "" then [$cg] else [] end) + $project) | unique)}' \
175
+ > "${_project_root}/.epam/settings.json"
176
+ fi
177
+
178
+ [ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] || return 0
179
+ local _facts_cfg="${EPAM_PROJECT_CONFIG_DIR}/codeline-facts.json"
180
+ [ -f "$_facts_cfg" ] || return 0
181
+
182
+ local _prd_for_lookup="${MAIN_PRD_FILE:-${PRD_FILE:-}}"
183
+ [ -f "${_prd_for_lookup:-}" ] || return 0
184
+ local _cl
185
+ _cl=$(jq -r --arg root "$_project_root" \
186
+ '.project.outputDirs[]? | select(.path == $root) | .codeline' \
187
+ "$_prd_for_lookup" 2>/dev/null | head -1)
188
+ [ -n "$_cl" ] || return 0
189
+
190
+ local _cl_facts
191
+ _cl_facts=$(jq -c --arg cl "$_cl" '.[$cl] // empty' "$_facts_cfg" 2>/dev/null)
192
+ if [ -n "$_cl_facts" ]; then
193
+ mkdir -p "${_project_root}/.epam"
194
+ echo "$_cl_facts" > "${_project_root}/.epam/codeline-facts.json"
195
+ fi
196
+ }
197
+
198
+ # ── The engine's perimeter at the staging seam ──────────────────────────────
199
+ #
200
+ # Directories epam-cli writes into a client codeline so its gates can read them. They
201
+ # are never client content and must never be committed there.
202
+ #
203
+ # The list is the CLASS, not one instance. Excluding only .epam (as this first did) let
204
+ # .deepeval/.deepeval_telemetry.txt into a live metrolinx commit hours later — the same
205
+ # defect wearing a different filename. orchestrations/ was added 2026-08-01 after the KB
206
+ # writer dropped orchestrations/agents/KB.md into a client repo; only orchestrations/logs/*
207
+ # had been excluded, so the "excluded one instance, not the class" gap reappeared under a
208
+ # new path. All were verified absent from the client baseline tree (origin/develop).
209
+ #
210
+ # Kept in sync with src/config/enginePaths.ts, which enforces the same perimeter at the
211
+ # WRITE seam — where it actually belongs. This is defence in depth: by the time a file
212
+ # reaches staging it already exists in the customer's working tree, where the writer-output
213
+ # manifest picks it up as though the writer produced it (live 20260804T225443Z).
214
+ # shellcheck source=engine-paths.sh
215
+ . "$(dirname "${BASH_SOURCE[0]}")/engine-paths.sh"
216
+ . "$(dirname "${BASH_SOURCE[0]}")/codeline-write-perimeter.sh"
217
+
218
+ # git_add_client_outputs <repo> [timeout_secs]
219
+ # Report a staging failure with git's own words. Writes to stderr so it lands in the run log
220
+ # beside the WARNING the caller emits; a bare "exit 1" is not a diagnosis.
221
+ _git_add_report_failure() {
222
+ local _repo="$1" _rc="$2" _stderr="$3" _summary="$4"
223
+ # A "FAILED ... (exit 0)" line is its own defect: it sends the next investigation after a
224
+ # failure that never happened. Caught by the state sweep the same hour this was added.
225
+ [ "$_rc" -eq 0 ] && return 0
226
+ echo "[git-add] FAILED in ${_repo} (exit ${_rc}): ${_summary}" >&2
227
+ if [ -n "$_stderr" ]; then
228
+ printf '%s\n' "$_stderr" | head -20 | sed 's/^/[git-add] /' >&2
229
+ else
230
+ echo "[git-add] (git printed nothing)" >&2
231
+ fi
232
+ }
233
+
234
+ # Stage every client change, never an engine artefact. Returns git's exit code.
235
+ #
236
+ # There is deliberately NO fallback that stages without the exclusions. Every previous
237
+ # call site had `... || git add -A`, which discarded the whole rule the moment the
238
+ # pathspec form returned non-zero — and `git add -A -- :!x` returns non-zero in ordinary
239
+ # situations (e.g. a repo with no HEAD). The exclusions existed at two of three sites and
240
+ # were bypassable at all three.
241
+ git_add_client_outputs() {
242
+ local _repo="$1"
243
+ local _timeout="${2:-${EPAM_COMMIT_TIMEOUT_SECS}}"
244
+ [ -n "$_repo" ] && [ -e "$_repo/.git" ] || return 0
245
+
246
+ local _excludes=() _resets=() _d
247
+ for _d in "${_ENGINE_OWNED_DIRS[@]}"; do
248
+ # ONE GLOB, NOT TWO PREFIX FORMS. ":!<dir>/*" is FATAL, not merely unmatched, when the
249
+ # directory is a symlink — git refuses a pathspec that crosses one:
250
+ #
251
+ # fatal: pathspec ':(exclude)node_modules/*' is beyond a symbolic link
252
+ #
253
+ # Live 2026-08-18, both lanes, at the LAST step of a run that had otherwise succeeded:
254
+ # the fix was correct, tests passed, the type check passed, and every commit failed with
255
+ # exit 128 because both codelines symlink node_modules to a shared install. Nothing could
256
+ # ever be committed in such a repo.
257
+ #
258
+ # The bare ":!<dir>" form survives the symlink but stops excluding NESTED copies, which
259
+ # would stage a client's vendored tree — fixing the error by weakening the exclusion. This
260
+ # glob is correct on all three counts: top-level contents, nested contents, and a
261
+ # symlinked directory.
262
+ _excludes+=( ":(exclude,glob)**/${_d}/**" ":(exclude,glob)**/${_d}" )
263
+ _resets+=( "$_d" )
264
+ done
265
+ # Build artefacts are not engine state, but staging them is never right either. This named
266
+ # node_modules, build and .next — one ecosystem — so a Rust codeline whose target/ was not
267
+ # gitignored had its whole build tree staged into the CUSTOMER'S repository. The list now
268
+ # comes from lib/ecosystem-registry.js (ecosystem artefacts) and config/repo-artifacts.json (editor
269
+ # and OS droppings), which is also where worktree-health-check.sh reads it — they were two
270
+ # hand-written lists that had drifted apart.
271
+ #
272
+ # NO SILENT FALLBACK. An empty exclusion list stages a client's build tree, so a handler that
273
+ # cannot answer stops the staging rather than widening it.
274
+ local _ex_out
275
+ if ! _ex_out=$("${NODE_BIN:-node}" "$(dirname "${BASH_SOURCE[0]}")/handlers/repo-exclude-patterns.js" pathspec); then
276
+ echo "[git-add] could not resolve the exclusion list — refusing to stage without it" >&2
277
+ return 1
278
+ fi
279
+ while IFS= read -r _ex; do
280
+ [ -n "$_ex" ] && _excludes+=( "$_ex" )
281
+ done <<< "$_ex_out"
282
+
283
+ # CAPTURE THE REASON. This was `2>/dev/null`, and live 2026-08-09 a run halted on
284
+ # "git add failed (exit 1)" with no diagnosis at all: the deliverable gate had passed, tsc
285
+ # had passed, the work was real, and two fixtures reproducing the plausible causes both
286
+ # returned 0 — the actual reason was unknown, because this line deleted it. git's message
287
+ # here is specific and useful ("index file smaller than expected", "The following paths are
288
+ # ignored by one of your .gitignore files") and is the difference between a fix and a theory.
289
+ # Held in a variable rather than passed straight through, so the success path stays silent:
290
+ # an ignored-path warning is normal and must not print on every story.
291
+ local _add_stderr
292
+ _add_stderr=$(git -C "$_repo" add -A -- "${_excludes[@]}" 2>&1)
293
+ local _rc=$?
294
+
295
+ # Belt and braces, run unconditionally: if the pathspec form above failed for any
296
+ # reason, anything engine-owned that slipped into the index comes straight back out.
297
+ # This is what makes the no-fallback rule safe rather than merely strict.
298
+ git -C "$_repo" reset -q -- "${_resets[@]}" 2>/dev/null || true
299
+
300
+ # ASK THE INDEX, NOT THE EXIT CODE.
301
+ #
302
+ # `git add` exits non-zero merely for NAMING an ignored path — which is exactly what the
303
+ # exclusion pathspecs above do. Live 2026-08-09: the writer installed a dependency, so a
304
+ # gitignored top-level node_modules appeared, git printed "The following paths are ignored
305
+ # by one of your .gitignore files" and exited 1 — with all 323 insertions correctly staged.
306
+ # The raw code propagated to commit_completed_story, the story was demoted as undelivered,
307
+ # the phase aborted and the remaining two codelines never ran.
308
+ #
309
+ # Whether work is staged is a fact, so read it. A non-zero exit with a populated index is a
310
+ # warning. A non-zero exit with an EMPTY index, when there was something to stage, is a real
311
+ # failure and still fails. The comment above already predicted this class ("returns non-zero
312
+ # in ordinary situations") without handling it.
313
+ # A TIMEOUT IS NEVER SUCCESS, whatever the index looks like. `timeout` reports 124, and a
314
+ # wedged git is a different condition from git objecting to an ignored pathspec: the repo
315
+ # may be locked or the filesystem hung, and a partially-populated index proves nothing about
316
+ # whether the rest would have staged. Checked BEFORE the index, or the index check silently
317
+ # converts a hang into a clean commit — which it did, until
318
+ # per-story-commit-and-partial-merge.test.ts caught it.
319
+ if [ "$_rc" -eq 124 ]; then
320
+ _git_add_report_failure "$_repo" "$_rc" "$_add_stderr" "timed out after ${_timeout}s"
321
+ return $_rc
322
+ fi
323
+
324
+ local _staged _pending
325
+ # wc -l, not `grep -c . || echo 0`: on empty input grep prints 0 AND exits 1, so the
326
+ # fallback appends a second 0 and the numeric comparison below errors out.
327
+ _staged=$(git -C "$_repo" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
328
+ if [ "${_staged:-0}" -gt 0 ]; then
329
+ return 0
330
+ fi
331
+ # Nothing staged. If nothing was stageable either, that is a correct no-op, not a failure.
332
+ # ASK THE SAME QUESTION THE STAGING ANSWERED.
333
+ #
334
+ # This was a bare `git status --porcelain | wc -l`, which counts EVERYTHING pending —
335
+ # including .epam/ and the other engine-owned paths that `git add` above deliberately
336
+ # excludes. So "nothing staged although something is pending" was a false conclusion
337
+ # whenever the only pending path was one we never intended to stage.
338
+ #
339
+ # Live 2026-08-09, gotransit, reproduced exactly by
340
+ # git-add-verdict-across-repo-states.test.ts: a gitignored top-level node_modules makes
341
+ # `git add` exit 1 merely for naming the ignored path; nothing stages because there is no
342
+ # client work; .epam/ is the only thing "pending" — and the story was demoted as
343
+ # undelivered, the phase aborted, and the codeline HALTed over a repo that had nothing to
344
+ # commit.
345
+ #
346
+ # engine_paths_filter is the same single definition the staging exclusions come from, and the
347
+ # artefact dirs now are too — this was a THIRD copy of the list, naming node_modules, build and
348
+ # .next. A pending target/ or .venv/ counted as work, so a Rust or Python repo with nothing to
349
+ # commit produced 'nothing reached the index although N path(s) are pending' and HALTed the
350
+ # codeline. Reusing $_ex_re, already resolved above; if it is empty the filter is skipped
351
+ # rather than degenerating into a regex that matches everything.
352
+ local _ex_re
353
+ _ex_re=$("${NODE_BIN:-node}" "$(dirname "${BASH_SOURCE[0]}")/handlers/repo-exclude-patterns.js" regex 2>/dev/null || echo "")
354
+ _pending=$(git -C "$_repo" status --porcelain 2>/dev/null \
355
+ | sed 's/^...//' \
356
+ | sed 's/^.* -> //' \
357
+ | engine_paths_filter \
358
+ | { if [ -n "$_ex_re" ]; then grep -vE "$_ex_re"; else cat; fi; } \
359
+ | wc -l | tr -d ' ')
360
+ if [ "$_rc" -ne 0 ] && [ "${_pending:-0}" -eq 0 ]; then
361
+ # git could not even report status: the repository is genuinely unusable.
362
+ if ! git -C "$_repo" status --porcelain >/dev/null 2>&1; then
363
+ _git_add_report_failure "$_repo" "$_rc" "$_add_stderr" "the repository is unusable — git cannot even report status"
364
+ return "$_rc"
365
+ fi
366
+ fi
367
+ [ "${_pending:-0}" -eq 0 ] && return 0
368
+ _git_add_report_failure "$_repo" "$_rc" "$_add_stderr" \
369
+ "nothing reached the index although ${_pending} path(s) are pending"
370
+ return $_rc
371
+ }
372
+
373
+ # story_branch_name <story_id> [suffix] — THE ONE RULE for a story's branch name, for every pipeline that works a story branch (B75, RS-10): the project's
374
+ # prefix (EPAM_BRANCH_PREFIX), "AI-<story_id>", and a release line's suffix (the argument, else EPAM_BRANCH_SUFFIX; an explicit empty suffix means none).
375
+ story_branch_name() { printf '%s' "${EPAM_BRANCH_PREFIX:-}AI-${1}${2-${EPAM_BRANCH_SUFFIX:-}}"; }
376
+
377
+ # _story_local_base_ref <root> <baseline> — the baseline as the checkout already has it, WITHOUT the network: the tag, then origin's branch, then the local
378
+ # branch. Empty when none resolves.
379
+ _story_local_base_ref() {
380
+ local _root="$1" _b="${2#refs/tags/}" _ref
381
+ _b="${_b#refs/heads/}"
382
+ [ -n "$_b" ] || return 0
383
+ for _ref in "refs/tags/${_b}" "refs/remotes/origin/${_b}" "refs/heads/${_b}"; do
384
+ git -C "$_root" rev-parse -q --verify "${_ref}^{commit}" >/dev/null 2>&1 && { printf '%s' "$_ref"; return 0; }
385
+ done
386
+ return 0
387
+ }
388
+
389
+ # _story_branch_carries_own_work <root> <branch> <story_id> [baseline] — true when <branch> exists and carries the story's own work. B75 (RS-1): the evidence
390
+ # is GIT — a commit of the story ("<story_id>: …", the convention every commit of the story follows) between the baseline and the branch — so it holds for
391
+ # any pipeline, wherever it keeps its records; a client's own history using the same ticket prefix does not count (only commits since the baseline). The bash
392
+ # pipeline's own record (LOG_DIR/story-changes.jsonl) is evidence too, as before. Never on a resume sent back to an earlier stage.
393
+ _story_branch_carries_own_work() {
394
+ local _root="$1" _br="$2" _id="$3" _base_name="${4:-}" _f="${LOG_DIR:-}/story-changes.jsonl" _sha _base _re
395
+ [ -n "$_id" ] || return 1
396
+ git -C "$_root" rev-parse -q --verify "refs/heads/${_br}" >/dev/null 2>&1 || return 1
397
+ if [ -n "${EPAM_RESUME_FROM_STAGE:-}" ] && declare -F _stage_rank >/dev/null 2>&1 \
398
+ && [ "$(_stage_rank "$EPAM_RESUME_FROM_STAGE")" -lt "$(_stage_rank pre-writer)" ]; then
399
+ return 1
400
+ fi
401
+ _base=$(_story_local_base_ref "$_root" "$_base_name")
402
+ if [ -n "$_base" ]; then
403
+ _re=$(printf '%s' "$_id" | sed 's/[][\.*^$/]/\\&/g')
404
+ [ -n "$(git -C "$_root" log -1 --format=%h --grep="^${_re}:" "${_base}..refs/heads/${_br}" 2>/dev/null)" ] && return 0
405
+ fi
406
+ [ -f "$_f" ] || return 1
407
+ while IFS= read -r _sha; do
408
+ [ -n "$_sha" ] && git -C "$_root" merge-base --is-ancestor "$_sha" "refs/heads/${_br}" 2>/dev/null && return 0
409
+ done < <(jq -r --arg id "$_id" 'select(.storyId == $id) | .sha // empty' "$_f" 2>/dev/null)
410
+ return 1
411
+ }
412
+
413
+ # _preserve_uncommitted <root> <story_id> — B75 (RS-3): before ANY reset, whatever the working tree holds that no commit holds (tracked edits and untracked
414
+ # files, the pipeline's own .epam/ aside) is committed to a rescue ref, epam-rescue/<story>-wip-<sha>, without touching the branch, the index or the tree.
415
+ # Nothing to preserve: nothing is done. Fails (non-zero) when it cannot — the caller then refuses to reset, as it does when a commit cannot be pinned.
416
+ _preserve_uncommitted() {
417
+ local _root="$1" _id="$2" _idx _tree _head _commit _ref
418
+ [ -n "$(git -C "$_root" status --porcelain --untracked-files=all -- . ':(exclude).epam' 2>/dev/null)" ] || return 0
419
+ _head=$(git -C "$_root" rev-parse -q --verify HEAD 2>/dev/null) || _head=""
420
+ _idx=$(mktemp -u "${TMPDIR:-/tmp}/epam-wip-index-XXXXXX") || return 1
421
+ GIT_INDEX_FILE="$_idx" git -C "$_root" add -A -- . ':(exclude).epam' >/dev/null 2>&1 || { rm -f "$_idx"; return 1; }
422
+ _tree=$(GIT_INDEX_FILE="$_idx" git -C "$_root" write-tree 2>/dev/null) || { rm -f "$_idx"; return 1; }
423
+ rm -f "$_idx"
424
+ _commit=$(git -C "$_root" -c user.name="${GIT_COMMITTER_NAME:-epam-rescue}" -c user.email="${GIT_COMMITTER_EMAIL:-epam-rescue@local}" \
425
+ commit-tree "$_tree" ${_head:+-p "$_head"} -m "epam-rescue: uncommitted work of ${_id}" 2>/dev/null) || return 1
426
+ _ref="epam-rescue/${_id}-wip-$(git -C "$_root" rev-parse --short "$_commit" 2>/dev/null || echo unknown)"
427
+ git -C "$_root" branch -f "$_ref" "$_commit" --quiet 2>/dev/null || return 1
428
+ warning " [story-branch] ${_id}: uncommitted work preserved on branch '${_ref}'"
429
+ warning " [story-branch] recover with: git -C '${_root}' checkout ${_ref} -- <path>"
430
+ return 0 # the work IS preserved; a logger that cannot write (it returns its own status) must not turn that into a failure
431
+ }
432
+
433
+ ensure_story_branch() {
434
+ local codeline_root="$1"
435
+ local story_id="$2"
436
+ local baseline_branch="${3:-${JIRA_BASELINE_BRANCH:-main}}"
437
+ # A base named as a ref (refs/tags/<tag>, refs/heads/<branch>) is the same base: the name below is resolved as a tag,
438
+ # then as origin's branch (live 2026-10-07: refs/tags/v7.14.0 became origin/refs/tags/v7.14.0 and no branch was made).
439
+ baseline_branch="${baseline_branch#refs/tags/}"; baseline_branch="${baseline_branch#refs/heads/}"
440
+
441
+ [ "${EPAM_BROWNFIELD:-0}" = "1" ] || return 0
442
+ [ -e "$codeline_root/.git" ] || return 0
443
+ [ -n "$story_id" ] || return 0
444
+
445
+ # EPAM_BRANCH_PREFIX is project-configurable (set in the project's own
446
+ # config.env) — never hardcoded here. Some client repos enforce a branch
447
+ # naming convention (e.g. commitlint/husky requiring a leading
448
+ # feature|bugfix|tech|release|hotfix/ segment); most projects have no such
449
+ # constraint, so the default preserves today's plain "AI-<story_id>" shape.
450
+ # ONE STORY BRANCH PER RELEASE LINE (lib/release-lines.sh): a line other than the codeline's own carries a
451
+ # suffix, so the codeline's own story branch is never reset by another line's work.
452
+ local _branch; _branch=$(story_branch_name "$story_id")
453
+
454
+ # A STORY THAT ALREADY HAS ITS OWN WORK ON ITS BRANCH CONTINUES FROM IT (AMSD-2974 run 9, 2026-10-14). A self-heal retry
455
+ # sets the story pending again and comes back here; the hard reset below moved the 7.x line's branch to v7.11.0 twice
456
+ # in one run, discarding the approved full port (c25e819), and the writer re-wrote it from nothing — paid twice, weaker.
457
+ # This run's own record of the story's commits (story-changes.jsonl) is what says the work is its own: a fresh run has
458
+ # none, and a resume sent back to an earlier stage (EPAM_RESUME_FROM_STAGE) starts over as asked.
459
+ if _story_branch_carries_own_work "$codeline_root" "$_branch" "$story_id" "$baseline_branch"; then
460
+ if git -C "$codeline_root" checkout -q "$_branch" 2>/dev/null; then
461
+ _preserve_uncommitted "$codeline_root" "$story_id" || { error " [story-branch] $story_id: uncommitted work could not be preserved — refusing to reset it away"; return 1; }
462
+ git -C "$codeline_root" reset --hard -q "$_branch" 2>/dev/null || true
463
+ git -C "$codeline_root" clean -fd -e .epam -q 2>/dev/null || true
464
+ _provision_epam_plugin_config "$codeline_root"
465
+ perimeter_apply "$codeline_root"
466
+ success " [story-branch] $story_id: on branch '${_branch}', continuing on its own work at $(git -C "$codeline_root" rev-parse --short HEAD) (working tree reset + cleaned)"
467
+ return 0
468
+ fi
469
+ warning " [story-branch] $story_id: could not check out '${_branch}' to continue its own work — basing it afresh"
470
+ fi
471
+
472
+ # THE START POINT IS WHAT VARIES, NOT THE PROCEDURE.
473
+ #
474
+ # This used to begin `git fetch origin` and return on failure, so a repository with no remote
475
+ # never reached checkout -B: the writer worked directly on the baseline branch and one warning
476
+ # line said so. That became a hard failure once the write perimeter went generic, because
477
+ # perimeter_apply — the call that REOPENS the repository — sits past that early return. A
478
+ # sealed repo with no remote could never be unsealed, and the writer was locked out of the one
479
+ # repository it was asked to change.
480
+ #
481
+ # With a remote, everything below is unchanged: fetch, then base off origin/<baseline>.
482
+ # Without one, the LOCAL baseline branch is the start point. With neither, this still fails.
483
+ local _base_ref=""
484
+ # A RELEASE LINE'S BASE IS A RELEASE: a tag, fetched from origin when the checkout lacks it. Tags are not
485
+ # branches, so there is no origin/<tag> to reset to — the tag itself is the start point.
486
+ if ! git -C "$codeline_root" rev-parse -q --verify "refs/tags/${baseline_branch}^{commit}" >/dev/null 2>&1 \
487
+ && git -C "$codeline_root" remote get-url origin >/dev/null 2>&1; then
488
+ git -C "$codeline_root" fetch origin "refs/tags/${baseline_branch}:refs/tags/${baseline_branch}" --quiet 2>/dev/null || true
489
+ fi
490
+ if git -C "$codeline_root" rev-parse -q --verify "refs/tags/${baseline_branch}^{commit}" >/dev/null 2>&1; then
491
+ _base_ref="refs/tags/${baseline_branch}"
492
+ elif git -C "$codeline_root" remote get-url origin >/dev/null 2>&1; then
493
+ if git -C "$codeline_root" fetch origin "$baseline_branch" --quiet 2>/dev/null; then
494
+ _base_ref="origin/${baseline_branch}"
495
+ else
496
+ # THE CODELINE'S OWN BASELINE. One project declares one baseline for several codelines, and a codeline need not
497
+ # have it (cx-shared's default is master, it has no develop). Left on its current branch, a fresh run's
498
+ # detective read earlier runs' commits as existing code (AMSD-2974, 2026-10-09). codeline_baseline_ref
499
+ # answers from the codeline's own refs: its default branch, origin/HEAD.
500
+ local _own; _own=$(codeline_baseline_ref "$codeline_root" "$baseline_branch")
501
+ if [ -n "$_own" ] && [ "$_own" != "$baseline_branch" ] \
502
+ && git -C "$codeline_root" fetch origin "${_own#origin/}" --quiet 2>/dev/null; then
503
+ _base_ref="$_own"
504
+ info " [story-branch] $story_id: this codeline has no ${baseline_branch} — basing on its own default, ${_own}"
505
+ else
506
+ warning " [story-branch] $story_id: could not fetch origin/${baseline_branch} — proceeding on current branch state"
507
+ return 1
508
+ fi
509
+ fi
510
+ elif git -C "$codeline_root" rev-parse --verify --quiet "refs/heads/${baseline_branch}" >/dev/null 2>&1; then
511
+ _base_ref="$baseline_branch"
512
+ info " [story-branch] $story_id: no 'origin' remote — basing off the local '${baseline_branch}'"
513
+ else
514
+ warning " [story-branch] $story_id: no 'origin' remote and no local '${baseline_branch}' — proceeding on current branch state"
515
+ return 1
516
+ fi
517
+
518
+ # NOTHING IS DISCARDED SILENTLY OR UNRECOVERABLY.
519
+ #
520
+ # The hard reset below is wanted and stays. What is not acceptable is what it did
521
+ # on 2026-08-14: a story that had COMPLETED — `npm run test` green, `tsc` green,
522
+ # 4 files committed — was rejected by the reviewer over one inconsistent
523
+ # dependency declaration, and the retry moved this branch pointer back to
524
+ # origin/develop. The commit stopped being reachable from any ref. It survived
525
+ # only in the reflog, where nothing in this pipeline looks and where gc removes
526
+ # it. The same shape orphaned three FINISHED gotransit commits the same day
527
+ # (e780a8b7 / 45c82f2a / 20c2cea4), recovered by hand.
528
+ #
529
+ # The log line "freshly based on origin/<baseline>" reads as hygiene. It was
530
+ # deletion. So: before the pointer moves, anything that would stop being
531
+ # reachable is pinned under a real ref and named — recovery must never depend on
532
+ # the reflog. This is a local ref only; it pushes nothing.
533
+ # THE BRANCH BEING RESET, NOT ONLY HEAD: a release line's branch is not what the codeline has checked out, and its
534
+ # approved port (c25e819, 2026-10-14) was moved off every ref with no rescue because only HEAD was measured.
535
+ local _tip _tips=(HEAD)
536
+ git -C "$codeline_root" rev-parse -q --verify "refs/heads/${_branch}" >/dev/null 2>&1 && _tips+=("refs/heads/${_branch}")
537
+ for _tip in "${_tips[@]}"; do
538
+ local _unreachable=0
539
+ _unreachable=$(git -C "$codeline_root" rev-list --count "${_base_ref}..${_tip}" 2>/dev/null || echo 0)
540
+ case "$_unreachable" in (''|*[!0-9]*) _unreachable=0 ;; esac
541
+ [ "$_unreachable" -gt 0 ] || continue
542
+ # Still reachable from another ref (HEAD's own branch, which checkout -B does not move): nothing is lost.
543
+ [ "$_tip" = HEAD ] && [ -n "$(git -C "$codeline_root" symbolic-ref -q HEAD 2>/dev/null)" ] \
544
+ && [ "$(git -C "$codeline_root" symbolic-ref -q --short HEAD)" != "$_branch" ] && continue
545
+ local _rescue_ref _head_short
546
+ _head_short=$(git -C "$codeline_root" rev-parse --short "$_tip" 2>/dev/null || echo unknown)
547
+ _rescue_ref="epam-rescue/${story_id}-${_head_short}"
548
+ if git -C "$codeline_root" branch -f "$_rescue_ref" "$_tip" --quiet 2>/dev/null; then
549
+ warning " [story-branch] $story_id: ${_unreachable} commit(s) would have been discarded by this reset — preserved on branch '${_rescue_ref}'"
550
+ warning " [story-branch] recover with: git -C '${codeline_root}' log ${_rescue_ref}"
551
+ else
552
+ error " [story-branch] $story_id: ${_unreachable} commit(s) are about to become unreachable and the rescue ref could not be created — refusing to reset"
553
+ return 1
554
+ fi
555
+ done
556
+
557
+ _preserve_uncommitted "$codeline_root" "$story_id" || { error " [story-branch] $story_id: uncommitted work could not be preserved — refusing to reset it away"; return 1; }
558
+
559
+ if git -C "$codeline_root" checkout -B "$_branch" "$_base_ref" --quiet 2>/dev/null; then
560
+ # checkout -B only forces tracked files that DIFFER between the old
561
+ # branch tip and the new start-point — a tracked file whose content
562
+ # is IDENTICAL in both survives untouched, dirty modifications and
563
+ # all. Found live 2026-08-02 (metrolinx, AMSD-2041 Writer Retest): a
564
+ # SEPARATE, already-killed run's leftover uncommitted contentstack.ts
565
+ # changes plus an untracked spec file survived checkout -B intact, so
566
+ # the NEXT run's agent burned its entire watchdog budget (600s+900s)
567
+ # reconciling old debris against an "approved" commit instead of
568
+ # doing fresh work — a real, systemic issue (the same class of
569
+ # leftover-worktree-state bug already documented for gotransit in
570
+ # agent-audit-2026-07-31.md). `checkout -B` promises "freshly based
571
+ # on origin" for the BRANCH; it never promised a clean WORKING TREE.
572
+ # reset --hard forces every tracked file back regardless of diff;
573
+ # clean -fd drops untracked cruft — the exact "predictable teardown"
574
+ # primitive brownfield-preflight-reset.sh already applies between
575
+ # RUNS and _selective_worktree_reset applies between RUNGS, now also
576
+ # applied here, once, before a story's FIRST attempt even begins.
577
+ git -C "$codeline_root" reset --hard "$_base_ref" --quiet 2>/dev/null || true
578
+ # -e .epam: THIS run's provisioned declarations for the codeline, written at lane setup just
579
+ # before this (the pre-flight reset already cleared any earlier run's). Cleaned here, the
580
+ # codeline lost dependency-check.json (vendorDirs, provisionCommand) — _provision_epam_plugin_config
581
+ # restores only settings, facts and the verification manifest — and every worktree
582
+ # verification then ran the declared install against the registry (multi-lane £0, 2026-10-02).
583
+ git -C "$codeline_root" clean -fd -e .epam --quiet 2>/dev/null || true
584
+ _provision_epam_plugin_config "$codeline_root"
585
+ # The repo is now on a story branch, which is where edits are allowed to
586
+ # land — reopen it. While it sat on the baseline branch it was chmod'd
587
+ # read-only (see lib/codeline-write-perimeter.sh): a spec-pass agent
588
+ # rewrote ~1050 lines of client source there before any writer ran, and
589
+ # a per-tool allowlist cannot stop that because `bash` bypasses it.
590
+ perimeter_apply "$codeline_root"
591
+ success " [story-branch] $story_id: on branch '${_branch}', freshly based on ${_base_ref} (working tree hard-reset + cleaned)"
592
+ return 0
593
+ fi
594
+
595
+ warning " [story-branch] $story_id: could not create/reset branch '${_branch}' off ${_base_ref} — proceeding on current branch"
596
+ return 1
597
+ }
598
+
599
+ # record_story_changes <story_id> <commit_root>
600
+ # Appends one JSON line describing the commit just made to $LOG_DIR/story-changes.jsonl:
601
+ # the sha, subject, changed files, insertion/deletion counts and the diff itself.
602
+ #
603
+ # Never fails its caller. A missing record degrades the report; a raised error here would fail a
604
+ # story that has already succeeded, which is strictly worse.
605
+ record_story_changes() {
606
+ local story_id="$1" root="$2"
607
+ local _t="${EPAM_COMMIT_TIMEOUT_SECS}"
608
+
609
+ if [ -z "${LOG_DIR:-}" ]; then
610
+ # Loud, not silent: without this the report has only the live codeline to go on.
611
+ warning " [record_story_changes] LOG_DIR unset — ${story_id}'s changes were not recorded for the run report"
612
+ return 1
613
+ fi
614
+ mkdir -p "$LOG_DIR" 2>/dev/null || return 1
615
+
616
+ local _sha _subject _when
617
+ _sha=$(git -C "$root" rev-parse HEAD 2>/dev/null) || return 1
618
+ [ -n "$_sha" ] || return 1
619
+ _subject=$(git -C "$root" log -1 --format=%s "$_sha" 2>/dev/null)
620
+ _when=$(git -C "$root" log -1 --format=%aI "$_sha" 2>/dev/null)
621
+
622
+ local _names _stat _diff
623
+ _names=$(git -C "$root" diff --name-only "${_sha}^" "$_sha" 2>/dev/null) \
624
+ || _names=$(git -C "$root" show --pretty=format: --name-only "$_sha" 2>/dev/null)
625
+ _stat=$(git -C "$root" diff --stat "${_sha}^" "$_sha" 2>/dev/null) \
626
+ || _stat=$(git -C "$root" show --pretty=format: --stat "$_sha" 2>/dev/null)
627
+ _diff=$(git -C "$root" diff "${_sha}^" "$_sha" 2>/dev/null) \
628
+ || _diff=$(git -C "$root" show --pretty=format: "$_sha" 2>/dev/null)
629
+
630
+ # BOUNDED. A pathological story must not write an unbounded diff into the evidence. Over the
631
+ # cap the diff is dropped WHOLE and said to be dropped — never truncated mid-hunk into
632
+ # something that reads like a complete diff but is not one.
633
+ local _cap="${EPAM_STORY_DIFF_MAX_BYTES:-2000000}"
634
+ local _omitted=false
635
+ if [ "${#_diff}" -gt "$_cap" ]; then
636
+ _diff=""
637
+ _omitted=true
638
+ fi
639
+
640
+ STORY_ID="$story_id" SHA="$_sha" SUBJECT="$_subject" WHEN="$_when" \
641
+ NAMES="$_names" STAT="$_stat" DIFF="$_diff" OMITTED="$_omitted" \
642
+ "${NODE_BIN:-node}" -e '
643
+ const g = (k) => process.env[k] || "";
644
+ const files = g("NAMES").split("\n").map(s => s.trim()).filter(Boolean);
645
+ const diff = g("DIFF");
646
+ const count = (p, q) => diff.split("\n")
647
+ .filter(l => l.startsWith(p) && !l.startsWith(q)).length;
648
+ process.stdout.write(JSON.stringify({
649
+ storyId: g("STORY_ID"), sha: g("SHA"), subject: g("SUBJECT"),
650
+ committedAt: g("WHEN"), files,
651
+ insertions: count("+", "+++"), deletions: count("-", "---"),
652
+ diffstat: g("STAT"), diff, diffOmitted: g("OMITTED") === "true",
653
+ }) + "\n");
654
+ ' >> "$LOG_DIR/story-changes.jsonl" 2>/dev/null || {
655
+ warning " [record_story_changes] could not record ${story_id}'s changes for the run report"
656
+ return 1
657
+ }
658
+ return 0
659
+ }
660
+
661
+ # The flags of every pipeline commit — lib/pipeline-commit.sh says why (the codeline's hooks are skipped).
662
+ # shellcheck source=lib/pipeline-commit.sh
663
+ . "$(dirname "${BASH_SOURCE[0]}")/pipeline-commit.sh"
664
+
665
+ # worktree_uncommitted_work <worktree> — prints the tracked files a worktree changed but never committed;
666
+ # empty when there are none (or no worktree). Step 17 used to call a lane with no commits and completed
667
+ # stories "nothing to merge" — with the writer's changes still sitting uncommitted in the worktree (its
668
+ # commit refused by the codeline's hooks): the work was silently dropped (real-estate rehearsal,
669
+ # 2026-10-05). Untracked files are not counted: the pipeline provisions its own (.epam/) there.
670
+ worktree_uncommitted_work() {
671
+ local _w="${1:-}"
672
+ [ -n "$_w" ] && [ -e "$_w/.git" ] || return 0
673
+ git -C "$_w" status --porcelain --untracked-files=no 2>/dev/null | sed 's/^...//'
674
+ }
675
+
676
+ # commit_completed_story <story_id>
677
+ # Stages and commits whatever a completed story wrote, scoped to the current
678
+ # GIT_WORK_ROOT (the worktree checkout when running --worktree, the main repo
679
+ # otherwise, falling back to PROJECT_ROOT for callers that only set that).
680
+ # Best-effort: a commit failure here must not fail the story itself, since
681
+ # the retry/health-check machinery downstream still has its own commit gates.
682
+ commit_completed_story() {
683
+ local story_id="$1"
684
+ local _commit_root="${GIT_WORK_ROOT:-$PROJECT_ROOT}"
685
+ # Bounded timeout on git operations (added 2026-07-06): a live run's story-
686
+ # level 600s watchdog killed the whole claude.sh subprocess with zero log
687
+ # output after a story succeeded — generate_story_contract()/
688
+ # commit_completed_story() were the only unlogged steps left, and neither
689
+ # had any bound on how long its git/python calls could take (e.g. a stale
690
+ # lock, a slow filesystem). 60s is generous for `git add`/`git commit` on
691
+ # this project's size; a hang here now fails fast and visibly instead of
692
+ # silently consuming the entire story-level watchdog budget.
693
+ local _git_timeout="${EPAM_COMMIT_TIMEOUT_SECS}"
694
+
695
+ # set +e/-e around this block (found live, 2026-07-14, tier3-travel-app
696
+ # run — first time a worktree lane ran real multi-story work): under
697
+ # set -e (active for this whole script), `CMD1 || CMD2` as a bare
698
+ # statement DOES still abort the script if CMD2 (the last command in the
699
+ # || list) also fails — the fallback `git add -A` failing for ANY reason
700
+ # (not just the 124-timeout case this code checks for) silently killed
701
+ # the entire claude.sh process here, before `_add_rc=$?` was ever
702
+ # reached, with zero warning logged and every remaining story in this
703
+ # worktree lane (SKY-003-impl/-test, SKY-004 in the observed incident)
704
+ # never even attempted.
705
+ # ONE staging rule, one implementation. This block used to be duplicated in
706
+ # worktree-health-check.sh (which excluded only orchestrations/logs/*, so
707
+ # orchestrations/agents/KB.md passed straight through) and absent entirely from
708
+ # run-agent-orchestration.sh's Step 9 (a bare `git add -A`). Three copies is how a
709
+ # rule drifts; see git_add_client_outputs for the full history.
710
+ set +e
711
+ git_add_client_outputs "$_commit_root" "$_git_timeout"
712
+ local _add_rc=$?
713
+ set -e
714
+ if [ "$_add_rc" -ne 0 ]; then
715
+ if [ "$_add_rc" -eq 124 ]; then
716
+ warning " [commit_completed_story] git add timed out after ${_git_timeout}s for ${story_id} — work remains staged/uncommitted"
717
+ else
718
+ warning " [commit_completed_story] git add failed (exit ${_add_rc}) for ${story_id} — work remains staged/uncommitted"
719
+ fi
720
+ return 1
721
+ fi
722
+
723
+ local _changed_count
724
+ _changed_count=$(git -C "$_commit_root" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
725
+ if [ "${_changed_count:-0}" -eq 0 ]; then
726
+ return 0
727
+ fi
728
+
729
+ # Generic credential scan (flow-gap analysis finding #2, 2026-07-12): no
730
+ # commit site in this pipeline scanned staged changes for accidentally-
731
+ # committed secrets before this. SAST (Step 4.2) is the first thing that
732
+ # even looks at code content for this, and it runs long after this commit
733
+ # would already be in git history. scan-secrets.sh is generic and stack-
734
+ # agnostic (well-known credential formats only) — see its own header.
735
+ local _scan_sh="${SCRIPT_DIR}/scan-secrets.sh"
736
+ if [ "${SKIP_SECRET_SCAN:-true}" != "true" ] && [ -f "$_scan_sh" ]; then
737
+ local _scan_output _scan_rc
738
+ # set +e/-e (found live, 2026-07-14, same incident as the git-add fix
739
+ # above): `var=$(failing_cmd)` as a bare assignment statement is
740
+ # ALSO a set -e trigger — scan-secrets.sh exiting non-zero (its
741
+ # intentional, designed signal for "found a secret") killed the
742
+ # whole script on THIS line, one statement before `_scan_rc=$?` and
743
+ # the warning/return-1 handling below ever ran, so a real secret hit
744
+ # (or, per this incident, any other non-zero exit from the scan)
745
+ # silently took down every remaining story in the lane instead of
746
+ # gracefully unstaging and skipping just this one commit.
747
+ set +e
748
+ _scan_output=$(bash "$_scan_sh" "$_commit_root" 2>&1)
749
+ _scan_rc=$?
750
+ set -e
751
+ if [ "$_scan_rc" -ne 0 ]; then
752
+ warning " [commit_completed_story] $_scan_output"
753
+ warning " [commit_completed_story] Refusing to commit for ${story_id} — unstaging (SECRET_SCAN)"
754
+ git -C "$_commit_root" reset 2>/dev/null || true
755
+ return 1
756
+ fi
757
+ fi
758
+
759
+ # Real stderr (e.g. a pre-commit hook's own output — husky/lint-staged
760
+ # can run type-check/eslint/prettier and legitimately fail or just be
761
+ # slow under concurrent-lane load) was previously discarded entirely
762
+ # (>/dev/null 2>&1), so a commit failure here was never diagnosable
763
+ # after the fact — found live 2026-08-01, AMSD-2041: a real, verified-
764
+ # correct, tsc-passing fix sat staged/uncommitted with no way to tell
765
+ # whether a hook rejected it, it timed out, or something else failed.
766
+ # set +e/-e (same reason as the git-add block above): this whole function
767
+ # runs under set -e, so a bare `_commit_output=$(failing_cmd)` assignment
768
+ # aborts the script immediately on a real commit failure — found while
769
+ # testing this exact fix, 2026-08-01 — silently reintroducing the "commit
770
+ # failure gives zero diagnosis" defect this stderr capture exists to fix.
771
+ # Message leads with story_id, colon-separated — found live 2026-08-02:
772
+ # a client repo's own commitlint (commitlint-plugin-jira-rules) rejected
773
+ # "story: complete AMSD-2041 (3 file(s))" because the ticket ID must be
774
+ # the FIRST token, not buried mid-message. This isn't Jira-specific
775
+ # knowledge baked in here — ticket-ID-first is the standard shape most
776
+ # commit-message linters expect, so it's a strictly more correct default
777
+ # template, not a per-project config lookup. "story complete" is kept as
778
+ # a stable marker — reset-to-baseline.sh's `git log --grep` and
779
+ # run-agent-orchestration.sh's git-history story-id fallback both key off
780
+ # it; both were updated in the SAME change (see their own comments).
781
+ set +e
782
+ local _commit_output
783
+ local _hook_flags=(); mapfile -t _hook_flags < <(pipeline_commit_flags)
784
+ _commit_output=$(git -C "$_commit_root" commit ${_hook_flags[@]+"${_hook_flags[@]}"} -m "${story_id}: story complete (${_changed_count} file(s))" 2>&1)
785
+ local _commit_rc=$?
786
+ set -e
787
+ if [ "$_commit_rc" -eq 0 ]; then
788
+ log " Committed ${_changed_count} file(s) for ${story_id}"
789
+ # RECORD WHAT THIS STORY WROTE, HERE, NOW.
790
+ #
791
+ # The run report used to reconstruct the diff at report time — `git log <baseline>..HEAD`
792
+ # against the live codeline, baseline read from the shared, every-phase-overwritten
793
+ # phase-baseline-sha.txt. The codeline is reset to origin/<base> at the start of each
794
+ # cycle, so by report time that pair no longer brackets the story: the range comes back
795
+ # empty and code.html renders "0 files changed / 0 lines / not recorded" for a run that
796
+ # demonstrably committed a fix. Live: Successful-Run-Sept-07-1, AMSD-1919.
797
+ #
798
+ # This commit is the only moment the story's changes are unambiguously identifiable, so
799
+ # they are captured now, into LOG_DIR (which archive-run-artifacts.sh carries into the
800
+ # run's evidence directory). A later reset cannot invalidate a fact already on disk.
801
+ record_story_changes "$story_id" "$_commit_root" || true
802
+ # Refresh the CodeGraph index now that this story's writes are final
803
+ # on disk. Until 2026-08-06 the index was built ONCE per run, before
804
+ # any writer ran, and never rebuilt — so the reviewer's codegraph_query
805
+ # tool (team-lead-review.sh hands it out explicitly to check "does a
806
+ # helper already exist?") was reading a pre-writer snapshot and could
807
+ # not see the writer's own output. See codegraph-reindex.sh's docstring
808
+ # for the full diagnosis. Never blocks: the script always exits 0.
809
+ local _cg_reindex="${SCRIPT_DIR:-}/codegraph-reindex.sh"
810
+ if [ -n "${SCRIPT_DIR:-}" ] && [ -f "$_cg_reindex" ]; then
811
+ bash "$_cg_reindex" "$_commit_root" "post-commit ${story_id}" || true
812
+ fi
813
+ elif [ "$_commit_rc" -eq 124 ]; then
814
+ warning " [commit_completed_story] git commit timed out after ${_git_timeout}s for ${story_id} — work remains staged/uncommitted"
815
+ else
816
+ warning " Commit failed for ${story_id} (exit ${_commit_rc}) — work remains staged/uncommitted. Output:"
817
+ warning " $_commit_output"
818
+ fi
819
+ }
820
+
821
+ # Setup git worktrees for parallel execution.
822
+ # Runs git commands from GIT_WORK_ROOT (falling back to PROJECT_ROOT for
823
+ # callers, like codemie-claude.sh, that only set that) — the directory
824
+ # containing .git. Worktrees are created as siblings of that root.
825
+ # codeline_baseline_ref <codeline_root> <declared_branch>
826
+ #
827
+ # THE REF A CODELINE'S WORK IS CUT FROM, from LOCAL refs only — no remote is contacted. The declared
828
+ # baseline when this codeline has it (origin/<b>, else <b>); otherwise this codeline's own default
829
+ # branch (refs/remotes/origin/HEAD). One project declares one baseline for several codelines, and a
830
+ # codeline need not have it (AMSD-2974, 2026-10-02: cx-shared's default is master, it has no
831
+ # develop). Prints nothing when nothing is found — never a guess.
832
+ codeline_baseline_ref() {
833
+ local _root="${1:-}" _declared="${2:-}"
834
+ [ -n "$_root" ] && [ -e "$_root/.git" ] || return 0
835
+ if [ -n "$_declared" ]; then
836
+ if git -C "$_root" rev-parse -q --verify "refs/remotes/origin/${_declared}" >/dev/null 2>&1; then
837
+ printf 'origin/%s\n' "$_declared"; return 0
838
+ fi
839
+ if git -C "$_root" rev-parse -q --verify "refs/heads/${_declared}" >/dev/null 2>&1; then
840
+ printf '%s\n' "$_declared"; return 0
841
+ fi
842
+ # A release line's base is a release tag (lib/release-lines.sh).
843
+ if git -C "$_root" rev-parse -q --verify "refs/tags/${_declared}^{commit}" >/dev/null 2>&1; then
844
+ printf '%s\n' "$_declared"; return 0
845
+ fi
846
+ fi
847
+ local _head
848
+ _head=$(git -C "$_root" symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null || true)
849
+ [ -n "$_head" ] && printf '%s\n' "$_head"
850
+ return 0
851
+ }
852
+
853
+ setup_worktrees() {
854
+ local worktrees=("primary" "independent")
855
+ local git_root
856
+ git_root="$(cd "${GIT_WORK_ROOT:-$PROJECT_ROOT}" && pwd)"
857
+ local git_basename
858
+ git_basename="$(basename "$git_root")"
859
+
860
+ log "Setting up git worktrees (git root: $git_root)..."
861
+
862
+ # Validate the resolved root is a git repo
863
+ if ! git -C "$git_root" rev-parse --is-inside-work-tree &>/dev/null; then
864
+ error "Git root ($git_root) is not a git repository"
865
+ return 1
866
+ fi
867
+
868
+ for wt in "${worktrees[@]}"; do
869
+ local wt_path="$git_root/../${git_basename}-wt-$wt"
870
+ local wt_branch="wt-$wt"
871
+
872
+ # A directory existing at $wt_path is NOT sufficient proof of a usable
873
+ # worktree — a prior crash (or a raw `rm -rf` instead of `git worktree
874
+ # remove`) can leave a stale, non-git-tracked directory here, or a
875
+ # directory whose worktree registration was lost. Silently "continuing"
876
+ # on directory-existence alone left the CALLER believing a real
877
+ # worktree was set up when it wasn't — verify it's actually a
878
+ # registered, valid worktree of THIS repo before skipping creation.
879
+ if [ -d "$wt_path" ]; then
880
+ # `git worktree list --porcelain` reports canonicalized, resolved
881
+ # paths — $wt_path contains a literal `..` component, so a raw
882
+ # string comparison against the porcelain output NEVER matches
883
+ # even for a genuinely valid worktree. Resolve $wt_path the same
884
+ # way before comparing.
885
+ local wt_path_resolved
886
+ wt_path_resolved="$(cd "$wt_path" 2>/dev/null && pwd)"
887
+ if [ -n "$wt_path_resolved" ] && git -C "$git_root" worktree list --porcelain 2>/dev/null | grep -q "^worktree ${wt_path_resolved}$"; then
888
+ warning "Worktree already exists and is valid: $wt_path"
889
+ continue
890
+ fi
891
+ warning "Stale non-worktree directory found at $wt_path (not registered with git) — removing before recreating"
892
+ rm -rf "$wt_path"
893
+ fi
894
+
895
+ # Delete branch if it exists from previous run
896
+ if git -C "$git_root" show-ref --verify --quiet "refs/heads/$wt_branch"; then
897
+ info "Deleting existing branch: $wt_branch"
898
+ git -C "$git_root" branch -D "$wt_branch" 2>/dev/null || true
899
+ fi
900
+
901
+ # Create worktree with a new branch based on current HEAD
902
+ info "Creating worktree: $wt ($wt_path) on branch $wt_branch"
903
+ git -C "$git_root" worktree add -b "$wt_branch" "$wt_path" HEAD || {
904
+ error "Failed to create worktree: $wt"
905
+ return 1
906
+ }
907
+ done
908
+
909
+ success "Worktrees created successfully"
910
+ return 0
911
+ }
912
+
913
+ # Cleanup git worktrees
914
+ cleanup_worktrees() {
915
+ local worktrees=("primary" "independent")
916
+ local git_root
917
+ git_root="$(cd "${GIT_WORK_ROOT:-$PROJECT_ROOT}" && pwd)"
918
+ local git_basename
919
+ git_basename="$(basename "$git_root")"
920
+
921
+ log "Cleaning up git worktrees..."
922
+
923
+ for wt in "${worktrees[@]}"; do
924
+ local wt_path="$git_root/../${git_basename}-wt-$wt"
925
+ local wt_branch="wt-$wt"
926
+
927
+ # Check if worktree exists
928
+ if [ ! -d "$wt_path" ]; then
929
+ info "Worktree does not exist: $wt_path (already removed)"
930
+ else
931
+ # Remove worktree — fall back to manual rm + prune if `git worktree
932
+ # remove` fails (e.g. the directory was already partially deleted
933
+ # out-of-band), so a failed removal never leaves the checkout
934
+ # behind for the next run to trip over.
935
+ info "Removing worktree: $wt ($wt_path)"
936
+ if ! git -C "$git_root" worktree remove "$wt_path" --force 2>/dev/null; then
937
+ warning "git worktree remove failed for $wt — falling back to manual rm + prune"
938
+ rm -rf "$wt_path"
939
+ fi
940
+ fi
941
+
942
+ # Prune BEFORE attempting the branch delete below — if the worktree
943
+ # directory was removed out-of-band (not via `git worktree remove`),
944
+ # git still considers the branch "checked out" by the orphaned admin
945
+ # metadata and silently refuses `git branch -D` until pruned. This bug
946
+ # was found live via this exact scenario in this function's own tests.
947
+ git -C "$git_root" worktree prune 2>/dev/null || true
948
+
949
+ # Delete the branch too — a worktree checkout being removed does NOT
950
+ # delete the branch it pointed to, and a leftover branch collides with
951
+ # the NEXT setup_worktrees() call's `git worktree add -b $wt_branch`
952
+ # (the exact "fatal: a branch named 'wt-primary' already exists" live
953
+ # failure this fixes). setup_worktrees() also deletes stale branches
954
+ # defensively, but cleanup should not rely on the next run to do it.
955
+ if git -C "$git_root" show-ref --verify --quiet "refs/heads/$wt_branch"; then
956
+ git -C "$git_root" branch -D "$wt_branch" 2>/dev/null || true
957
+ fi
958
+ done
959
+
960
+ # Prune worktree references
961
+ git -C "$git_root" worktree prune
962
+
963
+ # Final verification — a pristine cleanup MUST end with zero wt-* worktrees
964
+ # registered. Fail loudly instead of silently leaving a corrupt registry.
965
+ local remaining
966
+ remaining=$(git -C "$git_root" worktree list --porcelain 2>/dev/null | grep -c "^worktree .*-wt-\(primary\|independent\)$" || true)
967
+ if [ "${remaining:-0}" -gt 0 ]; then
968
+ error "Worktree cleanup incomplete — ${remaining} wt-* worktree(s) still registered"
969
+ git -C "$git_root" worktree list
970
+ return 1
971
+ fi
972
+
973
+ success "Worktrees cleaned up"
974
+ return 0
975
+ }