amsd-pipeline-workflow 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,13 +5,17 @@ own scripts) and is installed by the pipeline's installer.
5
5
 
6
6
  ## Install
7
7
 
8
+ Into an existing AMSD pipeline install (the pipeline itself is not changed):
9
+
8
10
  ```bash
9
- npx amsd-pipeline@<version> --dest <dir> --ref v<version> --no-docker --workflows
11
+ npx amsd-pipeline@<version> --dest <dir> --ref v<version> --no-docker # the pipeline, as always
12
+ npx amsd-pipeline-workflow install --into <dir> [--codeline-cache <cache>]
10
13
  ```
11
14
 
12
- `--workflows` installs the `amsd-pipeline-workflow` version the pipeline release pins
13
- (`orchestrations/config/claude-workflows.json`) into `<dir>/claude-workflows/`. `--workflows <file.tgz>` installs a
14
- local package instead (for testing a build before it is published). `--check` verifies it; `--uninstall` removes it.
15
+ This writes only `<dir>/claude-workflows/` and, with `--codeline-cache`, each codeline's cached roster and prompts
16
+ (`<cache>/orchestrations/projects/<project>/.prompt-cache/<codeline>/` and its `.complete-<codeline>[.brownfield]` marker)
17
+ 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/`.
15
19
 
16
20
  Needs: Node 20+, the `claude` CLI, `unshare` (util-linux) and `systemd-run --user` (the memory bound, `bin/bounded`).
17
21
 
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // amsd-pipeline-workflow — the AMSD pipeline as Claude workflows. Installed by the pipeline's own installer
4
- // (install.sh --workflows) into <install>/claude-workflows/, which writes install.json there naming the install.
3
+ // amsd-pipeline-workflow — the AMSD pipeline as Claude workflows, installed INTO an existing pipeline install with no
4
+ // change to the pipeline (operator, 2026-10-18).
5
+ // amsd-pipeline-workflow install --into <pipeline install> [--codeline-cache <dir>]
6
+ // writes <install>/claude-workflows/ (this package's published files, and install.json naming the install) and,
7
+ // with --codeline-cache, each codeline's cache entry from <dir>/orchestrations/projects/<p>/.prompt-cache/ — its
8
+ // <codeline>/ folder (the minted roster and the prompts) and its .complete-<codeline>[.brownfield] marker — into the
9
+ // same project of the install, so the pipeline's own reuse step restores them instead of minting and generating
10
+ // again. Nothing else in the install is written.
5
11
  // amsd-pipeline-workflow run <driver/run.js arguments> — --install defaults to the install this was installed into
6
12
  // amsd-pipeline-workflow version
7
13
  const fs = require('fs');
@@ -9,9 +15,51 @@ const path = require('path');
9
15
  const { spawnSync } = require('child_process');
10
16
 
11
17
  const ROOT = path.resolve(__dirname, '..');
18
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
12
19
  const [cmd, ...rest] = process.argv.slice(2);
13
- if (cmd === 'version') { process.stdout.write(`${require(path.join(ROOT, 'package.json')).version}\n`); process.exit(0); }
14
- if (cmd !== 'run') { process.stderr.write('usage: amsd-pipeline-workflow run <driver arguments> | version\n'); process.exit(64); }
20
+ const fail = (m) => { process.stderr.write(`amsd-pipeline-workflow: ${m}\n`); process.exit(1); };
21
+ const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
22
+
23
+ if (cmd === 'version') { process.stdout.write(`${PKG.version}\n`); process.exit(0); }
24
+
25
+ if (cmd === 'install') {
26
+ const into = opt('--into'); const cacheDir = opt('--codeline-cache');
27
+ if (!into) fail('install needs --into <pipeline install>');
28
+ const inst = path.resolve(into);
29
+ if (!fs.existsSync(path.join(inst, 'orchestrations', 'scripts')) || !fs.existsSync(path.join(inst, 'orchestrations', 'projects'))) fail(`${inst} is not a pipeline install (no orchestrations/scripts and orchestrations/projects)`);
30
+ if (cacheDir && !fs.existsSync(path.join(path.resolve(cacheDir), 'orchestrations', 'projects'))) fail(`${cacheDir} holds no codeline cache (no orchestrations/projects)`);
31
+ // the package: exactly its published files (package.json "files"), into a new folder, then swapped in
32
+ const dest = path.join(inst, 'claude-workflows'); const tmp = `${dest}.new`;
33
+ fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true });
34
+ for (const f of [...PKG.files, 'package.json']) {
35
+ const src = path.join(ROOT, f.replace(/\/$/, ''));
36
+ if (fs.existsSync(src)) fs.cpSync(src, path.join(tmp, f.replace(/\/$/, '')), { recursive: true });
37
+ }
38
+ fs.writeFileSync(path.join(tmp, 'install.json'), `${JSON.stringify({ install: inst }, null, 2)}\n`);
39
+ fs.rmSync(dest, { recursive: true, force: true }); fs.renameSync(tmp, dest);
40
+ process.stdout.write(`claude workflows ${PKG.version} → ${dest}\n`);
41
+ if (cacheDir) {
42
+ const projects = path.join(path.resolve(cacheDir), 'orchestrations', 'projects');
43
+ let n = 0;
44
+ for (const p of fs.readdirSync(projects)) {
45
+ const src = path.join(projects, p, '.prompt-cache'); const proj = path.join(inst, 'orchestrations', 'projects', p);
46
+ if (!fs.existsSync(src) || !fs.existsSync(proj)) continue;
47
+ for (const m of fs.readdirSync(src).filter((x) => x.startsWith('.complete-'))) {
48
+ const cl = m.replace(/^\.complete-/, '').replace(/\.brownfield$/, '');
49
+ if (!fs.existsSync(path.join(src, cl))) continue;
50
+ const dst = path.join(proj, '.prompt-cache'); fs.mkdirSync(dst, { recursive: true });
51
+ fs.rmSync(path.join(dst, cl), { recursive: true, force: true });
52
+ fs.cpSync(path.join(src, cl), path.join(dst, cl), { recursive: true });
53
+ fs.copyFileSync(path.join(src, m), path.join(dst, m));
54
+ process.stdout.write(`codeline cache: ${p} / ${cl}\n`); n += 1;
55
+ }
56
+ }
57
+ if (!n) process.stdout.write(`codeline cache: ${cacheDir} holds none to carry\n`);
58
+ }
59
+ process.exit(0);
60
+ }
61
+
62
+ if (cmd !== 'run') { process.stderr.write('usage: amsd-pipeline-workflow install --into <pipeline install> [--codeline-cache <dir>] | run <driver arguments> | version\n'); process.exit(64); }
15
63
  const args = [...rest];
16
64
  const linked = path.join(ROOT, 'install.json');
17
65
  if (!args.includes('--install') && fs.existsSync(linked)) args.unshift('--install', JSON.parse(fs.readFileSync(linked, 'utf8')).install);
package/driver/run.js CHANGED
@@ -72,6 +72,10 @@ async function main(a) {
72
72
  const ck = path.join(a.install, 'orchestrations', 'projects', a.project, 'runs', a['recorded-run'] || a.run, 'lanes', a['recorded-lane'] || Object.keys(map)[0], 'checkpoint');
73
73
  for (const f of ['roster.json', 'agent-profiles.json', 'profiles.json']) if (fs.existsSync(path.join(ck, f))) fs.copyFileSync(path.join(ck, f), path.join(project, f));
74
74
  }
75
+ // the run's profiles: AGENT_PROFILES_FILE defaults to the agents folder's (run-agent-orchestration.sh:404) — the run's
76
+ // own copy of it when no checkpoint gave one (a fresh install's project holds none); the install's is never written
77
+ const agentsProfiles = path.join(a.install, 'orchestrations', 'agents', 'profiles.json');
78
+ if (!fs.existsSync(path.join(project, 'profiles.json')) && fs.existsSync(agentsProfiles)) fs.copyFileSync(agentsProfiles, path.join(project, 'profiles.json'));
75
79
  const er = spawnSync('bash', [path.join(__dirname, 'env.sh'), a.install, a.project, a.run, runDir, set], { encoding: 'utf8', env: baseEnv, maxBuffer: 64 * 1024 * 1024 });
76
80
  if (er.status !== 0) throw new Error(`the run's environment did not load: ${er.stderr}`);
