amsd-pipeline-workflow 0.1.21 → 0.1.22

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/driver/runtime.js CHANGED
@@ -19,6 +19,9 @@ const { spawnSync } = require('child_process');
19
19
  const ROOT = path.resolve(__dirname, '..');
20
20
  const { recordSession } = require('./session-cost.js');
21
21
 
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'];
24
+
22
25
  function toMarkdown(name, def) {
23
26
  // generator/agents.js toMarkdown, from the agents JSON entry (frontmatter keys + `prompt` as the body)
24
27
  const { prompt, ...fm } = def;
@@ -65,6 +68,12 @@ function liveCommand({ name, script, agentsJson, args, sessionDir, env, cwd }) {
65
68
  if (Object.keys(map).length) pre.push({ matcher: [...matchers].join('|') || '.*', hooks: [{ type: 'command', command: dispatch, ...(timeout ? { timeout } : {}) }] });
66
69
  settings.hooks = { ...(settings.hooks || {}), PreToolUse: pre };
67
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
+ }
68
77
  fs.copyFileSync(script, path.join(wfDir, `${name}.js`));
69
78
  // THE RUNNER WAITS FOR ITS WORKFLOW (LIVE-AMSD-2974-3, 2026-10-18: the runner answered "It's still running in the
70
79
  // background…" and the session exited, taking the Spec workflow down with it): it answers only with the finished result
package/driver/stages.js CHANGED
@@ -8,6 +8,10 @@ const { spawnSync } = require('child_process');
8
8
  const runtime = require('./runtime.js');
9
9
  const { bash } = require('./pipeline.js');
10
10
 
11
+ // THE NAME EACH WORKFLOW IS RUN BY (and so the file it is placed in, <codeline>/.claude/workflows/<name>.js). No name may match a test runner's default pattern: the Spec workflow was "spec", the client's jest ran
12
+ // spec.js as a test, and every whole-suite run in a codeline reported the pipeline's own file as a failing suite (run 11, 2026-10-20; test/integration/workflow-placement.test.js).
13
+ const WORKFLOW_NAMES = { agents: 'codeline-agents', spec: 'spec-stage', writer: 'writer', assess: 'assess', bridge: 'bridge' };
14
+
11
15
  const ROOT = path.resolve(__dirname, '..');
12
16
  const gen = (script, args, env) => {
13
17
  const r = spawnSync(process.execPath, [path.join(ROOT, 'generator', script), ...args], { encoding: 'utf8', env, maxBuffer: 256 * 1024 * 1024 });
@@ -71,7 +75,7 @@ async function runGeneratedAgents(ctx) {
71
75
  ...(ctx.agentsDocuments ? ['--documents', ctx.agentsDocuments] : []), ...(env.JIRA_TICKET ? ['--ticket', env.JIRA_TICKET] : []),
72
76
  ...(env.EPAM_ESTIMATION_MANIFEST ? ['--estimation-manifest', env.EPAM_ESTIMATION_MANIFEST] : [])], env);
73
77
  const plan = JSON.parse(fs.readFileSync(path.join(out, 'plan.json'), 'utf8'));
74
- const res = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'codeline-agents', script: path.join(out, 'workflows', 'codeline-agents.js'),
78
+ const res = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: WORKFLOW_NAMES.agents, script: path.join(out, 'workflows', 'codeline-agents.js'),
75
79
  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
76
80
  standin: ctx.standin && ctx.standin('codeline-agents', { plan, out }), sessionDir: path.join(out, 'session'), approved: ctx.liveApproved });
77
81
  log(`[driver] Codeline, Agents: ${res.status}${res.reason ? ` — ${res.reason}` : ''} (${res.calls || 0} question(s); phases: ${(res.phases || []).join(' → ') || 'none'})`);
@@ -141,7 +145,7 @@ async function runPlan(ctx, env, { prd, story, phase, keep, out }) {
141
145
  '--codeline-map', Object.entries(ctx.map).map(([k, v]) => `${k}=${v}`).join(','),
142
146
  '--plan-only', '--story', story.id, '--plan-keep', keep, ...(ctx.docsFrom ? ['--docs-from', ctx.docsFrom] : [])], env);
143
147
  const specEnv = JSON.parse(fs.readFileSync(path.join(out, 'checks', 'spec', 'env.json'), 'utf8'));
144
- return runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'spec', script: path.join(out, 'workflows', 'spec.js'),
148
+ return runtime.run({ runtime: ctx.runtime, log: ctx.log, name: WORKFLOW_NAMES.spec, script: path.join(out, 'workflows', 'spec.js'),
145
149
  agentsJson: path.join(out, 'agents-spec.json'), args: withFeatures(ctx, {}), env: specEnv, standin: ctx.standin && ctx.standin('spec', { lane: { cl: first, wt: ctx.map[first] }, out }),
146
150
  sessionDir: path.join(out, 'session'), cwd: ctx.map[first], approved: ctx.liveApproved });
