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,74 @@
1
+ #!/usr/bin/env bash
2
+ # orchestration-resume.sh — SHOULD THIS RUN PICK UP WHERE ANOTHER LEFT OFF?
3
+ #
4
+ # Extracted verbatim from run-agent-orchestration.sh, where it sat as a top-level block among that
5
+ # file's function definitions and could therefore be reached only by running the whole pipeline.
6
+ #
7
+ # What it decides is not small: a resume restores a checkpoint, works out which steps to skip, and
8
+ # then continues the run or refuses it. Three refusals guard it, and the file prices the last one —
9
+ # a resume that skips the spec pass against a PRD carrying none of its output hands the writer a
10
+ # story with nothing to aim at, measured 2026-08-10 at $11.76 and no code.
11
+ #
12
+ # PURE CODE MOVEMENT. Assignments without `local` stay global, so ORCH_RUN_ID and the exported skip
13
+ # environment reach the rest of the run exactly as before, and `exit` still exits the script.
14
+ #
15
+ # Requires from its caller: is_parent, restore_run_checkpoint, list_run_checkpoints,
16
+ # resume_skip_env, resume_spec_output_present, error/info/success, and PRD_FILE.
17
+
18
+ # THE SPEC PASS RUNS ON A RESUME FOR THE STORIES THAT LACK IT. The run mode turns the pass off
19
+ # for a resume, which is right only while every pending story was elaborated; a story whose
20
+ # content the remediation restored from canonical (its elaboration had gone to a placeholder
21
+ # child) reached the writer with no spec, paid for (regintel run 20260915T101555Z, 2026-09-15).
22
+ # The pass is turned back on when any pending story of the phase carries no spec output; the
23
+ # spec runner then takes those stories and skips the elaborated ones (storiesForSpecPass).
24
+ resume_spec_mode_for_pending_stories() {
25
+ [ "${EPAM_SPEC_MODE:-1}" = "0" ] || return 0
26
+ [ -n "${PRD_FILE:-}" ] && [ -f "${PRD_FILE}" ] || return 0
27
+ local _lacking
28
+ _lacking=$("${NODE_BIN:-node}" "$SCRIPT_DIR/lib/handlers/stories-lacking-spec.js" "$PRD_FILE" "${PHASE:-}" 2>/dev/null | tr '\n' ' ')
29
+ [ -n "${_lacking// /}" ] || return 0
30
+ info "[orch] resume: EPAM_SPEC_MODE=1 — pending story(ies) with no spec output: ${_lacking}(the spec pass runs for these and skips the rest)"
31
+ export EPAM_SPEC_MODE=1
32
+ return 0
33
+ }
34
+
35
+ apply_resume_if_requested() {
36
+ if is_parent && [ -n "${EPAM_RESUME_RUN:-}" ]; then
37
+ # The roster and its briefs are stored against the run that minted them, so a resumed run
38
+ # must BE that run — otherwise the store reads as another run's and is not re-applied.
39
+ export ORCH_RUN_ID="$EPAM_RESUME_RUN"
40
+
41
+ if ! restore_run_checkpoint "$EPAM_RESUME_RUN"; then
42
+ error "[orch] cannot resume run '${EPAM_RESUME_RUN}' — refusing to continue against un-restored state."
43
+ error "[orch] available checkpoints: $(list_run_checkpoints | tr '\n' ' ' 2>/dev/null || echo none)"
44
+ exit 1
45
+ fi
46
+
47
+ if ! _resume_env=$(resume_skip_env "$EPAM_RESUME_RUN"); then
48
+ error "[orch] cannot determine what to skip for run '${EPAM_RESUME_RUN}' — refusing to guess."
49
+ exit 1
50
+ fi
51
+ while IFS= read -r _assign; do
52
+ [ -n "$_assign" ] || continue
53
+ export "${_assign?}"
54
+ info "[orch] resume: ${_assign}"
55
+ done <<< "$_resume_env"
56
+ # Skipping the spec pass is only safe while its output still exists. restore_run_checkpoint
57
+ # and resume_skip_env both refuse rather than guess; this is the third case and was the
58
+ # silent one. Gated on the skip actually being in force — a resume that is about to RUN the
59
+ # spec pass has nothing to protect.
60
+ resume_spec_mode_for_pending_stories
61
+ if [ "${EPAM_SPEC_MODE:-1}" = "0" ] && ! resume_spec_output_present "$PRD_FILE"; then
62
+ error "[orch] resume '${EPAM_RESUME_RUN}' skips the spec pass (EPAM_SPEC_MODE=0), but the PRD"
63
+ error "[orch] at ${PRD_FILE} carries none of its output — no fixSiteAnalysis, no"
64
+ error "[orch] verificationCriteria, no declared files, no specification block."
65
+ error "[orch] Something overwrote the PRD after the spec pass ran. Running now would hand the"
66
+ error "[orch] writer a story with nothing to aim at — measured 2026-08-10 at \$11.76 and no code."
67
+ error "[orch] Recover with ONE of:"
68
+ error "[orch] - restore the PRD that carries the spec (git, or a run archive), then resume again"
69
+ error "[orch] - re-run the spec pass for this run: EPAM_SPEC_MODE=1 with the same EPAM_RESUME_RUN"
70
+ exit 1
71
+ fi
72
+ success "[orch] RESUMED run ${EPAM_RESUME_RUN} — continuing from its checkpoint"
73
+ fi
74
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # WHAT A PHASE'S EXIT CODE MEANS — DEFINED ONCE.
3
+ #
4
+ # run-agent-orchestration.sh exits non-zero for two completely different reasons, and
5
+ # until 2026-08-14 both were exit 2:
6
+ #
7
+ # REMEDIATED a gate applied a fix; re-running the phase is expected to now pass.
8
+ # HALT a change the reviewer never approved, at an EXHAUSTED ladder.
9
+ # Re-running cannot help — there is no higher rung to try.
10
+ #
11
+ # SEVEN separate call sites each wrote `if [ "$phase_exit" -eq 2 ]; then ...retry...`
12
+ # — run-agent-orchestration.sh (twice), orchestrate.sh, and four tier3 launchers
13
+ # (metrolinx, mock, skyscanner, travel-app) — so a HALT was retried by all of them.
14
+ # The first sweep for this found only three; the class was wider than the sites that
15
+ # happened to be open at the time, which is exactly why the comparison lives here now
16
+ # and not at any call site.
17
+ #
18
+ # WHAT THAT COST, live run 20260814T213253Z (metrolinx, AMSD-2041): Step 3.6 correctly
19
+ # printed "A change the reviewer never approved must NOT proceed — human review
20
+ # required" and exited. The caller read 2, called it remediation, and retried. The
21
+ # retry re-entered ensure_story_branch, which hard-reset the branch and orphaned the
22
+ # already-committed, already-green work; it then burned 12 further attempts against a
23
+ # ladder that had nothing left to escalate to, and failed. The pipeline spent ~15
24
+ # minutes and real money to arrive where Step 3.6 already was.
25
+ #
26
+ # The distinction is the whole point, so it lives in ONE place. A caller asks
27
+ # `phase_exit_is_retryable`; no caller compares a bare integer.
28
+
29
+ # Exit code vocabulary. Values are the contract with run-agent-orchestration.sh.
30
+ PHASE_EXIT_OK=0
31
+ PHASE_EXIT_REMEDIATED=2
32
+ PHASE_EXIT_HALT_REVIEW=3
33
+ PHASE_EXIT_TESTS_STRENGTHENED=4
34
+
35
+ # THE CODE WAS RIGHT; ITS TESTS WERE NOT (AMSD-2974 live L2, 2026-10-14). A gate finding that only tests can
36
+ # resolve — a surviving mutant in correct code — went to the AC remediator and back to the CODE writer, who is
37
+ # forbidden to touch tests, and looped. Such a finding now goes to the test-writer, which commits the
38
+ # strengthened test; the phase is re-run on that commit WITHOUT --reset, so the stories keep their approved
39
+ # work, Step 1 passes them as completed, and the review and the gates judge the new commit.
40
+
41
+ # phase_exit_is_retryable <code>
42
+ # True (0) ONLY for the code that means "a fix was applied, try again". Everything else
43
+ # — success, halt, and any unrecognised code — is not retryable. An unknown code is
44
+ # deliberately NOT retried: re-running an outcome nobody has classified is how a HALT
45
+ # came to be retried in the first place.
46
+ phase_exit_is_retryable() {
47
+ [ "${1:-}" = "$PHASE_EXIT_REMEDIATED" ] || [ "${1:-}" = "$PHASE_EXIT_TESTS_STRENGTHENED" ]
48
+ }
49
+
50
+ # phase_exit_retry_args <code> — the arguments a retry of this outcome re-runs the phase with, one per line.
51
+ # REMEDIATED changed what the stories must do, so they are reset and re-written; TESTS_STRENGTHENED changed only
52
+ # the tests, so the stories keep their work and the phase re-judges it.
53
+ phase_exit_retry_args() {
54
+ case "${1:-}" in
55
+ "$PHASE_EXIT_REMEDIATED") printf '%s\n' '--reset' ;;
56
+ *) ;;
57
+ esac
58
+ }
59
+
60
+ # phase_exit_describe <code>
61
+ # One line naming the outcome, so a failure reads as a decision rather than a number.
62
+ phase_exit_describe() {
63
+ case "${1:-}" in
64
+ "$PHASE_EXIT_OK") echo "completed" ;;
65
+ "$PHASE_EXIT_REMEDIATED") echo "gate remediation applied — a retry is expected to help" ;;
66
+ "$PHASE_EXIT_TESTS_STRENGTHENED") echo "the test-writer strengthened the tests a gate found insufficient — the gates re-run on that commit" ;;
67
+ "$PHASE_EXIT_HALT_REVIEW") echo "HALT: the reviewer never approved the change and the ladder is exhausted — human review required, a retry cannot help" ;;
68
+ *) echo "failed (exit ${1:-?})" ;;
69
+ esac
70
+ }
71
+
72
+ # phase_retry_mark <codeline-root> — what the codeline holds before a self-heal retry (its commit), for
73
+ # phase_retry_progressed. Empty when the root is not a repository.
74
+ phase_retry_mark() {
75
+ git -C "${1:-.}" rev-parse -q --verify HEAD 2>/dev/null || true
76
+ }
77
+
78
+ # phase_retry_progressed <codeline-root> <mark> — true when the retry changed what the codeline holds.
79
+ #
80
+ # THE PIPELINE NEVER LOOPS (operator, 2026-10-14: "pipeline should NEVER get into a loop!!!"). A retry is repeated
81
+ # while its gates remediate; AMSD-2974 live run L2 retried the 7.x line five times — each time the remediator added
82
+ # criteria, the writer committed nothing, the same code failed the same gate (~$2.50 a round). A retry that changed
83
+ # nothing cannot make the next one different: it is not repeated. With no mark (no repository), progress cannot be
84
+ # told, and the retry is not repeated either.
85
+ phase_retry_progressed() {
86
+ local _now
87
+ [ -n "${2:-}" ] || return 1
88
+ _now=$(phase_retry_mark "${1:-.}")
89
+ [ -n "$_now" ] && [ "$_now" != "$2" ]
90
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # pipeline_commit_flags — the flags every commit the PIPELINE makes passes to `git commit`, one per line.
3
+ #
4
+ # A codeline's hooks are written for its team's own checkout and machine. Operator decisions,
5
+ # 2026-10-05 (AMSD-2974): the pipeline's commits skip them — first inside worktrees, then in the
6
+ # checkout too. Proven on a copy of next.gotransit.com: its pre-commit hook (husky + lint-staged)
7
+ # type-checks the WHOLE project, which fails on the untouched checkout with 6 errors because the
8
+ # team's node_modules is older than its own develop branch expects (it needs an `npm install`, which
9
+ # the pipeline may not run) — so the hook refused every pipeline commit there, whatever it held, and
10
+ # the reproducing test of a correct fix was never committed. In worktrees the same hook also refused
11
+ # the branch name and sourced a helper git does not track.
12
+ #
13
+ # What the hook checks is still checked by the pipeline: the story's own verification, Step 19/20's
14
+ # type check judged against the phase baseline, the lint gate, review and the QA gates. The Step 17
15
+ # merge onto the story branch still runs the codeline's commit-msg hook (git merge has no --no-verify
16
+ # here), and its message is written to pass it.
17
+ pipeline_commit_flags() {
18
+ echo "--no-verify"
19
+ }
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+ /**
3
+ * planHash(plan) — the story's plan, identified: md5 of its JSON with keys sorted at every level; '' when there is
4
+ * none. What a spec was built under (story.specification.planHash) is compared with the plan the story has now.
5
+ */
6
+ const crypto = require('crypto');
7
+
8
+ function stable(v) {
9
+ if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
10
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(',')}}`;
11
+ return JSON.stringify(v);
12
+ }
13
+
14
+ function planHash(plan) {
15
+ if (!plan || typeof plan !== 'object') return '';
16
+ return crypto.createHash('md5').update(stable(plan)).digest('hex');
17
+ }
18
+
19
+ module.exports = { planHash };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ /**
3
+ * THE PLAN DECIDES THE CODELINES A STORY IS WORKED IN.
4
+ *
5
+ * Live AMSD-2974 run 1, 2026-10-09: discovery grounded four codelines; the AC gate's router picked one; the detective was
6
+ * shown that one ("No other codeline is in scope for this investigation") and planned no consumer and no back-port. The
7
+ * plan — written once, by the detective that reads the code — is the decision on which codelines change and which only
8
+ * receive (operator, 2026-10-06); the router's pick is only where the story starts.
9
+ *
10
+ * Both answers come from the run's own record of what discovery grounded (codeline-facts.json: one top-level key per
11
+ * codeline, each with its path, rewritten every run). Nothing is invented: a codeline the record does not hold is never
12
+ * given a lane, and a lane the story already has is never taken away.
13
+ */
14
+
15
+ /** The codelines discovery grounded this run, with their paths, in the record's order. */
16
+ function discovered(facts) {
17
+ if (!facts || typeof facts !== 'object') return [];
18
+ return Object.entries(facts)
19
+ .filter(([k, v]) => !k.startsWith('_') && v && typeof v === 'object' && typeof v.path === 'string' && v.path)
20
+ .map(([codeline, v]) => ({ codeline, path: v.path }));
21
+ }
22
+
23
+ function storyLanes(story) {
24
+ return (Array.isArray(story && story.codelines) && story.codelines.length ? story.codelines : [story && story.codeline])
25
+ .filter(Boolean);
26
+ }
27
+
28
+ /**
29
+ * planScope(story, prd, facts) → { codelines, outputDirs }: what the detective writing the plan is shown — the story's
30
+ * own codelines first, then every other codeline discovery grounded, each with where it is.
31
+ */
32
+ function planScope(story, prd, facts) {
33
+ const dirs = (prd && prd.project && Array.isArray(prd.project.outputDirs)) ? prd.project.outputDirs : [];
34
+ const found = discovered(facts);
35
+ const codelines = [...new Set([...storyLanes(story), ...found.map((f) => f.codeline)])];
36
+ const outputDirs = codelines.map((cl) => dirs.find((d) => d && d.codeline === cl) || found.find((f) => f.codeline === cl))
37
+ .filter(Boolean).map((d) => ({ codeline: d.codeline, path: d.path }));
38
+ return { codelines, outputDirs };
39
+ }
40
+
41
+ /** Every codeline a plan names: its parts in plan order, then each package's provider and consumers. */
42
+ function plannedCodelines(plan) {
43
+ if (!plan || typeof plan !== 'object') return [];
44
+ const parts = (Array.isArray(plan.codelines) ? plan.codelines : []).slice()
45
+ .sort((a, b) => (Number(a && a.order) || 0) - (Number(b && b.order) || 0)).map((c) => c && c.codeline);
46
+ const pkgs = Array.isArray(plan.packages) ? plan.packages : [];
47
+ const around = pkgs.flatMap((p) => [p && p.providedBy, ...((p && Array.isArray(p.consumers)) ? p.consumers.map((c) => c && c.codeline) : [])]);
48
+ return [...new Set([...parts, ...around].filter(Boolean))];
49
+ }
50
+
51
+ /**
52
+ * adoptPlanCodelines(story, prd, facts) → the codelines added: every codeline the story's plan names that discovery
53
+ * grounded becomes one of the story's codelines (story.codelines, routed one first) and one of the run's
54
+ * project.outputDirs, with its path. Mutates story and prd.
55
+ */
56
+ function adoptPlanCodelines(story, prd, facts) {
57
+ if (!story || !story.overallPlan) return [];
58
+ const found = discovered(facts);
59
+ const have = storyLanes(story);
60
+ const added = plannedCodelines(story.overallPlan)
61
+ .filter((cl) => !have.includes(cl) && found.some((f) => f.codeline === cl));
62
+ if (!added.length) return [];
63
+ story.codelines = [...have, ...added];
64
+ prd.project = prd.project || {};
65
+ const dirs = Array.isArray(prd.project.outputDirs) ? prd.project.outputDirs : [];
66
+ for (const cl of added) {
67
+ if (!dirs.some((d) => d && d.codeline === cl)) dirs.push({ codeline: cl, path: found.find((f) => f.codeline === cl).path });
68
+ }
69
+ prd.project.outputDirs = dirs;
70
+ return added;
71
+ }
72
+
73
+ module.exports = { planScope, adoptPlanCodelines, plannedCodelines, discovered };
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bash
2
+ # WHAT A QA GATE IS SHOWN DECIDES WHAT IT CAN CONCLUDE.
3
+ #
4
+ # Live 2026-09-09, run 20260908T215555Z — AMSD-1919, a one-line brownfield defect fixed at
5
+ # CheckoutForm.tsx:306. Two gates were paid for and neither could reach a verdict about the change:
6
+ #
7
+ # qa-gate:runtime-boundary "CheckoutForm.tsx itself is unmodified (git diff confirms no
8
+ # changes)." — false, and stated with confidence
9
+ # qa-gate:mutant-hunter "truncated to the first 100 of 436 lines and does not include the
10
+ # case-sensitivity comparison logic around line 306" — true, and
11
+ # the reason it could only answer WARN
12
+ #
13
+ # Two different faults, one shape: the evidence window could not contain the answer.
14
+ #
15
+ # 1. The diff was built as `git diff "${_rev_base:-HEAD~1}" HEAD`, and `_rev_base=` appears
16
+ # nowhere in the engine. The fallback was not a fallback, it was the only behaviour — and
17
+ # HEAD~1..HEAD is the last commit alone. repro-test-writer always commits AFTER the writer,
18
+ # so on every brownfield defect that window holds the reproducing test and never the fix.
19
+ #
20
+ # 2. The excerpt was `head -n <window>` — anchored at the top of the file, so a 436-line
21
+ # component whose change sits at line 306 is out of reach at any window size. An earlier fix
22
+ # added a truncation NOTICE, which is why the agent refused honestly rather than guessing;
23
+ # it did not make the agent see.
24
+ #
25
+ # Both are fixed by deriving, never by declaring a number here: the base is the phase baseline the
26
+ # run itself recorded, and every window size stays in config/evidence-windows.json.
27
+ #
28
+ # Sourced (not executed) — every function prints to stdout and returns non-zero when it cannot
29
+ # produce evidence, so a caller can say "no evidence" instead of shipping a misleading window.
30
+
31
+ # qa_gate_diff <project_root> <log_dir>
32
+ #
33
+ # The diff a QA gate must judge: everything THIS PHASE changed, from the baseline the phase
34
+ # recorded. story_outputs_baseline_ref is the single reader of phase-baseline-sha.txt and already
35
+ # serves story_outputs_deleted and story_outputs_files; this is the third consumer, not a second
36
+ # way of answering the same question.
37
+ #
38
+ # ABSENT IS NOT AN EMPTY DIFF. A missing or unusable baseline returns non-zero WITH a sentence
39
+ # saying so, because "nothing changed" and "I could not tell what changed" are opposite findings
40
+ # and the gate acts on them differently.
41
+ qa_gate_diff() {
42
+ local _root="${1:-}" _log="${2:-}"
43
+ if [ -z "$_root" ] || [ -z "$_log" ]; then
44
+ printf '(qa_gate_diff: no project root or log dir given — this gate has no diff to judge)'
45
+ return 1
46
+ fi
47
+
48
+ local _base=""
49
+ if command -v story_outputs_baseline_ref >/dev/null 2>&1; then
50
+ _base=$(story_outputs_baseline_ref "$_log" 2>/dev/null) || _base=""
51
+ fi
52
+ if [ -z "$_base" ]; then
53
+ printf '(no phase baseline was recorded for this run — this gate has no diff to judge, and MUST NOT read that as "nothing changed")'
54
+ return 1
55
+ fi
56
+
57
+ if ! git -C "$_root" rev-parse --verify --quiet "${_base}^{commit}" >/dev/null 2>&1; then
58
+ printf '(the recorded phase baseline %s is not a commit in this repository — this gate has no diff to judge)' "$_base"
59
+ return 1
60
+ fi
61
+
62
+ # A BASELINE THAT IS NOT AN ANCESTOR OF HEAD CANNOT DESCRIBE THIS PHASE.
63
+ #
64
+ # Live 2026-09-09, the second resume of run 20260908T215555Z: the baseline was recorded before
65
+ # ensure_story_branch hard-reset the story branch onto origin/<baseline>, so it named a commit
66
+ # from the PREVIOUS leg that the reset then orphaned. Diffing an orphan is not empty — it is a
67
+ # cross-branch comparison, and it produced 8 insertions and 11 deletions in the reproducing
68
+ # test with no sign of the source fix. runtime-boundary read that and reported the change was
69
+ # "confined to a Jest/RTL spec file" for the second run running.
70
+ #
71
+ # Refused rather than diffed: this whole file exists to keep "nothing changed" apart from
72
+ # "I cannot tell what changed", and a plausible wrong diff is the worse of the two.
73
+ if ! git -C "$_root" merge-base --is-ancestor "$_base" HEAD >/dev/null 2>&1; then
74
+ printf '(the recorded phase baseline %s is not an ancestor of HEAD — it does not describe this phase, so this gate has no diff to judge. A branch reset after the baseline was recorded is the usual cause.)' "$_base"
75
+ return 1
76
+ fi
77
+
78
+ # The size is DECLARED, like every other evidence window. A literal here would be the exact
79
+ # thing config/evidence-windows.json exists to stop.
80
+ local _cap
81
+ _cap=$(evidence_window qaGateDiffLines 2>/dev/null) || _cap=""
82
+ if [ -z "$_cap" ]; then
83
+ printf '(the qaGateDiffLines evidence window is not declared — refusing to invent a size)'
84
+ return 1
85
+ fi
86
+
87
+ git -C "$_root" diff "$_base" HEAD 2>/dev/null | head -n "$_cap"
88
+ }
89
+
90
+ # qa_gate_excerpt <project_root> <log_dir> <repo_relative_path> <window_name>
91
+ #
92
+ # The part of a file a gate needs: the region THIS PHASE changed, with the declared window's worth
93
+ # of surrounding context — never the first N lines, which is a window on whatever the file happens
94
+ # to open with.
95
+ #
96
+ # A file that fits inside its window is printed whole and says nothing about truncation: claiming
97
+ # a cut that did not happen is its own kind of misleading.
98
+ qa_gate_excerpt() {
99
+ local _root="${1:-}" _log="${2:-}" _rel="${3:-}" _win_name="${4:-}"
100
+ # CALLERS HOLD BOTH SHAPES. story_outputs_sources yields repo-relative paths and the
101
+ # find-fallback yields absolute ones; a function that accepted only one silently produced
102
+ # "(unreadable)" for the other, which reads exactly like a file that is not there.
103
+ local _abs
104
+ case "$_rel" in
105
+ /*) _abs="$_rel"; _rel="${_rel#"$_root"/}" ;;
106
+ *) _abs="$_root/$_rel" ;;
107
+ esac
108
+ [ -f "$_abs" ] || { printf '(%s: unreadable)' "$_rel"; return 1; }
109
+
110
+ local _window
111
+ _window=$(evidence_window "$_win_name" 2>/dev/null) || _window=""
112
+ if [ -z "$_window" ] || [ "$_window" -le 0 ] 2>/dev/null; then
113
+ printf '(the %s evidence window is not declared — refusing to invent a size)' "$_win_name"
114
+ return 1
115
+ fi
116
+
117
+ local _total
118
+ _total=$(wc -l < "$_abs" 2>/dev/null | tr -d '[:space:]')
119
+ [ -n "$_total" ] || _total=0
120
+
121
+ printf -- '--- %s' "$_rel"
122
+
123
+ # Whole file, no claim of truncation.
124
+ if [ "$_total" -le "$_window" ]; then
125
+ printf -- ' ---\n'
126
+ cat "$_abs"
127
+ return 0
128
+ fi
129
+
130
+ # WHERE THE CHANGE IS, asked of git rather than assumed. -U0 so the hunk headers name only
131
+ # lines that actually changed; the surrounding context is this function's to decide.
132
+ local _first="" _last="" _base=""
133
+ if command -v story_outputs_baseline_ref >/dev/null 2>&1; then
134
+ _base=$(story_outputs_baseline_ref "$_log" 2>/dev/null) || _base=""
135
+ fi
136
+ if [ -n "$_base" ] && git -C "$_root" rev-parse --verify --quiet "${_base}^{commit}" >/dev/null 2>&1; then
137
+ local _hdr _start _count
138
+ while IFS= read -r _hdr; do
139
+ # @@ -a,b +c,d @@ — c is the first changed line on the new side, d how many.
140
+ _start=$(printf '%s' "$_hdr" | sed -n 's/^@@ -[0-9,]* +\([0-9]*\).*/\1/p')
141
+ _count=$(printf '%s' "$_hdr" | sed -n 's/^@@ -[0-9,]* +[0-9]*,\([0-9]*\).*/\1/p')
142
+ [ -n "$_start" ] || continue
143
+ [ -n "$_count" ] || _count=1
144
+ [ "$_count" -gt 0 ] 2>/dev/null || _count=1
145
+ local _end=$(( _start + _count - 1 ))
146
+ { [ -z "$_first" ] || [ "$_start" -lt "$_first" ]; } && _first="$_start"
147
+ { [ -z "$_last" ] || [ "$_end" -gt "$_last" ]; } && _last="$_end"
148
+ done < <(git -C "$_root" diff -U0 "$_base" HEAD -- "$_rel" 2>/dev/null | grep '^@@')
149
+ fi
150
+
151
+ local _from _to _anchored="around this phase's change"
152
+ if [ -n "$_first" ] && [ -n "$_last" ]; then
153
+ # Centre the window on the changed region.
154
+ local _span=$(( _last - _first + 1 ))
155
+ local _pad=$(( (_window - _span) / 2 ))
156
+ [ "$_pad" -lt 0 ] && _pad=0
157
+ _from=$(( _first - _pad ))
158
+ [ "$_from" -lt 1 ] && _from=1
159
+ _to=$(( _from + _window - 1 ))
160
+ if [ "$_to" -gt "$_total" ]; then
161
+ _to="$_total"
162
+ _from=$(( _to - _window + 1 ))
163
+ [ "$_from" -lt 1 ] && _from=1
164
+ fi
165
+ else
166
+ # NAMED, not silent. Falling back to the head of the file is the old behaviour, and the
167
+ # gate must be told that is what it is looking at.
168
+ _from=1
169
+ _to="$_window"
170
+ [ "$_to" -gt "$_total" ] && _to="$_total"
171
+ _anchored="from the START of the file — this phase's changed lines could not be located"
172
+ fi
173
+
174
+ printf -- ' (lines %s-%s of %s, %s) ---\n' "$_from" "$_to" "$_total" "$_anchored"
175
+ sed -n "${_from},${_to}p" "$_abs"
176
+ }
177
+
178
+ # qa_phase_baseline_sha <project_root> [baseline_branch]
179
+ #
180
+ # THE COMMIT THIS PHASE'S WORK DIVERGED FROM, which is not the same as HEAD.
181
+ #
182
+ # run-agent-orchestration.sh captured `rev-parse HEAD` before the story loop, and
183
+ # ensure_story_branch then hard-reset the story branch onto origin/<baseline> — orphaning the very
184
+ # commit just recorded. On a FIRST run the two agree, because HEAD is already the base; on every
185
+ # RESUME the recorded baseline is the previous leg's tip and every diff-based gate reads it:
186
+ # review-ranger, mutant-hunter, fuzz-weaver, sast-sentinel, runtime-boundary and the tsc/eslint
187
+ # baseline gates.
188
+ #
189
+ # The merge-base gives the same answer before and after the reset, which is what makes it a
190
+ # baseline rather than a snapshot of whatever the branch happened to be carrying.
191
+ #
192
+ # NOTHING IS INVENTED. A base branch that does not resolve falls back to HEAD — the previous
193
+ # behaviour exactly — rather than to a guessed ref.
194
+ qa_phase_baseline_sha() {
195
+ local _root="${1:-}" _branch="${2:-}"
196
+ [ -n "$_root" ] || return 1
197
+
198
+ local _head
199
+ _head=$(git -C "$_root" rev-parse --verify --quiet HEAD 2>/dev/null) || _head=""
200
+ [ -n "$_head" ] || return 1
201
+
202
+ if [ -n "$_branch" ]; then
203
+ # origin/<branch> first, as ensure_story_branch resolves it, then the bare name.
204
+ local _ref
205
+ for _ref in "origin/$_branch" "$_branch"; do
206
+ git -C "$_root" rev-parse --verify --quiet "${_ref}^{commit}" >/dev/null 2>&1 || continue
207
+ local _mb
208
+ _mb=$(git -C "$_root" merge-base "$_head" "$_ref" 2>/dev/null) || _mb=""
209
+ if [ -n "$_mb" ]; then
210
+ printf '%s' "$_mb"
211
+ return 0
212
+ fi
213
+ done
214
+ fi
215
+
216
+ printf '%s' "$_head"
217
+ }
218
+
219
+ # qa_record_phase_baseline <root> <base-branch> <log-dir> — WRITE THE PHASE BASELINE every gate reads.
220
+ #
221
+ # One recording for both ways a phase runs its stories. It lived only in Step 8 (main-branch stories),
222
+ # so a brownfield lane that runs its story in a worktree — and skips Step 8 — had no
223
+ # phase-baseline-sha.txt: every pre-existing failure was charged to its writer, eight attempts in a
224
+ # row (£0 multi-lane run, 2026-10-04). Nothing resolvable is said out loud, never written as a guess.
225
+ qa_record_phase_baseline() {
226
+ local _root="${1:-}" _branch="${2:-}" _log_dir="${3:-${LOG_DIR:-}}" _sha
227
+ [ -n "$_root" ] && [ -n "$_log_dir" ] || return 1
228
+ _sha="$(qa_phase_baseline_sha "$_root" "$_branch" 2>/dev/null || echo "")"
229
+ if [ -z "$_sha" ]; then
230
+ warning "[orch] could not resolve a baseline commit in $_root — every diff-based gate will compare against nothing"
231
+ return 1
232
+ fi
233
+ printf '%s\n' "$_sha" > "$_log_dir/phase-baseline-sha.txt"
234
+ printf '%s' "$_sha"
235
+ }