77
81
  let env = { ...JSON.parse(er.stdout), AGENT_PROFILES_FILE: path.join(project, 'profiles.json'), PIPELINE_PROFILES: path.join(project, 'profiles.json'),
@@ -84,6 +84,29 @@ function hubSi(art, agent, codelineRoot, work, projectEnv) {
84
84
  return relativizeLocalPaths(fs.readFileSync(out, 'utf8'), [codelineRoot]);
85
85
  }
86
86
 
87
+ /** THE ROSTER THE RUN STARTS FROM (lib/mint-and-spec.sh:156–232). When this run's codeline has a completion marker and a
88
+ * recorded roster, the pipeline restores that roster before the mint (both of its reuse branches, :170–175 and :183–192)
89
+ * and the hub reads each agent's SI from it at call time; the stage's agents are generated from that roster — by the
90
+ * pipeline's own restoreMinted, on a scratch copy of the project (the install is never written). Otherwise the project's
91
+ * roster as it is. The codeline: EPAM_CODELINE_ID, else the PRD's single output folder (codelineFromPrd, :157–165). */
92
+ function siProjectDir(art, prdFile, projectEnv, work) {
93
+ const env = { ...process.env, ...projectEnv };
94
+ if (env.EPAM_REGENERATE_CODELINE_ASSETS === '1') return art.projectDir;
95
+ const b = require(path.join(art.scripts, 'lib', 'project-prompt-builder.js'));
96
+ const cl = String(env.EPAM_CODELINE_ID || '').trim() || b.codelineFromPrd(prdFile);
97
+ if (!cl) return art.projectDir;
98
+ const marker = path.join(art.projectDir, '.prompt-cache', `.complete-${cl}${env.EPAM_BROWNFIELD === '1' ? '.brownfield' : ''}`); // prompt-variant.sh:15–21
99
+ if (!fs.existsSync(marker) || !b.hasMintedSnapshot({ projectConfigDir: art.projectDir, codeline: cl })) return art.projectDir;
100
+ const dir = path.join(work, 'si-project'); fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir, { recursive: true });
101
+ for (const f of fs.readdirSync(art.projectDir)) {
102
+ if (f === 'runs' || f === 'prompts') continue;
103
+ if (f === '.prompt-cache') fs.symlinkSync(path.join(art.projectDir, f), path.join(dir, f));
104
+ else fs.cpSync(path.join(art.projectDir, f), path.join(dir, f), { recursive: true });
105
+ }
106
+ b.restoreMinted({ projectConfigDir: dir, codeline: cl });
107
+ return dir;
108
+ }
109
+
87
110
  function stepRunner(name, description, hookArgs, firstTier) {
88
111
  return { name, pipelineAgent: null, seam: null, ladder: '',
89
112
  frontmatter: { name, description, model: firstTier.startModel,
@@ -158,8 +181,9 @@ function main(a) {
158
181
  const scratch = path.join(work, 'seam'); fs.mkdirSync(scratch, { recursive: true });
159
182
  const defs = []; const agentMap = {}; const si = {}; const noSi = [];
160
183
  const projectEnv = pipe.projectEnv(art);
184
+ const siArt = { ...art, projectDir: siProjectDir(art, seedPrd, projectEnv, work) };
161
185
  for (const s of seamNames) {
162
- const body = hubSi(art, s, path.resolve(a['codeline-root']), work, projectEnv);
186
+ const body = hubSi(siArt, s, path.resolve(a['codeline-root']), work, projectEnv);
163
187
  // A seam the hub has no SI for is a call the hub itself refuses (llm-handler.sh: "refusing the call"); it gets no
164
188
  // agent here either, and is listed.
165
189
  if (body == null) { noSi.push(s); continue; }
package/package.json CHANGED
@@ -1,10 +1,25 @@
1
1
  {
2
2
  "name": "amsd-pipeline-workflow",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The AMSD pipeline as Claude workflows. Installed by the amsd-pipeline installer: install.sh --workflows",
5
5
  "license": "UNLICENSED",
6
- "bin": { "amsd-pipeline-workflow": "bin/amsd-pipeline-workflow.js" },
7
- "files": ["bin/", "driver/", "generator/", "hooks/", "mcp/", "README.md"],
8
- "engines": { "node": ">=20" },
9
- "amsdPipeline": { "tested": "2.0.78" }
6
+ "bin": {
7
+ "amsd-pipeline-workflow": "bin/amsd-pipeline-workflow.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "driver/",
12
+ "generator/",
13
+ "hooks/",
14
+ "mcp/",
15
+ "test/layer1/harness.js",
16
+ "test/layer1/hook-harness.js",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "amsdPipeline": {
23
+ "tested": "2.0.78"
24
+ }
10
25
  }
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+ // LAYER 1 HARNESS (docs/TESTING.md) — runs a REAL workflow script's body with the workflow runtime's primitives,
3
+ // agents answered by a generative stand-in. No AI. Matches the runtime rules the docs state:
4
+ // - `export const meta` must be a plain literal and the first statement;
5
+ // - Date.now(), Math.random() and a no-argument new Date() THROW inside the script;
6
+ // - an agent that fails resolves to null; parallel()/pipeline() keep nulls in place;
7
+ // - an agent given a `schema` returns an object matching it.
8
+ const vm = require('vm');
9
+
10
+ /** Fill a JSON schema's required fields generatively (the shape a model would return), then apply overrides. */
11
+ function fromSchema(schema, overrides) {
12
+ const out = {};
13
+ const props = (schema && schema.properties) || {};
14
+ for (const k of (schema && schema.required) || Object.keys(props)) {
15
+ const t = (props[k] || {}).type;
16
+ out[k] = t === 'boolean' ? false : t === 'number' || t === 'integer' ? 0 : t === 'array' ? [] : t === 'object' ? fromSchema(props[k], {}) : `generated-${k}`;
17
+ }
18
+ return { ...out, ...(overrides || {}) };
19
+ }
20
+
21
+ /**
22
+ * Run a workflow script.
23
+ * @param {string} source the script text (as emitted)
24
+ * @param {object} args the `args` global
25
+ * @param {(call) => object|null} answer stand-in (may be async): given {n, prompt, opts}, return overrides for a schema answer, a
26
+ * string for a text answer, or null for an agent that failed
27
+ */
28
+ async function run(source, args, answer) {
29
+ const metaMatch = source.match(/^\s*export const meta\s*=\s*(\{[\s\S]*?\n\})\s*\n/);
30
+ if (!metaMatch) throw new Error('script does not start with `export const meta = {…}`');
31
+ const meta = vm.runInNewContext(`(${metaMatch[1]})`, {}); // a literal, or this throws
32
+ const body = source.slice(metaMatch[0].length);
33
+ const calls = []; const phases = []; const logs = [];
34
+ const agent = async (prompt, opts = {}) => {
35
+ const call = { n: calls.length + 1, prompt: String(prompt), opts, phase: phases[phases.length - 1] || null };
36
+ calls.push(call);
37
+ const a = await answer(call);
38
+ if (a === null || a === undefined) return null;
39
+ if (opts.schema) return fromSchema(opts.schema, typeof a === 'object' ? a : {});
40
+ return typeof a === 'string' ? a : JSON.stringify(a);
41
+ };
42
+ const parallel = async (thunks) => Promise.all(thunks.map((t) => t().catch(() => null)));
43
+ const pipeline = async (items, ...stages) => Promise.all(items.map(async (it, i) => {
44
+ let v = it;
45
+ for (const st of stages) { try { v = await st(v, it, i); } catch { return null; } }
46
+ return v;
47
+ }));
48
+ const BadDate = function (...a) { if (!a.length) throw new Error('new Date() is not allowed in a workflow'); return new Date(...a); };
49
+ BadDate.now = () => { throw new Error('Date.now() is not allowed in a workflow'); };
50
+ const BadMath = Object.create(Math); BadMath.random = () => { throw new Error('Math.random() is not allowed in a workflow'); };
51
+ const ctx = vm.createContext({ agent, parallel, pipeline, phase: (t) => phases.push(t), log: (m) => logs.push(m),
52
+ args, Date: BadDate, Math: BadMath, JSON, Array, Object, String, Number, Boolean, Set, Map, Promise, Error, RegExp });
53
+ const result = await vm.runInContext(`(async () => {\n${body}\n})()`, ctx);
54
+ return { meta, result, calls, phases, logs };
55
+ }
56
+
57
+ module.exports = { run, fromSchema };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ // THE LAYER-1 HARNESS WITH THE AGENTS' HOOKS (docs/TESTING.md). test/layer1/harness.js runs the real script with a
3
+ // stand-in for every agent; this runs, in addition, each agent definition's own PreToolUse hook on the stand-in's answer
4
+ // — the generated command, from the generated agents file — exactly as Claude Code would when the agent submits its
5
+ // StructuredOutput: the hook's `updatedInput` becomes the answer the script sees. An agent the stand-in fails (null)
6
+ // never answers, so its hook never runs. No AI.
7
+ const fs = require('fs');
8
+ const { spawnSync } = require('child_process');
9
+ const { run } = require('./harness.js');
10
+
11
+ /**
12
+ * @param {string} source the emitted script
13
+ * @param {object} args the script's `args`
14
+ * @param {string} agentsFile the generated agents JSON (frontmatter hooks per agent name)
15
+ * @param {(call) => object|null} answer the stand-in: the raw answer object, or null for an agent that failed
16
+ */
17
+ async function runWithHooks(source, args, agentsFile, answer) {
18
+ const defs = JSON.parse(fs.readFileSync(agentsFile, 'utf8'));
19
+ const hookRuns = [];
20
+ const r = await run(source, args, (call) => {
21
+ const a = answer(call);
22
+ if (a === null || a === undefined) return null;
23
+ const def = defs[call.opts.agentType];
24
+ if (!def) throw new Error(`no generated definition for agent type '${call.opts.agentType}'`);
25
+ const pre = ((def.hooks || {}).PreToolUse || []).filter((h) => new RegExp(`^(${h.matcher})$`).test('StructuredOutput'));
26
+ let input = a;
27
+ 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
+ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
30
+ hookRuns.push({ agentType: call.opts.agentType, status: res.status, stderr: (res.stderr || '').slice(-2000) });
31
+ if (res.status !== 0) throw new Error(`hook of ${call.opts.agentType} exited ${res.status}: ${(res.stderr || '').slice(-1500)}`);
32
+ const outLine = String(res.stdout || '').trim().split('\n').pop();
33
+ const o = outLine ? JSON.parse(outLine) : {};
34
+ const upd = o.hookSpecificOutput && o.hookSpecificOutput.updatedInput;
35
+ if (upd) input = upd;
36
+ }
37
+ call.answered = input;
38
+ return input;
39
+ });
40
+ return { ...r, hookRuns };
41
+ }
42
+
43
+ module.exports = { runWithHooks };