amsd-pipeline-workflow 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -1,20 +1,37 @@
1
1
  # amsd-pipeline-workflow
2
2
 
3
3
  The AMSD pipeline driven as Claude workflows. It runs **against an installed AMSD pipeline** (it calls the pipeline's
4
- own scripts) and is installed by the pipeline's installer.
4
+ own scripts) with the shared components (`amsd-pipeline-shared`) installed into it. Neither the pipeline nor its
5
+ installer is changed.
5
6
 
6
7
  ## Install
7
8
 
9
+ Three steps, in this order, into one install:
10
+
8
11
  ```bash
9
- npx amsd-pipeline@<version> --dest <dir> --ref v<version> --no-docker --workflows
12
+ npx amsd-pipeline@2.0.80 --dest <dir> --ref v2.0.80 --no-docker # 1. the pipeline, as always
13
+ npx amsd-pipeline-shared@<version> install --into <dir> # 2. the shared gateway, reviewer, messages
14
+ npx amsd-pipeline-workflow@<version> install --into <dir> [--codeline-cache <cache>] # 3. the workflows
10
15
  ```
11
16
 
12
- `--workflows` installs the `amsd-pipeline-workflow` version the pipeline release pins
13
- (`orchestrations/config/claude-workflows.json`) into `<dir>/claude-workflows/`. `--workflows <file.tgz>` installs a
14
- local package instead (for testing a build before it is published). `--check` verifies it; `--uninstall` removes it.
17
+ Step 3 writes only `<dir>/claude-workflows/` and, with `--codeline-cache`, each codeline's cached roster and prompts
18
+ (`<cache>/orchestrations/projects/<project>/.prompt-cache/<codeline>/` and its `.complete-<codeline>[.brownfield]` marker)
19
+ into the same project, so the pipeline reuses them instead of minting and generating again. The pipeline still decides at
20
+ launch whether each is current. Reinstalling replaces `claude-workflows/`. Without step 2 the workflows still run, but
21
+ messaging is off (said at generation) and the gateway's hub keeps 2.0.80's defect B56 (a failed call reads as success).
15
22
 
16
23
  Needs: Node 20+, the `claude` CLI, `unshare` (util-linux) and `systemd-run --user` (the memory bound, `bin/bounded`).
17
24
 
25
+ ## What every agent call gets
26
+
27
+ - **Retries, the ladder and self-heal, for every agent** (`generator/gateway.js`, `hooks/step-call-failed.sh`): a call with
28
+ no usable answer goes to the pipeline's own decisions — the failure on the agent's ladder, its next rung, the
29
+ previous-refusal block, the KB episode — and is asked again, up to `EPAM_CALL_MAX_ATTEMPTS`; only then is it no answer.
30
+ The Codeline, Agents stage runs the pipeline's own hub, which does the same.
31
+ - **Agent-to-agent messages**: any agent may answer with `messages` ([{to, type, subject, text}]); "router" (or no one) goes
32
+ to the stage's router — the reviewer in the Writer, the coordinator in Spec. Delivered in the recipient's next prompt,
33
+ on the pipeline's bus (`hooks/messages-out.js`), no extra model call.
34
+
18
35
  ## Run
19
36
 
20
37
  ```bash
@@ -25,6 +42,15 @@ Needs: Node 20+, the `claude` CLI, `unshare` (util-linux) and `systemd-run --use
25
42
  Langfuse tracing follows the install's own settings (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`): each agent call is
26
43
  sent as one generation through the pipeline's own emitter.
27
44
 
45
+ ## Live runs: the run note and the abort
46
+
47
+ `tools/rehearse.js --live yes` (and `--start live`, `--leg 2|3`, `--tag`) keeps one note per run in the operator's vault
48
+ (`pipeline/live/<run>.md`, `tools/live-tracker.js`), refreshed every 2 minutes while a leg runs: legs, cost by stage,
49
+ the Langfuse session id (proven by a launch trace read back before each leg), the lanes, the messages, and the six
50
+ **abort-class checks** — retries, ladder, self-heal, costing, LLM settings, reviewer verdicts. A red one aborts the leg at
51
+ once (`tools/abort-watch.sh` kills the leg's process group) and the leg is recorded ABORTED with the reason.
52
+ At £0, `--plant writer,reviewer` makes those agents give no answer once per lane, to watch the gateway climb.
53
+
28
54
  ## Version
29
55
 
30
56
  `amsd-pipeline-workflow version`. `package.json` `amsdPipeline.tested` names the pipeline release it was tested against.
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // amsd-pipeline-workflow — the AMSD pipeline as Claude workflows. Installed by the pipeline's own installer
4
- // (install.sh --workflows) into <install>/claude-workflows/, which writes install.json there naming the install.
3
+ // amsd-pipeline-workflow — the AMSD pipeline as Claude workflows, installed INTO an existing pipeline install with no
4
+ // change to the pipeline (operator, 2026-10-18).
5
+ // amsd-pipeline-workflow install --into <pipeline install> [--codeline-cache <dir>]
6
+ // writes <install>/claude-workflows/ (this package's published files, and install.json naming the install) and,
7
+ // with --codeline-cache, each codeline's cache entry from <dir>/orchestrations/projects/<p>/.prompt-cache/ — its
8
+ // <codeline>/ folder (the minted roster and the prompts) and its .complete-<codeline>[.brownfield] marker — into the
9
+ // same project of the install, so the pipeline's own reuse step restores them instead of minting and generating
10
+ // again. Nothing else in the install is written.
5
11
  // amsd-pipeline-workflow run <driver/run.js arguments> — --install defaults to the install this was installed into
6
12
  // amsd-pipeline-workflow version
7
13
  const fs = require('fs');
@@ -9,9 +15,51 @@ const path = require('path');
9
15
  const { spawnSync } = require('child_process');
10
16
 
11
17
  const ROOT = path.resolve(__dirname, '..');
18
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
12
19
  const [cmd, ...rest] = process.argv.slice(2);
13
- if (cmd === 'version') { process.stdout.write(`${require(path.join(ROOT, 'package.json')).version}\n`); process.exit(0); }
14
- if (cmd !== 'run') { process.stderr.write('usage: amsd-pipeline-workflow run <driver arguments> | version\n'); process.exit(64); }
20
+ const fail = (m) => { process.stderr.write(`amsd-pipeline-workflow: ${m}\n`); process.exit(1); };
21
+ const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
22
+
23
+ if (cmd === 'version') { process.stdout.write(`${PKG.version}\n`); process.exit(0); }
24
+
25
+ if (cmd === 'install') {
26
+ const into = opt('--into'); const cacheDir = opt('--codeline-cache');
27
+ if (!into) fail('install needs --into <pipeline install>');
28
+ const inst = path.resolve(into);
29
+ if (!fs.existsSync(path.join(inst, 'orchestrations', 'scripts')) || !fs.existsSync(path.join(inst, 'orchestrations', 'projects'))) fail(`${inst} is not a pipeline install (no orchestrations/scripts and orchestrations/projects)`);
30
+ if (cacheDir && !fs.existsSync(path.join(path.resolve(cacheDir), 'orchestrations', 'projects'))) fail(`${cacheDir} holds no codeline cache (no orchestrations/projects)`);
31
+ // the package: exactly its published files (package.json "files"), into a new folder, then swapped in
32
+ const dest = path.join(inst, 'claude-workflows'); const tmp = `${dest}.new`;
33
+ fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true });
34
+ for (const f of [...PKG.files, 'package.json']) {
35
+ const src = path.join(ROOT, f.replace(/\/$/, ''));
36
+ if (fs.existsSync(src)) fs.cpSync(src, path.join(tmp, f.replace(/\/$/, '')), { recursive: true });
37
+ }
38
+ fs.writeFileSync(path.join(tmp, 'install.json'), `${JSON.stringify({ install: inst }, null, 2)}\n`);
39
+ fs.rmSync(dest, { recursive: true, force: true }); fs.renameSync(tmp, dest);
40
+ process.stdout.write(`claude workflows ${PKG.version} → ${dest}\n`);
41
+ if (cacheDir) {
42
+ const projects = path.join(path.resolve(cacheDir), 'orchestrations', 'projects');
43
+ let n = 0;
44
+ for (const p of fs.readdirSync(projects)) {
45
+ const src = path.join(projects, p, '.prompt-cache'); const proj = path.join(inst, 'orchestrations', 'projects', p);
46
+ if (!fs.existsSync(src) || !fs.existsSync(proj)) continue;
47
+ for (const m of fs.readdirSync(src).filter((x) => x.startsWith('.complete-'))) {
48
+ const cl = m.replace(/^\.complete-/, '').replace(/\.brownfield$/, '');
49
+ if (!fs.existsSync(path.join(src, cl))) continue;
50
+ const dst = path.join(proj, '.prompt-cache'); fs.mkdirSync(dst, { recursive: true });
51
+ fs.rmSync(path.join(dst, cl), { recursive: true, force: true });
52
+ fs.cpSync(path.join(src, cl), path.join(dst, cl), { recursive: true });
53
+ fs.copyFileSync(path.join(src, m), path.join(dst, m));
54
+ process.stdout.write(`codeline cache: ${p} / ${cl}\n`); n += 1;
55
+ }
56
+ }
57
+ if (!n) process.stdout.write(`codeline cache: ${cacheDir} holds none to carry\n`);
58
+ }
59
+ process.exit(0);
60
+ }
61
+
62
+ if (cmd !== 'run') { process.stderr.write('usage: amsd-pipeline-workflow install --into <pipeline install> [--codeline-cache <dir>] | run <driver arguments> | version\n'); process.exit(64); }
15
63
  const args = [...rest];
