amsd-pipeline-workflow 0.1.26 → 0.1.27

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.
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
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
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
5
+ // its input names the calling agent (agent_type). driver/runtime.js liveCommand writes this one PreToolUse hook into the session's settings file; it
6
6
  // runs the calling agent's own hook command — exactly as its definition declares it — with the same input, and passes its
7
7
  // output and exit code back unchanged. An agent with no hook for this tool passes through (no output, exit 0).
8
8
  // node driver/hook-dispatch.js <map.json> map: { "<agent_type>": [{ matcher, command }...] }
@@ -15,7 +15,11 @@ process.stdin.on('end', () => {
15
15
  let ev = {};
16
16
  try { ev = JSON.parse(input); } catch { process.exit(0); }
17
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 || ''));
18
+ // THE PLUGIN NAMES ITS AGENTS "<plugin>:<agent>" (driver/runtime.js): the agent is the part after the colon, and the hook is handed its own agent's
19
+ // name as the pipeline's hooks know it — every hook reads agent_type as it did when the agents were placed bare
20
+ const agent = String(ev.agent_type || '').replace(/^.*:/, '');
21
+ if (ev.agent_type !== undefined) { ev.agent_type = agent; input = JSON.stringify(ev); }
22
+ const hooks = (map[agent] || []).filter((h) => !h.matcher || new RegExp(`^(?:${h.matcher})$`).test(ev.tool_name || ''));
19
23
  for (const h of hooks) {
20
24
  const r = spawnSync('bash', ['-c', h.command], { input, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
21
25
  if (r.stdout) process.stdout.write(r.stdout);
package/driver/runtime.js CHANGED
@@ -7,8 +7,9 @@
7
7
  // (test/layer1/hook-harness.js); each agent is answered by a generative stand-in (`standin(call)`); the
8
8
  // hooks are the real ones, run in `env`.
9
9
  // live — one clean headless Claude Code session per workflow (decision 15), memory-bounded with
10
- // bin/bounded (this package's memory bound), the workflow's agent definitions as FILES in the session's .claude/agents/
11
- // (the Writer has ~150 of them: one --agents argument would pass Linux's 128 KB argument cap). Live runs
10
+ // bin/bounded (this package's memory bound), the workflow, its agent definitions (as FILES: the Writer has ~150 of
11
+ // them, one --agents argument would pass Linux's 128 KB argument cap) and its hook settings in a BUNDLE under the session's
12
+ // folder — OUTSIDE the codeline the session works in (--plugin-dir, --settings): the pipeline puts nothing in a client's checkout. Live runs
12
13
  // spend tokens: liveCommand() only builds the command; runLive() refuses unless the operator's yes is
13
14
  // given (EPAM_DRIVER_LIVE_APPROVED=yes AND opts.approved === true). Never run by any test.
14
15
  const fs = require('fs');
@@ -19,8 +20,8 @@ const { spawnSync } = require('child_process');
19
20
  const ROOT = path.resolve(__dirname, '..');
20
21
  const { recordSession } = require('./session-cost.js');
21
22
 
22
- // the names workflows were placed under by earlier versions of this driver and that a test runner's default pattern matches
23
- const LEGACY_WORKFLOW_FILES = ['spec'];
23
+ /** The plugin the session's bundle is: its agents are named "<PLUGIN>:<agent>" and its workflows run as "<PLUGIN>:<workflow>". */
24
+ const PLUGIN = 'amsd';
24
25
 
25
26
  function toMarkdown(name, def) {
26
27
  // generator/agents.js toMarkdown, from the agents JSON entry (frontmatter keys + `prompt` as the body)
@@ -33,23 +34,55 @@ function toMarkdown(name, def) {
33
34
  return `---${yaml({ name, ...fm })}\n---\n\n${String(prompt || '').trim()}\n`;
34
35
  }
35
36
 
37
+ /** The end of a script's `export const meta = {…}` literal (and the rest of its line): braces inside strings do not count. */
38
+ function metaEnd(src) {
39
+ const m = /^export const meta = \{/.exec(src);
40
+ if (!m) throw new Error('a workflow script must begin with `export const meta = {…}`');
41
+ let depth = 0; let quote = '';
42
+ for (let i = m[0].length - 1; i < src.length; i += 1) {
43
+ const c = src[i];
44
+ if (quote) { if (c === '\\') i += 1; else if (c === quote) quote = ''; continue; }
45
+ if (c === "'" || c === '"' || c === '`') quote = c;
46
+ else if (c === '{') depth += 1;
47
+ else if (c === '}' && (depth -= 1) === 0) { const nl = src.indexOf('\n', i); return nl < 0 ? src.length : nl + 1; }
48
+ }
49
+ throw new Error('the workflow script\'s `export const meta` literal is not closed');
50
+ }
51
+
36
52
  /**
37
- * The live session, prepared but not started: its folder (workflow + agents as files) and the exact command.
38
- * Writes only under sessionDir.
53
+ * THE PLUGIN NAMES ITS AGENTS "<plugin>:<agent>" (an unprefixed type is "not found", tried 2026-10-21) and the generated scripts name them bare:
54
+ * the placed copy shadows agent() with one that adds the prefix where the call is made. Nothing in the generated script changes.
55
+ */
56
+ function agentPrelude(plugin) {
57
+ return `const __amsdAgent = globalThis.agent\nconst agent = (p, o) => __amsdAgent(p, o && o.agentType && !String(o.agentType).includes(':') ? { ...o, agentType: '${plugin}:' + o.agentType } : o)\n`;
58
+ }
59
+ function withAgentPrefix(src, plugin = PLUGIN) {
60
+ const at = metaEnd(src);
61
+ return `${src.slice(0, at)}${agentPrelude(plugin)}${src.slice(at)}`;
62
+ }
63
+
64
+ /**
65
+ * The live session, prepared but not started: its bundle and the exact command. Writes only under sessionDir — NEVER in the folder the session
66
+ * works in (operator, 2026-10-21: the pipeline puts nothing in a client's checkout; run 11's workflow file there became a deliverable, was
67
+ * restored by the Writer and run as a test by the client's jest).
68
+ *
69
+ * <sessionDir>/bundle/ the plugin (--plugin-dir): .claude-plugin/plugin.json, workflows/<name>.js, agents/<agent>.md
70
+ * <sessionDir>/settings.json the agents' hooks (--settings): one dispatcher over <sessionDir>/hook-map.json
71
+ *
72
+ * THE FOLDER THE AGENTS WORK IN (cwd): prompts' paths are relative to the codeline, as the hub runs every agent from its PROJECT_ROOT
73
+ * (lib/relative-paths.js), so the session starts there. Without one, the session's own folder.
39
74
  */
40
75
  function liveCommand({ name, script, agentsJson, args, sessionDir, env, cwd }) {
41
- // THE FOLDER THE AGENTS WORK IN (cwd): prompts' paths are relative to the codeline, as the hub runs every agent from its
42
- // PROJECT_ROOT (lib/relative-paths.js), so the session starts there; its .claude/ is listed in that copy's own
43
- // .git/info/exclude — never committed, never reported as a change. Without one, the session's own folder.
44
76
  const home = cwd || sessionDir;
45
- if (cwd) excludeLocally(cwd, '/.claude/');
46
- const agentsDir = path.join(home, '.claude', 'agents');
47
- const wfDir = path.join(home, '.claude', 'workflows');
48
- fs.mkdirSync(agentsDir, { recursive: true }); fs.mkdirSync(wfDir, { recursive: true });
77
+ const bundle = path.join(sessionDir, 'bundle');
78
+ fs.rmSync(bundle, { recursive: true, force: true }); // a later session in this folder must not inherit an agent this one does not have
79
+ const agentsDir = path.join(bundle, 'agents'); const wfDir = path.join(bundle, 'workflows');
80
+ fs.mkdirSync(agentsDir, { recursive: true }); fs.mkdirSync(wfDir, { recursive: true }); fs.mkdirSync(path.join(bundle, '.claude-plugin'), { recursive: true });
81
+ fs.writeFileSync(path.join(bundle, '.claude-plugin', 'plugin.json'), `${JSON.stringify({ name: PLUGIN, version: '1.0.0', description: 'The pipeline\'s workflow, agents and hooks for one session' })}\n`);
49
82
  const defs = JSON.parse(fs.readFileSync(agentsJson, 'utf8'));
50
- // THE AGENTS' HOOKS RUN FROM THE SESSION'S PROJECT SETTINGS (driver/hook-dispatch.js): the real Workflow tool does not run
51
- // a hook in a workflow agent's frontmatter, and does run one in <cwd>/.claude/settings.json, whose input names the agent
52
- // (tools/hook-probe.js, 2026-10-18). The frontmatter is written without them, so nothing could ever run twice.
83
+ // THE AGENTS' HOOKS RUN FROM THE SESSION'S SETTINGS (driver/hook-dispatch.js): the real Workflow tool does not run a hook in a workflow
84
+ // agent's frontmatter, and does run one in the settings it is given, whose input names the agent (tools/hook-probe.js, 2026-10-18).
85
+ // The frontmatter is written without them, so nothing could ever run twice.
53
86
  const map = {}; const matchers = new Set(); let timeout = 0;
54
87
  for (const [n, d] of Object.entries(defs)) {
55
88
  for (const g of (((d.hooks || {}).PreToolUse) || [])) for (const h of (g.hooks || [])) {
@@ -60,41 +93,21 @@ function liveCommand({ name, script, agentsJson, args, sessionDir, env, cwd }) {
60
93
  const { hooks: _h, ...noHooks } = d;
61
94
  fs.writeFileSync(path.join(agentsDir, `${n}.md`), toMarkdown(n, noHooks));
62
95
  }
63
- const mapFile = path.join(home, '.claude', 'hook-map.json'); fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
64
- const settingsFile = path.join(home, '.claude', 'settings.json');
65
- let settings = {}; try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch { settings = {}; }
96
+ const mapFile = path.join(sessionDir, 'hook-map.json'); fs.writeFileSync(mapFile, JSON.stringify(map, null, 2));
97
+ const settingsFile = path.join(sessionDir, 'settings.json');
66
98
  const dispatch = `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(__dirname, 'hook-dispatch.js'))} ${JSON.stringify(mapFile)}`;
67
- const pre = (((settings.hooks || {}).PreToolUse) || []).filter((g) => !(g.hooks || []).some((h) => String(h.command || '').includes('hook-dispatch.js')));
68
- if (Object.keys(map).length) pre.push({ matcher: [...matchers].join('|') || '.*', hooks: [{ type: 'command', command: dispatch, ...(timeout ? { timeout } : {}) }] });
69
- settings.hooks = { ...(settings.hooks || {}), PreToolUse: pre };
70
- fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
71
- // A LEGACY FILE OF THE PIPELINE'S OWN goes (the Spec workflow was placed as spec.js, which a client's jest runs as a test — run 11): only a file named like a legacy workflow that carries a workflow header
72
- // of that same name; a client's file of the same name is never touched
73
- for (const legacy of LEGACY_WORKFLOW_FILES) {
74
- const f = path.join(wfDir, `${legacy}.js`);
75
- try { if (new RegExp(`^export const meta = \\{\\s*name: '${legacy}'`).test(fs.readFileSync(f, 'utf8')) && legacy !== name) fs.rmSync(f); } catch { /* none */ }
76
- }
77
- fs.copyFileSync(script, path.join(wfDir, `${name}.js`));
99
+ const pre = Object.keys(map).length ? [{ matcher: [...matchers].join('|') || '.*', hooks: [{ type: 'command', command: dispatch, ...(timeout ? { timeout } : {}) }] }] : [];
100
+ fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { PreToolUse: pre } }, null, 2));
101
+ fs.writeFileSync(path.join(wfDir, `${name}.js`), withAgentPrefix(fs.readFileSync(script, 'utf8')));
78
102
  // THE RUNNER WAITS FOR ITS WORKFLOW (LIVE-AMSD-2974-3, 2026-10-18: the runner answered "It's still running in the
79
103
  // background…" and the session exited, taking the Spec workflow down with it): it answers only with the finished result
80
104
  // the session itself waits for its workflow (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 below); the runner has no task-wait tool
81
- 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.`;
105
+ const prompt = `Run the workflow "${PLUGIN}:${name}" with these args, exactly: ${JSON.stringify(args || {})}. When it has finished, reply with its result as JSON and nothing else.`;
82
106
  const claude = (env && env.CLAUDE_BIN) || 'claude';
83
107
  // memory scope (bin/bounded) → CPU bound derived from that scope (bin/cpu-bound, the pipeline's own bound_self_affinity) → the session: its tools (jest included) cannot out-size the scope (run 11 OOM)
84
108
  const argv = [path.join(__dirname, '..', 'bin', 'bounded'), path.join(__dirname, '..', 'bin', 'cpu-bound'), claude, '-p', prompt, '--output-format', 'json',
85
- '--setting-sources', 'project', '--permission-mode', 'bypassPermissions'];
86
- return { cwd: home, argv, agents: Object.keys(defs).length, workflow: path.join(wfDir, `${name}.js`) };
87
- }
88
-
89
- /** A pattern in a repository's own, uncommitted exclude list (.git/info/exclude), once. */
90
- function excludeLocally(repo, pattern) {
91
- const g = spawnSync('git', ['-C', repo, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' });
92
- if (g.status !== 0) return;
93
- const f = path.resolve(repo, g.stdout.trim());
94
- const cur = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
95
- if (cur.split('\n').includes(pattern)) return;
96
- fs.mkdirSync(path.dirname(f), { recursive: true });
97
- fs.writeFileSync(f, `${cur.replace(/\n?$/, cur ? '\n' : '')}${pattern}\n`);
109
+ '--plugin-dir', bundle, '--settings', settingsFile, '--setting-sources', 'project', '--permission-mode', 'bypassPermissions'];
110
+ return { cwd: home, argv, agents: Object.keys(defs).length, workflow: path.join(wfDir, `${name}.js`), bundle, settings: settingsFile };
98
111
  }
99
112
 
100
113
  /** The pipeline's tool commands as named variables (llm-handler.sh:240–245: lib/pipeline-tools.js commandEnv(), exported
@@ -219,11 +232,11 @@ async function run(o) {
219
232
  // carry its story, as the story's attempts do in the pipeline
220
233
  if (res && (res.__session || !res.__throw) && opts.env.SCRIPT_DIR && (opts.env.PHASE_COST_FILE || opts.env.LOG_DIR)) {
221
234
  res.__cost = recordSession(opts.env, res.__session, { stage: opts.name, storyId: opts.costStory || null, phase: opts.env.PHASE || null,
222
- projectsDir: res.__session && res.__session.projectsDir, log: opts.log });
235
+ projectsDir: res.__session && res.__session.projectsDir, agentsDir: opts.sessionDir ? path.join(opts.sessionDir, 'bundle', 'agents') : undefined, log: opts.log });
223
236
  await Promise.all(res.__cost.traces || []);
224
237
  }
225
238
  if (res && res.__throw) throw res.__throw;
226
239
  return res;
227
240
  }
228
241
 
229
- module.exports = { run, liveCommand, toMarkdown };
242
+ module.exports = { run, liveCommand, toMarkdown, withAgentPrefix, agentPrelude, metaEnd, PLUGIN };
@@ -41,7 +41,7 @@ function transcriptTokens(file) {
41
41
  * toolCallsInTranscript, read from the call's own transcript). For a workflow agent: its prompt is the first user turn of its
42
42
  * transcript, its reply the answer it gave (its StructuredOutput, else its last text), its tool calls by the pipeline's own
43
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) {
44
+ function callContent(file, scriptsDir, agentName, agentsDir) {
45
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
46
  const first = lines.find((d) => d.type === 'user' && d.message);
47
47
  const asst = lines.filter((d) => d.type === 'assistant' && d.message && Array.isArray(d.message.content)).flatMap((d) => d.message.content);
@@ -50,11 +50,11 @@ function callContent(file, scriptsDir, agentName) {
50
50
  // eslint-disable-next-line global-require, import/no-dynamic-require
51
51
  const tx = require(path.join(scriptsDir, 'lib', 'transcript-tool-calls.js'));
52
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;
53
+ // the agent's standing instructions: its definition in the session's bundle (<session>/bundle/agents/<agent>.md, written by
54
+ // driver/runtime.js liveCommand; the plugin names the agent "<plugin>:<agent>") — sent to the model as its system prompt, not in the transcript
55
+ const agentType = String((lines.find((d) => d.agentType) || {}).agentType || agentName || '').replace(/^.*:/, '');
56
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 = ''; }
57
+ try { const md = fs.readFileSync(path.join(agentsDir, `${agentType}.md`), 'utf8'); system = md.replace(/^---[\s\S]*?\n---\n+/, ''); } catch { system = ''; }
58
58
  const prompt = first ? first.message.content : '';
59
59
  return { input: system ? { system, prompt } : prompt, output: so ? JSON.stringify(so.input) : (text || ''), // text: the emitter's output is text
60
60
  toolCalls: tx.toolCallsInTranscript(file), startedAt: ts[0] || null, endedAt: ts[ts.length - 1] || null };
@@ -85,7 +85,8 @@ function sessionEntries(projectsDir, sessionId) {
85
85
  for (const f of fs.readdirSync(rd).filter((x) => /^agent-.*\.jsonl$/.test(x)).sort()) {
86
86
  let meta = {}; try { meta = JSON.parse(fs.readFileSync(path.join(rd, f.replace(/\.jsonl$/, '.meta.json')), 'utf8')); } catch { meta = {}; }
87
87
  const label = meta.description || f;
88
- push(meta.agentType && meta.agentType !== 'workflow-subagent' ? meta.agentType : label, label, path.join(rd, f));
88
+ // the plugin names an agent "<plugin>:<agent>" (driver/runtime.js): the ledger and the traces name it as the pipeline does
89
+ push(meta.agentType && meta.agentType !== 'workflow-subagent' ? String(meta.agentType).replace(/^.*:/, '') : label, label, path.join(rd, f));
89
90
  }
90
91
  }
91
92
  }
@@ -133,7 +134,7 @@ function allocate(entries, modelUsage, prices = {}) {
133
134
  * Record one session's cost. session: {sessionId, modelUsage, totalCostUsd} (the session's result).
134
135
  * Returns {rows, totalCostUsd, exact: bool (per-call rows written), reason, traces: the Langfuse sends, Promise<boolean>[]}.
135
136
  */
136
- function recordSession(env, session, { stage, storyId, phase, projectsDir, log }) {
137
+ function recordSession(env, session, { stage, storyId, phase, projectsDir, agentsDir, log }) {
137
138
  const ledger = env.PHASE_COST_FILE || path.join(env.LOG_DIR, 'phase-cost.jsonl');
138
139
  // eslint-disable-next-line global-require, import/no-dynamic-require
139
140
  const { appendLedgerRecord } = require(path.join(env.SCRIPT_DIR, 'lib', 'cost-emitter.js'));
@@ -155,7 +156,7 @@ function recordSession(env, session, { stage, storyId, phase, projectsDir, log }
155
156
  costIsEstimate: true, costUnknown: e.costUnknown } }));
156
157
  // each agent call to Langfuse with its prompt, its answer and its tool calls, as the pipeline's cost seam traces each
157
158
  // of its calls (lib/cost-emitter.js → lib/langfuse-emit.js); off without keys
158
- const c = e.file ? callContent(e.file, env.SCRIPT_DIR, e.agent) : {};
159
+ const c = e.file ? callContent(e.file, env.SCRIPT_DIR, e.agent, agentsDir) : {};
159
160
  traces.push(emitGeneration({ agent: e.agent, storyId, phase, model: e.model, startedAt: c.startedAt, endedAt: c.endedAt,
160
161
  input: c.input, output: c.output, toolCalls: c.toolCalls, costUsd: e.costUsd, tokensIn: e.tokens.in, tokensOut: e.tokens.out,
161
162
  cacheRead: e.tokens.cacheRead, cacheCreate: e.tokens.cacheCreate, costIsEstimate: true }, { ...process.env, ...env }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amsd-pipeline-workflow",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
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": {