amsd-pipeline-shared 0.5.3 → 0.5.5

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.
@@ -88,3 +88,7 @@ ac166207d8a1d93a4c50a88568ef6e5c scripts/lib/handlers/write-missing-plans.js
88
88
  ee4366721c38cbc8cd72ee0f779d22a8 scripts/lib/plan-scope.js
89
89
  aab7063cf68a9ecd54f6ef735adf895e scripts/lib/engine-paths.sh
90
90
  1d0346da09c13150e511809034396949 scripts/lib/pipeline-commit.sh
91
+ a01137cb36b65a3b80a8327404f4ed7a scripts/lib/story-acs-block.sh
92
+ 043b6c85d1505ebc5da6c6b158000330 scripts/lib/plan-of-record.sh
93
+ 1cc1978d614fca875ad1117e9ac42f93 scripts/lib/render-engine-prompt.sh
94
+ bad91b18bf546145a58423c770ba1a3a prompts/templates/criterion-origin.json
@@ -6,6 +6,7 @@ config/resume-preserves.json
6
6
  config/run-modes.json
7
7
  prompts/doctor-repair.md
8
8
  prompts/templates/codeline-discovery.json
9
+ prompts/templates/criterion-origin.json
9
10
  scripts/agent-attempt-analyst.sh
10
11
  scripts/lib/agent-contract.js
11
12
  scripts/lib/agent-ladder.sh
@@ -77,12 +78,14 @@ scripts/lib/phase-exit.sh
77
78
  scripts/lib/pipeline-commit.sh
78
79
  scripts/lib/pipeline-tools.js
79
80
  scripts/lib/plan-hash.js
81
+ scripts/lib/plan-of-record.sh
80
82
  scripts/lib/plan-scope.js
81
83
  scripts/lib/preflight.sh
82
84
  scripts/lib/prelaunch.js
83
85
  scripts/lib/qa-gate-evidence.sh
84
86
  scripts/lib/relative-paths.js
85
87
  scripts/lib/release-lines.sh
88
+ scripts/lib/render-engine-prompt.sh
86
89
  scripts/lib/render-prompt-section.js
87
90
  scripts/lib/resume-semantics.sh
88
91
  scripts/lib/run-checkpoint.sh
@@ -91,6 +94,7 @@ scripts/lib/seam-invocation.js
91
94
  scripts/lib/seam-ladder.sh
92
95
  scripts/lib/self-heal.js
93
96
  scripts/lib/stage-coverage-gate.sh
97
+ scripts/lib/story-acs-block.sh
94
98
  scripts/lib/story-outputs.sh
95
99
  scripts/lib/story-retry-state.sh
96
100
  scripts/lib/tool-timeouts.js
