amsd-pipeline-workflow 0.1.1 → 0.1.6

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,24 +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
 
8
- Into an existing AMSD pipeline install (the pipeline itself is not changed):
9
+ Three steps, in this order, into one install:
9
10
 
10
11
  ```bash
11
- npx amsd-pipeline@<version> --dest <dir> --ref v<version> --no-docker # the pipeline, as always
12
- npx amsd-pipeline-workflow install --into <dir> [--codeline-cache <cache>]
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
13
15
  ```
14
16
 
15
- This writes only `<dir>/claude-workflows/` and, with `--codeline-cache`, each codeline's cached roster and prompts
17
+ Step 3 writes only `<dir>/claude-workflows/` and, with `--codeline-cache`, each codeline's cached roster and prompts
16
18
  (`<cache>/orchestrations/projects/<project>/.prompt-cache/<codeline>/` and its `.complete-<codeline>[.brownfield]` marker)
17
19
  into the same project, so the pipeline reuses them instead of minting and generating again. The pipeline still decides at
18
- launch whether each is current. Reinstalling replaces `claude-workflows/`.
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).
19
22
 
20
23
  Needs: Node 20+, the `claude` CLI, `unshare` (util-linux) and `systemd-run --user` (the memory bound, `bin/bounded`).
21
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
+
22
35
  ## Run
23
36
 
24
37
  ```bash
@@ -29,6 +42,15 @@ Needs: Node 20+, the `claude` CLI, `unshare` (util-linux) and `systemd-run --use
29
42
  Langfuse tracing follows the install's own settings (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`): each agent call is
30
43
  sent as one generation through the pipeline's own emitter.
31
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
+
32
54
  ## Version
33
55
 
34
56
  `amsd-pipeline-workflow version`. `package.json` `amsdPipeline.tested` names the pipeline release it was tested against.
@@ -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
@@ -88,7 +88,7 @@ async function main(a) {
88
88
  recordedPrd: a.prd ? path.resolve(a.prd) : null, docsFrom: a['docs-from'] ? path.resolve(a['docs-from']) : null,
89
89
  // --agents-stage generated (decision 40): the Codeline, Agents workflow runs, from the PRD on disk as given (--prd, its
90
90
  // codelines where the run's discovery placed them), the discovery record and the documents a run fetched
91
- 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,
92
92
  agentsDiscovery: a['agents-discovery'] ? path.resolve(a['agents-discovery']) : null, agentsDocuments: a['agents-documents'] ? path.resolve(a['agents-documents']) : null,
93
93
  rewriteCodelines: (f, m) => rewriteCodelines(f, m),
94
94
  liveApproved: !!a['live-approved'], launchEnv: launch.env, standin: null };
@@ -120,8 +120,10 @@ async function main(a) {
120
120
  if (env.EPAM_BROWNFIELD === '1' && !env.JIRA_CODELINE_ROOT) missing.push('JIRA_CODELINE_ROOT');
121
121
  if (missing.length) return outcome({ status: 'failed', exit: 1, reason: `[jira] Missing required env vars: ${missing.join(' ')}` });
122
122
  // :514–529 — ingest reads Jira (a client system): the driver only takes the skip, with the PRD on disk
123
- 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' });
124
- 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}` });
125
127
  if (ctx.recordedPrd) {
126
128
  fs.copyFileSync(ctx.recordedPrd, env.PRD_FILE);
127
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,44 @@ 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
+ // THE SESSION'S ANSWER, KEPT (the self-heal listener, tools/live-tracker.js): a stage ended by an agent error the gateway
131
+ // did not take must be visible in the run's own records — the driver logs only "failed".
132
+ try { fs.writeFileSync(path.join(opts.sessionDir, `${opts.name}.result.json`), `${JSON.stringify(res && typeof res === 'object' ? res : { status: 'failed', unreadable: String(out.result).slice(0, 2000) }, null, 2)}\n`); } catch { /* evidence only */ }
133
+ if (!res || typeof res !== 'object') {
134
+ return { status: 'failed', exit: 1, reason: `the ${opts.name} session's answer is not JSON: ${String(out.result).slice(0, 300)}`, __session };
135
+ }
136
+ return { ...res, __session };
87
137
  }
88
138
 
89
139
  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));
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const gateway = require('./gateway.js');
2
3
  // THE CODELINE, AGENTS WORKFLOW: plan (data) + emitted script (decisions 10, 15, 16, 25).
3
4
  //
4
5
  // The script holds the stage's ORDER — the pipeline's own: the launcher's reset (orchestrate.sh), the resume