147
151
  }
@@ -154,7 +158,7 @@ async function runSpec(ctx, lane, env, { prd, phase, out }) {
154
158
  '--lane-log-dir', env.LOG_DIR, ...(ctx.docsFrom ? ['--docs-from', ctx.docsFrom] : []),
155
159
  ...(env.EPAM_RESUME_RUN ? ['--resume'] : [])], env);
156
160
  const specEnv = JSON.parse(fs.readFileSync(path.join(out, 'checks', 'spec', 'env.json'), 'utf8'));
157
- const result = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'spec', script: path.join(out, 'workflows', 'spec.js'),
161
+ const result = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: WORKFLOW_NAMES.spec, script: path.join(out, 'workflows', 'spec.js'),
158
162
  agentsJson: path.join(out, 'agents-spec.json'), args: withFeatures(ctx, {}), env: specEnv, standin: ctx.standin && ctx.standin('spec', { lane, out }),
159
163
  sessionDir: path.join(out, 'session'), cwd: lane.wt, approved: ctx.liveApproved });
160
164
  // the spec pass's PRD is the lane's PRD (its PRD_FILE): the workflow worked on its own copy
@@ -227,7 +231,7 @@ async function runWriter(ctx, lane, env, { story, out, lineSlug, reviewCommitted
227
231
  if (k in ctx.launchEnv && String(ctx.launchEnv[k]) !== String(val)) throw new Error(`the Writer was generated with ${k}=${val}, the run was launched with ${ctx.launchEnv[k]}`);
228
232
  }
229
233
  }
230
- const res = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'writer', script: path.join(out, 'workflows', 'writer.js'),
234
+ const res = await runtime.run({ runtime: ctx.runtime, log: ctx.log, name: WORKFLOW_NAMES.writer, script: path.join(out, 'workflows', 'writer.js'),
231
235
  agentsJson: path.join(out, 'agents.json'), args: withFeatures(ctx, { runId: ctx.run, story }), env, standin: ctx.standin && ctx.standin('writer', { lane, out, story }),
232
236
  sessionDir: path.join(out, 'session'), cwd: lane.wt, approved: ctx.liveApproved, costStory: story });
233
237
  persistFailures(out, env.LOG_DIR);
@@ -257,7 +261,7 @@ async function runAssess(ctx, lane, env, { label, out }) {
257
261
  fs.rmSync(out, { recursive: true, force: true });
258
262
  gen(path.join('..', 'driver', 'generate-assess.js'), ['--install', ctx.install, '--codeline', lane.wt, '--out', out, '--label', label, '--set', ctx.set,
259
263
  '--env', JSON.stringify(env)], env);
260
- return runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'assess', script: path.join(out, 'workflows', 'assess.js'),
264
+ return runtime.run({ runtime: ctx.runtime, log: ctx.log, name: WORKFLOW_NAMES.assess, script: path.join(out, 'workflows', 'assess.js'),
261
265
  agentsJson: path.join(out, 'agents-assess.json'), args: {}, env, standin: ctx.standin && ctx.standin('assess', { lane, out }),
262
266
  sessionDir: path.join(out, 'session'), approved: ctx.liveApproved });
263
267
  }