@@ -0,0 +1,15 @@
1
+ {
2
+ "$why": [
3
+ "2026-10-14 (operator: \"You are not permitted to hard code engine prose\"). The words that mark a criterion the",
4
+ "pipeline's remediator added — so the reviewer may retire it when it cannot be met, and never one of the plan's —",
5
+ "were a string in lib/story-acs-block.sh. One definition: the criteria block renders it beside such a criterion,",
6
+ "the reviewer's contract (review-finding-routing) names it, and retire-criteria.py accepts a name copied with it."
7
+ ],
8
+ "description": "How a criterion the remediator added is marked wherever criteria are shown to an agent.",
9
+ "id": "criterion-origin",
10
+ "version": 1,
11
+ "placeholders": [],
12
+ "bodies": {
13
+ "remediation": "[added by remediation — not the plan]"
14
+ }
15
+ }
@@ -614,9 +614,15 @@ archive_prior_lane_state() {
614
614
  fi
615
615
  # B80 (RS-7): the run's start is its id when the id is a timestamp (as before); for ANY other id (every Claude workflows id is LIVE-…) it is NOW — the
616
616
  # first time this function runs for the run — which is only safe with the marker that stops a resume from archiving the run's own state
617
+ # B80 fix (run 11 leg 3): a RESUME's lane state is its own. Without a timestamp id there is no way to tell the run's earlier legs' files from an earlier run's, and
618
+ # the run's first legs may predate the marker — so a resume never archives (the released 0.5.3 moved 113 files of the run's own state and the Writer re-ran).
617
619
  local _start _how=""
618
620
  _start=$(printf '%s' "$_run" | sed -nE 's/^([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2})Z?$/\1-\2-\3 \4:\5:\6 UTC/p')
619
621
  if [ -z "$_start" ]; then
622
+ if [ -n "${EPAM_RESUME_RUN:-}" ]; then
623
+ log "[orch] the lane state of '${_run}' is kept: this is a resume, and its earlier legs' files are its own (the run id carries no start time to tell them from an earlier run's)" || true
624
+ return 0
625
+ fi
620
626
  if [ -z "$_marker" ]; then
621
627
  warning "[orch] the earlier lane state is not archived: run id '${_run}' carries no start time and there is no EPAM_PROJECT_CONFIG_DIR to record that it was archived (a resume would archive the run's own state)" || true
622
628
  return 0
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # plan-of-record.sh — THE STORY'S PLAN AS APPROVED AT PAUSE 2, WRITTEN ONCE, NEVER OVERWRITTEN.
3
+ #
4
+ # Operator, 2026-10-14 (option 3): which criteria are the plan's is decided by the plan approved at pause 2 — not by a
5
+ # criterion's shape. The old rule ("exactly {text, status} means a remediator added it") was hard coding. The plan of
6
+ # record lives beside the lane's pre-writer checkpoint; a release line reads its lane's (the line's plan is the lane's).
7
+ # A run paused before this existed derives it once from that checkpoint — never from an earlier stage, which is not
8
+ # the approved plan.
9
+
10
+ # shellcheck source=run-checkpoint.sh
11
+ declare -F checkpoint_dir >/dev/null 2>&1 || . "$(dirname "${BASH_SOURCE[0]}")/run-checkpoint.sh"
12
+
13
+ # plan_of_record_file [run-id] — the path (it may not exist yet).
14
+ plan_of_record_file() {
15
+ local _d; _d=$(EPAM_RELEASE_LINE='' checkpoint_dir "${1:-}" 2>/dev/null) || return 1
16
+ [ -n "$_d" ] || return 1
17
+ printf '%s/plan-of-record.json' "$_d"
18
+ }
19
+
20
+ # record_plan_of_record <prd> — write the plan of record from <prd>, ONCE: an existing one is never replaced.
21
+ record_plan_of_record() {
22
+ local _src="${1:-}" _f
23
+ _f=$(plan_of_record_file) || return 0
24
+ [ -s "$_f" ] && return 0
25
+ [ -n "$_src" ] && [ -f "$_src" ] || return 0
26
+ mkdir -p "$(dirname "$_f")" 2>/dev/null || true
27
+ jq '{stories: [(.stories // [])[] | {id, acceptanceCriteria: (.acceptanceCriteria // [])}]}' "$_src" > "$_f.tmp" 2>/dev/null \
28
+ && mv "$_f.tmp" "$_f" || rm -f "$_f.tmp"
29
+ }
30
+
31
+ # plan_of_record_ensured — the plan of record's path when there is one: written now from the lane's pre-writer checkpoint
32
+ # if this run paused before plans of record were kept; nothing when no pause-2 checkpoint exists.
33
+ plan_of_record_ensured() {
34
+ local _f _d
35
+ _f=$(plan_of_record_file) || return 0
36
+ if [ ! -s "$_f" ]; then
37
+ _d=$(dirname "$_f")
38
+ [ -f "$_d/checkpoint.json" ] && [ -f "$_d/prd.json" ] || return 0
39
+ [ "$(_stage_rank "$(jq -r '.stage // empty' "$_d/checkpoint.json" 2>/dev/null)")" -ge "$(_stage_rank pre-writer)" ] || return 0
40
+ record_plan_of_record "$_d/prd.json"
41
+ fi
42
+ [ -s "$_f" ] && printf '%s' "$_f"
43
+ }
@@ -229,9 +229,14 @@ qa_record_phase_baseline() {
229
229
  # B77 (RS-4, = B73): THE BASELINE IS RECORDED ONCE for a run and phase. A resume starts the phase again; the baseline it had must not move to wherever HEAD
230
230
  # is by then (that made "committed since the baseline" empty). A new phase, or a new run in the same log folder, has a different key and records its own.
231
231
  _key="${PHASE:-main}|${ORCH_RUN_ID:-}"
232
- if [ -f "$_bf" ] && [ -f "$_kf" ] && [ "$(cat "$_kf" 2>/dev/null)" = "$_key" ]; then
232
+ # A RESUME ADOPTS A LEGACY BASELINE (a sha with no key file, recorded by the code before B77 — found replaying run 11's gotransit lane): on a resume it is the run's own, so a
233
+ # valid commit is kept and the key is written for every later call. Without a resume (a new run in the same folder) a keyless value is not trusted, as before.
234
+ local _legacy=0
235
+ [ -f "$_bf" ] && [ ! -f "$_kf" ] && [ -n "${EPAM_RESUME_RUN:-}" ] && _legacy=1
236
+ if [ -f "$_bf" ] && { [ "$_legacy" = 1 ] || { [ -f "$_kf" ] && [ "$(cat "$_kf" 2>/dev/null)" = "$_key" ]; }; }; then
233
237
  _have=$(tr -d '[:space:]' < "$_bf" 2>/dev/null)
234
238
  if [ -n "$_have" ] && git -C "$_root" cat-file -e "${_have}^{commit}" 2>/dev/null; then
239
+ [ "$_legacy" = 1 ] && printf '%s\n' "$_key" > "$_kf"
235
240
  printf '%s' "$_have"
236
241
  return 0
237
242
  fi
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # render-engine-prompt.sh — render a template-layer prompt from shell.
4
+ #
5
+ # Several seams that still carry embedded prompts are shell, not JavaScript, so
6
+ # they need the same renderer the JS side uses. This is a thin wrapper over
7
+ # lib/engine-prompt.js: one renderer, one strictness, one set of rules.
8
+ #
9
+ # render_engine_prompt <template-id> <values-json-file>
10
+ #
11
+ # VALUES ARRIVE VIA A FILE, NEVER ARGV.
12
+ # Prompt values routinely carry a whole diff, a suite dump or a PRD fragment.
13
+ # ARG_MAX is 2 MiB, and passing a value that exceeds it makes the command exit
14
+ # 126 with an empty result — which is exactly how the FailureAnalyst died on
15
+ # 2026-08-15, silently, because the failure looked like a parse error three
16
+ # steps later. A file has no such limit.
17
+ #
18
+ # Exit status is the contract: non-zero means nothing was rendered, and the
19
+ # caller must refuse to invoke an agent rather than send it an empty prompt.
20
+ # ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ # shellcheck disable=SC2148
23
+ _REP_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+
25
+ render_engine_prompt() {
26
+ local _id="$1" _values_file="$2" _body_key="${3:-}"
27
+
28
+ if [ -z "$_id" ] || [ -z "$_values_file" ]; then
29
+ echo "[render-engine-prompt] usage: render_engine_prompt <template-id> <values-json-file>" >&2
30
+ return 2
31
+ fi
32
+ if [ ! -f "$_values_file" ]; then
33
+ echo "[render-engine-prompt] values file not found: $_values_file" >&2
34
+ return 2
35
+ fi
36
+
37
+ local _out _err
38
+ _err=$(mktemp "${TMPDIR:-/tmp}/render-prompt-err-XXXXXX")
39
+ # The values file path is the only thing on argv; the values themselves are read
40
+ # from disk by node.
41
+ _out=$("${NODE_BIN:-node}" -e '
42
+ const fs = require("fs");
43
+ const { renderEngineTemplate } = require(process.argv[1]);
44
+ const values = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
45
+ process.stdout.write(renderEngineTemplate(process.argv[2], values, process.argv[4] || undefined));
46
+ ' "$_REP_LIB_DIR/engine-prompt.js" "$_id" "$_values_file" "$_body_key" 2>"$_err")
47
+ local _rc=$?
48
+
49
+ if [ "$_rc" -ne 0 ] || [ -z "$_out" ]; then
50
+ # Loud, and naming the template. An agent invoked with an empty prompt answers
51
+ # from nothing and the answer looks like every other answer.
52
+ echo "[render-engine-prompt] FAILED to render '$_id': $(cat "$_err" 2>/dev/null)" >&2
53
+ rm -f "$_err"
54
+ return 1
55
+ fi
56
+ rm -f "$_err"
57
+ printf '%s' "$_out"
58
+ }
59
+
60
+ # render_or_keep <template-id> <values-json-file> [<body-key>]
61
+ #
62
+ # Renders, or prints NOTHING and returns non-zero — so the caller's variable keeps whatever it
63
+ # already held instead of being blanked:
64
+ #
65
+ # _r="$(render_or_keep writer-plan-section "$_vals" execution_plan)" && prompt="$_r"
66
+ #
67
+ # WHY. `prompt="$(render_engine_prompt ...)"` discards the exit status, so a dead render assigns
68
+ # the empty string. Live metrolinx AMSD-2041, 2026-08-19: jq hit the 128KB argv cap, both the
69
+ # writer prompt and the retry amendment silently became empty, and the run climbed the entire model
70
+ # ladder invoking agents with nothing in hand — visible as attempts returning in 0.01 min having
71
+ # consumed zero tokens. The header of this file already stated the rule the callers broke: exit
72
+ # status is the contract, and a caller must refuse rather than send an empty prompt.
73
+ #
74
+ # AN EMPTY SUCCESSFUL RENDER IS ALSO A FAILURE here. At the call site the two are the same event —
75
+ # an agent handed nothing — and only one of them was ever going to be noticed.
76
+ render_or_keep() {
77
+ local _out _rc
78
+ _out=$(render_engine_prompt "$@")
79
+ _rc=$?
80
+ if [ "$_rc" -ne 0 ] || [ -z "$_out" ]; then
81
+ if command -v warning >/dev/null 2>&1; then
82
+ warning " [render] '${1:-?}' produced nothing (exit ${_rc}) — keeping the previous value rather than blanking it"
83
+ else
84
+ echo "[render-engine-prompt] '${1:-?}' produced nothing (exit ${_rc}) — caller keeps its previous value" >&2
85
+ fi
86
+ return 1
87
+ fi
88
+ printf '%s' "$_out"
89
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # story-acs-block.sh — the acceptance-criteria BLOCK a seam prompt receives, or nothing.
3
+ #
4
+ # Brownfield judges on verification criteria: a brownfield story has no acceptance criteria, and an
5
+ # empty "ACCEPTANCE CRITERIA:" heading over nothing is worse than no heading (operator, 2026-09-01,
6
+ # ea920ee7). Greenfield is authored WITH acceptance criteria and they are in scope (operator,
7
+ # 2026-09-11). Both hold when the heading travels with the criteria: present, the block is the
8
+ # heading and the numbered list; absent, the block is empty and the template's __STORY_ACS__
9
+ # (declared mayBeEmpty) renders as nothing. Four seams read it — failure-analyst, team-lead-review,
10
+ # code-review-cycle, code-graph-detective (spec-mode-runner.js builds the same shape in JS) — so
11
+ # the shape is defined here once.
12
+ #
13
+ # story_acs_block <prd_file> <story_id> [heading]
14
+ # stdout: "<heading>\nAC1: ...\nAC2: ...\n\n" when the story declares acceptance criteria,
15
+ # "" otherwise. Default heading "ACCEPTANCE CRITERIA:".
16
+ # A CRITERION THE REMEDIATOR ADDED SAYS SO (operator, 2026-10-14: a reviewer may retire one that cannot be met — never
17
+ # one of the plan's). What the plan is comes from the plan of record (lib/plan-of-record.sh); the remediators also stamp
18
+ # what they add {origin: "remediation"}.
19
+ # THE MARKER IS TEMPLATE PROSE (prompts/templates/criterion-origin.json) — never a literal here. The criteria block renders
20
+ # it, the reviewer's contract names it (story_criterion_mark_values), and retire-criteria.py accepts a name copied with it.
21
+ # shellcheck source=plan-of-record.sh
22
+ declare -F plan_of_record_ensured >/dev/null 2>&1 || . "$(dirname "${BASH_SOURCE[0]}")/plan-of-record.sh"
23
+ # shellcheck source=render-engine-prompt.sh
24
+ declare -F render_engine_prompt >/dev/null 2>&1 || . "$(dirname "${BASH_SOURCE[0]}")/render-engine-prompt.sh"
25
+ story_criterion_mark() {
26
+ local _v _m; _v=$(mktemp "${TMPDIR:-/tmp}/criterion-origin-XXXXXX.json"); echo '{}' > "$_v"
27
+ _m=$(render_engine_prompt criterion-origin "$_v" remediation); rm -f "$_v"; printf '%s' "$_m"
28
+ }
29
+ # story_criterion_mark_values — the mark as a values object for a template that names it (__REMEDIATION_MARK__).
30
+ story_criterion_mark_values() {
31
+ jq -cn --arg m "$(story_criterion_mark)" '{"__REMEDIATION_MARK__": $m}'
32
+ }
33
+ # B84 (finding RS-18): WHAT IS LOST IS SAID. The block stays what callers expect (stdout empty, status 0 — nothing is added to or removed from a prompt),
34
+ # but a PRD that cannot be read, a PRD without the story, or a plan of record that cannot be read is named on stderr: the reviewer would otherwise judge without
35
+ # criteria, or with the remediator's criteria unmarked, and nothing would say so. A story that simply declares no criteria (brownfield) is silent.
36
+ _story_acs_said() { printf '[story-acs-block] %s\n' "$*" >&2; }
37
+ story_acs_block() {
38
+ local _prd="${1:-}" _id="${2:-}" _heading="${3:-ACCEPTANCE CRITERIA:}"
39
+ [ -n "$_prd" ] && [ -n "$_id" ] || return 0
40
+ if [ ! -f "$_prd" ]; then _story_acs_said "the PRD $_prd was not found — no acceptance-criteria block for $_id"; return 0; fi
41
+ # WHAT THE PLAN IS — the plan of record (lib/plan-of-record.sh), never a criterion's shape. A criterion it does not
42
+ # list, or one the remediator stamped, is marked. With no plan of record only the stamp marks.
43
+ local _plan _list _plan_json=null _err
44
+ _plan=$(plan_of_record_ensured 2>/dev/null || true)
45
+ if [ -n "$_plan" ]; then
46
+ _plan_json=$(jq -c --arg id "$_id" '[.stories[] | select(.id == $id) | .acceptanceCriteria[]? | if type == "object" then (.text // "") else tostring end]' "$_plan" 2>/dev/null) \
47
+ || { _story_acs_said "the plan of record $_plan cannot be read — the criteria of $_id are shown but not marked against the plan"; _plan_json=null; }
48
+ [ -n "$_plan_json" ] || _plan_json=null
49
+ fi
50
+ _err=$(mktemp "${TMPDIR:-/tmp}/story-acs-err-XXXXXX")
51
+ _list=$(jq -r --arg id "$_id" --arg mark "$(story_criterion_mark)" --argjson plan "$_plan_json" \
52
+ '.stories[] | select(.id == $id) | .acceptanceCriteria // [] | map(select(. != null and . != "")) | to_entries
53
+ | map(.value as $v | ($v | if type == "object" then (.text // tostring) else tostring end) as $t
54
+ | "AC\(.key+1): " + $t
55
+ + (if (($v | type) == "object" and (($v.origin // "") == "remediation"))
56
+ or ($plan != null and (($plan | index($t)) == null))
57
+ then " " + $mark else "" end))
58
+ | join("\n")' \
59
+ "$_prd" 2>"$_err")
60
+ local _rc=$?
61
+ if [ "$_rc" -ne 0 ]; then
62
+ _story_acs_said "the PRD $_prd is not readable for $_id ($(head -c 200 "$_err" | tr '\n' ' ')) — no acceptance-criteria block"
63
+ rm -f "$_err"; return 0
64
+ fi
65
+ rm -f "$_err"
66
+ if [ -z "$_list" ] && ! jq -e --arg id "$_id" '.stories[]? | select(.id == $id)' "$_prd" >/dev/null 2>&1; then
67
+ _story_acs_said "story $_id is not in the PRD $_prd — no acceptance-criteria block"
68
+ return 0
69
+ fi
70
+ [ -n "$_list" ] || return 0
71
+ printf '%s\n%s\n\n' "$_heading" "$_list"
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amsd-pipeline-shared",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "The AMSD pipeline's shared components, one source for the bash pipeline and its Claude workflows: the model-call gateway (llm-handler.sh — retry, ladder, self-heal, cost) and the libraries it runs.",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {