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 +28 -6
- package/driver/hook-dispatch.js +26 -0
- package/driver/run.js +5 -3
- package/driver/runtime.js +56 -6
- package/driver/session-cost.js +31 -4
- package/driver/stages.js +14 -5
- package/generator/agents-workflow.js +18 -4
- package/generator/gateway.js +46 -0
- package/generator/gateway.wf.js +40 -0
- package/generator/generate-spec.js +16 -0
- package/generator/generate.js +21 -0
- package/generator/mailbox.wf.js +20 -0
- package/generator/spec-workflow.js +17 -3
- package/generator/writer-workflow.js +47 -32
- package/hooks/agents-sandbox.sh +11 -0
- package/hooks/messages-out.js +33 -0
- package/hooks/pipeline-step.js +5 -2
- package/hooks/step-call-failed.sh +66 -0
- package/package.json +3 -2
- package/test/layer1/harness.js +7 -0
- package/test/layer1/hook-harness.js +3 -2
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)
|
|
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
|
-
|
|
9
|
+
Three steps, in this order, into one install:
|
|
9
10
|
|
|
10
11
|
```bash
|
|
11
|
-
npx amsd-pipeline
|
|
12
|
-
npx amsd-pipeline-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
// the session
|
|
86
|
-
|
|
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 }) {
|
package/driver/session-cost.js
CHANGED
|
@@ -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
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
|
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' },
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/generator/generate.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
516
|
-
if (!finding) finding = await
|
|
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
|
|
524
|
-
if (!rm) rm = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/hooks/agents-sandbox.sh
CHANGED
|
@@ -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
|
+
}
|
package/hooks/pipeline-step.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/test/layer1/harness.js
CHANGED
|
@@ -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.
|
|
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)}`);
|