@@ -18,8 +19,21 @@ function emit(plan) {
18
19
  return `export const meta = {
19
20
  name: 'codeline-agents',
20
21
  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 })))}],
22
+ phases: ${JSON.stringify([{ title: 'Codeline, Agents' }, ...plan.stagePhases.map((x) => ({ title: x.title }))])},
22
23
  }
24
+
25
+ // AN AGENT THAT NEVER CALLS StructuredOutput: the real Workflow tool throws for it ("subagent completed without calling
26
+ // StructuredOutput"), it does not return null — LIVE-AMSD-2974-4b's Spec stage died on it. The pipeline takes the same
27
+ // failure as no answer (runAgentForJson), so it is null here and the caller's own no-answer path follows. Any other error
28
+ // (an abort, the budget) still stops the run.
29
+ async function agentOrNull(prompt, opts) {
30
+ try { return await agent(prompt, opts) } catch (e) {
31
+ if (!${gateway.NO_ANSWER}.test(String(e && e.message))) throw e
32
+ log(\`\${(opts && opts.label) || 'an agent'}: no StructuredOutput — taken as no answer\`)
33
+ return null
34
+ }
35
+ }
36
+
23
37
  // GENERATED by amsd-pipeline-workflow/generator — do not edit. Source: ${plan.source}
24
38
  const PLAN = ${JSON.stringify(plan)}
25
39
  const REPLY = ${JSON.stringify(REPLY_SCHEMA)}
@@ -69,11 +83,11 @@ async function ask(step, req) {
69
83
  }
70
84
  calls++
71
85
  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 } : {}),
86
+ const a = await agentOrNull(req.prompt, { agentType, ...(req.model ? { model: req.model } : {}), ...(req.effort ? { effort: req.effort } : {}),
73
87
  schema: REPLY, label: \`\${step}: \${req.agent} (#\${calls})\`, phase: phaseAt >= 0 ? PLAN.stagePhases[phaseAt].title : 'Codeline, Agents' })
74
88
  if (a && a.step) return a.step
75
89
  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' })
90
+ const f = await agentOrNull('Reply now.', { agentType: PLAN.runners.failed, schema: RUN, label: \`\${step}: \${req.agent} failed\`, phase: 'Codeline, Agents' })
77
91
  return (f && f.step) || { step, error: 'the failed-call step runner gave no result' }
78
92
  }
79
93
 
@@ -81,7 +95,7 @@ phase('Codeline, Agents')
81
95
  for (const step of PLAN.steps) {
82
96
  // Decision 18: the agents step without the project prompt layer, when 'project-prompts' is off.
83
97
  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' })
98
+ const s = await agentOrNull('Reply now.', { agentType: runner, schema: RUN, label: \`step \${step}\`, phase: 'Codeline, Agents' })
85
99
  let r = (s && s.step) || { step, error: 'the step runner gave no result' }
86
100
  while (r.needs) {
87
101
  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,46 @@
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
+ // AN ANSWER THE AGENT NEVER GAVE IN USABLE FORM — the Workflow tool's own errors for it: the agent ended without calling
9
+ // StructuredOutput; or it called it and its output never matched the schema (the tool's retry cap). Each is a failed call,
10
+ // as the pipeline takes it (no usable output); any other error (an abort, the budget) is not. Defined once, used by the
11
+ // gateway and by every template's agentOrNull. (LIVE-AMSD-2974-4b: the retry-cap error went unrecognised and took the
12
+ // Writer down.)
13
+ const NO_ANSWER = String.raw`/without calling StructuredOutput|StructuredOutput retry cap|does not match required schema/`;
14
+ const text = `\n${fs.readFileSync(path.join(__dirname, 'gateway.wf.js'), 'utf8')}`.split('__NO_ANSWER__').join(NO_ANSWER);
15
+ const mailbox = `\n${fs.readFileSync(path.join(__dirname, 'mailbox.wf.js'), 'utf8')}`;
16
+
17
+ /**
18
+ * The gateway's text for a plan: the mailbox only when the stage has messaging (plan.messages), its delivery wording the
19
+ * shared component's own deliveryBlock (lib/agent-messages.js), embedded at generation so it is defined once.
20
+ */
21
+ function textFor(plan) {
22
+ if (!plan.messages) return `\nconst deliveryBlock = () => ''\nfunction withMessages(s) { return s }\nfunction mailFor() { return [] }\nfunction post() {}\nconst MAILBOX = {}\n${text}`;
23
+ return `\nconst deliveryBlock = ${plan.messages.deliverySrc}\n${mailbox}\n${text}`;
24
+ }
25
+
26
+ /**
27
+ * Messaging for a stage: every agent definition (not the step runners) gets the hook that writes its messages to the
28
+ * run's bus; the agents map (definition → pipeline agent) is written; the plan's messages settings returned. Off, and
29
+ * said, when the shared component is not installed (no lib/agent-messages.js in the install).
30
+ */
31
+ function messagesSetup({ defs, outDir, busDir, router, story, install, scripts }) {
32
+ const lib = path.join(scripts, 'lib', 'agent-messages.js');
33
+ if (!fs.existsSync(lib)) { process.stderr.write(`[generate] messages OFF: the shared component is not installed (${lib})\n`); return null; }
34
+ const map = Object.fromEntries(defs.filter((d) => !d.name.startsWith('pipeline-step-')).map((d) => [d.name, d.pipelineAgent || d.name]));
35
+ const mapFile = path.join(outDir, 'messages-agents.json'); fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
36
+ const q = (x) => `'${String(x).replace(/'/g, "'\\''")}'`;
37
+ const cmd = [process.execPath, path.resolve(__dirname, '..', 'hooks', 'messages-out.js'), 'answer', '--bus', busDir, '--map', mapFile, '--router', router,
38
+ '--install', install, ...(story ? ['--story', story] : [])].map(q).join(' ');
39
+ for (const d of defs) {
40
+ if (d.name.startsWith('pipeline-step-')) continue;
41
+ d.frontmatter.hooks = d.frontmatter.hooks || {};
42
+ d.frontmatter.hooks.PreToolUse = [...(d.frontmatter.hooks.PreToolUse || []), { matcher: 'StructuredOutput', hooks: [{ type: 'command', command: cmd }] }];
43
+ }
44
+ return { router, bus: busDir, deliverySrc: require(lib).deliveryBlock.toString() };
45
+ }
46
+ module.exports = { text, textFor, messagesSetup, NO_ANSWER };
@@ -0,0 +1,40 @@
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
+ const msg = String(e && e.message)
19
+ if (!__NO_ANSWER__.test(msg)) throw e
20
+ why = /without calling StructuredOutput/.test(msg) ? 'the agent ended without calling StructuredOutput — its answer was never submitted'
21
+ : `the answer did not match its required format (${msg.replace(/^[\s\S]*?(StructuredOutput error: |does not match)/, '$1').slice(0, 400)}) — give the answer in exactly that format`
22
+ }
23
+ if (a !== null && a !== undefined) {
24
+ if (mail.length) MAILBOX[who.agent] = []
25
+ if (a && typeof a === 'object' && a.messages) post(who.agent, a.messages)
26
+ return a
27
+ }
28
+ if (!why) why = 'the agent produced no answer'
29
+ if (attempt >= PLAN.call.attempts) break
30
+ 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 } })}`,
31
+ { agentType: PLAN.call.failed, schema: CALLSTEP, label: `${opts.label || who.agent}: call failed (attempt ${attempt}/${PLAN.call.attempts})`, phase: opts.phase })
32
+ const r = f && f.result
33
+ 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 }
34
+ log(`${opts.label || who.agent}: ${r.line}${r.episode ? ' · KB episode recorded' : ' · NO KB episode'}`)
35
+ if (r.model) model = r.model
36
+ p = p + (r.block || '')
37
+ }
38
+ log(`${opts.label || who.agent}: no usable answer after ${attempt} attempt(s) — taken as no answer`)
39
+ return null
40
+ }
@@ -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
 
@@ -232,6 +232,17 @@ function main(a) {
232
232
  path.resolve(__dirname, '..', 'hooks', 'pipeline-step.js'), 'answer', '--step', step, '--pipeline', pipelineJson, '--state', checksState].map(q).join(' ') }] }] } },
233
233
  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.' });
234
234
  }
235
+ // THE GATEWAY'S FAILED-CALL STEP (hooks/step-call-failed.sh; llm-handler.sh "UNIFORM CALL RESILIENCE"): after an agent call
236
+ // that produced no usable answer the workflow hands this runner the call ({agent, model, story, reason, attempt}); its hook
237
+ // records the failure on the agent's ladder, resolves the next rung, renders the previous-refusal block and records the
238
+ // KB episode — the pipeline's own functions — and the workflow re-asks on what it returns.
239
+ defs.push({ name: 'pipeline-step-call-failed', pipelineAgent: null, seam: null, ladder: '',
240
+ frontmatter: { name: 'pipeline-step-call-failed', description: "Hands a failed agent call to the pipeline's gateway step (its hook decides).",
241
+ model: firstTier.startModel, ...((firstTier.rungs || [])[0] && firstTier.rungs[0].reasoningEffort ? { effort: firstTier.rungs[0].reasoningEffort } : {}),
242
+ maxTurns: 3, tools: [], omitClaudeMd: true,
243
+ hooks: { PreToolUse: [{ matcher: 'StructuredOutput', hooks: [{ type: 'command', command: [process.execPath,
244
+ path.resolve(__dirname, '..', 'hooks', 'pipeline-step.js'), 'answer', '--step', 'call-failed', '--pipeline', pipelineJson, '--state', checksState].map(q).join(' ') }] }] } },
245
+ 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.' });
235
246
  // THE DYNAMIC TOOL'S REVIEW (failure-healing.sh _write_reviewed_dynamic_tool → prd-change-review.sh
236
247
  // run_change_with_reviewer_retry "tool_creation"): the prd-change-reviewer under its own name for this change type, its
237
248
  // hook writing the tool on "pass" (hooks/tool-record.js review); the prd-change-summarizer, its hook post-processing the
@@ -387,6 +398,11 @@ function main(a) {
387
398
  path.resolve(__dirname, '..', 'hooks', 'pipeline-step.js'), 'answer', '--step', 'no-change', '--pipeline', pipelineJson, '--state', checksState].map(q).join(' ') }] }] } } });
388
399
  }
389
400
  for (const d of defs) d.body = rel(d.body);
401
+ // AGENT-TO-AGENT MESSAGES (generator/gateway.js messagesSetup): the reviewer is the Writer's router; the bus is the stage's.
402
+ // Before the definitions are written: it adds the messages hook to each.
403
+ fs.mkdirSync(a.out, { recursive: true });
404
+ const messagesPlan = require('./gateway.js').messagesSetup({ defs, outDir: a.out, busDir: path.join(checksState, 'messages'),
405
+ router: (defs.find((d) => d.name === nameOf(roles.reviewer)) || {}).pipelineAgent || nameOf(roles.reviewer), story: a.story, install: a.install, scripts: art.scripts });
390
406
  agents.writeDefinitions(defs, path.join(a.out, 'agents'));
391
407
 
392
408
  // THE CHECKS BASELINE, recorded once on the untouched pause-2 copy by the codeline's own declarations (D5, C1).
@@ -577,6 +593,11 @@ function main(a) {
577
593
  // The bases travel to the script: values filled at run time pass through the same rewrite when sent (the hub does it).
578
594
  plan.relativeBases = bases;
579
595
  for (const k of ['analyst', 'reviewer', 'changeReviewer', 'remediator']) fs.writeFileSync(path.join(a.out, 'prompts', `${k}.md`), plan.prompts[k]);
596
+ // EVERY AGENT CALL THROUGH THE GATEWAY: the pipeline agent each definition stands for and the model it starts on (the
597
+ // ladder steps from it); the step runners are not agents and are asked once. EPAM_CALL_MAX_ATTEMPTS as the hub reads it.
598
+ plan.call = { attempts: Number(process.env.EPAM_CALL_MAX_ATTEMPTS || 3), failed: 'pipeline-step-call-failed', story: a.story,
599
+ agents: Object.fromEntries(defs.filter((d) => !d.name.startsWith('pipeline-step-')).map((d) => [d.name, { agent: d.pipelineAgent || d.name, model: d.frontmatter.model || '' }])) };
600
+ plan.messages = messagesPlan;
580
601
  fs.writeFileSync(path.join(a.out, 'plan.json'), JSON.stringify(plan, null, 2));
581
602
  fs.mkdirSync(path.join(a.out, 'workflows'), { recursive: true });
582
603
  fs.writeFileSync(path.join(a.out, 'workflows', 'writer.js'), writerWorkflow.emit(plan));
@@ -0,0 +1,20 @@
1
+ // THE MAILBOX (agent-to-agent messages, the shared component lib/agent-messages.js; operator 2026-10-18): what agents send
2
+ // with their answers (`messages`), held for the recipient's next call and put at the end of its prompt — the hook
3
+ // (hooks/messages-out.js) has already written them to the run's bus and marks them read when the recipient answers.
4
+ // "router" or no recipient → the stage's router. None of this asks a model: no extra call.
5
+ const MAILBOX = {}
6
+ const MSG_FIELD = { type: 'array', description: 'Optional: messages to other agents on this run — each {to: an agent name, or "router" (the stage\'s router), type, subject, text}.',
7
+ items: { type: 'object', required: ['subject', 'text'], properties: { to: { type: 'string' }, type: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string' } } } }
8
+ function withMessages(schema) {
9
+ if (!PLAN.messages || !schema || typeof schema !== 'object' || !schema.properties || schema.properties.messages) return schema
10
+ return { ...schema, properties: { ...schema.properties, messages: MSG_FIELD } }
11
+ }
12
+ function mailFor(agent) { return MAILBOX[agent] || [] }
13
+ function post(from, messages) {
14
+ for (const m of [].concat(messages || [])) {
15
+ if (!m || !m.subject || !m.text) continue
16
+ const to = (!m.to || String(m.to).toLowerCase() === 'router') ? PLAN.messages.router : String(m.to)
17
+ ;(MAILBOX[to] = MAILBOX[to] || []).push({ from_agent: from, type: m.type || 'info', priority: m.priority || 'normal', subject: m.subject, body: { text: m.text } })
18
+ log(`message: ${from} → ${to}: ${m.subject}`)
19
+ }
20
+ }
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const gateway = require('./gateway.js');
2
3
  // THE SPEC WORKFLOW (Spec → pause 2): the order of spec-mode-runner.js run() and its callees, re-expressed as a script
3
4
  // (decisions 10, 15, 16). Every model call is an agent with its seam's definition; every judgement on an answer is the
4
5
  // pipeline's own code, run by the hook on that answer (hooks/spec-step.js) — the script only follows the result: which
@@ -13,6 +14,19 @@ function emit(plan) {
13
14
  description: 'Spec stage for one lane: spec coordinator, ticket links, detective, openspec with its verification-criteria guard, change review, speckit, coordinator and model review, pause 2',
14
15
  phases: [{ title: 'Spec' }, { title: 'Review' }, { title: 'Pause 2' }],
15
16
  }
17
+
18
+ // AN AGENT THAT NEVER CALLS StructuredOutput: the real Workflow tool throws for it ("subagent completed without calling
19
+ // StructuredOutput"), it does not return null — LIVE-AMSD-2974-4b's Spec stage died on it. The pipeline takes the same
20
+ // failure as no answer (runAgentForJson), so it is null here and the caller's own no-answer path follows. Any other error
21
+ // (an abort, the budget) still stops the run.
22
+ async function agentOrNull(prompt, opts) {
23
+ try { return await agent(prompt, opts) } catch (e) {
24
+ if (!${gateway.NO_ANSWER}.test(String(e && e.message))) throw e
25
+ log(\`\${(opts && opts.label) || 'an agent'}: no StructuredOutput — taken as no answer\`)
26
+ return null
27
+ }
28
+ }
29
+ ${gateway.textFor(plan)}
16
30
  // GENERATED by amsd-pipeline-workflow/generator/generate-spec.js — do not edit. Source: ${plan.source}
17
31
  const PLAN = ${JSON.stringify(plan)}
18
32
  const A = PLAN.agents, P = PLAN.prompts, M = PLAN.models, E = PLAN.efforts, L = PLAN.limits, SCH = PLAN.schemas
@@ -30,13 +44,13 @@ const halted = (reason) => ({ status: 'failed', exit: 1, reason, features: FEATU
30
44
  const broken = (r) => !r || r.error === true
31
45
  // An agent whose answer reached its hook: the hook's result. null: the agent produced nothing (a runner failure).
32
46
  async function ask(prompt, opts) {
33
- const a = await agent(prompt, { ...opts, phase: current })
47
+ const a = await agentCall(prompt, { ...opts, phase: current })
34
48
  if (a === null) return null
35
49
  if (!a.result) return { error: true, reason: \`\${opts.label}: the answer reached no hook result\` }
36
50
  return a.result
37
51
  }
38
52
  async function step(name, label) {
39
- const a = await agent('Reply now.', { agentType: name, schema: STEP, label, phase: current })
53
+ const a = await agentCall('Reply now.', { agentType: name, schema: STEP, label, phase: current })
40
54
  if (!a || !a.result) return { error: true, reason: \`\${label}: the step runner gave no result\` }
41
55
  return a.result
42
56
  }
@@ -291,7 +305,7 @@ async function replayed(R, schemas, what) {
291
305
  while (r && r.pending) {
292
306
  const q = r.pending, agentType = R.agents[q.agent]
293
307
  if (!agentType) return { error: true, reason: \`\${what} asked '\${q.agent}', which this stage has no definition for\` }
294
- const ans = await agent(q.prompt, { agentType, ...(q.model ? { model: q.model } : {}), schema: schemas[q.agent] || SCH.cr,
308
+ const ans = await agentCall(q.prompt, { agentType, ...(q.model ? { model: q.model } : {}), schema: schemas[q.agent] || SCH.cr,
295
309
  label: \`\${what}: call \${q.n} (\${q.agent})\`, phase: 'Pause 2' })
296
310
  r = ans ? (ans.cpa || ans.next) : await step(R.fail, \`\${what}: call \${q.n} got no answer\`)
297
311
  }
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const gateway = require('./gateway.js');
2
3
  // THE WRITER WORKFLOW: plan (data) + emitted script (decisions 10, 12, 15, 16).
3
4
  //
4
5
  // The plan is everything decided at generation from the pipeline's artefacts and functions: the agents (by role), the
@@ -70,6 +71,20 @@ function emit(plan) {
70
71
  description: 'Writer stage for one story: writer, checks (hook-owned verdict), failure analyst, model ladder, review cycle',
71
72
  phases: [{ title: 'Writer' }, { title: 'Review' }],
72
73
  }
74
+
75
+ // AN AGENT THAT NEVER CALLS StructuredOutput: the real Workflow tool throws for it ("subagent completed without calling
76
+ // StructuredOutput"), it does not return null — LIVE-AMSD-2974-4b's Spec stage died on it. The pipeline takes the same
77
+ // failure as no answer (runAgentForJson), so it is null here and the caller's own no-answer path follows. Any other error
78
+ // (an abort, the budget) still stops the run.
79
+ async function agentOrNull(prompt, opts) {
80
+ try { return await agent(prompt, opts) } catch (e) {
81
+ if (!${gateway.NO_ANSWER}.test(String(e && e.message))) throw e
82
+ log(\`\${(opts && opts.label) || 'an agent'}: no StructuredOutput — taken as no answer\`)
83
+ return null
84
+ }
85
+ }
86
+
87
+ ${gateway.textFor(plan)}
73
88
  // GENERATED by amsd-pipeline-workflow/generator — do not edit. Source: ${plan.source}
74
89
  const PLAN = ${JSON.stringify(plan)}
75
90
 
@@ -301,12 +316,12 @@ function appendCriteria(acs, texts) {
301
316
  async function remediateRejection(issues, label) {
302
317
  const findings = (issues || []).filter((i) => /^(blocker|critical|major)$/i.test((i && i.severity) || ''))
303
318
  if (!findings.length) { log(\`\${label}: no blocker/major finding — nothing to remediate\`); return 0 }
304
- const rm = await agent(fill(PLAN.prompts.remediator, { review_findings: JSON.stringify(findings), existing_acs: JSON.stringify(state.acs) }),
319
+ const rm = await agentCall(fill(PLAN.prompts.remediator, { review_findings: JSON.stringify(findings), existing_acs: JSON.stringify(state.acs) }),
305
320
  { agentType: PLAN.agents.remediator, schema: SCHEMAS.remediation, label: \`remediation \${label}\`, phase: 'Review' })
306
321
  if (!rm) { log(\`\${label}: the remediator returned nothing — the writer retries with the review text only\`); return 0 }
307
322
  const after = appendCriteria(state.acs, rm.acs)
308
323
  if (after.length === state.acs.length) return 0
309
- const cr = await agent(fill(PLAN.prompts.changeReviewer, { before: JSON.stringify(state.acs), after: JSON.stringify(after) }),
324
+ const cr = await agentCall(fill(PLAN.prompts.changeReviewer, { before: JSON.stringify(state.acs), after: JSON.stringify(after) }),
310
325
  { agentType: PLAN.agents.changeReviewer, schema: SCHEMAS.changeReview, label: \`change review \${label}\`, phase: 'Review' })
311
326
  if (!cr || cr.verdict !== 'pass') { log(\`\${label}: the proposed criteria were not approved (\${cr ? cr.verdict : 'no verdict'}) — nothing added\`); return 0 }
312
327
  const added = after.length - state.acs.length
@@ -359,7 +374,7 @@ async function reproStep(prep) {
359
374
  if (prep.error) { log(\`\${at}: the test writer cannot run — \${prep.error}\${prep.findingMode ? '' : ' (Step 3.55 will block)'}\`); return { committed: false, reason: prep.error } }
360
375
  let source = null
361
376
  if (prep.targetQuestion) {
362
- const q = await agent(relativize(prep.targetQuestion), { agentType: PLAN.agents.reproWriter, schema: SCHEMAS.reproTarget, label: 'repro: which file', phase: 'Writer' })
377
+ const q = await agentCall(relativize(prep.targetQuestion), { agentType: PLAN.agents.reproWriter, schema: SCHEMAS.reproTarget, label: 'repro: which file', phase: 'Writer' })
363
378
  const f = q ? fileFromText(q.text) : ''
364
379
  if (f && (prep.testable || []).includes(f) && prep.candidates[f]) source = f
365
380
  else if (f) log(\`3.54: target choice '\${f}' is not a testable file of this change — ignoring\`)
@@ -371,7 +386,7 @@ async function reproStep(prep) {
371
386
  const fb = { typecheck: '', assertions: '', commit_rejected: '' }
372
387
  for (let i = 0; i < PLAN.repro.maxAttempts; i++) {
373
388
  const model = PLAN.repro.ladder[Math.min(i, PLAN.repro.ladder.length - 1)]
374
- const a = await agent(relativize(cand.prompt) + fb.typecheck + fb.assertions + fb.commit_rejected,
389
+ const a = await agentCall(relativize(cand.prompt) + fb.typecheck + fb.assertions + fb.commit_rejected,
375
390
  { agentType: PLAN.agents.reproWriter, model, schema: SCHEMAS.repro, label: \`repro test #\${i + 1}\`, phase: 'Writer' })
376
391
  const v = (a && a.validation) || { valid: false, committed: false, class: 'no_file', feedback: '', reason: a ? 'the answer carries no validation' : 'the agent ended without an answer' }
377
392
  if (v.valid) return { committed: !!v.committed, attempts: i + 1, test: a.test, reason: v.reason }
@@ -387,7 +402,7 @@ async function reproStep(prep) {
387
402
  // and records it as where the next writer attempt starts (hooks/step-review-refresh.sh). A failure says so in the diff's
388
403
  // place — never an empty diff — and leaves the tree unknown.
389
404
  async function refreshed(prev, label) {
390
- const a = await agent('Reply now.', { agentType: PLAN.agents.stepReviewRefresh, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label, phase: 'Writer' })
405
+ const a = await agentCall('Reply now.', { agentType: PLAN.agents.stepReviewRefresh, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label, phase: 'Writer' })
391
406
  const res = a && a.result
392
407
  if (!res || res.error) {
393
408
  const why = \`[the story's change could not be computed for the review: \${res ? res.reason : 'the step runner gave no result'}]\`
@@ -401,7 +416,7 @@ const isTestWork = (i) => !!i && typeof i === 'object' && i.work === 'tests'
401
416
  // result is never a pass: it says why.
402
417
  async function stepResult(agentType, label) {
403
418
  if (!agentType) return { error: true, reason: \`no step-runner definition for \${label}\` }
404
- const s = await agent('Reply now.', { agentType, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label, phase: 'Writer' })
419
+ const s = await agentCall('Reply now.', { agentType, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label, phase: 'Writer' })
405
420
  return (s && s.result) || { error: true, reason: s ? 'the step runner returned no result' : 'the step runner ended without an answer' }
406
421
  }
407
422
 
@@ -418,7 +433,7 @@ async function retryExtension(retry) {
418
433
  const pr = await stepResult(at.prep, 'retry extension: question')
419
434
  if (pr.error) { log(\`retry extension: the question could not be prepared — \${pr.reason}\`); return 0 }
420
435
  if (!pr.ask) { log(\`retry extension: not asked — \${pr.decidedBy}\${pr.reason ? \`: \${pr.reason}\` : ''}\`); return 0 }
421
- const a = await agent(relativize(pr.prompt), { agentType: PLAN.agents.retryCoordinator, schema: SCHEMAS.retryExt, label: 'retry extension', phase: 'Writer' })
436
+ const a = await agentCall(relativize(pr.prompt), { agentType: PLAN.agents.retryCoordinator, schema: SCHEMAS.retryExt, label: 'retry extension', phase: 'Writer' })
422
437
  if (!a) log('retry extension: the coordinator ended without an answer')
423
438
  const ap = await stepResult(at.apply, 'retry extension: decision')
424
439
  if (ap.error) { log(\`retry extension: the decision could not be applied — \${ap.reason}\`); return 0 }
@@ -431,7 +446,7 @@ async function tcWriter(attempt) {
431
446
  if (p.error) { log(\`attempt \${attempt + 1}: the tc-writer's question could not be built — \${p.reason}\`); return }
432
447
  if (!p.needed) { if (p.message) log(p.message); return }
433
448
  log(\` [tc-writer] Generating TCs for phase '\${p.phase || '<unset>'}' (post-impl, pre-test)...\`)
434
- await agent(relativize(p.prompt), { agentType: PLAN.agents.tcWriter, ...(p.model ? { model: p.model } : {}), schema: SCHEMAS.tc,
449
+ await agentCall(relativize(p.prompt), { agentType: PLAN.agents.tcWriter, ...(p.model ? { model: p.model } : {}), schema: SCHEMAS.tc,
435
450
  label: \`tc-writer after #\${attempt + 1}\`, phase: 'Writer' })
436
451
  const r = await stepResult(PLAN.agents.stepTcApply, \`tc-writer apply #\${attempt + 1}\`)
437
452
  if (r.ok && r.testCriteria) {
@@ -500,7 +515,7 @@ async function phaseGates(attempt) {
500
515
  // (and reverted) by the step runner
501
516
  if (s20.fix) {
502
517
  for (let i = 1; i <= s20.fix.maxAttempts; i++) {
503
- await agent(relativize(s20.fix.prompt), { agentType: PLAN.agents.lintFixer, schema: { type: 'object', properties: { summary: { type: 'string' } } },
518
+ await agentCall(relativize(s20.fix.prompt), { agentType: PLAN.agents.lintFixer, schema: { type: 'object', properties: { summary: { type: 'string' } } },
504
519
  label: \`Step 20: lint repair #\${i}\`, phase: 'Review' })
505
520
  const v = await stepResult(PLAN.agents.lintFixVerifyAt[i], \`Step 20: verify repair #\${i}\`)
506
521
  if (v.repaired) { log('Step 20: findings repaired in place — lint clean, types compile, tests still pass'); return fin({ status: 'done', exit: 0, step19: s19, step20: { ...s20, repaired: true } }) }
@@ -512,22 +527,22 @@ async function phaseGates(attempt) {
512
527
  // the gate-finding-analyst: one retry on no answer, in the template's retry form, on ESCALATION_MODEL_HIGH when set
513
528
  const gfSchema = { type: 'object', required: ['story_id'], properties: { gate: { type: 'string' }, story_id: { type: 'string' }, file: { type: 'string' },
514
529
  line: { type: 'integer' }, rule: { type: 'string' }, message: { type: 'string' }, suggested_fix: { type: 'string' } } }
515
- let finding = await agent(relativize(s20.remediation.findingPrompt), { agentType: PLAN.agents.gateAnalyst, schema: gfSchema, label: 'Step 20: lint finding', phase: 'Review' })
516
- if (!finding) finding = await agent(relativize(s20.remediation.findingRetryPrompt), { agentType: PLAN.agents.gateAnalyst, schema: gfSchema,
530
+ let finding = await agentCall(relativize(s20.remediation.findingPrompt), { agentType: PLAN.agents.gateAnalyst, schema: gfSchema, label: 'Step 20: lint finding', phase: 'Review' })
531
+ if (!finding) finding = await agentCall(relativize(s20.remediation.findingRetryPrompt), { agentType: PLAN.agents.gateAnalyst, schema: gfSchema,
517
532
  ...(PLAN.lint.escalationModelHigh ? { model: PLAN.lint.escalationModelHigh } : {}), label: 'Step 20: lint finding (retry)', phase: 'Review' })
518
533
  const sid = finding && typeof finding.story_id === 'string' ? finding.story_id : ''
519
534
  if (!sid) return failed('could not map the lint failure to a story')
520
535
  // the story-ac-remediator (template lint-ac-remediator); its criteria judged by the prd-change-reviewer (lib/reviewed-acs.sh)
521
536
  const remPrompt = fill(PLAN.prompts['lint.remediator'], { current_acs: sid === story.id ? pyJsonIndent2(state.acs) : '', lint_story_id: sid, finding: JSON.stringify(finding) })
522
537
  const remSchema = { type: 'object', required: ['new_acs'], properties: { new_acs: { type: 'array', items: { type: 'string' } } } }
523
- let rm = await agent(remPrompt, { agentType: PLAN.agents.remediator, schema: remSchema, label: 'Step 20: lint remediation', phase: 'Review' })
524
- if (!rm) rm = await agent(fill(PLAN.prompts['lint.remediatorRetry'], { lint_ac_prompt: remPrompt }), { agentType: PLAN.agents.remediator, schema: remSchema,
538
+ let rm = await agentCall(remPrompt, { agentType: PLAN.agents.remediator, schema: remSchema, label: 'Step 20: lint remediation', phase: 'Review' })
539
+ if (!rm) rm = await agentCall(fill(PLAN.prompts['lint.remediatorRetry'], { lint_ac_prompt: remPrompt }), { agentType: PLAN.agents.remediator, schema: remSchema,
525
540
  ...(PLAN.lint.escalationModelHigh ? { model: PLAN.lint.escalationModelHigh } : {}), label: 'Step 20: lint remediation (retry)', phase: 'Review' })
526
541
  let added = 0
527
542
  if (rm && sid === story.id) {
528
543
  const after = appendLintCriteria(state.acs, rm.new_acs)
529
544
  if (after.length !== state.acs.length) {
530
- const cr = await agent(fill(PLAN.prompts.changeReviewer, { before: JSON.stringify(state.acs), after: JSON.stringify(after) }),
545
+ const cr = await agentCall(fill(PLAN.prompts.changeReviewer, { before: JSON.stringify(state.acs), after: JSON.stringify(after) }),
531
546
  { agentType: PLAN.agents.changeReviewer, schema: SCHEMAS.changeReview, label: 'Step 20: change review', phase: 'Review' })
532
547
  if (cr && cr.verdict === 'pass') { added = after.length - state.acs.length; state.acs = after }
533
548
  else log(\`Step 20: the proposed criteria were not approved (\${cr ? cr.verdict : 'no verdict'}) — nothing added\`)
@@ -546,11 +561,11 @@ async function implementedSteps(checks, attempt) {
546
561
  // when it is red, the agent (its write fence: only tests that existed before the story) updates them or reports a
547
562
  // regression, and its judge decides. An unresolved RED suite blocks after the reproduction gate (below).
548
563
  if (PLAN.brownfield) {
549
- const uc = await agent('Reply now.', { agentType: PLAN.agents.stepUitCheck, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: '3.545 suite check', phase: 'Writer' })
564
+ const uc = await agentCall('Reply now.', { agentType: PLAN.agents.stepUitCheck, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: '3.545 suite check', phase: 'Writer' })
550
565
  const ucr = (uc && uc.result) || { needed: false, error: true, reason: uc ? 'the step runner returned no result' : 'the step runner ended without an answer' }
551
566
  uit = { passed: !ucr.error, reason: ucr.reason }
552
567
  if (ucr.needed) {
553
- const ua = await agent(relativize(ucr.prompt || ''), { agentType: PLAN.agents.invalidatedTests, model: PLAN.repro.ladder[0],
568
+ const ua = await agentCall(relativize(ucr.prompt || ''), { agentType: PLAN.agents.invalidatedTests, model: PLAN.repro.ladder[0],
554
569
  schema: { type: 'object', required: ['verdict'], properties: { verdict: { type: 'string', enum: ['updated', 'regression', 'none'] }, summary: { type: 'string' } } },
555
570
  label: '3.545 invalidated tests', phase: 'Writer' })
556
571
  uit = (ua && ua.result) || { passed: false, reason: ua ? 'the answer carries no judgement' : 'the agent ended without an answer' }
@@ -560,7 +575,7 @@ async function implementedSteps(checks, attempt) {
560
575
  // STEP 3.55 — the bug-reproduction gate (brownfield-repro-test-gate.sh, run by the step runner's hook): a block stops the
561
576
  // story before review, exit 2, as run-agent-orchestration.sh does.
562
577
  if (PLAN.brownfield) {
563
- const g = await agent('Reply now.', { agentType: PLAN.agents.stepReproGate, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: '3.55 reproduction gate', phase: 'Writer' })
578
+ const g = await agentCall('Reply now.', { agentType: PLAN.agents.stepReproGate, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: '3.55 reproduction gate', phase: 'Writer' })
564
579
  const res = (g && g.result) || { ran: false, passed: false, reason: g ? 'the step runner returned no result' : 'the step runner ended without an answer' }
565
580
  gate = res
566
581
  if (!res.passed) return end({ status: 'blocked', exit: 2, story: story.id, attempts: attempt + 1, reason: \`Step 3.55: the reproduction gate blocked the story — \${res.reason || 'no reason'}\` })
@@ -569,12 +584,12 @@ async function implementedSteps(checks, attempt) {
569
584
  if (uit && !uit.passed) return end({ status: 'blocked', exit: 2, story: story.id, attempts: attempt + 1, reason: \`Step 3.55: the test suite is RED and Step 3.545 could not reconcile it — \${uit.reason}\` })
570
585
  // STEP 3.56 — advisory: does the story's test cover each verification criterion (vc-coverage-check.sh, one question
571
586
  // per criterion, in order)? The uncovered ones reach the reviewer; nothing here fails the story.
572
- const vp = await agent('Reply now.', { agentType: PLAN.agents.stepVcPrep, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: '3.56 coverage questions', phase: 'Writer' })
587
+ const vp = await agentCall('Reply now.', { agentType: PLAN.agents.stepVcPrep, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: '3.56 coverage questions', phase: 'Writer' })
573
588
  const vpr = (vp && vp.result) || { needed: false, reason: 'the step runner gave no result' }
574
589
  coverage = { reason: vpr.reason, testFile: vpr.testFile || null, results: [] }
575
590
  if (vpr.needed) {
576
591
  for (const [i, q] of (vpr.prompts || []).entries()) {
577
- const v = await agent(relativize(q.prompt), { agentType: PLAN.agents.coverage, schema: { type: 'object', required: ['covered'],
592
+ const v = await agentCall(relativize(q.prompt), { agentType: PLAN.agents.coverage, schema: { type: 'object', required: ['covered'],
578
593
  properties: { covered: { type: 'boolean' }, case: { type: 'string' }, why: { type: 'string' } } }, label: \`3.56 criterion \${i + 1}\`, phase: 'Writer' })
579
594
  // no answer is NOT evidence of a gap: recorded as unknown, counted neither way
580
595
  coverage.results.push(v ? { ...v, vc: q.vc } : { vc: q.vc, covered: null })
@@ -632,7 +647,7 @@ if (FEATURES.includes('plan-mode')) {
632
647
  while (pr && pr.pending) {
633
648
  const q = pr.pending, def = PLAN.agents.plan.agents[q.agent]
634
649
  if (!def) { pr = { error: true, reason: \`the planning functions asked '\${q.agent}', which this stage has no definition for\` }; break }
635
- const ans = await agent(relativize(q.prompt), { agentType: def.name, ...(q.model ? { model: q.model } : {}), schema: def.text ? SCHEMAS.planText : SCHEMAS.planReview,
650
+ const ans = await agentCall(relativize(q.prompt), { agentType: def.name, ...(q.model ? { model: q.model } : {}), schema: def.text ? SCHEMAS.planText : SCHEMAS.planReview,
636
651
  label: \`plan: call \${q.n} (\${q.agent})\`, phase: 'Writer' })
637
652
  pr = ans ? ans.plan : await stepResult(PLAN.agents.plan.fail, \`plan: call \${q.n} got no answer\`)
638
653
  }
@@ -666,7 +681,7 @@ for (; attempt <= budget; attempt++) {
666
681
  // was changed, and this rung's starting point is recorded.
667
682
  const re = (PLAN.agents.rungEntryAt[attempt] || {})[prevModel]
668
683
  if (re) {
669
- const e = await agent('Reply now.', { agentType: re, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: \`rung entry #\${attempt + 1}\`, phase: 'Writer' })
684
+ const e = await agentCall('Reply now.', { agentType: re, schema: { type: 'object', properties: { done: { type: 'boolean' } } }, label: \`rung entry #\${attempt + 1}\`, phase: 'Writer' })
670
685
  const er = (e && e.result) || { error: true, reason: e ? 'the step runner returned no result' : 'the step runner ended without an answer' }
671
686
  if (er.error) log(\`attempt \${attempt + 1}: the rung-entry step could not run — \${er.reason}\`)
672
687
  else if (er.reset) log(\`attempt \${attempt + 1}: the codeline was reset to the baseline (the attempt changed no fix site the spec requires)\`)
@@ -705,7 +720,7 @@ for (; attempt <= budget; attempt++) {
705
720
  const pre = await stepResult(PLAN.agents.preAttemptAt[attempt], \`pre-attempt #\${attempt + 1}\`)
706
721
  if (pre.error) log(\`attempt \${attempt + 1}: the pre-attempt step could not run — \${pre.reason}\`)
707
722
  else escalationHistory = pre.escalationHistory || ''
708
- const w = await agent(prompt,
723
+ const w = await agentCall(prompt,
709
724
  { agentType: (PLAN.agents.writerAt[attempt] || {})[rung.model] || PLAN.agents.writer, model: rung.model, effort: rung.effort, schema: SCHEMAS.writer, label: \`writer #\${attempt + 1} (rung \${rung.rung})\`, phase: 'Writer' })
710
725
  invocations++
711
726
  if (w === null) {
@@ -757,7 +772,7 @@ for (; attempt <= budget; attempt++) {
757
772
  // computed it (the pipeline's _attempt_change_summary) — never the writer's own account of its work.
758
773
  // __VERIFICATION_FAILURE__: the failure text, or — when the pipeline has none — the "## Attempt Failure — <class>"
759
774
  // summary (failure-healing.sh:1139–1157, built by the classify hook)
760
- const a = await agent(fill(PLAN.prompts.analyst, { ...base, story_acs: analystCriteria(), verification_failure: fl && typeof fl.analystFailure === 'string' ? fl.analystFailure : problems,
775
+ const a = await agentCall(fill(PLAN.prompts.analyst, { ...base, story_acs: analystCriteria(), verification_failure: fl && typeof fl.analystFailure === 'string' ? fl.analystFailure : problems,
761
776
  attempt_changes: checks.changes, healing_history: healingHistory(healing) }),
762
777
  { agentType: PLAN.agents.analystAt[attempt] || PLAN.agents.analyst, schema: SCHEMAS.analyst, label: \`analyst after #\${attempt + 1}\`, phase: 'Writer' })
763
778
  a0 = a
@@ -782,13 +797,13 @@ for (; attempt <= budget; attempt++) {
782
797
  if (hv.toolReview) {
783
798
  let current = hv.toolReview.candidate, verdict = null
784
799
  for (let i = 1; i <= PLAN.toolReview.maxAttempts; i++) {
785
- const tr = await agent(fill(PLAN.prompts['tool.review'], { before: hv.toolReview.before, after: current }),
800
+ const tr = await agentCall(fill(PLAN.prompts['tool.review'], { before: hv.toolReview.before, after: current }),
786
801
  { agentType: PLAN.agents.toolReviewer, schema: SCHEMAS.changeReview, label: \`tool review #\${i} after #\${attempt + 1}\`, phase: 'Writer' })
787
802
  // a reviewer that could not be reached is a rejection (run_prd_change_reviewer's fallback answer)
788
803
  verdict = tr || { verdict: 'fail', issues: ['the reviewer could not be reached — the change was NOT reviewed'] }
789
804
  if (verdict.verdict === 'pass') break
790
805
  if (i < PLAN.toolReview.maxAttempts) {
791
- const sm = await agent(fill(PLAN.prompts['tool.summarize'], { issues: (verdict.issues || []).map(String).join('; ') || 'no details', rejected_text: current }),
806
+ const sm = await agentCall(fill(PLAN.prompts['tool.summarize'], { issues: (verdict.issues || []).map(String).join('; ') || 'no details', rejected_text: current }),
792
807
  { agentType: PLAN.agents.toolSummarizer, schema: { type: 'object', required: ['script'], properties: { script: { type: 'string' } } },
793
808
  label: \`tool rewrite #\${i} after #\${attempt + 1}\`, phase: 'Writer' })
794
809
  if (sm && typeof sm.script === 'string') current = sm.script
@@ -807,13 +822,13 @@ for (; attempt <= budget; attempt++) {
807
822
  if (hv.kb && hv.kb.review) {
808
823
  let current = hv.kb.review.candidate, kbPass = false
809
824
  for (let i = 1; i <= 3; i++) {
810
- const kr = await agent(fill(PLAN.prompts['kb.review'], { before: hv.kb.review.before, after: current }),
825
+ const kr = await agentCall(fill(PLAN.prompts['kb.review'], { before: hv.kb.review.before, after: current }),
811
826
  { agentType: PLAN.agents.kbReviewer, schema: SCHEMAS.changeReview, label: \`KB review #\${i} after #\${attempt + 1}\`, phase: 'Writer' })
812
827
  const v = kr ? kr.verdict : null
813
828
  if (v === 'pass') { kbPass = true; break }
814
829
  if (v !== 'fail') { log(\`analyst after #\${attempt + 1}: the KB entry was NOT reviewed (verdict '\${v || 'none'}') — not kept\`); break }
815
830
  if (i < 3) {
816
- const sm = await agent(fill(PLAN.prompts['kb.summarize'], { issues: ((kr && kr.issues) || []).map(String).join('; ') || 'no details', rejected_text: current }),
831
+ const sm = await agentCall(fill(PLAN.prompts['kb.summarize'], { issues: ((kr && kr.issues) || []).map(String).join('; ') || 'no details', rejected_text: current }),
817
832
  { agentType: PLAN.agents.kbSummarizer, schema: { type: 'object', required: ['text'], properties: { text: { type: 'string' } } },
818
833
  label: \`KB rewrite #\${i} after #\${attempt + 1}\`, phase: 'Writer' })
819
834
  if (sm && typeof sm.text === 'string') current = sm.text
@@ -831,7 +846,7 @@ for (; attempt <= budget; attempt++) {
831
846
  const { after, refused } = checkFixes(state.acs, a.ac_patches)
832
847
  if (refused.length) log(\`analyst after #\${attempt + 1}: criteria fix refused — \${refused.join('; ')}\`)
833
848
  if (JSON.stringify(after) !== JSON.stringify(state.acs)) {
834
- const cr = await agent(fill(PLAN.prompts.changeReviewer, { before: JSON.stringify(state.acs), after: JSON.stringify(after) }),
849
+ const cr = await agentCall(fill(PLAN.prompts.changeReviewer, { before: JSON.stringify(state.acs), after: JSON.stringify(after) }),
835
850
  { agentType: PLAN.agents.changeReviewer, schema: SCHEMAS.changeReview, label: \`change review after #\${attempt + 1}\`, phase: 'Writer' })
836
851
  kept = !!cr && cr.verdict === 'pass'
837
852
  if (kept) state.acs = after
@@ -856,7 +871,7 @@ for (; attempt <= budget; attempt++) {
856
871
  const ob = [...(((state.otherTc || {})[sid]) || (PLAN.otherTcFacts || {})[sid] || [])]
857
872
  const oa = applyTcPatches(ob, a.tc_patches.filter((pt) => pt && pt.storyId === sid))
858
873
  if (JSON.stringify(oa) === JSON.stringify(ob)) continue
859
- const ocr = await agent(fill(tpl, { before: JSON.stringify(ob), after: JSON.stringify(oa) }),
874
+ const ocr = await agentCall(fill(tpl, { before: JSON.stringify(ob), after: JSON.stringify(oa) }),
860
875
  { agentType: PLAN.agents.changeReviewer, schema: SCHEMAS.changeReview, label: \`test-criteria review of \${sid} after #\${attempt + 1}\`, phase: 'Writer' })
861
876
  if (ocr && ocr.verdict === 'pass') { state.otherTc = { ...(state.otherTc || {}), [sid]: oa }; log(\`analyst after #\${attempt + 1}: \${sid}'s test criteria patched (reviewed)\`) }
862
877
  else log(\`analyst after #\${attempt + 1}: TC patch on \${sid} not approved by the reviewer (\${ocr ? ocr.verdict : 'no verdict'}) — reverting\`)
@@ -865,7 +880,7 @@ for (; attempt <= budget; attempt++) {
865
880
  const before = [...(state.tcFacts || [])]
866
881
  const after = applyTcPatches(before, mine)
867
882
  if (tgt === 'tc' || JSON.stringify(after) !== JSON.stringify(before)) {
868
- const cr = await agent(fill(PLAN.prompts['changeReviewer.tc'], { before: JSON.stringify(before), after: JSON.stringify(after) }),
883
+ const cr = await agentCall(fill(PLAN.prompts['changeReviewer.tc'], { before: JSON.stringify(before), after: JSON.stringify(after) }),
869
884
  { agentType: PLAN.agents.changeReviewer, schema: SCHEMAS.changeReview, label: \`test-criteria review after #\${attempt + 1}\`, phase: 'Writer' })
870
885
  if (cr && cr.verdict === 'pass') {
871
886
  if (JSON.stringify(after) !== JSON.stringify(before)) { state.tcFacts = after; state.tc = { ...(state.tc || {}), facts: after }; kept = true }
@@ -924,7 +939,7 @@ for (; attempt <= budget; attempt++) {
924
939
  // too, as team-lead-review.sh records it (reviewIncomplete) — is in the reviewer's memory.
925
940
  let r = null
926
941
  for (;;) {
927
- r = await agent(fill(PLAN.prompts.reviewer, { ...base, story_acs: noTrail(criteriaBlock(state.acs, 'ACCEPTANCE CRITERIA:')),
942
+ r = await agentCall(fill(PLAN.prompts.reviewer, { ...base, story_acs: noTrail(criteriaBlock(state.acs, 'ACCEPTANCE CRITERIA:')),
928
943
  prior_review: priorReviews(), story_diff: checks.storyDiff, test_files: checks.testFiles, uncovered_vc_block: uncoveredVc !== null ? uncoveredVc : checks.uncoveredVc }),
929
944
  // team-lead-review.sh: on the writer's model (the rung that produced this work); effort stays the reviewer's own.
930
945
  { agentType: PLAN.agents.reviewer, model: rung.model, schema: SCHEMAS.review, label: \`review #\${reviews.length + 1}\`, phase: 'Review' })
@@ -955,7 +970,7 @@ for (; attempt <= budget; attempt++) {
955
970
  // writer in gate-finding mode, which commits the strengthened test; it leaves the implementer's findings whatever the
956
971
  // test writer's outcome. In greenfield the implementer owns its tests and keeps them.
957
972
  if (PLAN.brownfield && kept.some(isTestWork)) {
958
- const rt = await agent('Reply now.', { agentType: PLAN.agents.stepReviewTests, schema: { type: 'object', properties: { done: { type: 'boolean' } } },
973
+ const rt = await agentCall('Reply now.', { agentType: PLAN.agents.stepReviewTests, schema: { type: 'object', properties: { done: { type: 'boolean' } } },
959
974
  label: \`review #\${reviews.length}: test findings\`, phase: 'Review' })
960
975
  const res = (rt && rt.result) || { routed: 0, reason: rt ? 'the step runner returned no result' : 'the step runner ended without an answer' }
961
976
  if (res.prep) {
@@ -80,6 +80,17 @@ MOUNTS="$STATE/run/mounts.sh"
80
80
  jq -r '(.seedLogs // {}) | to_entries[] | [.key, .value] | @tsv' "$PJ" | while IFS=$'\t' read -r name src; do
81
81
  printf '[ -e %q ] || cp %q %q\n' "$ORCH/logs/$name" "$src" "$ORCH/logs/$name"
82
82
  done
83
+ # THE CODELINE FACTS OF A RECORDED DISCOVERY: discovery is the single writer of <project>/codeline-facts.json
84
+ # (lib/codeline-discovery.js:739–748, writeCodelineFacts from its own output), and pre-run-reset.sh:1029–1038 clears it
85
+ # every launch; with the discovery recorded (the ingest skipped, decision 37) it is written here from that output, by
86
+ # the same function — never over a file the run already has. Without it no plan codeline can become a lane
87
+ # (lib/plan-scope.js adoptPlanCodelines).
88
+ _disc="$(j '.seedLogs["codeline-discovery.json"] // empty')"
89
+ if [ -n "$_disc" ]; then
90
+ printf '[ -e %q ] || EPAM_PROJECT_CONFIG_DIR=%q %q -e %q %q %q\n' "$ORCH/projects/$PROJECT/codeline-facts.json" "$ORCH/projects/$PROJECT" \
91
+ "$NODE20" 'require(process.argv[1]).writeCodelineFacts({ projectConfigDir: process.env.EPAM_PROJECT_CONFIG_DIR, codelines: JSON.parse(require("fs").readFileSync(process.argv[2], "utf8")).codelines })' \
92
+ "$ORCH/scripts/lib/codeline-facts.js" "$ORCH/logs/codeline-discovery.json"
93
+ fi
83
94
  # THE PRD ON DISK (pipeline.json seedPrd; decision 37 — initial testing skips the Jira ingest): the launcher's reset
84
95
  # clears a brownfield PRD to its empty sentinel (orchestrate.sh:316–328), and with EPAM_SKIP_JIRA_INGEST=1 nothing
85
96
  # writes it again; the PRD the run starts from is placed after the reset, once — never over one the run has grown
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // AGENT-TO-AGENT MESSAGES, ON THE RUN'S BUS (operator, 2026-10-18; the shared component amsd-pipeline-shared
4
+ // lib/agent-messages.js, laid into the install by its own install). On an agent's structured answer: every message it sends
5
+ // (`messages`: [{to, type, subject, text}]) is written to the bus from that agent — "router" or no recipient goes to the
6
+ // stage's router — and the messages delivered to it in this call (the gateway put them in its prompt) are marked read.
7
+ // Never blocks the answer: a bus that cannot be written is said on stderr and the answer passes unchanged.
8
+ // messages-out.js answer --bus <dir> --map <agents map json> --router <pipeline agent> --install <install> [--story <id>]
9
+ // stdin: the PreToolUse hook's JSON. stdout: nothing (the answer is not changed).
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ function args(argv) { const a = {}; for (let i = 0; i < argv.length; i += 2) a[argv[i].replace(/^--/, '')] = argv[i + 1]; return a; }
14
+
15
+ if (require.main === module) {
16
+ const a = args(process.argv.slice(3));
17
+ let input = {};
18
+ try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }
19
+ if (input.tool_name !== 'StructuredOutput') process.exit(0);
20
+ const lib = path.join(a.install || '', 'orchestrations', 'scripts', 'lib', 'agent-messages.js');
21
+ if (!fs.existsSync(lib)) { process.stderr.write(`[messages] the shared messages component is not installed (${lib}) — messages NOT sent\n`); process.exit(0); }
22
+ const m = require(lib);
23
+ let map = {}; try { map = JSON.parse(fs.readFileSync(a.map, 'utf8')); } catch { map = {}; }
24
+ const me = map[input.agent_type] || input.agent_type || 'agent';
25
+ try {
26
+ for (const x of [].concat((input.tool_input || {}).messages || [])) {
27
+ if (!x || !x.subject || !x.text) continue;
28
+ m.send({ dir: a.bus, from: me, to: x.to, router: a.router, type: x.type || 'info', subject: x.subject, text: x.text, story: a.story || '', priority: x.priority || 'normal' });
29
+ }
30
+ m.receive({ dir: a.bus, agent: me, markRead: true });
31
+ } catch (e) { process.stderr.write(`[messages] ${e.message}\n`); }
32
+ process.exit(0);
33
+ }
@@ -13,7 +13,7 @@ const STEPS = { 'repro-gate': 'step-repro-gate.sh', 'uit-check': 'step-uit-check
13
13
  'pre-review': 'step-pre-review.sh', 'kb-section': 'step-kb-section.sh', 'lint-gate': 'step-lint-gate.sh', 'lint-fix-verify': 'step-lint-fix-verify.sh',
14
14
  'no-change': 'step-no-change.sh', 'tc-prep': 'step-tc-prep.sh', 'tc-apply': 'step-tc-apply.sh', 'kb-fallback': 'step-kb-fallback.sh',
15
15
  'retry-ext-prep': 'step-retry-ext-prep.sh', 'retry-ext-apply': 'step-retry-ext-apply.sh',
16
- 'plan-start': 'step-plan-start.sh', 'plan-fail': 'step-plan-fail.sh' }; // the step scripts, beside this file
16
+ 'plan-start': 'step-plan-start.sh', 'plan-fail': 'step-plan-fail.sh', 'call-failed': 'step-call-failed.sh' }; // the step scripts, beside this file
17
17
  function args(argv) { const a = {}; for (let i = 0; i < argv.length; i += 2) a[argv[i].replace(/^--/, '')] = argv[i + 1]; return a; }
18
18
 
19
19
  if (require.main === module) {
@@ -27,7 +27,10 @@ if (require.main === module) {
27
27
  else {
28
28
  // a per-attempt step runner's own values (decision 27): its retry and the model the previous attempt ran on
29
29
  const env = { ...process.env, ...(a.retry !== undefined ? { RUNG_RETRY: String(a.retry) } : {}), ...(a['prev-model'] ? { RUNG_PREV_MODEL: a['prev-model'] } : {}),
30
- ...(a.model ? { RUNG_MODEL: a.model } : {}) };
30
+ ...(a.model ? { RUNG_MODEL: a.model } : {}),
31
+ // a failed agent call (the gateway, hooks/step-call-failed.sh): the call the step runner was told to hand on
32
+ ...(() => { const c = (input.tool_input || {}).call || {}; return { CALL_AGENT: String(c.agent || ''), CALL_MODEL: String(c.model || ''),
33
+ CALL_STORY: String(c.story || 'global'), CALL_REASON: String(c.reason || ''), CALL_ATTEMPT: String(c.attempt || 1) }; })() };
31
34
  const r = spawnSync('bash', [path.join(__dirname, script), a.pipeline, a.state], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
32
35
  try { result = JSON.parse(r.stdout); } catch { result = { ran: false, passed: false, reason: (r.stderr || `exit ${r.status}`).slice(-400) }; }
33
36
  }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # A FAILED AGENT CALL, AS THE PIPELINE'S GATEWAY TAKES IT (llm-handler.sh "UNIFORM CALL RESILIENCE" :977–998, :1080–1082),
3
+ # by the pipeline's own functions, run by the step runner's hook. The workflow's gateway calls this after an attempt that
4
+ # produced no usable answer, then re-asks on what it returns — retry, ladder and self-heal for EVERY agent (operator,
5
+ # 2026-10-18). No AI.
6
+ # step-call-failed.sh <pipeline.json> <state-dir> env CALL_AGENT (the pipeline agent), CALL_MODEL (the model just used),
7
+ # CALL_STORY, CALL_REASON (why the attempt failed), CALL_ATTEMPT
8
+ # 1. agent_ladder_record_failure, then agent_ladder_model: the next rung on the chain this agent's archetype declares;
9
+ # 2. the previous-refusal block (the hub's _ai_prompt_with_failure: the reason, redacted, its tail) for the next prompt;
10
+ # 3. kb_record_episode: the episode, so self-heal learns from it (the hub's _ai_kb_after_failure).
11
+ # Prints {"model": "<next>", "decision": "escalating|top|no-chain", "block": "<previous-refusal>", "episode": bool, "line": "..."}.
12
+ set -uo pipefail
13
+ PJ="${1:?pipeline.json}" STATE="${2:?state dir}"
14
+ INSTALL=$(jq -r .install "$PJ") PROJECT=$(jq -r .project "$PJ")
15
+ S="$INSTALL/orchestrations/scripts"
16
+ : "${CALL_AGENT:?}" "${CALL_MODEL:=}" "${CALL_STORY:=global}" "${CALL_REASON:=the agent produced no usable answer}" "${CALL_ATTEMPT:=1}"
17
+ export EPAM_PROJECT_CONFIG_DIR="${EPAM_PROJECT_CONFIG_DIR:-$(jq -r '.projectConfig // empty' "$PJ")}"
18
+ [ -n "$EPAM_PROJECT_CONFIG_DIR" ] && [ -d "$EPAM_PROJECT_CONFIG_DIR" ] || EPAM_PROJECT_CONFIG_DIR="$INSTALL/orchestrations/projects/$PROJECT"
19
+ # the ladder's state lives with the run (agent-ladder.sh: "THE STATE DIES WITH THE RUN") — this stage's state folder
20
+ export LOG_DIR="$STATE/gateway"; mkdir -p "$LOG_DIR"
21
+ export NODE_BIN="${NODE_BIN:-$(command -v node)}"
22
+ _log="$LOG_DIR/call-failed.log"
23
+ {
24
+ # shellcheck source=/dev/null
25
+ . "$S/lib/model-ladders.sh"
26
+ # the project's chains, EPAM_MODEL_LADDER_<TIER> (sourcing only defines the exporter — the hub's model-ladder.sh calls it)
27
+ export_model_ladders "$EPAM_PROJECT_CONFIG_DIR/llm-settings.json"
28
+ # shellcheck source=/dev/null
29
+ . "$S/lib/agent-ladder.sh"
30
+ # shellcheck source=/dev/null
31
+ . "$S/lib/kb-apply.sh"
32
+ } >>"$_log" 2>&1 || { jq -cn --rawfile m "$_log" '{error:true, reason:"the pipeline ladder/KB libraries did not load", message:$m}'; exit 0; }
33
+ declare -F agent_ladder_model >/dev/null || { jq -cn '{error:true, reason:"agent_ladder_model is not defined"}'; exit 0; }
34
+
35
+ agent_ladder_record_failure "$CALL_AGENT" "$CALL_STORY"
36
+ next="$(agent_ladder_model "$CALL_AGENT" "$CALL_STORY" "$CALL_MODEL" 2>>"$_log")"
37
+ if [ -n "$next" ] && [ "$next" != "$CALL_MODEL" ]; then
38
+ decision=escalating; line="[ai-run] attempt $((CALL_ATTEMPT + 1)) for '${CALL_AGENT}' — escalating ${CALL_MODEL} -> ${next}"
39
+ elif agent_ladder_exhausted "$CALL_AGENT" "$CALL_STORY" "$CALL_MODEL" 2>>"$_log"; then
40
+ decision=top; next="$CALL_MODEL"; line="[ai-run] attempt $((CALL_ATTEMPT + 1)) for '${CALL_AGENT}' — at the top of its declared chain (${CALL_MODEL:-default}), retrying the same rung"
41
+ else
42
+ decision=no-chain; next="$CALL_MODEL"; line="[ai-run] attempt $((CALL_ATTEMPT + 1)) for '${CALL_AGENT}' — no chain declared for this agent's tier, retrying on ${CALL_MODEL:-default}"
43
+ fi
44
+
45
+ # the hub's _ai_prompt_with_failure: never a credential in a prompt; bounded, the tail kept
46
+ why="$(printf '%s' "$CALL_REASON" | sed -E 's/(sk-[A-Za-z0-9_-]{8,}|ey[A-Za-z0-9_-]{20,}|[A-Za-z0-9_-]{32,})/[REDACTED]/g' | tail -c 1200)"
47
+ block="$("$NODE_BIN" -e '
48
+ try {
49
+ const { renderEngineTemplate } = require(process.argv[1] + "/lib/engine-prompt.js");
50
+ process.stdout.write(renderEngineTemplate("previous-refusal", { __REASON__: process.argv[2], __ARTEFACT__: process.argv[3] || "answer" }));
51
+ } catch (e) { process.stdout.write(""); }
52
+ ' "$S" "$why" "$CALL_AGENT" 2>>"$_log")"
53
+
54
+ episode=false
55
+ if declare -F kb_record_episode >/dev/null; then
56
+ printf '%s' "$CALL_REASON" | kb_record_episode "$CALL_STORY" "$CALL_AGENT" "model call produced no usable output" 2>>"$_log" && episode=true
57
+ fi
58
+ printf '%s\n' "$line" >>"$_log"
59
+ # THE SELF-HEAL LISTENER'S RECORD (tools/live-tracker.js "Abort-class checks"): one event per failed call, in the run's own
60
+ # folder — what failed, on which model, where the ladder sent it, whether the KB learned it.
61
+ jq -cn --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg agent "$CALL_AGENT" --arg story "$CALL_STORY" --argjson attempt "$CALL_ATTEMPT" \
62
+ --arg from "$CALL_MODEL" --arg to "$next" --arg decision "$decision" --argjson episode "$episode" --arg reason "$CALL_REASON" \
63
+ '{ts:$ts, kind:"call-failed", agent:$agent, story:$story, attempt:$attempt, from:$from, to:$to, decision:$decision, episode:$episode, reason:$reason}' \
64
+ >> "$LOG_DIR/events.jsonl"
65
+ jq -cn --arg model "$next" --arg decision "$decision" --arg block "$block" --argjson episode "$episode" --arg line "$line" \
66
+ '{model:$model, decision:$decision, block:$block, episode:$episode, line:$line}'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amsd-pipeline-workflow",
3
- "version": "0.1.1",
3
+ "version": "0.1.6",
4
4
  "description": "The AMSD pipeline as Claude workflows. Installed by the amsd-pipeline installer: install.sh --workflows",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "amsdPipeline": {
23
- "tested": "2.0.78"
23
+ "tested": "2.0.78",
24
+ "shared": "0.2.0"
24
25
  }
25
26
  }
@@ -28,8 +28,15 @@ function fromSchema(schema, overrides) {
28
28
  async function run(source, args, answer) {
29
29
  const metaMatch = source.match(/^\s*export const meta\s*=\s*(\{[\s\S]*?\n\})\s*\n/);
30
30
  if (!metaMatch) throw new Error('script does not start with `export const meta = {…}`');
31
+ // THE WORKFLOW TOOL'S OWN RULES, as the live tool enforced them on 2026-10-18 ("meta must be a pure literal: spread not
32
+ // allowed in meta"): meta holds no spread, template literal or call — checked with the strings taken out
33
+ const unstring = (x) => x.replace(/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g, '""');
34
+ const metaCode = unstring(metaMatch[1]);
35
+ if (/\.\.\.|`|\(/.test(metaCode)) throw new Error('Invalid workflow script: meta must be a pure literal (no spread, template literal or call)');
31
36
  const meta = vm.runInNewContext(`(${metaMatch[1]})`, {}); // a literal, or this throws