@@ -267,9 +271,9 @@ async function runBridge(ctx, lane, env, { prd, out }) {
267
271
  fs.rmSync(out, { recursive: true, force: true });
268
272
  gen(path.join('..', 'driver', 'generate-bridge.js'), ['--install', ctx.install, '--project', ctx.project, '--run', ctx.run, '--lane', lane.cl,
269
273
  '--codeline', lane.wt, '--prd', prd, '--out', out, '--set', ctx.set, '--env', JSON.stringify({ LOG_DIR: ctx.env.LOG_DIR })], env);
270
- return runtime.run({ runtime: ctx.runtime, log: ctx.log, name: 'bridge', script: path.join(out, 'workflows', 'bridge.js'),
274
+ return runtime.run({ runtime: ctx.runtime, log: ctx.log, name: WORKFLOW_NAMES.bridge, script: path.join(out, 'workflows', 'bridge.js'),
271
275
  agentsJson: path.join(out, 'agents-bridge.json'), args: {}, env, standin: ctx.standin && ctx.standin('bridge', { lane, out }),
272
276
  sessionDir: path.join(out, 'session'), approved: ctx.liveApproved });
273
277
  }
274
278
 
275
- module.exports = { runPlan, runCodelineAgents, runSpec, runPrePause, runWriter, runBridge, runAssess, featureOn, persistFailures };
279
+ module.exports = { WORKFLOW_NAMES, runPlan, runCodelineAgents, runSpec, runPrePause, runWriter, runBridge, runAssess, featureOn, persistFailures };
@@ -533,6 +533,14 @@ function main(a) {
533
533
  const chain = [d.frontmatter && d.frontmatter.model].filter(Boolean);
534
534
  while (pairs[chain[chain.length - 1]] && !chain.includes(pairs[chain[chain.length - 1]])) chain.push(pairs[chain[chain.length - 1]]);
535
535
  return chain;
536
+ })(),
537
+ // WHY THE EARLIER ATTEMPTS OF THIS STORY WERE BLOCKED (run 11, 2026-10-20: five blind retries of one gate block): the last three reproduction-gate reasons in the lane's failure history
538
+ // (hooks/pipeline-step.js recordGateBlock appends them; the driver persists the file), for the repro-test writer's prompt
539
+ priorBlocks: (() => {
540
+ try {
541
+ return fs.readFileSync(path.join(process.env.LOG_DIR || '', 'story-failures.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
542
+ .filter((r) => r && r.storyId === a.story && r.failureClass === 'repro-gate-block' && String(r.reason || '').trim()).map((r) => String(r.reason).trim()).slice(-3);
543
+ } catch { return []; }
536
544
  })() },
537
545
  // The path with no jumps (for reading), and the step from every reachable state (generator/ladder.sh): the workflow walks
538
546
  // the steps, so a jump lands where the pipeline's would.
@@ -12,7 +12,7 @@ const { packPlan } = require('./pack-plan.js');
12
12
  function emit(plan) {
13
13
  const packed = packPlan(plan);
14
14
  return `export const meta = {
15
- name: 'spec',
15
+ name: 'spec-stage',
16
16
  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',
17
17
  phases: [{ title: 'Spec' }, { title: 'Review' }, { title: 'Pause 2' }],
18
18
  }
@@ -390,9 +390,13 @@ async function reproStep(prep) {
390
390
  const schema = { ...SCHEMAS.repro, properties: { ...SCHEMAS.repro.properties,
391
391
  source: { type: 'string', enum: [source], description: 'the source file your test covers: exactly this path' },
392
392
  test: { type: 'string', description: \`the path of the test file you wrote, relative to the codeline root (planned: \${cand.target})\` } } }
393
+ // EARLIER ATTEMPTS OF THIS STORY WERE BLOCKED BY THE REPRODUCTION GATE (the lane's failure history, plan.repro.priorBlocks): the writer is told why, after the pipeline's own prompt
394
+ const blocked = (PLAN.repro.priorBlocks || []).length
395
+ ? \`\\n\\nEARLIER ATTEMPTS OF THIS STORY WERE BLOCKED BY THE REPRODUCTION GATE — read why before you write the test. The gate runs your test with the fix reverted to the story's baseline and requires it to FAIL there; a test that passes without the fix reproduces nothing.\\n\${PLAN.repro.priorBlocks.map((r) => \`- \${r}\`).join('\\n')}\\n\`
396
+ : ''
393
397
  for (let i = 0; i < PLAN.repro.maxAttempts; i++) {
394
398
  const model = PLAN.repro.ladder[Math.min(i, PLAN.repro.ladder.length - 1)]
395
- const a = await agentCall(relativize(cand.prompt) + fb.typecheck + fb.assertions + fb.commit_rejected,
399
+ const a = await agentCall(relativize(cand.prompt) + blocked + fb.typecheck + fb.assertions + fb.commit_rejected,
396
400
  { agentType: PLAN.agents.reproWriter, model, schema, label: \`repro test #\${i + 1}\`, phase: 'Writer' })
397
401
  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' }
398
402
  if (v.valid) return { committed: !!v.committed, attempts: i + 1, test: a.test, reason: v.reason }
@@ -16,28 +16,58 @@ const STEPS = { 'repro-gate': 'step-repro-gate.sh', 'uit-check': 'step-uit-check
16
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
+ /** The step's result: its script's last line (or why there is none). */
20
+ function runScriptStep(a, input) {
21
+ const script = STEPS[a.step];
22
+ if (!script) return { ran: false, passed: false, reason: `unknown step '${a.step}'` };
23
+ // a per-attempt step runner's own values (decision 27): its retry and the model the previous attempt ran on
24
+ const env = { ...process.env, ...(a.retry !== undefined ? { RUNG_RETRY: String(a.retry) } : {}), ...(a['prev-model'] ? { RUNG_PREV_MODEL: a['prev-model'] } : {}),
25
+ ...(a.model ? { RUNG_MODEL: a.model } : {}),
26
+ // a failed agent call (the gateway, hooks/step-call-failed.sh): the call the step runner was told to hand on
27
+ ...(() => { const c = (input.tool_input || {}).call || {}; return { CALL_AGENT: String(c.agent || ''), CALL_MODEL: String(c.model || ''),
28
+ CALL_STORY: String(c.story || 'global'), CALL_REASON: String(c.reason || ''), CALL_ATTEMPT: String(c.attempt || 1) }; })() };
29
+ const r = spawnSync('bash', [path.join(__dirname, script), a.pipeline, a.state], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
30
+ // THE STEP'S RESULT IS ITS LAST LINE (each step script ends with one `jq -c` line); anything the pipeline printed ahead of
31
+ // it is kept on stderr, never read as the result and never let onto this hook's stdout (R3, the B60 class)
32
+ const lines = String(r.stdout || '').split('\n').filter((l) => l.trim());
33
+ if (lines.length > 1) process.stderr.write(`${lines.slice(0, -1).join('\n')}\n`);
34
+ try { return JSON.parse(lines[lines.length - 1]); } catch { return { ran: false, passed: false, reason: (r.stderr || `exit ${r.status}`).slice(-400) }; }
35
+ }
36
+
37
+ /**
38
+ * A GATE BLOCK IS FAILURE HISTORY (run 11, 2026-10-20: the reproduction gate blocked one story five times and nothing carried the reason to the next attempt).
39
+ * When the reproduction gate RAN and did not pass, one record is appended to the run's story-failures.jsonl in the pipeline's own format
40
+ * (story-attempt.sh:2666–2688: storyId, failureClass, model, attempt, exitCode, rawBytes, outputTokens, maxIterations, timestamp) plus the gate's reason: the
41
+ * driver persists the file to the lane's log (driver/stages.js persistFailures) and the next generation reads it (generator/generate.js plan.repro.priorBlocks).
42
+ */
43
+ function recordGateBlock(a, result) {
44
+ if (a.step !== 'repro-gate' || !result || result.ran !== true || result.passed !== false) return null;
45
+ const pj = JSON.parse(fs.readFileSync(a.pipeline, 'utf8'));
46
+ const file = path.join(a.state, 'pipeline-env', 'logs', 'story-failures.jsonl');
47
+ let prior = 0;
48
+ try { prior = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter((r) => r.storyId === pj.story && r.failureClass === 'repro-gate-block').length; } catch { prior = 0; }
49
+ const rec = { storyId: pj.story, failureClass: 'repro-gate-block', model: 'gate', attempt: prior + 1, exitCode: 2, rawBytes: 0, outputTokens: 0, maxIterations: 0,
50
+ timestamp: new Date().toISOString(), gate: '3.55', reason: String(result.reason || '').slice(0, 1500) };
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ fs.appendFileSync(file, `${JSON.stringify(rec)}\n`);
53
+ return rec;
54
+ }
55
+
56
+ /** The hook's answer for one StructuredOutput call: the step's result, written back into the answer. runStep is injectable for tests. */
57
+ function answer(a, input, runStep) {
58
+ if (input.tool_name !== 'StructuredOutput') return false;
59
+ const result = (runStep || runScriptStep)(a, input);
60
+ try { recordGateBlock(a, result); } catch { /* a record that cannot be written never changes what the workflow is told */ }
61
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow',
62
+ updatedInput: { ...(input.tool_input || {}), step: a.step, result } } })}\n`);
63
+ return true;
64
+ }
65
+
19
66
  if (require.main === module) {
20
67
  const a = args(process.argv.slice(3));
21
68
  let input = {};
22
69
  try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* nothing */ }
23
- if (input.tool_name !== 'StructuredOutput') process.exit(0);
24
- let result;
25
- const script = STEPS[a.step];
26
- if (!script) result = { ran: false, passed: false, reason: `unknown step '${a.step}'` };
27
- else {
28
- // a per-attempt step runner's own values (decision 27): its retry and the model the previous attempt ran on
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 } : {}),
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) }; })() };
34
- const r = spawnSync('bash', [path.join(__dirname, script), a.pipeline, a.state], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
35
- // THE STEP'S RESULT IS ITS LAST LINE (each step script ends with one `jq -c` line); anything the pipeline printed ahead of
36
- // it is kept on stderr, never read as the result and never let onto this hook's stdout (R3, the B60 class)
37
- const lines = String(r.stdout || '').split('\n').filter((l) => l.trim());
38
- if (lines.length > 1) process.stderr.write(`${lines.slice(0, -1).join('\n')}\n`);
39
- try { result = JSON.parse(lines[lines.length - 1]); } catch { result = { ran: false, passed: false, reason: (r.stderr || `exit ${r.status}`).slice(-400) }; }
40
- }
41
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow',
42
- updatedInput: { ...(input.tool_input || {}), step: a.step, result } } })}\n`);
70
+ if (!answer(a, input)) process.exit(0);
43
71
  }
72
+
73
+ module.exports = { answer, recordGateBlock, STEPS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amsd-pipeline-workflow",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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": {