16
64
  const linked = path.join(ROOT, 'install.json');
17
65
  if (!args.includes('--install') && fs.existsSync(linked)) args.unshift('--install', JSON.parse(fs.readFileSync(linked, 'utf8')).install);
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // THE AGENTS' HOOKS, IN A LIVE SESSION (2026-10-18): the real Workflow tool does not run a hook declared in a workflow
4
+ // agent's frontmatter (tools/hook-probe.js: never fired), but does run one declared in the session's project settings, and
5
+ // its input names the calling agent (agent_type). driver/runtime.js liveCommand installs this one PreToolUse hook there; it
6
+ // runs the calling agent's own hook command — exactly as its definition declares it — with the same input, and passes its
7
+ // output and exit code back unchanged. An agent with no hook for this tool passes through (no output, exit 0).
8
+ // node driver/hook-dispatch.js <map.json> map: { "<agent_type>": [{ matcher, command }...] }
9
+ const fs = require('fs');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ let input = '';
13
+ process.stdin.on('data', (c) => { input += c; });
14
+ process.stdin.on('end', () => {
15
+ let ev = {};
16
+ try { ev = JSON.parse(input); } catch { process.exit(0); }
17
+ const map = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
18
+ const hooks = (map[ev.agent_type] || []).filter((h) => !h.matcher || new RegExp(`^(?:${h.matcher})$`).test(ev.tool_name || ''));
19
+ for (const h of hooks) {
20
+ const r = spawnSync('bash', ['-c', h.command], { input, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
21
+ if (r.stdout) process.stdout.write(r.stdout);
22
+ if (r.stderr) process.stderr.write(r.stderr);
23
+ if (r.status !== 0) process.exit(r.status === null ? 1 : r.status);
24
+ }
25
+ process.exit(0);
26
+ });
package/driver/run.js CHANGED
@@ -72,6 +72,10 @@ async function main(a) {
72
72
  const ck = path.join(a.install, 'orchestrations', 'projects', a.project, 'runs', a['recorded-run'] || a.run, 'lanes', a['recorded-lane'] || Object.keys(map)[0], 'checkpoint');
73
73
  for (const f of ['roster.json', 'agent-profiles.json', 'profiles.json']) if (fs.existsSync(path.join(ck, f))) fs.copyFileSync(path.join(ck, f), path.join(project, f));
74
74
  }
75
+ // the run's profiles: AGENT_PROFILES_FILE defaults to the agents folder's (run-agent-orchestration.sh:404) — the run's
76
+ // own copy of it when no checkpoint gave one (a fresh install's project holds none); the install's is never written
77
+ const agentsProfiles = path.join(a.install, 'orchestrations', 'agents', 'profiles.json');
78
+ if (!fs.existsSync(path.join(project, 'profiles.json')) && fs.existsSync(agentsProfiles)) fs.copyFileSync(agentsProfiles, path.join(project, 'profiles.json'));
75
79
  const er = spawnSync('bash', [path.join(__dirname, 'env.sh'), a.install, a.project, a.run, runDir, set], { encoding: 'utf8', env: baseEnv, maxBuffer: 64 * 1024 * 1024 });
76
80
  if (er.status !== 0) throw new Error(`the run's environment did not load: ${er.stderr}`);
77
81
  let env = { ...JSON.parse(er.stdout), AGENT_PROFILES_FILE: path.join(project, 'profiles.json'), PIPELINE_PROFILES: path.join(project, 'profiles.json'),
@@ -84,7 +88,7 @@ async function main(a) {
84
88
  recordedPrd: a.prd ? path.resolve(a.prd) : null, docsFrom: a['docs-from'] ? path.resolve(a['docs-from']) : null,
85
89
  // --agents-stage generated (decision 40): the Codeline, Agents workflow runs, from the PRD on disk as given (--prd, its
86
90
  // codelines where the run's discovery placed them), the discovery record and the documents a run fetched
87
- agentsStage: a['agents-stage'] || 'recorded', seedPrd: a.prd ? path.resolve(a.prd) : null,
91
+ agentsStage: a['agents-stage'] || 'recorded', seedPrd: a.prd ? path.resolve(a.prd) : null, agentsIssues: a['agents-issues'] ? path.resolve(a['agents-issues']) : null,
88
92
  agentsDiscovery: a['agents-discovery'] ? path.resolve(a['agents-discovery']) : null, agentsDocuments: a['agents-documents'] ? path.resolve(a['agents-documents']) : null,
89
93
  rewriteCodelines: (f, m) => rewriteCodelines(f, m),
90
94
  liveApproved: !!a['live-approved'], launchEnv: launch.env, standin: null };
@@ -116,8 +120,10 @@ async function main(a) {
116
120
  if (env.EPAM_BROWNFIELD === '1' && !env.JIRA_CODELINE_ROOT) missing.push('JIRA_CODELINE_ROOT');
117
121
  if (missing.length) return outcome({ status: 'failed', exit: 1, reason: `[jira] Missing required env vars: ${missing.join(' ')}` });
118
122
  // :514–529 — ingest reads Jira (a client system): the driver only takes the skip, with the PRD on disk
119
- if (env.EPAM_SKIP_JIRA_INGEST !== '1') return outcome({ status: 'failed', exit: 1, reason: 'ingest reads Jira: the driver runs only with EPAM_SKIP_JIRA_INGEST=1 (the PRD on disk); a live ingest needs the operator\'s yes' });
120
- if (!ctx.recordedPrd && !fs.existsSync(env.PRD_FILE)) return outcome({ status: 'failed', exit: 1, reason: `[jira] resume asked to skip ingest but no PRD exists at ${env.PRD_FILE}` });
123
+ // a LIVE START (--agents-issues): the story was fetched before the run (GET, operator's yes 2026-10-18) and the stage's own
124
+ // ingest, in its sandbox, builds the PRD from it — the driver itself reads no client system
125
+ if (!ctx.agentsIssues && env.EPAM_SKIP_JIRA_INGEST !== '1') return outcome({ status: 'failed', exit: 1, reason: 'ingest reads Jira: the driver runs only with EPAM_SKIP_JIRA_INGEST=1 (the PRD on disk) or a live start (--agents-issues, the story fetched before the run)' });
126
+ if (!ctx.agentsIssues && !ctx.recordedPrd && !fs.existsSync(env.PRD_FILE)) return outcome({ status: 'failed', exit: 1, reason: `[jira] resume asked to skip ingest but no PRD exists at ${env.PRD_FILE}` });
121
127
  if (ctx.recordedPrd) {
122
128
  fs.copyFileSync(ctx.recordedPrd, env.PRD_FILE);
123
129
  rewriteCodelines(env.PRD_FILE, map);
package/driver/runtime.js CHANGED
@@ -44,9 +44,32 @@ function liveCommand({ name, script, agentsJson, args, sessionDir, env, cwd }) {
44
44
  const wfDir = path.join(home, '.claude', 'workflows');
45
45
  fs.mkdirSync(agentsDir, { recursive: true }); fs.mkdirSync(wfDir, { recursive: true });
46
46
  const defs = JSON.parse(fs.readFileSync(agentsJson, 'utf8'));
47
- for (const [n, d] of Object.entries(defs)) fs.writeFileSync(path.join(agentsDir, `${n}.md`), toMarkdown(n, d));
47
+ // THE AGENTS' HOOKS RUN FROM THE SESSION'S PROJECT SETTINGS (driver/hook-dispatch.js): the real Workflow tool does not run
48
+ // a hook in a workflow agent's frontmatter, and does run one in <cwd>/.claude/settings.json, whose input names the agent
49
+ // (tools/hook-probe.js, 2026-10-18). The frontmatter is written without them, so nothing could ever run twice.
50
+ const map = {}; const matchers = new Set(); let timeout = 0;
51
+ for (const [n, d] of Object.entries(defs)) {
52
+ for (const g of (((d.hooks || {}).PreToolUse) || [])) for (const h of (g.hooks || [])) {
53
+ (map[n] = map[n] || []).push({ matcher: g.matcher || '', command: h.command });
54
+ if (g.matcher) matchers.add(g.matcher);
55
+ timeout = Math.max(timeout, Number(h.timeout || 0));
56
+ }
57
+ const { hooks: _h, ...noHooks } = d;
58
+ fs.writeFileSync(path.join(agentsDir, `${n}.md`), toMarkdown(n, noHooks));
59
+ }
60
+ const mapFile = path.join(home, '.claude', 'hook-map.json'); fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
61
+ const settingsFile = path.join(home, '.claude', 'settings.json');
62
+ let settings = {}; try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch { settings = {}; }
63
+ const dispatch = `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(__dirname, 'hook-dispatch.js'))} ${JSON.stringify(mapFile)}`;
64
+ const pre = (((settings.hooks || {}).PreToolUse) || []).filter((g) => !(g.hooks || []).some((h) => String(h.command || '').includes('hook-dispatch.js')));
65
+ if (Object.keys(map).length) pre.push({ matcher: [...matchers].join('|') || '.*', hooks: [{ type: 'command', command: dispatch, ...(timeout ? { timeout } : {}) }] });
66
+ settings.hooks = { ...(settings.hooks || {}), PreToolUse: pre };
67
+ fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
48
68
  fs.copyFileSync(script, path.join(wfDir, `${name}.js`));
49
- const prompt = `Run the workflow "${name}" with these args, exactly, and return its result as JSON: ${JSON.stringify(args || {})}`;
69
+ // THE RUNNER WAITS FOR ITS WORKFLOW (LIVE-AMSD-2974-3, 2026-10-18: the runner answered "It's still running in the
70
+ // background…" and the session exited, taking the Spec workflow down with it): it answers only with the finished result
71
+ // the session itself waits for its workflow (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 below); the runner has no task-wait tool
72
+ const prompt = `Run the workflow "${name}" with these args, exactly: ${JSON.stringify(args || {})}. When it has finished, reply with its result as JSON and nothing else.`;
50
73
  const claude = (env && env.CLAUDE_BIN) || 'claude';
51
74
  const argv = [path.join(__dirname, '..', 'bin', 'bounded'), claude, '-p', prompt, '--output-format', 'json',
52
75
  '--setting-sources', 'project', '--permission-mode', 'bypassPermissions'];
@@ -73,17 +96,41 @@ function pipelineToolEnv(env) {
73
96
  try { return require(f).commandEnv(); } catch { return {}; }
74
97
  }
75
98
 
99
+ /** The workflow's result as the session wrote it: JSON; or the last ```json fence in its closing message, prose around it
100
+ * (the live CLI's answer of 2026-10-18: "The workflow finished successfully … Result: ```json {…}```"); null when there
101
+ * is no JSON object to read. */
102
+ function resultJson(text) {
103
+ const t = String(text || '').trim();
104
+ const tryParse = (x) => { try { const v = JSON.parse(x); return v && typeof v === 'object' ? v : null; } catch { return null; } };
105
+ const whole = tryParse(t);
106
+ if (whole) return whole;
107
+ const fences = [...t.matchAll(/```(?:json)?\s*\n([\s\S]*?)\n?```/g)].map((m) => m[1]);
108
+ for (let i = fences.length - 1; i >= 0; i -= 1) { const v = tryParse(fences[i].trim()); if (v) return v; }
109
+ return null;
110
+ }
111
+
76
112
  function runLive(opts) {
77
113
  const cmd = liveCommand(opts);
78
114
  if (process.env.EPAM_DRIVER_LIVE_APPROVED !== 'yes' || opts.approved !== true) {
79
115
  throw new Error(`live runtime refused: a live run spends tokens and needs the operator's yes (EPAM_DRIVER_LIVE_APPROVED=yes and --live-approved). It would run: (cd ${cmd.cwd} && ${cmd.argv.map((x) => JSON.stringify(x)).join(' ')})`);
80
116
  }
81
- const r = spawnSync(cmd.argv[0], cmd.argv.slice(1), { cwd: cmd.cwd, env: { ...process.env, ...(opts.env || {}) }, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
117
+ // NO IDLE CAP ON THE WAIT FOR THE WORKFLOW (Claude Code docs, headless "Background tasks at exit": a `-p` run "stops whatever
118
+ // is still running after 10 minutes of continuous idle waiting by default and drops its partial result … set
119
+ // CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS … to 0 to wait without one"): LIVE-AMSD-2974-3's Spec (16 min) and -4's first leg
120
+ // (11 min) were cut there. The session's stderr is kept beside it as evidence.
121
+ const r = spawnSync(cmd.argv[0], cmd.argv.slice(1), { cwd: cmd.cwd, env: { CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: '0', ...process.env, ...(opts.env || {}) }, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
122
+ try { fs.mkdirSync(opts.sessionDir, { recursive: true }); fs.writeFileSync(path.join(opts.sessionDir, `${opts.name}.stderr`), String(r.stderr || '')); } catch { /* evidence only */ }
82
123
  if (r.status !== 0) throw new Error(`the live session for ${opts.name} exited ${r.status}: ${(r.stderr || '').slice(-2000)}`);
83
124
  const out = JSON.parse(r.stdout);
84
- const res = typeof out.result === 'string' ? JSON.parse(out.result) : out.result;
85
- // the session's own cost figure (decision 41a): driver/session-cost.js finds its transcripts by this id
86
- return { ...res, __session: { sessionId: out.session_id, modelUsage: out.modelUsage || {}, totalCostUsd: out.total_cost_usd } };
125
+ // the session's own cost figure (decision 41a), taken BEFORE its answer is read: an answer that cannot be read must
126
+ // never lose what the session spent (found by tools/cost-probe.js, 2026-10-18). driver/session-cost.js finds its
127
+ // transcripts by this id.
128
+ const __session = { sessionId: out.session_id, modelUsage: out.modelUsage || {}, totalCostUsd: out.total_cost_usd };
129
+ const res = typeof out.result === 'string' ? resultJson(out.result) : out.result;
130
+ if (!res || typeof res !== 'object') {
131
+ return { status: 'failed', exit: 1, reason: `the ${opts.name} session's answer is not JSON: ${String(out.result).slice(0, 300)}`, __session };
132
+ }
133
+ return { ...res, __session };
87
134
  }
88
135
 
89
136
  async function runStandin({ script, agentsJson, args, env, standin, sessionDir }) {
@@ -36,6 +36,30 @@ function transcriptTokens(file) {
36
36
  return byModel;
37
37
  }
38
38
 
39
+ /** ONE AGENT CALL'S CONTENT, AS THE PIPELINE TRACES EACH OF ITS CALLS (lib/cost-emitter.js emitCostSnapshot: input =
40
+ * promptForTrace, the call's prompt; output = replyTextFrom, its reply; toolCalls = transcript-tool-calls.js
41
+ * toolCallsInTranscript, read from the call's own transcript). For a workflow agent: its prompt is the first user turn of its
42
+ * transcript, its reply the answer it gave (its StructuredOutput, else its last text), its tool calls by the pipeline's own
43
+ * reader. Operator, 2026-10-18: "I need all llm calls captured"; "Did you not read the code from the current pipeline?" */
44
+ function callContent(file, scriptsDir, agentName) {
45
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
46
+ const first = lines.find((d) => d.type === 'user' && d.message);
47
+ const asst = lines.filter((d) => d.type === 'assistant' && d.message && Array.isArray(d.message.content)).flatMap((d) => d.message.content);
48
+ const so = asst.filter((b) => b && b.type === 'tool_use' && b.name === 'StructuredOutput').pop();
49
+ const text = asst.filter((b) => b && b.type === 'text' && b.text).map((b) => b.text).pop();
50
+ // eslint-disable-next-line global-require, import/no-dynamic-require
51
+ const tx = require(path.join(scriptsDir, 'lib', 'transcript-tool-calls.js'));
52
+ const ts = lines.map((d) => d.timestamp).filter(Boolean);
53
+ // the agent's standing instructions: its definition in the session's folder (<cwd>/.claude/agents/<agent>.md, written by
54
+ // driver/runtime.js liveCommand) — sent to the model as its system prompt, not in the transcript
55
+ const cwd = (lines.find((d) => d.cwd) || {}).cwd; const agentType = (lines.find((d) => d.agentType) || {}).agentType || agentName;
56
+ let system = '';
57
+ try { const md = fs.readFileSync(path.join(cwd, '.claude', 'agents', `${agentType}.md`), 'utf8'); system = md.replace(/^---[\s\S]*?\n---\n+/, ''); } catch { system = ''; }
58
+ const prompt = first ? first.message.content : '';
59
+ return { input: system ? { system, prompt } : prompt, output: so ? JSON.stringify(so.input) : (text || ''), // text: the emitter's output is text
60
+ toolCalls: tx.toolCallsInTranscript(file), startedAt: ts[0] || null, endedAt: ts[ts.length - 1] || null };
61
+ }
62
+
39
63
  /** The session's folder under the Claude projects folder (found by its id: the folder name is Claude Code's own). */
40
64
  function sessionDir(projectsDir, sessionId) {
41
65
  if (!sessionId || !fs.existsSync(projectsDir)) return null;
@@ -51,7 +75,7 @@ function sessionEntries(projectsDir, sessionId) {
51
75
  const s = sessionDir(projectsDir, sessionId);
52
76
  if (!s) return null;
53
77
  const out = [];
54
- const push = (agent, label, file) => { for (const [model, tokens] of Object.entries(transcriptTokens(file))) out.push({ agent, label, model, tokens }); };
78
+ const push = (agent, label, file) => { for (const [model, tokens] of Object.entries(transcriptTokens(file))) out.push({ agent, label, model, tokens, file }); };
55
79
  push('workflow-runner', 'workflow-runner', s.main);
56
80
  const runs = path.join(s.dir, 'subagents', 'workflows');
57
81
  if (fs.existsSync(runs)) {
@@ -108,8 +132,11 @@ function recordSession(env, session, { stage, storyId, phase, projectsDir, log }
108
132
  rows.push(appendLedgerRecord({ ledgerFile: ledger, agent: e.agent, storyId, phase, model: e.model,
109
133
  cost: { costUsd: e.costUsd, tokensIn: e.tokens.in, tokensOut: e.tokens.out, tokensCached: e.tokens.cacheRead, tokensCacheCreate: e.tokens.cacheCreate,
110
134
  costIsEstimate: true, costUnknown: e.costUnknown } }));
111
- // traced as the pipeline's cost seam traces each call (lib/cost-emitter.js → lib/langfuse-emit.js); off without keys
112
- traces.push(emitGeneration({ agent: e.agent, storyId, phase, model: e.model, costUsd: e.costUsd, tokensIn: e.tokens.in, tokensOut: e.tokens.out,
135
+ // each agent call to Langfuse with its prompt, its answer and its tool calls, as the pipeline's cost seam traces each
136
+ // of its calls (lib/cost-emitter.js → lib/langfuse-emit.js); off without keys
137
+ const c = e.file ? callContent(e.file, env.SCRIPT_DIR, e.agent) : {};
138
+ traces.push(emitGeneration({ agent: e.agent, storyId, phase, model: e.model, startedAt: c.startedAt, endedAt: c.endedAt,
139
+ input: c.input, output: c.output, toolCalls: c.toolCalls, costUsd: e.costUsd, tokensIn: e.tokens.in, tokensOut: e.tokens.out,
113
140
  cacheRead: e.tokens.cacheRead, cacheCreate: e.tokens.cacheCreate, costIsEstimate: true }, { ...process.env, ...env }));
114
141
  }
115
142
  if (log) log(`[cost] ${stage}: ${rows.length} row(s), $${Number(session.totalCostUsd || 0).toFixed(4)} (the session's own figure; per call by tokens, estimated)`);
@@ -129,4 +156,4 @@ function recordSession(env, session, { stage, storyId, phase, projectsDir, log }
129
156
  }
130
157
  }
131
158
 
132
- module.exports = { recordSession, sessionEntries, allocate, transcriptTokens };
159
+ module.exports = { recordSession, sessionEntries, allocate, transcriptTokens, callContent };
package/driver/stages.js CHANGED
@@ -38,14 +38,22 @@ async function runCodelineAgents(ctx) {
38
38
  // the roster, briefs and prompts linked at the pause. Then the driver's own post-roster checkpoint and pause, as above.
39
39
  async function runGeneratedAgents(ctx) {
40
40
  const { env, log } = ctx;
41
- for (const k of ['seedPrd', 'agentsDiscovery']) if (!ctx[k]) return { status: 'failed', exit: 1, reason: `the generated Codeline, Agents stage needs --${k === 'seedPrd' ? 'prd' : 'agents-discovery'}` };
41
+ // LIVE START (--agents-issues, operator 2026-10-18: reuse only the agents and prompts): the issues the tracker returned
42
+ // for this run, fetched live before it; the stage's own ingest builds the PRD and runs codeline discovery. Otherwise the
43
+ // PRD on disk and a recorded discovery (decision 37).
44
+ const liveStart = !!ctx.agentsIssues;
45
+ if (!liveStart) for (const k of ['seedPrd', 'agentsDiscovery']) if (!ctx[k]) return { status: 'failed', exit: 1, reason: `the generated Codeline, Agents stage needs --${k === 'seedPrd' ? 'prd' : 'agents-discovery'} (or --agents-issues)` };
42
46
  const w1 = path.join(ctx.runDir, 'w1'); const out = path.join(w1, 'gen'); const state = path.join(w1, 'state'); const estate = path.join(w1, 'estate');
43
47
  fs.rmSync(w1, { recursive: true, force: true }); fs.mkdirSync(estate, { recursive: true });
44
48
  const root = env.JIRA_CODELINE_ROOT;
45
49
  if (!root) return { status: 'failed', exit: 1, reason: 'JIRA_CODELINE_ROOT is unset: the stage cannot place the codelines' };
46
50
  // the estate: a directory per codeline the discovery names under the root — this run's copy mounted over it, an empty
47
51
  // repository where the run has none (the prompts name only directories and paths)
48
- const disc = JSON.parse(fs.readFileSync(ctx.agentsDiscovery, 'utf8'));
52
+ // the estate: with a recorded discovery, its codelines; live, every codeline copy of this run (each where it lies, under the root)
53
+ // live: every codeline copy, placed at <root>/<its folder> inside the sandbox — the copy itself stays where it is, outside
54
+ // the root (a copy inside the root would be hidden by the estate laid over the root first)
55
+ const disc = liveStart ? { codelines: Object.entries(ctx.map).map(([name, p]) => ({ name, path: path.join(root, path.basename(p)) })) } : JSON.parse(fs.readFileSync(ctx.agentsDiscovery, 'utf8'));
56
+ for (const p of Object.values(ctx.map)) if (path.dirname(p) === root) return { status: 'failed', exit: 1, reason: `the codeline copy ${p} lies inside the codeline root ${root}: the stage's estate would hide it — keep copies outside the root` };
49
57
  const mounts = [];
50
58
  for (const c of disc.codelines || []) {
51
59
  if (path.dirname(c.path) !== root) return { status: 'failed', exit: 1, reason: `discovery places ${c.name} at ${c.path}, outside the codeline root ${root}` };
@@ -55,15 +63,16 @@ async function runGeneratedAgents(ctx) {
55
63
  }
56
64
  const first = (disc.codelines || []).map((c) => ctx.map[c.name]).find(Boolean);
57
65
  if (!first) return { status: 'failed', exit: 1, reason: 'no codeline the discovery names has a copy in --codeline-map' };
58
- const issues = path.join(w1, 'issues.json'); fs.writeFileSync(issues, '[]\n'); // the ingest is skipped (decision 37)
66
+ const issues = path.join(w1, 'issues.json');
67
+ if (liveStart) fs.copyFileSync(ctx.agentsIssues, issues); else fs.writeFileSync(issues, '[]\n'); // decision 37: no ingest
59
68
  gen('generate-agents.js', ['--install', ctx.install, '--project', ctx.project, '--run', ctx.run, '--out', out, '--state', state,
60
69
  '--codeline-root', root, '--estate', estate, '--codeline', first, '--issues', issues, '--set', ctx.set, ...mounts,
61
- '--seed-prd', ctx.seedPrd, '--seed-log', `codeline-discovery.json=${ctx.agentsDiscovery}`,
70
+ ...(liveStart ? [] : ['--seed-prd', ctx.seedPrd, '--seed-log', `codeline-discovery.json=${ctx.agentsDiscovery}`]),
62
71
  ...(ctx.agentsDocuments ? ['--documents', ctx.agentsDocuments] : []), ...(env.JIRA_TICKET ? ['--ticket', env.JIRA_TICKET] : []),
63
72
  ...(env.EPAM_ESTIMATION_MANIFEST ? ['--estimation-manifest', env.EPAM_ESTIMATION_MANIFEST] : [])], env);
64
73
  const plan = JSON.parse(fs.readFileSync(path.join(out, 'plan.json'), 'utf8'));
65
74
  const res = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'codeline-agents', script: path.join(out, 'workflows', 'codeline-agents.js'),
66
- agentsJson: path.join(out, 'agents.json'), args: withFeatures(ctx, {}), env: { ...env, EPAM_SKIP_JIRA_INGEST: '1' },
75
+ agentsJson: path.join(out, 'agents.json'), args: withFeatures(ctx, {}), env: liveStart ? (({ EPAM_SKIP_JIRA_INGEST: _s, ...rest }) => rest)(env) : { ...env, EPAM_SKIP_JIRA_INGEST: '1' }, // a live start runs the stage's own ingest
67
76
  standin: ctx.standin && ctx.standin('codeline-agents', { plan, out }), sessionDir: path.join(out, 'session'), approved: ctx.liveApproved });
68
77
  log(`[driver] Codeline, Agents: ${res.status}${res.reason ? ` — ${res.reason}` : ''} (${res.calls || 0} question(s); phases: ${(res.phases || []).join(' → ') || 'none'})`);
69
78
  fs.writeFileSync(path.join(w1, 'result.json'), JSON.stringify(res, null, 2));
@@ -18,8 +18,21 @@ function emit(plan) {
18
18
  return `export const meta = {
19
19
  name: 'codeline-agents',
20
20
  description: 'Codeline, Agents stage: reset, resume, ingest (discovery, AC gate, PRD), agents (survey, mint, roster, roles, prompts), pause 1',
21
- phases: [{ title: 'Codeline, Agents' }, ...${JSON.stringify(plan.stagePhases.map((x) => ({ title: x.title })))}],
21
+ phases: ${JSON.stringify([{ title: 'Codeline, Agents' }, ...plan.stagePhases.map((x) => ({ title: x.title }))])},
22
22
  }
23
+
24
+ // AN AGENT THAT NEVER CALLS StructuredOutput: the real Workflow tool throws for it ("subagent completed without calling
25
+ // StructuredOutput"), it does not return null — LIVE-AMSD-2974-4b's Spec stage died on it. The pipeline takes the same
26
+ // failure as no answer (runAgentForJson), so it is null here and the caller's own no-answer path follows. Any other error
27
+ // (an abort, the budget) still stops the run.
28
+ async function agentOrNull(prompt, opts) {
29
+ try { return await agent(prompt, opts) } catch (e) {
30
+ if (!/without calling StructuredOutput/.test(String(e && e.message))) throw e
31
+ log(\`\${(opts && opts.label) || 'an agent'}: no StructuredOutput — taken as no answer\`)
32
+ return null
33
+ }
34
+ }
35
+
23
36
  // GENERATED by amsd-pipeline-workflow/generator — do not edit. Source: ${plan.source}
24
37
  const PLAN = ${JSON.stringify(plan)}
25
38
  const REPLY = ${JSON.stringify(REPLY_SCHEMA)}
@@ -69,11 +82,11 @@ async function ask(step, req) {
69
82
  }
70
83
  calls++
71
84
  asked.push({ step, phase: phaseAt >= 0 ? PLAN.stagePhases[phaseAt].title : null, agent: req.agent, model: req.model })
72
- const a = await agent(req.prompt, { agentType, ...(req.model ? { model: req.model } : {}), ...(req.effort ? { effort: req.effort } : {}),
85
+ const a = await agentOrNull(req.prompt, { agentType, ...(req.model ? { model: req.model } : {}), ...(req.effort ? { effort: req.effort } : {}),
73
86
  schema: REPLY, label: \`\${step}: \${req.agent} (#\${calls})\`, phase: phaseAt >= 0 ? PLAN.stagePhases[phaseAt].title : 'Codeline, Agents' })
74
87
  if (a && a.step) return a.step
75
88
  log(\`\${step}: \${req.agent} gave no answer — the pipeline is told the call failed\`)
76
- const f = await agent('Reply now.', { agentType: PLAN.runners.failed, schema: RUN, label: \`\${step}: \${req.agent} failed\`, phase: 'Codeline, Agents' })
89
+ const f = await agentOrNull('Reply now.', { agentType: PLAN.runners.failed, schema: RUN, label: \`\${step}: \${req.agent} failed\`, phase: 'Codeline, Agents' })
77
90
  return (f && f.step) || { step, error: 'the failed-call step runner gave no result' }
78
91
  }
79
92
 
@@ -81,7 +94,7 @@ phase('Codeline, Agents')
81
94
  for (const step of PLAN.steps) {
82
95
  // Decision 18: the agents step without the project prompt layer, when 'project-prompts' is off.
83
96
  const runner = (step === 'agents' && !FEATURES.includes('project-prompts')) ? PLAN.runners['agents:no-project-prompts'] : PLAN.runners[step]
84
- const s = await agent('Reply now.', { agentType: runner, schema: RUN, label: \`step \${step}\`, phase: 'Codeline, Agents' })
97
+ const s = await agentOrNull('Reply now.', { agentType: runner, schema: RUN, label: \`step \${step}\`, phase: 'Codeline, Agents' })
85
98
  let r = (s && s.step) || { step, error: 'the step runner gave no result' }
86
99
  while (r.needs) {
87
100
  if (calls >= PLAN.maxCalls) return done({ status: 'stopped', exit: 1, step, reason: \`the stage reached its question cap (\${PLAN.maxCalls}) inside step \${step}\` })
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ // THE GATEWAY EVERY GENERATED WORKFLOW ASKS ITS AGENTS THROUGH (Spec, Writer) — defined once, included by each template
3
+ // (generator/spec-workflow.js, generator/writer-workflow.js). The workflow-script code lives in gateway.wf.js (the gateway)
4
+ // and mailbox.wf.js (agent-to-agent messages); it needs PLAN.call {attempts, failed, story, agents}, PLAN.messages, the
5
+ // workflow's agentOrNull and log.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const text = `\n${fs.readFileSync(path.join(__dirname, 'gateway.wf.js'), 'utf8')}`;
9
+ const mailbox = `\n${fs.readFileSync(path.join(__dirname, 'mailbox.wf.js'), 'utf8')}`;
10
+
11
+ /**
12
+ * The gateway's text for a plan: the mailbox only when the stage has messaging (plan.messages), its delivery wording the
13
+ * shared component's own deliveryBlock (lib/agent-messages.js), embedded at generation so it is defined once.
14
+ */
15
+ function textFor(plan) {
16
+ if (!plan.messages) return `\nconst deliveryBlock = () => ''\nfunction withMessages(s) { return s }\nfunction mailFor() { return [] }\nfunction post() {}\nconst MAILBOX = {}\n${text}`;
17
+ return `\nconst deliveryBlock = ${plan.messages.deliverySrc}\n${mailbox}\n${text}`;
18
+ }
19
+
20
+ /**
21
+ * Messaging for a stage: every agent definition (not the step runners) gets the hook that writes its messages to the
22
+ * run's bus; the agents map (definition → pipeline agent) is written; the plan's messages settings returned. Off, and
23
+ * said, when the shared component is not installed (no lib/agent-messages.js in the install).
24
+ */
25
+ function messagesSetup({ defs, outDir, busDir, router, story, install, scripts }) {
26
+ const lib = path.join(scripts, 'lib', 'agent-messages.js');
27
+ if (!fs.existsSync(lib)) { process.stderr.write(`[generate] messages OFF: the shared component is not installed (${lib})\n`); return null; }
28
+ const map = Object.fromEntries(defs.filter((d) => !d.name.startsWith('pipeline-step-')).map((d) => [d.name, d.pipelineAgent || d.name]));
29
+ const mapFile = path.join(outDir, 'messages-agents.json'); fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
30
+ const q = (x) => `'${String(x).replace(/'/g, "'\\''")}'`;
31
+ const cmd = [process.execPath, path.resolve(__dirname, '..', 'hooks', 'messages-out.js'), 'answer', '--bus', busDir, '--map', mapFile, '--router', router,
32
+ '--install', install, ...(story ? ['--story', story] : [])].map(q).join(' ');
33
+ for (const d of defs) {
34
+ if (d.name.startsWith('pipeline-step-')) continue;
35
+ d.frontmatter.hooks = d.frontmatter.hooks || {};
36
+ d.frontmatter.hooks.PreToolUse = [...(d.frontmatter.hooks.PreToolUse || []), { matcher: 'StructuredOutput', hooks: [{ type: 'command', command: cmd }] }];
37
+ }
38
+ return { router, bus: busDir, deliverySrc: require(lib).deliveryBlock.toString() };
39
+ }
40
+ module.exports = { text, textFor, messagesSetup };
@@ -0,0 +1,38 @@
1
+ // EVERY AGENT CALL THROUGH THE PIPELINE'S GATEWAY (llm-handler.sh "UNIFORM CALL RESILIENCE" :977–1090; operator 2026-10-18:
2
+ // retries and self-heal for ALL agents). An attempt that produced no usable answer — no answer at all, or no
3
+ // StructuredOutput — goes to the gateway's failed-call step (hooks/step-call-failed.sh: the failure on the agent's ladder,
4
+ // its next rung, the previous-refusal block, the KB episode, by the pipeline's own functions), and the call is asked again
5
+ // on that rung with that block, up to the hub's EPAM_CALL_MAX_ATTEMPTS. Only then is it no answer, and the caller's own
6
+ // no-answer path runs. A call that answers is asked once. Step runners are not agents: asked once.
7
+ const CALLSTEP = { type: 'object', properties: { done: { type: 'boolean' }, call: { type: 'object' } } }
8
+ async function agentCall(prompt, opts) {
9
+ const who = (PLAN.call && PLAN.call.agents || {})[opts.agentType]
10
+ if (!who) return agentOrNull(prompt, opts)
11
+ const story = (String(opts.label || '').match(/^([A-Z][A-Z0-9]+-\d+):/) || [])[1] || PLAN.call.story || 'global'
12
+ const mail = mailFor(who.agent)
13
+ let model = opts.model || who.model, p = prompt + deliveryBlock(mail), attempt = 1
14
+ const schema = withMessages(opts.schema)
15
+ for (; ; attempt++) {
16
+ let a = null, why = ''
17
+ try { a = await agent(p, { ...opts, ...(schema ? { schema } : {}), ...(model ? { model } : {}) }) } catch (e) {
18
+ if (!/without calling StructuredOutput/.test(String(e && e.message))) throw e
19
+ why = 'the agent ended without calling StructuredOutput — its answer was never submitted'
20
+ }
21
+ if (a !== null && a !== undefined) {
22
+ if (mail.length) MAILBOX[who.agent] = []
23
+ if (a && typeof a === 'object' && a.messages) post(who.agent, a.messages)
24
+ return a
25
+ }
26
+ if (!why) why = 'the agent produced no answer'
27
+ if (attempt >= PLAN.call.attempts) break
28
+ const f = await agentOrNull(`Reply at once with this structured answer, copied exactly: ${JSON.stringify({ done: true, call: { agent: who.agent, model, story, reason: why, attempt } })}`,
29
+ { agentType: PLAN.call.failed, schema: CALLSTEP, label: `${opts.label || who.agent}: call failed (attempt ${attempt}/${PLAN.call.attempts})`, phase: opts.phase })
30
+ const r = f && f.result
31
+ if (!r || r.error || r.ran === false) { log(`${opts.label || who.agent}: the gateway's failed-call step gave no result (${r ? r.reason : 'no answer'}) — no further attempt`); break }
32
+ log(`${opts.label || who.agent}: ${r.line}${r.episode ? ' · KB episode recorded' : ' · NO KB episode'}`)
33
+ if (r.model) model = r.model
34
+ p = p + (r.block || '')
35
+ }
36
+ log(`${opts.label || who.agent}: no usable answer after ${attempt} attempt(s) — taken as no answer`)
37
+ return null
38
+ }
@@ -84,6 +84,29 @@ function hubSi(art, agent, codelineRoot, work, projectEnv) {
84
84
  return relativizeLocalPaths(fs.readFileSync(out, 'utf8'), [codelineRoot]);
85
85
  }
86
86
 
87
+ /** THE ROSTER THE RUN STARTS FROM (lib/mint-and-spec.sh:156–232). When this run's codeline has a completion marker and a
88
+ * recorded roster, the pipeline restores that roster before the mint (both of its reuse branches, :170–175 and :183–192)
89
+ * and the hub reads each agent's SI from it at call time; the stage's agents are generated from that roster — by the
90
+ * pipeline's own restoreMinted, on a scratch copy of the project (the install is never written). Otherwise the project's
91
+ * roster as it is. The codeline: EPAM_CODELINE_ID, else the PRD's single output folder (codelineFromPrd, :157–165). */
92
+ function siProjectDir(art, prdFile, projectEnv, work) {
93
+ const env = { ...process.env, ...projectEnv };
94
+ if (env.EPAM_REGENERATE_CODELINE_ASSETS === '1') return art.projectDir;
95
+ const b = require(path.join(art.scripts, 'lib', 'project-prompt-builder.js'));
96
+ const cl = String(env.EPAM_CODELINE_ID || '').trim() || b.codelineFromPrd(prdFile);
97
+ if (!cl) return art.projectDir;
98
+ const marker = path.join(art.projectDir, '.prompt-cache', `.complete-${cl}${env.EPAM_BROWNFIELD === '1' ? '.brownfield' : ''}`); // prompt-variant.sh:15–21
99
+ if (!fs.existsSync(marker) || !b.hasMintedSnapshot({ projectConfigDir: art.projectDir, codeline: cl })) return art.projectDir;
100
+ const dir = path.join(work, 'si-project'); fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir, { recursive: true });
101
+ for (const f of fs.readdirSync(art.projectDir)) {
102
+ if (f === 'runs' || f === 'prompts') continue;
103
+ if (f === '.prompt-cache') fs.symlinkSync(path.join(art.projectDir, f), path.join(dir, f));
104
+ else fs.cpSync(path.join(art.projectDir, f), path.join(dir, f), { recursive: true });
105
+ }
106
+ b.restoreMinted({ projectConfigDir: dir, codeline: cl });
107
+ return dir;
108
+ }
109
+
87
110
  function stepRunner(name, description, hookArgs, firstTier) {
88
111
  return { name, pipelineAgent: null, seam: null, ladder: '',
89
112
  frontmatter: { name, description, model: firstTier.startModel,
@@ -158,8 +181,9 @@ function main(a) {
158
181
  const scratch = path.join(work, 'seam'); fs.mkdirSync(scratch, { recursive: true });
159
182
  const defs = []; const agentMap = {}; const si = {}; const noSi = [];
160
183
  const projectEnv = pipe.projectEnv(art);
184
+ const siArt = { ...art, projectDir: siProjectDir(art, seedPrd, projectEnv, work) };
161
185
  for (const s of seamNames) {
162
- const body = hubSi(art, s, path.resolve(a['codeline-root']), work, projectEnv);
186
+ const body = hubSi(siArt, s, path.resolve(a['codeline-root']), work, projectEnv);
163
187
  // A seam the hub has no SI for is a call the hub itself refuses (llm-handler.sh: "refusing the call"); it gets no
164
188
  // agent here either, and is listed.
165
189
  if (body == null) { noSi.push(s); continue; }
@@ -250,6 +250,15 @@ function main(a) {
250
250
  ...((firstTier.rungs || [])[0] && firstTier.rungs[0].reasoningEffort ? { effort: firstTier.rungs[0].reasoningEffort } : {}),
251
251
  maxTurns: 3, tools: [], omitClaudeMd: true, hooks: hook(event, extra) },
252
252
  body: 'Reply at once by giving your structured answer {"done": true}. Do nothing else: a hook runs the step, and its result is the answer.' }); return name; };
253
+ // THE GATEWAY'S FAILED-CALL STEP (hooks/step-call-failed.sh, through the step runners' hook hooks/pipeline-step.js):
254
+ // the same step the Writer's gateway hands a failed call to — the pipeline's ladder, previous-refusal and KB episode.
255
+ defs.push({ name: 'pipeline-step-call-failed', pipelineAgent: null, seam: null, ladder: '',
256
+ frontmatter: { name: 'pipeline-step-call-failed', description: "Hands a failed agent call to the pipeline's gateway step (its hook decides).",
257
+ model: firstTier.startModel, ...((firstTier.rungs || [])[0] && firstTier.rungs[0].reasoningEffort ? { effort: firstTier.rungs[0].reasoningEffort } : {}),
258
+ maxTurns: 3, tools: [], omitClaudeMd: true,
259
+ hooks: { PreToolUse: [{ matcher: 'StructuredOutput', hooks: [{ type: 'command', command: [process.execPath,
260
+ path.resolve(__dirname, '..', 'hooks', 'pipeline-step.js'), 'answer', '--step', 'call-failed', '--pipeline', pjFile, '--state', state].map(q).join(' ') }] }] } },
261
+ body: 'Reply at once by giving your structured answer: {"done": true, "call": <the JSON object your task gives, copied exactly>}. Do nothing else: a hook runs the step, and its result is the answer.' });
253
262
  const A = { assign: copy(bases.coordinator, 'spec-assign', 'assign', [], 'the spec agents\' assignment'),
254
263
  review: copy(bases.coordinator, 'spec-review', 'review', [], 'the coordinator\'s review of the specs'),
255
264
  model: copy(bases.coordinator, 'spec-model', 'model', [], 'the model review'),
@@ -390,6 +399,13 @@ function main(a) {
390
399
  P.assign = rel(P.assign); P.review = rel(P.review); P.model = rel(P.model);
391
400
  for (const sid of Object.keys(P.stories)) for (const k of Object.keys(P.stories[sid])) P.stories[sid][k] = rel(P.stories[sid][k]);
392
401
  for (const d of defs) d.body = rel(d.body);
402
+ // EVERY AGENT CALL THROUGH THE GATEWAY (generator/gateway.js): the pipeline agent each definition stands for and its
403
+ // start model; the step runners are asked once. A Spec call names its story in its label; others are run-wide.
404
+ plan.call = { attempts: Number(process.env.EPAM_CALL_MAX_ATTEMPTS || 3), failed: 'pipeline-step-call-failed', story: 'global',
405
+ agents: Object.fromEntries(defs.filter((d) => !d.name.startsWith('pipeline-step-')).map((d) => [d.name, { agent: d.pipelineAgent || d.name, model: d.frontmatter.model || '' }])) };
406
+ // AGENT-TO-AGENT MESSAGES (generator/gateway.js messagesSetup): the coordinator (its review) is the Spec stage's router
407
+ plan.messages = require('./gateway.js').messagesSetup({ defs, outDir: a.out, busDir: path.join(state, 'messages'),
408
+ router: (defs.find((d) => d.name === A.review) || {}).pipelineAgent || A.review, install: a.install, scripts: art.scripts });
393
409
  return { plan, defs };
394
410
  });
395
411