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,93 @@
1
+ #!/usr/bin/env bash
2
+ # NAMED RUN MODES. "Resume the writer" is one word, not six variables to remember.
3
+ #
4
+ # On 2026-08-13 an operator asked for a writer resume. It was launched with three environment
5
+ # variables and needed six, so the CPA pre-pass, the pre-phase skill assessment and the regression
6
+ # guard ran anyway — each a separate default-ACTIVE step, none tied to the stated intent. The
7
+ # operator noticed four minutes in.
8
+ #
9
+ # THIS FILE KNOWS NO MODES. It reads orchestrations/config/run-modes.json. Adding a mode is a
10
+ # config edit; the engine never learns a mode's name.
11
+ #
12
+ # TWO RULES:
13
+ # FAILS CLOSED — an unknown mode, or a declaration file that will not parse, is a hard error.
14
+ # Silently ignoring either runs every gate the operator believed they had turned off.
15
+ # PRECEDENCE IS operator environment > mode > config-file default. A mode must beat a default
16
+ # (config.env carries SKIP_REGRESSION_GUARD=false, which silently defeated writer-only) while
17
+ # still yielding to a deliberate choice on the launch command — writer-only WITH the regression
18
+ # baseline is a legitimate thing to ask for. The two are only distinguishable before the config
19
+ # files load, which is what snapshot_operator_env captures.
20
+
21
+ RUN_MODES_FILE="${RUN_MODES_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../config/run-modes.json}"
22
+
23
+ # snapshot_operator_env — record which variables the OPERATOR set, before any config file is read.
24
+ #
25
+ # A mode must override a config-file DEFAULT (config.env carries SKIP_REGRESSION_GUARD=false, which
26
+ # silently defeated writer-only on 2026-08-13) while still yielding to a deliberate choice on the
27
+ # launch command. Those two are indistinguishable once both are in the environment — unless you
28
+ # look BEFORE the config files load, which is the one moment only the operator's values exist.
29
+ #
30
+ # Launchers call this before their first load_env_file_safe. A caller that never calls it gets the
31
+ # mode applied in full, which is the safe failure: a half-applied intent is worse than none.
32
+ snapshot_operator_env() {
33
+ EPAM_OPERATOR_SET_VARS=" $(compgen -e 2>/dev/null | tr '\n' ' ')"
34
+ export EPAM_OPERATOR_SET_VARS
35
+ }
36
+
37
+ # run_mode_env <mode> — print the KEY=VALUE lines a mode declares. Non-zero if unknown.
38
+ run_mode_env() {
39
+ local _mode="${1:-}"
40
+ [ -n "$_mode" ] || return 0
41
+ "${NODE_BIN:-node}" -e '
42
+ const fs = require("fs");
43
+ const [file, mode] = process.argv.slice(1);
44
+ let doc;
45
+ try {
46
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
47
+ } catch (e) {
48
+ process.stderr.write(`run-modes: cannot read ${file}: ${e.message}\n`);
49
+ process.exit(2);
50
+ }
51
+ const modes = doc.modes || {};
52
+ if (!Object.prototype.hasOwnProperty.call(modes, mode)) {
53
+ process.stderr.write(`run-modes: unknown mode "${mode}". Declared: ${Object.keys(modes).join(", ")}\n`);
54
+ process.exit(3);
55
+ }
56
+ const skips = modes[mode].skips || [];
57
+ process.stdout.write(skips.join("\n") + (skips.length ? "\n" : ""));
58
+ ' "$RUN_MODES_FILE" "$_mode"
59
+ }
60
+
61
+ # apply_run_mode <mode> — export what the mode declares, leaving anything already set alone.
62
+ apply_run_mode() {
63
+ local _mode="${1:-}"
64
+ [ -n "$_mode" ] || return 0
65
+
66
+ local _env
67
+ _env=$(run_mode_env "$_mode") || {
68
+ if command -v error >/dev/null 2>&1; then
69
+ error "[run-mode] refusing to run: mode '$_mode' is not declared in $RUN_MODES_FILE"
70
+ else
71
+ printf '[run-mode] refusing to run: mode %s is not declared in %s\n' "$_mode" "$RUN_MODES_FILE" >&2
72
+ fi
73
+ return 1
74
+ }
75
+
76
+ local _line _key _val _already
77
+ while IFS= read -r _line; do
78
+ [ -n "$_line" ] || continue
79
+ _key="${_line%%=*}"
80
+ _val="${_line#*=}"
81
+ # Set by the OPERATOR — before any config file was read — so it wins. A value that only
82
+ # appeared once config.env loaded is a project default, and the mode is more specific than
83
+ # a default: the operator named this intent on the command line.
84
+ case "${EPAM_OPERATOR_SET_VARS:-}" in
85
+ *" ${_key} "*) continue ;;
86
+ esac
87
+ eval "export ${_key}=\"\$_val\""
88
+ done <<< "$_env"
89
+
90
+ RESOLVED_RUN_MODE="$_mode"
91
+ export RESOLVED_RUN_MODE
92
+ return 0
93
+ }
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env bash
2
+ # story-outputs.sh — one shared answer to "what did this run actually produce?"
3
+ #
4
+ # Step 20's lint gate was fixed by handing it the writers' output instead of
5
+ # letting it rediscover scope by linting the whole tree (see
6
+ # lib/eslint-baseline-gate.sh). The reviewers still inferred it, each in its own
7
+ # slightly different way — team-lead-review.sh, review-ranger and mutant-hunter
8
+ # all reimplemented `git diff --name-only <baseline>..HEAD`. Three copies of one
9
+ # idea is how they drift, and they already had:
10
+ #
11
+ # * `<baseline>..HEAD` is commit-to-commit, so writer output not yet committed
12
+ # is invisible to review. The repro-test-writer commits separately from the
13
+ # impl agent, so this is a normal state, not an edge case.
14
+ # * All three fell back to `HEAD~1` when the baseline SHA file was missing —
15
+ # silently. That is the review-oracle failure shape: the diff covers an
16
+ # arbitrary previous commit, the reviewers examine nothing this run
17
+ # produced, and the gate reports success.
18
+ #
19
+ # The producer is record_story_outputs() in claude.sh. This is the reader.
20
+ # STORY_OUTPUTS_SOURCE is set to manifest | baseline diff | none so callers can
21
+ # report which one they got — a gate that quietly changes how it computes its
22
+ # own scope is indistinguishable from one that is broken.
23
+
24
+ # Paths the pipeline writes into a client repo. Never story output.
25
+ # Was a two-entry regex ('^(\.codegraph/|\.epam/)') that missed orchestrations/,
26
+ # .deepeval/ and .contracts/ — which is how orchestrations/agents/KB.md was recorded as
27
+ # upexpress's writer output on run 20260804T225443Z. One shared definition now.
28
+ # shellcheck source=engine-paths.sh
29
+ . "$(dirname "${BASH_SOURCE[0]}")/engine-paths.sh"
30
+
31
+ # Test-file conventions. Deliberately broad: mutant-hunter used to look only for
32
+ # `*.test.ts` and therefore found nothing on the live metrolinx codeline, whose
33
+ # tests are all `.spec.ts` — it reported "(no test files found)" on a run that
34
+ # had just written a reproducing spec.
35
+ _STORY_OUTPUTS_TEST_RE='(\.|_)(spec|test)\.[A-Za-z0-9]+$|/__tests__/|(^|/)test_[^/]+$'
36
+
37
+ # story_outputs_record <project_root> <log_dir>
38
+ # Appends what this producer just put in the tree to the phase manifest.
39
+ #
40
+ # Shared because there is more than one producer and they finish at different
41
+ # times. Live metrolinx 2026-07-26: only the story loop recorded, at deliverable
42
+ # verification — but the repro-test-writer commits its .spec.ts AFTERWARDS, so
43
+ # the test never entered the manifest. mutant-hunter, freshly rewired to read
44
+ # its tests from that manifest, saw none, every mutant survived, it scored 0 and
45
+ # failed the gate — on a run whose test the repro gate had just proven fails on
46
+ # baseline and passes with the fix. The gate then ran its remediation pipeline
47
+ # against a finding that existed only because of the missing entry.
48
+ #
49
+ # Additive and idempotent: each producer records its own contribution and the
50
+ # list is the union. Writes nothing when there is no baseline to diff against
51
+ # (greenfield) — an ABSENT manifest means "fall back and say so", while an EMPTY
52
+ # one would assert the writers produced nothing. Never fails the caller.
53
+ story_outputs_record() {
54
+ local project_root="$1"
55
+ local log_dir="$2"
56
+ [ -n "$log_dir" ] || return 0
57
+ [ -n "$project_root" ] && [ -e "$project_root/.git" ] || return 0
58
+
59
+ # THE BASELINE SHA IS THE LANE'S IDENTITY. It resolves only in that lane's own repo
60
+ # (verified against all three live metrolinx lanes), so requiring it makes git's object
61
+ # database the identity oracle — no path comparison, no configuration.
62
+ #
63
+ # The `origin/${JIRA_BASELINE_BRANCH:-develop}` fallback this replaces is the silent
64
+ # wrong-scope shape this file's own header complains about: any repo that happens to
65
+ # have that ref satisfies it, including the engine's own.
66
+ local _ref
67
+ _ref=$(story_outputs_baseline_ref "$log_dir") || return 0
68
+ [ -n "$_ref" ] || return 0
69
+ git -C "$project_root" rev-parse --verify --quiet "${_ref}^{commit}" >/dev/null 2>&1 || return 0
70
+
71
+ local _manifest="$log_dir/story-outputs-${PHASE:-core}.txt"
72
+ # --diff-filter=ACMRT: Added/Copied/Modified/Renamed/Typechanged. DELETED paths are
73
+ # excluded deliberately — a path handed to a reviewer as "writer output" must exist, or
74
+ # story_outputs_tests nominates a test that cannot run. Deletions are reported
75
+ # separately by story_outputs_deleted, because a writer removing 10 tests is exactly
76
+ # what a gate needs to see and the old manifest lost it entirely.
77
+ local _produced
78
+ _produced=$( { git -C "$project_root" diff --name-only --diff-filter=ACMRT "$_ref" 2>/dev/null
79
+ git -C "$project_root" ls-files --others --exclude-standard 2>/dev/null; } | \
80
+ engine_paths_filter | grep -v '^$' | sort -u )
81
+
82
+ # OVERWRITE, never union. The previous form was
83
+ # { cat "$_manifest"; printf '%s' "$_produced"; } | sort -u
84
+ # which can only ever grow: a file the writer reverted, or one recorded when a wrong
85
+ # root was passed, stayed listed forever with no provenance to say when it entered.
86
+ # The union was also unnecessary — the diff is against the PHASE BASELINE, so work a
87
+ # LATE producer committed (the repro-test-writer commits its spec after the story loop)
88
+ # is already included by recomputing.
89
+ #
90
+ # Written even when empty: with a valid baseline, "nothing was produced" is a FACT and
91
+ # the manifest should say so. ABSENT still means "no baseline — fall back and say so".
92
+ printf '%s\n' "$_produced" | grep -v '^$' > "${_manifest}.tmp" 2>/dev/null || true
93
+ mv "${_manifest}.tmp" "$_manifest" 2>/dev/null || rm -f "${_manifest}.tmp"
94
+ return 0
95
+ }
96
+
97
+ # story_outputs_baseline_ref <log_dir> — the phase baseline SHA, or non-zero if unknown.
98
+ story_outputs_baseline_ref() {
99
+ local _log_dir="$1"
100
+ [ -f "$_log_dir/phase-baseline-sha.txt" ] || return 1
101
+ local _sha
102
+ _sha=$(tr -d '[:space:]' < "$_log_dir/phase-baseline-sha.txt" 2>/dev/null)
103
+ [ -n "$_sha" ] || return 1
104
+ printf '%s' "$_sha"
105
+ }
106
+
107
+ # story_outputs_deleted <project_root> <log_dir> — paths this phase REMOVED.
108
+ #
109
+ # The reviewer on run 20260804T225443Z reported the writer had deleted a 179-line test file
110
+ # containing 10 tests, with no replacement. No manifest showed it: the producer recorded
111
+ # deletions as though they were outputs, and every consumer then filtered them out as
112
+ # missing files. Test count going DOWN is a gate-relevant fact and now has a channel.
113
+ story_outputs_deleted() {
114
+ local project_root="$1"
115
+ local log_dir="$2"
116
+ [ -n "$project_root" ] && [ -e "$project_root/.git" ] || return 0
117
+ local _ref
118
+ _ref=$(story_outputs_baseline_ref "$log_dir") || return 0
119
+ git -C "$project_root" rev-parse --verify --quiet "${_ref}^{commit}" >/dev/null 2>&1 || return 0
120
+ git -C "$project_root" diff --name-only --diff-filter=D "$_ref" 2>/dev/null | \
121
+ engine_paths_filter | grep -v '^$' | sort -u
122
+ return 0
123
+ }
124
+
125
+ # story_outputs_files <project_root> <log_dir>
126
+ # Prints repo-relative paths, one per line. Sets STORY_OUTPUTS_SOURCE.
127
+ story_outputs_files() {
128
+ local project_root="$1"
129
+ local log_dir="$2"
130
+ STORY_OUTPUTS_SOURCE="none"
131
+
132
+ [ -n "$project_root" ] && [ -e "$project_root/.git" ] || return 0
133
+
134
+ local manifest="$log_dir/story-outputs-${PHASE:-core}.txt"
135
+ local raw=""
136
+ # -f, not -s: an EMPTY manifest is a real answer ("the writers produced nothing"),
137
+ # distinct from an ABSENT one ("no baseline — fall back and say so").
138
+ if [ -f "$manifest" ]; then
139
+ STORY_OUTPUTS_SOURCE="manifest"
140
+ raw=$(cat "$manifest" 2>/dev/null)
141
+ else
142
+ local baseline=""
143
+ if [ -f "$log_dir/phase-baseline-sha.txt" ]; then
144
+ baseline=$(tr -d '[:space:]' < "$log_dir/phase-baseline-sha.txt" 2>/dev/null)
145
+ fi
146
+ if [ -z "$baseline" ]; then
147
+ # No manifest and no baseline. Previously every caller substituted
148
+ # HEAD~1 here and reviewed an arbitrary commit. Returning an empty
149
+ # scope lets the caller say "nothing to review" truthfully instead.
150
+ return 0
151
+ fi
152
+ # TO STDERR: this function's stdout IS the scope a caller captures; a warning there was counted as a changed
153
+ # file and sent an unchanged story to review (rehearsal 2026-10-08).
154
+ command -v warning >/dev/null 2>&1 && \
155
+ warning " no writer-output manifest at $manifest — falling back to the baseline diff for scope" >&2
156
+ export STORY_OUTPUTS_SOURCE="baseline diff"
157
+ raw=$( { git -C "$project_root" diff --name-only --diff-filter=ACMRT "$baseline" 2>/dev/null
158
+ git -C "$project_root" ls-files --others --exclude-standard 2>/dev/null; } )
159
+ fi
160
+
161
+ printf '%s\n' "$raw" | grep -v '^$' | engine_paths_filter | sort -u
162
+ return 0
163
+ }
164
+
165
+ # story_outputs_tests <project_root> <log_dir> — test files only
166
+ story_outputs_tests() {
167
+ story_outputs_files "$1" "$2" | grep -E "$_STORY_OUTPUTS_TEST_RE" || true
168
+ }
169
+
170
+ # story_outputs_tests_for <project_root> <log_dir> <story_id> — THIS story's test files.
171
+ #
172
+ # story_outputs_tests reads the PHASE manifest, which carries no story attribution at all. So a
173
+ # caller looping over stories and taking `| head -1` gave every story in the phase the SAME test
174
+ # file — the first one written by anybody. Step 3.56 did exactly that, and reported story B's
175
+ # verification-criteria coverage against story A's test: a check that ran, produced a plausible
176
+ # artefact, and measured the wrong thing.
177
+ #
178
+ # The story's own commit is the attribution that does exist. commit_completed_story writes
179
+ # "<story_id>: story complete (N file(s))" and keeps that marker stable precisely so it can be
180
+ # grepped — reset-to-baseline.sh already keys off it.
181
+ #
182
+ # Prints nothing when the story has no commit. That is a real answer: no commit means no files,
183
+ # which the caller must report as "not checked", never as "covered".
184
+ story_outputs_tests_for() {
185
+ local project_root="$1" story_id="$3"
186
+ [ -n "$project_root" ] && [ -e "$project_root/.git" ] || return 0
187
+ [ -n "$story_id" ] || return 0
188
+ # EVERY COMMIT OF THE STORY, not the one that says "story complete".
189
+ #
190
+ # The writer no longer produces a single commit: the fix and the bug-reproducing test land
191
+ # separately, and the fix's subject is free-form. Measured 2026-08-28 on the first fully green
192
+ # paid run — MOCK3-1's fix was "Fix: correct fare boundary for riders aged exactly 65" with no
193
+ # marker, and MOCK3-2's marker commit carried only its source. Both stories reported "no test
194
+ # file in the writer manifest — coverage NOT checked" while their tests sat on the branch. The
195
+ # gate ran, warned, and measured nothing, on a run reported green.
196
+ #
197
+ # The story-id PREFIX is the attribution the commit convention already requires, and it keeps
198
+ # the property this function exists for: one story never sees another's test.
199
+ #
200
+ # Scoped to the phase baseline where one is recorded, so a previous run's commit for the same
201
+ # story id cannot be mistaken for this run's work.
202
+ local _range=""
203
+ if [ -n "${2:-}" ] && [ -f "$2/phase-baseline-sha.txt" ]; then
204
+ local _base; _base=$(tr -d '[:space:]' < "$2/phase-baseline-sha.txt" 2>/dev/null)
205
+ [ -n "$_base" ] && git -C "$project_root" rev-parse --verify --quiet "$_base" >/dev/null 2>&1 \
206
+ && _range="${_base}..HEAD"
207
+ fi
208
+ git -C "$project_root" log ${_range:+"$_range"} --grep="^${story_id}:" \
209
+ --pretty=format: --name-only 2>/dev/null \
210
+ | grep -E "$_STORY_OUTPUTS_TEST_RE" 2>/dev/null | sort -u || true
211
+ }
212
+
213
+ # story_resume_completed_by_commit <project_root> <lane_log_dir> <lane_prd>
214
+ #
215
+ # A RESUME DOES NOT REWRITE COMMITTED WORK. A lane resumes from its pre-writer checkpoint, whose PRD predates the
216
+ # writer, so every story reads "not completed" and the writer is invoked again — AMSD-2974 run 9, resume 3: "I didn't
217
+ # need to change any code this turn: the earlier commit d429022 … already contains all three fixes", a paid call that
218
+ # changed nothing, then a full review. A story whose work is committed in THIS run (the "<story>:" convention, scoped
219
+ # to the lane's phase baseline) with no review rejection NEWER than that commit is marked completed in the lane PRD:
220
+ # the writer skips it and the review judges the committed change. A newer rejection leaves it for the writer.
221
+ story_resume_completed_by_commit() {
222
+ local project_root="$1" log_dir="$2" prd="$3"
223
+ [ -n "$project_root" ] && [ -e "$project_root/.git" ] && [ -f "$prd" ] || return 0
224
+ [ -f "$log_dir/phase-baseline-sha.txt" ] || return 0
225
+ local _base; _base=$(tr -d '[:space:]' < "$log_dir/phase-baseline-sha.txt" 2>/dev/null)
226
+ [ -n "$_base" ] && git -C "$project_root" rev-parse --verify --quiet "$_base" >/dev/null 2>&1 || return 0
227
+ local _id _ct _fb _fbt
228
+ for _id in $(jq -r '.stories[]? | select(.completed != true) | .id' "$prd" 2>/dev/null); do
229
+ _ct=$(git -C "$project_root" log "${_base}..HEAD" --grep="^${_id}:" -1 --format=%ct 2>/dev/null)
230
+ [ -n "$_ct" ] || continue
231
+ _fb="$log_dir/review-feedback-${_id}.json"
232
+ if [ -f "$_fb" ]; then
233
+ _fbt=$(stat -c %Y "$_fb" 2>/dev/null || echo 0)
234
+ [ "${_fbt:-0}" -gt "$_ct" ] && continue
235
+ fi
236
+ local _tmp; _tmp=$(mktemp)
237
+ jq --arg id "$_id" '(.stories[] | select(.id == $id)) |= (. + {completed: true})' "$prd" > "$_tmp" \
238
+ && mv "$_tmp" "$prd" || rm -f "$_tmp"
239
+ echo "[resume] ${_id}: its work is committed in this run ($(git -C "$project_root" log "${_base}..HEAD" --grep="^${_id}:" -1 --format=%h 2>/dev/null)) and no review rejection is newer — the writer is not invoked again; the review judges the committed change"
240
+ done
241
+ return 0
242
+ }
243
+
244
+ # story_outputs_sources <project_root> <log_dir> — everything that is not a test
245
+ story_outputs_sources() {
246
+ story_outputs_files "$1" "$2" | grep -v -E "$_STORY_OUTPUTS_TEST_RE" || true
247
+ }
248
+
249
+ # ── THE RUNG THAT PRODUCED IT ────────────────────────────────────────────────
250
+ #
251
+ # Operator, 2026-08-22: "if writer's model moves up ladder, the reviewer must follow along and
252
+ # use the same rung EXACT setup the writer used to converge. No hard coding. No guards.
253
+ # Persistence."
254
+ #
255
+ # A RUNG IS NOT A MODEL. The writer's ladder moves model, provider, reasoning effort and
256
+ # temperature together — rung 1 sets effort medium / temp 0, rung 2 escalates the model, rung 3
257
+ # goes effort high / temp 0.7 — so a reviewer handed only the model still judges converged work
258
+ # on a configuration the writer never ran.
259
+ #
260
+ # WRITTEN ON EVERY ATTEMPT, at the point the rung is settled and before the writer is invoked
261
+ # (claude.sh, immediately after the escalation block closes). Deliberately not on a success
262
+ # path: the last write is by definition the rung that produced the final output, whatever
263
+ # happened on the way there, and a story that ends in rejection still has a real rung.
264
+ #
265
+ # Why persisted rather than passed in memory: run-agent-orchestration.sh spawns the writer as a
266
+ # CHILD PROCESS per story (:2333) and the reviewer as a separate process per cycle (:7909). No
267
+ # in-memory value crosses that. The reviewer's own ladder resume already works this way, for
268
+ # this same reason.
269
+ _story_rung_file() { echo "$1/story-rung/$2.json"; }
270
+
271
+ # story_rung_record <log_dir> <story_id>
272
+ # Captures the CURRENT rung from the writer's own variables. No arguments, on purpose: a
273
+ # parameter list is a second definition of what a rung is, and it drifts the first time
274
+ # claude.sh adds a knob to the ladder.
275
+ story_rung_record() {
276
+ local log_dir="$1" story_id="$2"
277
+ [ -n "$log_dir" ] && [ -n "$story_id" ] || return 0
278
+ mkdir -p "$log_dir/story-rung" 2>/dev/null || true
279
+ jq -n \
280
+ --arg model "${STORY_MODEL:-}" \
281
+ --arg provider "${STORY_PROVIDER:-}" \
282
+ --arg effort "${EPAM_REASONING_EFFORT:-}" \
283
+ --arg temperature "${EPAM_TEMPERATURE:-}" \
284
+ --arg iterations "${STORY_MAX_ITERATIONS:-}" \
285
+ --arg outTokens "${STORY_MAX_OUTPUT_TOKENS:-}" \
286
+ '{model:$model, provider:$provider, reasoningEffort:$effort,
287
+ temperature:$temperature, maxIterations:$iterations, maxOutputTokens:$outTokens}' \
288
+ > "$(_story_rung_file "$log_dir" "$story_id")" 2>/dev/null || true
289
+ }
290
+
291
+ # story_rung_get <log_dir> <story_id> <field>
292
+ # Empty when the story has no rung on record. ABSENT IS NOT A DEFAULT: a caller that substitutes
293
+ # something here makes a judge authoritative about work whose setup it never saw.
294
+ # A RUNG RECORD THAT IS GONE IS DERIVED FROM THE RUN'S OWN LEDGER — never guessed, never written by
295
+ # hand. regintel resume 3 (2026-09-21): the reset had wiped story-rung/ and the reviewer refused a
296
+ # story the run itself had completed. The cost ledger holds the completed attempt's resolvedModel
297
+ # and effort; the activity ledger the provider the story started on. What is derived is persisted,
298
+ # marked derivedFrom, so the next reader finds a record. A story the ledger never completed derives
299
+ # nothing, and the reviewer's refusal stands.
300
+ _story_rung_derive() {
301
+ local log_dir="$1" story_id="$2" f cost act model effort provider
302
+ cost="$log_dir/phase-cost.jsonl"; act="$log_dir/agent-activity.jsonl"
303
+ [ -f "$cost" ] || return 0
304
+ model=$(jq -r --arg s "$story_id" 'select(.story_id == $s and .status == "completed") | .resolvedModel // ""' "$cost" 2>/dev/null | grep -v '^$' | tail -1)
305
+ [ -n "$model" ] || return 0
306
+ effort=$(jq -r --arg s "$story_id" 'select(.story_id == $s and .status == "completed") | .effort // ""' "$cost" 2>/dev/null | tail -1)
307
+ provider=""
308
+ [ -f "$act" ] && provider=$(jq -r --arg s "$story_id" 'select((.storyId // .story_id) == $s and .provider != null) | .provider' "$act" 2>/dev/null | grep -v '^$' | tail -1)
309
+ f="$(_story_rung_file "$log_dir" "$story_id")"
310
+ mkdir -p "$(dirname "$f")" 2>/dev/null || true
311
+ jq -n --arg model "$model" --arg provider "$provider" --arg effort "$effort" \
312
+ '{model:$model, provider:$provider, reasoningEffort:$effort, temperature:"", maxIterations:"", maxOutputTokens:"",
313
+ derivedFrom:"ledger: phase-cost.jsonl (completed attempt) + agent-activity.jsonl (provider)"}' > "$f" 2>/dev/null || true
314
+ echo "[story-rung] $story_id: no record on disk — rung derived from this run's ledger (model=$model provider=${provider:-?})" >&2
315
+ }
316
+
317
+ story_rung_get() {
318
+ local f; f="$(_story_rung_file "$1" "$2")"
319
+ [ -f "$f" ] || _story_rung_derive "$1" "$2"
320
+ [ -f "$f" ] || return 0
321
+ jq -r --arg k "$3" '.[$k] // ""' "$f" 2>/dev/null || true
322
+ }