32
37
  const body = source.slice(metaMatch[0].length);
38
+ // and the body never reads the clock or randomness (they would break resume)
39
+ if (/\bDate\.now\s*\(|\bMath\.random\s*\(|\bnew Date\s*\(\s*\)/.test(unstring(body))) throw new Error('Invalid workflow script: Date.now(), Math.random() and argless new Date() are not available');
33
40
  const calls = []; const phases = []; const logs = [];
34
41
  const agent = async (prompt, opts = {}) => {
35
42
  const call = { n: calls.length + 1, prompt: String(prompt), opts, phase: phases[phases.length - 1] || null };
@@ -2,7 +2,8 @@
2
2
  // THE LAYER-1 HARNESS WITH THE AGENTS' HOOKS (docs/TESTING.md). test/layer1/harness.js runs the real script with a
3
3
  // stand-in for every agent; this runs, in addition, each agent definition's own PreToolUse hook on the stand-in's answer
4
4
  // — the generated command, from the generated agents file — exactly as Claude Code would when the agent submits its
5
- // StructuredOutput: the hook's `updatedInput` becomes the answer the script sees. An agent the stand-in fails (null)
5
+ // StructuredOutput: the hook's `updatedInput` becomes the answer the script sees. Its input names the calling agent
6
+ // (agent_type), as the real tool's does. An agent the stand-in fails (null)
6
7
  // never answers, so its hook never runs. No AI.
7
8
  const fs = require('fs');
8
9
  const { spawnSync } = require('child_process');
@@ -25,7 +26,7 @@ async function runWithHooks(source, args, agentsFile, answer) {
25
26
  const pre = ((def.hooks || {}).PreToolUse || []).filter((h) => new RegExp(`^(${h.matcher})$`).test('StructuredOutput'));
26
27
  let input = a;
27
28
  for (const h of pre) for (const c of h.hooks || []) {
28
- const res = spawnSync('bash', ['-c', c.command], { input: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'StructuredOutput', tool_input: input }),
29
+ const res = spawnSync('bash', ['-c', c.command], { input: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'StructuredOutput', tool_input: input, agent_type: call.opts.agentType }),
29
30
  encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
30
31
  hookRuns.push({ agentType: call.opts.agentType, status: res.status, stderr: (res.stderr || '').slice(-2000) });
31
32
  if (res.status !== 0) throw new Error(`hook of ${call.opts.agentType} exited ${res.status}: ${(res.stderr || '').slice(-1500)}`);