amsd-pipeline-shared 0.2.0 → 0.4.0

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.
@@ -6,6 +6,7 @@
6
6
  // amsd-pipeline-shared verify --into <install> every file is this package's (exit 1 and the list when not)
7
7
  // amsd-pipeline-shared revert --into <install> put back the files this package replaced
8
8
  // amsd-pipeline-shared messages send|receive|list --dir <bus> … the agent-to-agent bus (lib/agent-messages.js)
9
+ // amsd-pipeline-shared prelaunch --install <i> --project <p> --run-id <id> --out <dir> … the shared pre-launch
9
10
  // amsd-pipeline-shared version
10
11
  // A file is replaced only when the install holds the released copy this package was built from (docs/gateway-baseline-
11
12
  // 2.0.80.md5) or this package's own copy (a re-install); anything else is refused, file by file, and nothing is written.
@@ -23,6 +24,8 @@ const fail = (m, code = 1) => { process.stderr.write(`amsd-pipeline-shared: ${m}
23
24
 
24
25
  const [cmd, ...rest] = process.argv.slice(2);
25
26
  if (cmd === 'version') { process.stdout.write(`${PKG.version}\n`); process.exit(0); }
27
+ // THE SHARED PRE-LAUNCH, for every pipeline (lib/prelaunch.js): amsd-pipeline-shared prelaunch --install <i> --project <p> …
28
+ if (cmd === 'prelaunch') process.exit(require(path.join(ROOT, 'orchestrations', 'scripts', 'lib', 'prelaunch.js')).prelaunchCli(rest));
26
29
  // THE MESSAGE BUS FOR SHELL CALLERS (the bash pipeline's adoption, backlog B55): one JSON line out
27
30
  if (cmd === 'messages') {
28
31
  const m = require(path.join(ROOT, 'orchestrations', 'scripts', 'lib', 'agent-messages.js'));
@@ -59,6 +59,8 @@ content-retry ×3, the failure analyst's ladder loop, the roster review ×2). Ca
59
59
  - **On a fresh install the synthesiser cannot render its prompt**: `projects/<p>/prompts/kb-enforcement-synthesis.json`
60
60
  is a minted project prompt, absent on wf-280 ("project-authority prompt missing … Mint it"). To check against how the
61
61
  codeline cache supplies prompts before the next live leg; the self-heal listener must report a synthesis failure.
62
+ **Checked 2026-10-18: not a risk for a run with its codeline cache** — the run's project copy (run/project/prompts,
63
+ 44 prompts from .prompt-cache/<codeline>/prompts) holds it. A project with no minted or cached prompt set lacks it.
62
64
  - **The ladder position `mid` resolves to the middle tier, `high`** (seam-invocation.js resolveTierPosition: base/mid/top
63
65
  over the declared order medium/high/highest), whose chain is haiku → sonnet-5 → opus-5-5. config/llm-defaults.claude.json
64
66
  says of `medium`: "A `mid` seam never reaches Opus: 14 seams resolve here". 12 seams declare `mid` (ticket-links among
@@ -1,3 +1,4 @@
1
+ 6b83e0d700392eebdd1bae1ec233ab26 prompts/templates/codeline-discovery.json
1
2
  b2470a21b73b03abb95713f05acaa827 scripts/agent-attempt-analyst.sh
2
3
  c45cda62ef52ee95c8de6acead3408f8 scripts/lib/agent-contract.js
3
4
  f881c989bd12e76e13e017ff2d984041 scripts/lib/agent-ladder.sh
@@ -23,6 +24,7 @@ c0a3a88d1a895e0a86ea483cada15411 scripts/lib/kb-store.js
23
24
  ed15fbd317db2643b19a3fd7ecd1979a scripts/lib/model-ladder.sh
24
25
  4d2dc2a636736b5b77711ad7736f9075 scripts/lib/model-ladders.sh
25
26
  97d25b9108a01292d1b28da0d3c129e9 scripts/lib/pipeline-tools.js
27
+ c806c7a54ebfb7ea178ef8415bb70c1c scripts/lib/preflight.sh
26
28
  6bfffd3ba6eb845f738d3157168bf056 scripts/lib/relative-paths.js
27
29
  6894e248c7a86d07626b7e981d534354 scripts/lib/render-prompt-section.js
28
30
  912511e457b0a6f4801c479400b1c3a3 scripts/lib/seam-invocation.js
@@ -32,6 +34,9 @@ ed15fbd317db2643b19a3fd7ecd1979a scripts/lib/model-ladder.sh
32
34
  be6d0a378fa9c3bc9da6240877aaeae3 scripts/lib/tool-timeouts.js
33
35
  5574182fb27aaeabbfff261b3a8d6d39 scripts/lib/transcript-tool-calls.js
34
36
  c4b96230c15b52a845cf20fd3266fe01 scripts/llm-handler.sh
35
- 40c65de8360d5a9f59902fca26d1d7a8 scripts/update-monitor.sh
36
- dfa070b168a7c01fab8aab5a46d16192 scripts/team-lead-review.sh
37
+ 9b05c5b99b4f803c3a568ce5c069e6d3 scripts/preflight-check.sh
38
+ 740a55fe2ae0527a0598f3c751bacca7 scripts/preflight-prd-integrity.sh
39
+ bc5acdb9260923082e05ef3b67304028 scripts/preflight-static.sh
37
40
  f206025f304e8320b8417a6dd6606044 scripts/spec-mode-runner.js
41
+ dfa070b168a7c01fab8aab5a46d16192 scripts/team-lead-review.sh
42
+ 40c65de8360d5a9f59902fca26d1d7a8 scripts/update-monitor.sh
@@ -1,6 +1,8 @@
1
+ prompts/templates/codeline-discovery.json
1
2
  scripts/agent-attempt-analyst.sh
2
3
  scripts/lib/agent-contract.js
3
4
  scripts/lib/agent-ladder.sh
5
+ scripts/lib/agent-messages.js
4
6
  scripts/lib/agent-si.js
5
7
  scripts/lib/constraint-compiler.js
6
8
  scripts/lib/constraint-sanity.js
@@ -23,6 +25,8 @@ scripts/lib/llm-settings-resolve.js
23
25
  scripts/lib/model-ladder.sh
24
26
  scripts/lib/model-ladders.sh
25
27
  scripts/lib/pipeline-tools.js
28
+ scripts/lib/preflight.sh
29
+ scripts/lib/prelaunch.js
26
30
  scripts/lib/relative-paths.js
27
31
  scripts/lib/render-prompt-section.js
28
32
  scripts/lib/seam-invocation.js
@@ -32,7 +36,9 @@ scripts/lib/stage-coverage-gate.sh
32
36
  scripts/lib/tool-timeouts.js
33
37
  scripts/lib/transcript-tool-calls.js
34
38
  scripts/llm-handler.sh
35
- scripts/update-monitor.sh
36
- scripts/team-lead-review.sh
39
+ scripts/preflight-check.sh
40
+ scripts/preflight-prd-integrity.sh
41
+ scripts/preflight-static.sh
37
42
  scripts/spec-mode-runner.js
38
- scripts/lib/agent-messages.js
43
+ scripts/team-lead-review.sh
44
+ scripts/update-monitor.sh
@@ -0,0 +1,40 @@
1
+ {
2
+ "$why": [
3
+ "Moved out of lib/codeline-discovery.js buildDiscoveryPrompt on 2026-08-15 (2 of 20).",
4
+ "",
5
+ "This prompt decides which repositories a ticket belongs to, so its wording sets the scope",
6
+ "of the entire run. Extracted mechanically and pinned byte for byte by a golden capture",
7
+ "taken before the move.",
8
+ "",
9
+ "Requiring the module used to exit the requiring process: its argument check ran at import",
10
+ "time. Scoping that to direct invocation is what made the migration provable at all.",
11
+ "",
12
+ "Built from the golden OUTPUT, not from the source text. The first attempt extracted the",
13
+ "template literal verbatim and kept its JS escapes — a backslash-quote stayed two characters",
14
+ "instead of one — so the rendered prompt differed from the original by two bytes. The",
15
+ "byte-identity check caught it, which is the entire reason the golden is captured first.",
16
+ "",
17
+ "Facts added 2026-08-16. codeline-facts.json had no producer at all: every one in the repo was",
18
+ "typed by hand, so a new project had none and whatever a run learned died with it. Discovery",
19
+ "is the single producer because it is the only stage that opens every candidate repository.",
20
+ "",
21
+ "Rule 6 changed 2026-10-19 (version 3, amsd-pipeline-shared). The reason asked for \"what part of the ticket",
22
+ "it covers\", and the AC classifier reads each reason as that codeline's description (JIRA_CODELINE_DESC_*, ac-gate.js",
23
+ "codelineDesc). For a ticket naming several sites the reasons assigned work to each (\"the acceptance criteria apply to",
24
+ "GO\", \"must be wired here\"), and the classifier scoped the story to every site; where the reasons only described the",
25
+ "site (\"matching the ticket's GO component\") it chose the shared library. Same prompt, same model, both outcomes",
26
+ "(AMSD-2974, launch LIVE-AMSD-2974-8 vs the classifier probe; amsd-pipeline-workflow docs/probes/why-20261019). The",
27
+ "reason now describes the repository and its relation to the others; deciding the work stays with the classifier."
28
+ ],
29
+ "body": "You are the codeline-discovery agent for a brownfield engineering pipeline.\n\nYour task: given a set of Jira tickets and a manifest of local code repositories,\nidentify which repository (or repositories) each ticket's changes belong to.\n\nJIRA TICKETS:\n__ISSUES_SUMMARY__\n\nREPOSITORY MANIFEST (git repos only):\n__MANIFEST_SUMMARY__\n\nTHE MANIFEST ABOVE IS EVERY REPOSITORY IN THE ESTATE, in no particular order. It is\nnot ranked, not filtered and not scored: nothing has judged relevance before you, so\nposition carries no information and the first entry is not a hint.\n\nYOU HAVE TOOLS, AND THE ANSWER IS USUALLY IN THE CODE RATHER THAN IN THIS LISTING.\nNames and READMEs describe what a repository calls itself; the code shows what it\nactually implements, and two sibling repositories in the same estate routinely\ndescribe themselves almost identically.\n\n codegraph_query search indexed symbols. Pass codeline=<name from the manifest>\n to choose which repository to search; without it, a query is\n ambiguous when several are in scope.\n search find text across a repository.\n read_file open a specific file.\n list_files see how a repository is laid out.\n\nSEARCH BEFORE YOU DECIDE. Look for the thing the ticket actually asks about — the\nsymbol, the component, the configuration it names — and see which repository\ncontains it. A repository that mentions the domain in prose and a repository that\nimplements it are indistinguishable from this listing alone, and only one of them\nis the place the work belongs.\n\nRules:\n1. Match each ticket to the repository whose name, packageName, or description\n best fits the ticket's domain. Use context clues: service names, domain terms,\n technology references, component names, file path mentions.\n2. RETURN THE REPOSITORIES THE EVIDENCE SUPPORTS. An empty result stops the run,\n which is the correct outcome when the work genuinely cannot be placed — but it\n is almost never the honest answer, because you have tools and the estate is in\n front of you. Search first. If you still cannot ground a part of the ticket,\n put that part in \"unsure\" rather than selecting a repository to fill the slot:\n a guess promoted to a selection cannot be spotted afterwards, and the run will\n modify whatever you name.\n\n2a. ONE TICKET MAY SPAN SEVERAL REPOSITORIES, and you MUST return one entry for\n each. This estate is maintained as many separate codelines, so a single\n ticket routinely covers work in more than one of them — a change with both\n front-end and back-end parts, or one applied across several product areas.\n Returning a single repository for such a ticket silently drops the rest of\n the work: those repositories are never touched, and the run reports success\n having done a fraction of the job.\n\n The strongest evidence is the ticket's own \"components\" field, which is the\n tracker's record of which product areas it touches. When a ticket names\n several distinct product areas, return the repository that owns each one.\n Do not collapse them because one candidate scored highest — the ranking\n measures textual similarity to a single repository, not how much of the\n ticket that repository covers.\n\n Decide the count from the ticket. Do not aim for any particular number.\n3. SELECT ON EVIDENCE, NOT ON A HUNCH. Every selected repository must be grounded\n in something concrete you can point at:\n - a ticket component, label or phrase that names that product area, or\n - something in the repository itself (its name, its manifest, its code)\n that matches what the ticket asks for.\n POSITION IN THE LIST IS NOT EVIDENCE — there is no ranking to appeal to. Nor is\n \"it is probably the backend for X\": if you have to say likely, probably, or may\n be, you do not have evidence, you have a hunch. What you found with a tool is\n evidence, and it is the strongest kind available here.\n If a part of the ticket clearly belongs SOMEWHERE but you cannot ground it in\n one of these repositories, DO NOT SELECT ONE ANYWAY. Put it in \"unsure\" with\n what you could not resolve. An unresolved component is real information and a\n human can settle it; a guess promoted to a selection cannot be spotted later.\n Do not omit a candidate you CAN ground merely because another also fits — a\n ticket spanning several product areas needs all of them (rule 2a).\n4. Assign each selected repo a short codeline identifier: 2-20 chars, lowercase,\n alphanumeric only, no dots or dashes. Derive it from the directory name by\n removing only decoration - a domain suffix, or an organisation or platform\n prefix - and keeping the part that actually names the product. Keep every\n remaining word: for a directory \"alpha-beta-gamma\" the identifier is\n \"alphabetagamma\", NOT \"gamma\". Dropping words produces an identifier that no\n longer identifies the repository.\n5. Return exactly one entry only when the ticket genuinely belongs to exactly one\n repository. \"One entry\" is the right answer for a self-contained change, and\n the wrong answer for a ticket spanning several product areas — see rule 2a.\n6. \"reason\" must be one sentence explaining why THIS repository was selected: what the\n repository is, and how it relates to the ticket and to the other repositories you\n selected — for example, that it owns a component the ticket names, or that it uses a\n component another selected repository owns. Describe the REPOSITORY, not the work:\n do not say what must change, be built, wired or verified in it. Which repositories\n the work belongs to is decided after you, from the acceptance criteria, and it reads\n your reason as the description of the repository. Every selected repository needs its own\n reason — a single justification covering the whole set makes a wrong\n selection impossible to spot afterwards.\n7. \"evidence\" must quote the CONCRETE thing that grounds the selection: the\n ticket component/label/phrase verbatim, or the file, directory, symbol or\n manifest entry you found in that repository — including what a tool returned. It is not a second reason and not a restatement —\n if you cannot fill it without hedging, the repository belongs in \"unsure\",\n not in \"codelines\".\n8. REPORT WHAT AN AGENT WORKING IN THAT REPOSITORY WOULD NEED TO KNOW and could not\n read off the source — its \"facts\". You are the only stage that opens every\n candidate repository, so this is the one place the estate is visible at once.\n Useful facts are things like: how its tests are actually run and what they\n need in order to pass, what a build or commit hook demands before it will\n let a change through, where the source and tests live when it is not obvious,\n and any setup without which a correct change still fails.\n FACTS ARE OBSERVED, NEVER GUESSED. Each one must quote its \"source\": the file,\n script or manifest entry you read it from, verbatim enough that someone can\n open it and check. A fact you cannot source is a guess, and a guess here is\n handed to every agent that works in that repository as though it were\n established — worse than saying nothing. If you observed nothing beyond what\n the source already says, return an empty list. An empty list is an honest\n answer and it is reported; an invented fact is not detectable later.\n A FACT'S SOURCE IS A FILE INSIDE THAT REPOSITORY — its code, manifest, config,\n hooks, scripts or README. Nothing the pipeline produced is a source: not a PRD,\n not a ticket or its description, not a previous run's output, not a log. Those\n describe a ticket, not the repository; a \"fact\" taken from them is the ticket\n restated, and every later story in that repository would inherit it as truth.\n Do not restate the ticket, and do not describe what the change should be —\n these describe the REPOSITORY, and they outlive this ticket.\n\nOutput format (strict JSON, no markdown fences, no preamble, no trailing text):\n{\n \"codelines\": [\n { \"name\": \"<short-identifier-derived-from-the-directory-name>\",\n \"path\": \"/absolute/path/to/repo\",\n \"reason\": \"what this repository is, and how it relates to the ticket and to the other selected repositories\",\n \"evidence\": \"ticket component \\\"<the-component-verbatim>\\\"\",\n \"facts\": [\n { \"text\": \"what an agent working here needs to know and cannot read off the source\",\n \"source\": \"the file, script or manifest entry you read it from\" }\n ] }\n ],\n \"unsure\": [\n { \"part\": \"the part of the ticket you could not place\",\n \"why\": \"what you would need in order to place it\" }\n ]\n}",
30
+ "description": "Match each ticket to the repository or repositories its changes belong to.",
31
+ "id": "codeline-discovery",
32
+ "placeholders": [
33
+ "__ISSUES_SUMMARY__",
34
+ "__MANIFEST_SUMMARY__"
35
+ ],
36
+ "version": 3,
37
+ "seams": [
38
+ "codeline-discovery"
39
+ ]
40
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # preflight.sh — run the pre-launch assessment from any launcher, with no launcher having
3
+ # to know the check script's arguments.
4
+ #
5
+ # preflight-check.sh existed with six sections and was wired into exactly TWO of the eight
6
+ # launchers. The two being run daily (metrolinx, mock) were not among them, which is why
7
+ # four separate launch failures on 2026-08-05 — a stale dist twice, a dead observability
8
+ # stack, a project with no synthesis template — were each discovered by spending a launch
9
+ # instead of by the check that exists to prevent exactly that.
10
+ #
11
+ # The runner name and the project config directory are DERIVED here rather than passed:
12
+ # a launcher that had to repeat them is a launcher that can get them wrong, and one already
13
+ # did — tier3-skyscanner-app-run.sh passed --runner tier3-travel-app-run.sh, so it
14
+ # pre-flighted a different launcher than the one it was about to run.
15
+ #
16
+ # Usage: . lib/preflight.sh ; require_preflight # aborts the launch if anything fails
17
+
18
+ require_preflight() {
19
+ local script_dir="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
20
+ local check="$script_dir/preflight-check.sh"
21
+
22
+ if [ ! -f "$check" ]; then
23
+ echo " ✗ preflight-check.sh not found at $check — refusing to launch unassessed" >&2
24
+ return 1
25
+ fi
26
+
27
+ local -a args=()
28
+ # The runner is whichever launcher sourced this file. $0 is that launcher.
29
+ args+=(--runner "$(basename "${0:-unknown}")")
30
+ [ -n "${PRD_FILE:-}" ] && args+=(--prd "$PRD_FILE")
31
+ [ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] && args+=(--project-config "$EPAM_PROJECT_CONFIG_DIR")
32
+
33
+ echo "── Pre-flight assessment ─────────────────────────────────────────────────────"
34
+ if bash "$check" "${args[@]}"; then
35
+ # THE SHARED PRE-LAUNCH (amsd-pipeline-shared, operator 2026-10-19: one pre-launch for every pipeline): the checks
36
+ # every launch owes after the pipeline's own — Langfuse, the seeded ticket, the codeline cache, cost, nothing else
37
+ # running, memory, no clock above an agent, the 12-item checklist. Skipped only when it is the shared pre-launch
38
+ # that is running these checks (AMSD_PRELAUNCH_INNER), so the two never loop.
39
+ [ "${AMSD_PRELAUNCH_INNER:-}" = "1" ] && return 0
40
+ local _pl="$script_dir/lib/prelaunch.js" _install _plargs=()
41
+ _install="$(cd "$script_dir/../.." && pwd)"
42
+ if [ ! -f "$_pl" ]; then
43
+ echo " ✗ the shared pre-launch is not installed ($_pl) — refusing to launch unassessed" >&2
44
+ return 1
45
+ fi
46
+ _plargs=(--install "$_install" --project "$(basename "${EPAM_PROJECT_CONFIG_DIR:-${PROJECT_CONFIG_DIR:-unknown}}")"
47
+ --run-id "${ORCH_RUN_ID:-prelaunch-$(date -u +%Y%m%dT%H%M%SZ)}" --out "${LOG_DIR:-$_install/orchestrations/logs}/prelaunch"
48
+ --runner "$(basename "${0:-unknown}")" --preflight no)
49
+ [ -n "${JIRA_TICKET:-}" ] && [ -n "${AMSD_PRELAUNCH_RUNS:-}" ] && _plargs+=(--ticket "$JIRA_TICKET" --runs "$AMSD_PRELAUNCH_RUNS")
50
+ [ -n "${EPAM_CODELINE_ID:-}" ] && _plargs+=(--codeline "$EPAM_CODELINE_ID")
51
+ [ -n "${EPAM_PROVIDER_SET:-}" ] && _plargs+=(--set "$EPAM_PROVIDER_SET")
52
+ if "${NODE_BIN:-node}" "$_pl" "${_plargs[@]}"; then
53
+ return 0
54
+ fi
55
+ echo "" >&2
56
+ echo " ✗ Pre-launch FAILED — aborting before spending. Fix the items above." >&2
57
+ return 1
58
+ fi
59
+
60
+ # Deliberately not skippable by an env var. Every failure this catches cost a launch
61
+ # cycle or credits; an escape hatch here would be used on the run that needed it most.
62
+ echo "" >&2
63
+ echo " ✗ Pre-flight FAILED — aborting before spending. Fix the items above." >&2
64
+ return 1
65
+ }
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+ // THE PRE-LAUNCH, SHARED BY EVERY PIPELINE (operator, 2026-10-19: "Make pre-launch a common component. It needs to be
3
+ // shared between all pipelines."). Before any model call: the pipeline's own pre-flight (preflight-check.sh through
4
+ // lib/preflight.sh require_preflight, unchanged) and the checks the operator approved the same day, each from a failure
5
+ // that reached a live launch:
6
+ // 1 langfuse Langfuse answers and a launch trace is written and read back under the run's session id
7
+ // 2 seed the seeded prompts and agent files are the launched ticket's (byte-identical to a recorded run of it)
8
+ // 3 cache the codeline cache carries both layers: the prompt set and the per-prompt entries the builder reuses
9
+ // 4 cost the ledger writer records a priced call, and every model on the active ladders has a price
10
+ // 5 alone no other pipeline run or test is working against this install
11
+ // 6 memory the host can give the run what it needs and keep 3 GB for itself
12
+ // 7 no-timeout nothing above the launch can cut an agent off on a clock
13
+ // 8 checklist the operator's 12-item run checklist, scored: green / red / not-yet (decided in the run)
14
+ // Each check returns {name, ok, detail}; nothing here makes a model call. Any red stops the launch.
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const md5 = (f) => crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex');
22
+ const readJson = (f, d = null) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
23
+ const orchOf = (install) => path.join(install, 'orchestrations');
24
+ const scriptsOf = (install) => path.join(orchOf(install), 'scripts');
25
+ const res = (name, ok, detail, extra = {}) => ({ name, ok: !!ok, detail, ...extra });
26
+
27
+ // ── 0. the pipeline's own pre-flight, unchanged ──────────────────────────────────────────────────────────────────────
28
+ function preflight({ install, env, runner, prd }) {
29
+ const sd = scriptsOf(install);
30
+ // require_preflight names the runner from $0 (the launcher that sourced it): the runner's own name is passed as $0
31
+ // AMSD_PRELAUNCH_INNER: require_preflight runs this pre-launch after its own checks (every bash launcher); called from
32
+ // here, it runs the pipeline's checks alone — no loop
33
+ const r = spawnSync('bash', ['-c', '. "$1/lib/preflight.sh" && require_preflight', runner || 'orchestrate.sh', sd],
34
+ { env: { ...env, SCRIPT_DIR: sd, AMSD_PRELAUNCH_INNER: '1', ...(prd ? { PRD_FILE: prd } : {}) }, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
35
+ const out = `${r.stdout || ''}${r.stderr || ''}`;
36
+ const failed = (out.match(/^\s*✗ .*$/gm) || []).map((l) => l.trim());
37
+ return res('preflight', r.status === 0, r.status === 0 ? `the pipeline's pre-flight passed (${(out.match(/^\s*✓ /gm) || []).length} checks)` : `${failed.length} failed: ${failed.slice(0, 6).join(' | ')}`, { log: out });
38
+ }
39
+
40
+ // ── 1. langfuse ──────────────────────────────────────────────────────────────────────────────────────────────────────
41
+ /** The install's LANGFUSE_* settings, added to `env` where `env` has none. */
42
+ function langfuseEnv(install, env) {
43
+ const out = { ...env };
44
+ for (const l of (fs.existsSync(path.join(install, '.env')) ? fs.readFileSync(path.join(install, '.env'), 'utf8') : '').split('\n')) {
45
+ const m = l.match(/^(LANGFUSE_[A-Z_]+)=(.*)$/); if (m && !out[m[1]]) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
46
+ }
47
+ return out;
48
+ }
49
+ function langfuse({ install, env, runId, outDir, traceName = 'launch-prelaunch', waitMs = 2000, tries = 6 }) {
50
+ const e = langfuseEnv(install, env);
51
+ const lf = require(path.join(scriptsOf(install), 'lib', 'langfuse-emit.js'));
52
+ const cfg = lf.config(e);
53
+ const probe = cfg ? spawnSync('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '5', `${cfg.base}/api/public/health`], { encoding: 'utf8' }).stdout : '';
54
+ if (probe !== '200') return res('langfuse', false, `Langfuse is required and is not answering (${cfg ? `${cfg.base} → ${probe || 'no answer'}` : 'not configured: no keys or endpoint'})`);
55
+ const session = lf.sessionId({ ...e, ORCH_RUN_ID: runId });
56
+ fs.mkdirSync(outDir, { recursive: true });
57
+ const marker = path.join(outDir, `langfuse-${traceName}.js`);
58
+ fs.writeFileSync(marker, `require(${JSON.stringify(path.join(scriptsOf(install), 'lib', 'langfuse-emit.js'))}).emitGeneration({ agent: ${JSON.stringify(traceName)}, model: 'none', costUsd: 0, tokensIn: 0, tokensOut: 0, input: ${JSON.stringify(`pre-launch of ${runId}`)}, output: 'launch' }, process.env).then((ok) => process.exit(ok ? 0 : 1))`);
59
+ const sent = spawnSync(process.execPath, [marker], { env: { ...process.env, ...e, ORCH_RUN_ID: runId }, encoding: 'utf8' }).status === 0;
60
+ let found = 0;
61
+ for (let i = 0; i < tries && sent && !found; i += 1) {
62
+ spawnSync('sleep', [String(waitMs / 1000)]);
63
+ const q = spawnSync('curl', ['-s', '--max-time', '5', '-u', `${e.LANGFUSE_PUBLIC_KEY}:${e.LANGFUSE_SECRET_KEY}`, `${cfg.base}/api/public/traces?sessionId=${encodeURIComponent(session)}&name=${traceName}&limit=5`], { encoding: 'utf8' }).stdout;
64
+ try { found = (JSON.parse(q).data || []).filter((t) => t.sessionId === session && t.name === traceName).length; } catch { found = 0; }
65
+ }
66
+ return found ? res('langfuse', true, `trace '${traceName}' read back under session ${session} (${cfg.base})`, { session, base: cfg.base })
67
+ : res('langfuse', false, `no trace under Langfuse session ${session} could be read back (${sent ? 'sent, not found' : 'not sent'})`);
68
+ }
69
+
70
+ // ── 2. seed ──────────────────────────────────────────────────────────────────────────────────────────────────────────
71
+ function seededFiles(install, project) {
72
+ const pcd = path.join(orchOf(install), 'projects', project);
73
+ const { MINTED_FILES } = require(path.join(scriptsOf(install), 'lib', 'project-prompt-builder.js'));
74
+ const out = []; const pd = path.join(pcd, 'prompts');
75
+ if (fs.existsSync(pd)) for (const f of fs.readdirSync(pd).filter((n) => n.endsWith('.json')).sort()) out.push({ rel: path.join('prompts', f), abs: path.join(pd, f), name: f, kind: 'prompt' });
76
+ for (const f of MINTED_FILES) if (fs.existsSync(path.join(pcd, f))) out.push({ rel: f, abs: path.join(pcd, f), name: f, kind: 'minted' });
77
+ return out;
78
+ }
79
+ const prdNames = (prd, ticket) => ((prd && (prd.stories || prd.userStories)) || []).some((s) => s && (s.id === ticket || s.jiraKey === ticket));
80
+ function ticketSnapshots(runsRoots, ticket) {
81
+ const snaps = [];
82
+ for (const root of runsRoots) {
83
+ let runs = []; try { runs = fs.readdirSync(root); } catch { continue; }
84
+ for (const r of runs.sort()) {
85
+ const rd = path.join(root, r);
86
+ if (!prdNames(readJson(path.join(rd, 'checkpoint', 'prd.json')), ticket)) continue;
87
+ const walk = (d, depth) => {
88
+ if (depth > 6) return;
89
+ let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
90
+ for (const e of ents) if (e.isDirectory()) { const p = path.join(d, e.name); if (e.name === 'checkpoint') snaps.push({ run: rd, dir: p }); else walk(p, depth + 1); }
91
+ };
92
+ walk(rd, 0);
93
+ }
94
+ }
95
+ return snaps;
96
+ }
97
+ const snapshotCopy = (snap, f) => (f.kind === 'prompt' ? [path.join(snap.dir, 'prompts', f.name)] : [path.join(snap.dir, f.name), path.join(snap.dir, 'reviewed', f.name)]).find((p) => fs.existsSync(p)) || null;
98
+ function seed({ install, project, ticket, runsRoots }) {
99
+ const files = seededFiles(install, project);
100
+ if (!files.length) return res('seed', false, 'nothing is seeded — no prompts and no minted files on disk');
101
+ const prefix = String(ticket).replace(/-\d+$/, '').replace(/[^A-Za-z0-9]/g, '');
102
+ const keyRe = new RegExp(`\\b${prefix}-\\d+\\b`, 'g');
103
+ const otherTickets = files.map((f) => ({ file: f.rel, keys: [...new Set((fs.readFileSync(f.abs, 'utf8').match(keyRe) || []).filter((k) => k !== ticket))] })).filter((o) => o.keys.length);
104
+ const snaps = ticketSnapshots(runsRoots, ticket);
105
+ if (!snaps.length) return res('seed', false, `no recorded run of ${ticket} under ${runsRoots.join(', ')}`, { otherTickets });
106
+ const best = snaps.map((s) => {
107
+ const mismatched = []; const unproven = []; let same = 0;
108
+ for (const f of files) { const c = snapshotCopy(s, f); if (!c) unproven.push(f.rel); else if (md5(c) === md5(f.abs)) same += 1; else mismatched.push(f.rel); }
109
+ return { s, same, mismatched, unproven };
110
+ }).sort((x, y) => y.same - x.same)[0];
111
+ const snapshot = path.relative(path.dirname(best.s.run), best.s.dir);
112
+ const ok = !best.mismatched.length && !best.unproven.length && !otherTickets.length;
113
+ const why = [best.mismatched.length && `${best.mismatched.length} differ from ${snapshot} (${best.mismatched.slice(0, 4).join(', ')})`,
114
+ best.unproven.length && `${best.unproven.length} have no copy in it`, otherTickets.length && `${otherTickets.length} name another ticket`].filter(Boolean).join('; ');
115
+ return res('seed', ok, ok ? `all ${files.length} seeded file(s) are ${ticket}'s (${snapshot}), no other ticket named` : `not proven ${ticket}'s: ${why}`,
116
+ { files: files.length, snapshot, mismatched: best.mismatched, unproven: best.unproven, otherTickets });
117
+ }
118
+
119
+ // ── 3. cache ─────────────────────────────────────────────────────────────────────────────────────────────────────────
120
+ /**
121
+ * The prompts the builder GENERATES for a codeline, and so must find a per-prompt entry for to reuse: its own provisioning
122
+ * list, less the templates it installs as they are (copyVerbatim, and multi-body templates), present in the cached set.
123
+ * An entry is <cache>/<id>.json, or <id>.<variant>.json for a variant run (brownfield).
124
+ */
125
+ function cache({ install, project, codeline, variant = process.env.EPAM_BROWNFIELD === '1' ? 'brownfield' : '' }) {
126
+ const O = orchOf(install);
127
+ const C = path.join(O, 'projects', project, '.prompt-cache', codeline);
128
+ const b = require(path.join(scriptsOf(install), 'lib', 'project-prompt-builder.js'));
129
+ const tpl = (t) => path.join(O, 'prompts', 'templates', `${t}.json`);
130
+ const problems = [];
131
+ const prompts = fs.existsSync(path.join(C, 'prompts')) ? fs.readdirSync(path.join(C, 'prompts')).filter((n) => n.endsWith('.json')).map((n) => n.replace(/\.json$/, '')) : [];
132
+ if (!prompts.length) problems.push(`no cached prompt set (${path.join(C, 'prompts')})`);
133
+ const minted = b.MINTED_FILES.filter((f) => !fs.existsSync(path.join(C, 'minted', f)));
134
+ if (minted.length) problems.push(`minted files missing: ${minted.join(', ')}`);
135
+ const marker = path.join(O, 'projects', project, '.prompt-cache', `.complete-${codeline}${variant ? `.${variant}` : ''}`);
136
+ if (!fs.existsSync(marker)) problems.push(`no completion marker ${path.basename(marker)}`);
137
+ const list = b.provisioningList({ bootstrap: readJson(path.join(O, 'prompts', 'bootstrap.json'), {}), registry: readJson(path.join(O, 'agents', 'invocation-profiles.json'), {}), templateExists: (t) => fs.existsSync(tpl(t)) });
138
+ const multi = (t) => Object.prototype.hasOwnProperty.call(readJson(tpl(t), {}), 'bodies');
139
+ const required = list.filter((i) => prompts.includes(i) && !multi(i));
140
+ const has = (i) => fs.existsSync(path.join(C, `${i}.json`)) || (variant && fs.existsSync(path.join(C, `${i}.${variant}.json`)));
141
+ const missing = required.filter((i) => !has(i));
142
+ if (missing.length) problems.push(`${missing.length} of ${required.length} generated prompt(s) have no per-prompt entry, so any template change rebuilds them all (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''})`);
143
+ return res('cache', !problems.length, problems.length ? problems.join('; ') : `${codeline}: ${prompts.length} cached prompt(s), ${b.MINTED_FILES.length} minted file(s), ${required.length} per-prompt entries, marker present`,
144
+ { required: required.length, missing });
145
+ }
146
+
147
+ // ── 4. cost ──────────────────────────────────────────────────────────────────────────────────────────────────────────
148
+ /** Every model a provider set's ladders name (startModel, model, models, from/to), from its own declaration. */
149
+ function ladderModels(install, set) {
150
+ const d = readJson(path.join(orchOf(install), 'config', `llm-defaults.${set}.json`), {});
151
+ const out = new Set();
152
+ const walk = (v, key) => {
153
+ if (Array.isArray(v)) { for (const x of v) walk(x, key); return; }
154
+ if (v && typeof v === 'object') { for (const [k, x] of Object.entries(v)) walk(x, k); return; }
155
+ if (typeof v === 'string' && /model|^from$|^to$/i.test(key || '')) out.add(v);
156
+ };
157
+ walk(d.ladders || {}, '');
158
+ return [...out].sort();
159
+ }
160
+ function cost({ install, set, tmpDir }) {
161
+ const problems = [];
162
+ // (a) the ledger writer records a priced call
163
+ const ledger = path.join(tmpDir, 'prelaunch-ledger.jsonl');
164
+ fs.mkdirSync(tmpDir, { recursive: true }); fs.rmSync(ledger, { force: true });
165
+ try {
166
+ const { appendLedgerRecord } = require(path.join(scriptsOf(install), 'lib', 'cost-emitter.js'));
167
+ appendLedgerRecord({ ledgerFile: ledger, agent: 'prelaunch-cost-probe', phase: 'prelaunch', model: 'prelaunch-probe', cost: { costUsd: 0.0123, tokensIn: 10, tokensOut: 5 } });
168
+ const rows = fs.readFileSync(ledger, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
169
+ if (!(rows.length === 1 && Number(rows[0].task_cost_usd) === 0.0123)) problems.push(`the ledger writer did not record the probe's cost (${JSON.stringify(rows[0] || null).slice(0, 160)})`);
170
+ } catch (e) { problems.push(`the ledger writer failed: ${e.message}`); }
171
+ // (b) every ladder model is priced, so a call whose session never reports its cost (a killed leg) can still be priced
172
+ const prices = (readJson(path.join(orchOf(install), 'config', 'model-pricing.json'), {}) || {}).models || {};
173
+ const models = ladderModels(install, set);
174
+ const unpriced = models.filter((m) => !prices[m] || prices[m].inputPerMillion == null || prices[m].outputPerMillion == null);
175
+ if (!models.length) problems.push(`the ${set} set's ladders name no model`);
176
+ if (unpriced.length) problems.push(`no price in config/model-pricing.json for ladder model(s) ${unpriced.join(', ')} — a leg killed before its session reports a cost cannot be priced`);
177
+ return res('cost', !problems.length, problems.length ? problems.join('; ') : `the ledger records a priced call; all ${models.length} ladder model(s) priced`, { models, unpriced });
178
+ }
179
+
180
+ // ── 5. alone ─────────────────────────────────────────────────────────────────────────────────────────────────────────
181
+ function ancestors(pid, procDir = '/proc') {
182
+ const out = new Set();
183
+ let p = String(pid);
184
+ for (let i = 0; i < 64 && p && p !== '0'; i += 1) {
185
+ out.add(p);
186
+ let st = ''; try { st = fs.readFileSync(`${procDir}/${p}/stat`, 'utf8'); } catch { break; }
187
+ p = st.slice(st.lastIndexOf(')') + 2).split(' ')[1];
188
+ }
189
+ return out;
190
+ }
191
+ const cmdline = (pid, procDir = '/proc') => { try { return fs.readFileSync(`${procDir}/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean); } catch { return []; } };
192
+ function alone({ install, selfPid = process.pid, procDir = '/proc' }) {
193
+ const mine = ancestors(selfPid, procDir);
194
+ const inst = path.resolve(install);
195
+ const others = [];
196
+ let pids = []; try { pids = fs.readdirSync(procDir).filter((n) => /^\d+$/.test(n)); } catch { pids = []; }
197
+ for (const p of pids) {
198
+ if (mine.has(p)) continue;
199
+ const a = cmdline(p, procDir); if (!a.length) continue;
200
+ const s = a.join(' ');
201
+ let cwd = ''; try { cwd = fs.readlinkSync(`${procDir}/${p}/cwd`); } catch { cwd = ''; }
202
+ // a pipeline process of THIS install: anything running its scripts or a run against it; my own shell is excluded above
203
+ if ((s.includes(inst) || cwd.startsWith(inst)) && /\b(node|bash|sh|python3?|claude)\b/.test(a[0] + ' ' + (a[1] || ''))) others.push(`${p} ${s.slice(0, 140)}`);
204
+ }
205
+ return res('alone', !others.length, others.length ? `${others.length} other process(es) are working against ${inst}: ${others.slice(0, 3).join(' | ')}` : `nothing else is working against ${inst}`, { others });
206
+ }
207
+
208
+ // ── 6. memory ────────────────────────────────────────────────────────────────────────────────────────────────────────
209
+ function memory({ needMb, reserveMb = 3072, meminfo = '/proc/meminfo' }) {
210
+ const m = (fs.readFileSync(meminfo, 'utf8').match(/^MemAvailable:\s+(\d+) kB/m) || [])[1];
211
+ if (!m) return res('memory', false, `cannot read MemAvailable from ${meminfo}`);
212
+ const avail = Math.floor(Number(m) / 1024);
213
+ const ok = avail - reserveMb >= needMb;
214
+ return res('memory', ok, `${avail} MB available, ${reserveMb} MB kept for the host → ${avail - reserveMb} MB for the run; it needs ${needMb} MB`, { availMb: avail });
215
+ }
216
+
217
+ // ── 7. no-timeout ────────────────────────────────────────────────────────────────────────────────────────────────────
218
+ // operator: no timeout may abort an agent. A leg time limit the launcher declares, or a `timeout` wrapper anywhere above
219
+ // this process, is a clock that can cut an agent off mid-answer.
220
+ function noTimeout({ legTimeoutSecs = 0, selfPid = process.pid, procDir = '/proc' }) {
221
+ const clocks = [];
222
+ if (Number(legTimeoutSecs) > 0) clocks.push(`the launcher declares a ${legTimeoutSecs}s leg time limit`);
223
+ for (const p of ancestors(selfPid, procDir)) { const a = cmdline(p, procDir); if (a.length && path.basename(a[0]) === 'timeout') clocks.push(`process ${p} runs under '${a.slice(0, 3).join(' ')}'`); }
224
+ return res('no-timeout', !clocks.length, clocks.length ? clocks.join('; ') : 'no time limit above the launch', { clocks });
225
+ }
226
+
227
+ // ── 8. checklist ─────────────────────────────────────────────────────────────────────────────────────────────────────
228
+ // The operator's 12 items (2026-10-09). At pre-launch an item is GREEN or RED from what can be read now, or NOT-YET with
229
+ // the phase that decides it; any RED stops the launch. "by design skipped" is not a state.
230
+ function checklist({ env, results }) {
231
+ const by = Object.fromEntries(results.map((r) => [r.name, r]));
232
+ const man = env.EPAM_ESTIMATION_MANIFEST;
233
+ const it = (n, item, state, evidence) => ({ n, item, state, evidence });
234
+ const pf = by.preflight || {};
235
+ const items = [
236
+ it(1, 'Estimation manifest injected', !man ? 'RED' : (fs.existsSync(man) ? 'GREEN' : 'RED'), man ? `EPAM_ESTIMATION_MANIFEST=${man}${fs.existsSync(man) ? '' : ' (not found)'}` : 'EPAM_ESTIMATION_MANIFEST unset'),
237
+ it(2, 'Agent generation done agentically (reused roster in good order)', by.seed && by.cache ? (by.seed.ok && by.cache.ok ? 'GREEN' : 'RED') : 'NOT-YET', by.seed ? `${by.seed.detail}; ${by.cache ? by.cache.detail : ''}` : 'no seed to judge: decided at pause 1'),
238
+ it(3, 'Prompt generation done agentically (reused prompts in good order)', by.cache ? (by.cache.ok ? 'GREEN' : 'RED') : 'NOT-YET', by.cache ? by.cache.detail : 'decided at pause 1'),
239
+ it(4, 'Plan solid (checked against git facts)', 'NOT-YET', 'decided at pause 2 (plan of record)'),
240
+ it(5, 'Codelines correct', 'NOT-YET', 'decided at ingest (discovery + classification) and pause 1'),
241
+ it(6, 'Self-heal in place for ALL agents', pf.ok === false && /self-heal|routing/i.test(pf.detail || '') ? 'RED' : (pf.ok ? 'GREEN' : 'NOT-YET'), pf.ok ? 'pre-flight: self-heal observability routing passed' : (pf.detail || 'pre-flight not run')),
242
+ it(7, 'Ladder in place for ALL agents', pf.ok === false && /ladder|rung/i.test(pf.detail || '') ? 'RED' : (pf.ok ? 'GREEN' : 'NOT-YET'), pf.ok ? 'pre-flight: every assigned model is a rung of a declared ladder' : (pf.detail || 'pre-flight not run')),
243
+ it(8, 'Pause 1 and pause 2 working', env.EPAM_PAUSE_AFTER_AGENT_MINT === '1' && env.EPAM_PAUSE_BEFORE_WRITER === '1' ? 'GREEN' : 'RED', `EPAM_PAUSE_AFTER_AGENT_MINT=${env.EPAM_PAUSE_AFTER_AGENT_MINT || ''} EPAM_PAUSE_BEFORE_WRITER=${env.EPAM_PAUSE_BEFORE_WRITER || ''}`),
244
+ it(9, 'Cost collection and reporting working', by.cost ? (by.cost.ok ? 'GREEN' : 'RED') : 'NOT-YET', by.cost ? by.cost.detail : 'not checked'),
245
+ it(10, 'Worktrees up and working', 'NOT-YET', 'decided when the lanes open their worktrees'),
246
+ it(11, 'Langfuse cassette recording working', by.langfuse ? (by.langfuse.ok ? 'GREEN' : 'RED') : 'NOT-YET', by.langfuse ? by.langfuse.detail : 'not checked'),
247
+ it(12, 'Dashboards up, controlled by eleventy', pf.ok === false && /dashboard/i.test(pf.detail || '') ? 'RED' : (pf.ok ? 'GREEN' : 'NOT-YET'), pf.ok ? 'pre-flight: dashboard is up' : (pf.detail || 'pre-flight not run')),
248
+ ];
249
+ const red = items.filter((i) => i.state === 'RED');
250
+ return res('checklist', !red.length, `${items.filter((i) => i.state === 'GREEN').length} green, ${red.length} red, ${items.filter((i) => i.state === 'NOT-YET').length} not yet${red.length ? ` — red: ${red.map((i) => i.n).join(', ')}` : ''}`, { items });
251
+ }
252
+
253
+ /** The whole pre-launch, in order; every check runs (a report lists every red at once), the verdict is all-green. */
254
+ function prelaunch(o) {
255
+ const results = [];
256
+ if (o.preflight !== false) results.push(preflight(o));
257
+ results.push(langfuse({ install: o.install, env: o.env, runId: o.runId, outDir: o.outDir }));
258
+ if (o.ticket) results.push(seed({ install: o.install, project: o.project, ticket: o.ticket, runsRoots: o.runsRoots || [] }));
259
+ if (o.codeline) results.push(cache({ install: o.install, project: o.project, codeline: o.codeline }));
260
+ results.push(cost({ install: o.install, set: o.set || o.env.EPAM_PROVIDER_SET || 'claude', tmpDir: o.outDir }));
261
+ results.push(alone({ install: o.install }));
262
+ results.push(memory({ needMb: Number(o.needMb || 4096) }));
263
+ results.push(noTimeout({ legTimeoutSecs: o.legTimeoutSecs || 0 }));
264
+ results.push(checklist({ env: o.env, results }));
265
+ const report = { at: new Date().toISOString(), runId: o.runId, install: o.install, project: o.project, ticket: o.ticket || null,
266
+ ok: results.every((r) => r.ok), results: results.map(({ log, ...r }) => r) };
267
+ if (o.outDir) { fs.mkdirSync(o.outDir, { recursive: true }); fs.writeFileSync(path.join(o.outDir, 'prelaunch.json'), `${JSON.stringify(report, null, 2)}\n`);
268
+ const pf = results.find((r) => r.name === 'preflight'); if (pf && pf.log) fs.writeFileSync(path.join(o.outDir, 'prelaunch-preflight.log'), pf.log); }
269
+ return report;
270
+ }
271
+
272
+ module.exports = { prelaunch, prelaunchCli, preflight, langfuse, langfuseEnv, seed, cache, cost, ladderModels, alone, memory, noTimeout, checklist, ancestors };
273
+
274
+ /**
275
+ * THE COMMAND, for every pipeline:
276
+ * prelaunch.js --install <i> --project <p> --run-id <id> --out <dir> [--ticket <KEY> --runs <runs dir>[,…]]
277
+ * [--codeline <cl>] [--set <provider set>] [--need-mb <n>] [--leg-timeout-secs <s>] [--runner <launcher>]
278
+ * [--prd <file>] [--preflight no]
279
+ * Prints each check and the verdict; writes <out>/prelaunch.json; exit 0 all green, 1 any red, 64 usage.
280
+ */
281
+ function prelaunchCli(argv, env = process.env) {
282
+ const a = {}; for (let i = 0; i < argv.length; i += 2) a[argv[i].replace(/^--/, '')] = argv[i + 1];
283
+ for (const k of ['install', 'project', 'run-id', 'out']) if (!a[k]) { process.stderr.write(`prelaunch: --${k} is required\n`); return 64; }
284
+ const r = prelaunch({ install: path.resolve(a.install), project: a.project, runId: a['run-id'], outDir: path.resolve(a.out), env,
285
+ ticket: a.ticket, runsRoots: a.runs ? a.runs.split(',').map((d) => path.resolve(d)) : [], codeline: a.codeline,
286
+ set: a.set, needMb: a['need-mb'], legTimeoutSecs: a['leg-timeout-secs'], runner: a.runner, prd: a.prd, preflight: a.preflight !== 'no' });
287
+ process.stdout.write('── Pre-launch (amsd-pipeline-shared) ─────────────────────────────────────────\n');
288
+ for (const c of r.results) process.stdout.write(` ${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}\n`);
289
+ const cl = r.results.find((c) => c.name === 'checklist');
290
+ if (cl) for (const it of cl.items) process.stdout.write(` ${String(it.n).padStart(2)}. ${it.state.padEnd(7)} ${it.item} — ${it.evidence}\n`);
291
+ process.stdout.write(r.ok ? ` PRE-LAUNCH GREEN — ${path.join(a.out, 'prelaunch.json')}\n` : ` ✗ PRE-LAUNCH RED — the launch stops before any model call (${path.join(a.out, 'prelaunch.json')})\n`);
292
+ return r.ok ? 0 : 1;
293
+ }
294
+ if (require.main === module) process.exit(prelaunchCli(process.argv.slice(2)));