amsd-pipeline-workflow 0.1.11 → 0.1.13

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/run.js CHANGED
@@ -404,6 +404,24 @@ function provisionCodeline(ctx, cl, wt) {
404
404
  if (fs.existsSync(ap)) { fs.mkdirSync(epam, { recursive: true }); fs.copyFileSync(ap, path.join(epam, 'anti-patterns.json')); }
405
405
  }
406
406
 
407
+ /** A story's newest review record: the lane log's review-verdict-<story>.json, else (a run from before that record) the
408
+ * newest review-feedback.json any of this lane's Writers left; null when there is none. */
409
+ function reviewRecord(ctx, lane, env, id) {
410
+ const read = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
411
+ const v = env.LOG_DIR && read(path.join(env.LOG_DIR, `review-verdict-${id}.json`));
412
+ if (v) return { verdict: v.verdict, from: `review-verdict-${id}.json` };
413
+ const base = path.join(ctx.runDir, 'workflows', lane.cl);
414
+ const found = [];
415
+ for (const line of (fs.existsSync(base) ? fs.readdirSync(base) : [])) for (const tag of (fs.existsSync(path.join(base, line)) ? fs.readdirSync(path.join(base, line)) : [])) {
416
+ const f = path.join(base, line, tag, 'writer', id, 'checks', 'runs', 'review-feedback.json');
417
+ if (fs.existsSync(f)) found.push({ f, t: fs.statSync(f).mtimeMs });
418
+ }
419
+ if (!found.length) return null;
420
+ const newest = found.sort((a, b) => b.t - a.t)[0];
421
+ const fb = read(newest.f);
422
+ return fb ? { verdict: fb.verdict, from: path.relative(ctx.runDir, newest.f) } : null;
423
+ }
424
+
407
425
  // ── ONE PHASE OF ONE LANE: what `bash run-agent-orchestration.sh` does for it, around the two workflows ─────────────
408
426
  // Returns the phase's exit code (lib/phase-exit.sh).
409
427
  async function phaseRunner(ctx, lane, laneEnv, { prd, phase, args, tag, lineSlug }) {
@@ -425,6 +443,14 @@ async function phaseRunner(ctx, lane, laneEnv, { prd, phase, args, tag, lineSlug
425
443
  const p = JSON.parse(fs.readFileSync(prd, 'utf8'));
426
444
  const rq = (s) => ((s.status === 'failed' || s.status === 'blocked') && !(s.completed) ? { ...s, status: 'pending' } : s);
427
445
  p.stories = (p.stories || []).map(rq);
446
+ // ONCE REVIEWED, LOCKED (operator, 2026-10-20): a completed story whose newest review record is an approval is neither
447
+ // rewritten nor re-reviewed; an "escalated" stamp an EARLIER attempt left is cleared as the pipeline clears it on
448
+ // approval (lib/review-cycle.sh ~:180–195) — the stale stamp would otherwise fail Step 5 on work already approved
449
+ for (const s of p.stories) {
450
+ if (!(s.completed === true && s.reviewStatus === 'escalated')) continue;
451
+ const rec = reviewRecord(ctx, lane, env, s.id);
452
+ if (rec && rec.verdict === 'approved') { s.reviewStatus = null; log(`[driver] ${s.id}: locked — reviewed and approved (${rec.from}); its earlier escalated stamp is cleared, nothing is rewritten or re-reviewed`); }
453
+ }
428
454
  fs.writeFileSync(prd, JSON.stringify(p, null, 2));
429
455
  }
430
456
  // RAO:1112–1123 — the spec pass, unless the lane resumed with its spec done
@@ -471,6 +497,13 @@ async function phaseRunner(ctx, lane, laneEnv, { prd, phase, args, tag, lineSlug
471
497
  }
472
498
  // what Steps 3.545 and 3.55 stamp on the story, so a retry inherits the finding (driver/writer-stamps.js)
473
499
  if (st) applyGateStamps(st, res);
500
+ // THE LOCK, RECORDED where a later Writer cannot wipe it (the lane's log; its out dir is rebuilt each leg, B64): a Writer
501
+ // that ends 'done' ended on an approved review
502
+ if (res.status === 'done') {
503
+ const head = spawnSync('git', ['-C', lane.wt, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
504
+ fs.mkdirSync(env.LOG_DIR, { recursive: true });
505
+ fs.writeFileSync(path.join(env.LOG_DIR, `review-verdict-${s.id}.json`), `${JSON.stringify({ verdict: 'approved', head: head.status === 0 ? head.stdout.trim() : null, at: new Date().toISOString() })}\n`);
506
+ }
474
507
  fs.writeFileSync(prd, JSON.stringify(cur, null, 2));
475
508
  // lib/prd-state.sh update_story_status (the pipeline's), on the lane's PRD
476
509
  bash({ ...env, MAIN_PRD_FILE: prd, PRD_FILE: prd }, 'update_story_status', [s.id, res.exit === 0 ? 'completed' : 'failed'], { log });
@@ -492,7 +525,8 @@ async function phaseRunner(ctx, lane, laneEnv, { prd, phase, args, tag, lineSlug
492
525
  // RAO:4237–4311 — Step 5, THE PHASE GATE, by the pipeline's own check-phase-gate.sh as the orchestrator runs it
493
526
  // (SKIP_TESTS=true): review status, story completion, deliverables, cost variance. RETRY exits 1, ESCALATE exits 2; any
494
527
  // other code goes on (the pipeline's `case` has no default). The cost check reads the run's ledger (driver/session-cost.js).
495
- if (!order.length) return 0;
528
+ // THE GATE RUNS EVEN WHEN NO STORY RAN THIS TIME (R1): RAO has no exit before Step 5 — a resumed lane whose stories are
529
+ // all completed is still judged (an escalated review, a missing deliverable)
496
530
  const g = phaseGate(env, phase, log);
497
531
  if (g !== 0) return g;
498
532
  // RAO:4318–4337 — Step 24, the final post-phase assessment (feature skill-assessment); non-critical
package/driver/runtime.js CHANGED
@@ -120,8 +120,12 @@ function runLive(opts) {
120
120
  // (11 min) were cut there. The session's stderr is kept beside it as evidence.
121
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
122
  try { fs.mkdirSync(opts.sessionDir, { recursive: true }); fs.writeFileSync(path.join(opts.sessionDir, `${opts.name}.stderr`), String(r.stderr || '')); } catch { /* evidence only */ }
123
- if (r.status !== 0) throw new Error(`the live session for ${opts.name} exited ${r.status}: ${(r.stderr || '').slice(-2000)}`);
124
- const out = JSON.parse(r.stdout);
123
+ // A FAILED SESSION STILL SPENT (R5): its cost record is read from its output when there is one, and run() records it
124
+ // BEFORE the failure is thrown — the stage fails as before, the spend is never lost
125
+ let out = null; try { out = JSON.parse(r.stdout); } catch { out = null; }
126
+ const spent = out && out.session_id ? { sessionId: out.session_id, modelUsage: out.modelUsage || {}, totalCostUsd: out.total_cost_usd } : null;
127
+ if (r.status !== 0) return { __throw: new Error(`the live session for ${opts.name} exited ${r.status}: ${(r.stderr || '').slice(-2000)}`), __session: spent };
128
+ if (!out) return { __throw: new Error(`the live session for ${opts.name} printed no JSON: ${String(r.stdout || '').slice(0, 500)}`), __session: null };
125
129
  // the session's own cost figure (decision 41a), taken BEFORE its answer is read: an answer that cannot be read must
126
130
  // never lose what the session spent (found by tools/cost-probe.js, 2026-10-18). driver/session-cost.js finds its
127
131
  // transcripts by this id.
@@ -203,11 +207,12 @@ async function run(o) {
203
207
  else throw new Error(`unknown runtime '${opts.runtime}' (standin | live)`);
204
208
  // THE COST RECORD of every session, by the pipeline's own ledger writer (driver/session-cost.js); a Writer session's rows
205
209
  // carry its story, as the story's attempts do in the pipeline
206
- if (res && opts.env.SCRIPT_DIR && (opts.env.PHASE_COST_FILE || opts.env.LOG_DIR)) {
210
+ if (res && (res.__session || !res.__throw) && opts.env.SCRIPT_DIR && (opts.env.PHASE_COST_FILE || opts.env.LOG_DIR)) {
207
211
  res.__cost = recordSession(opts.env, res.__session, { stage: opts.name, storyId: opts.costStory || null, phase: opts.env.PHASE || null,
208
212
  projectsDir: res.__session && res.__session.projectsDir, log: opts.log });
209
213
  await Promise.all(res.__cost.traces || []);
210
214
  }
215
+ if (res && res.__throw) throw res.__throw;
211
216
  return res;
212
217
  }
213
218
 
@@ -450,7 +450,12 @@ async function tcWriter(attempt) {
450
450
  if (p.error) { log(\`attempt \${attempt + 1}: the tc-writer's question could not be built — \${p.reason}\`); return }
451
451
  if (!p.needed) { if (p.message) log(p.message); return }
452
452
  log(\` [tc-writer] Generating TCs for phase '\${p.phase || '<unset>'}' (post-impl, pre-test)...\`)
453
- await agentCall(relativize(p.prompt), { agentType: PLAN.agents.tcWriter, ...(p.model ? { model: p.model } : {}), schema: SCHEMAS.tc,
453
+ // THE ANSWER'S KEYS ARE THE STORIES THE QUESTION NAMES (R2: unnamed, the model answered {"output": …}, then a string)
454
+ const item = SCHEMAS.tc.additionalProperties
455
+ const ids = (p.stories || []).length ? p.stories : [story.id]
456
+ const schema = { type: 'object', required: ids, additionalProperties: false,
457
+ properties: Object.fromEntries(ids.map((id) => [id, { ...item, description: \`the test criteria for story \${id}, as an object (not a string)\` }])) }
458
+ await agentCall(relativize(p.prompt), { agentType: PLAN.agents.tcWriter, ...(p.model ? { model: p.model } : {}), schema,
454
459
  label: \`tc-writer after #\${attempt + 1}\`, phase: 'Writer' })
455
460
  const r = await stepResult(PLAN.agents.stepTcApply, \`tc-writer apply #\${attempt + 1}\`)
456
461
  if (r.ok && r.testCriteria) {
@@ -32,7 +32,11 @@ if (require.main === module) {
32
32
  ...(() => { const c = (input.tool_input || {}).call || {}; return { CALL_AGENT: String(c.agent || ''), CALL_MODEL: String(c.model || ''),
33
33
  CALL_STORY: String(c.story || 'global'), CALL_REASON: String(c.reason || ''), CALL_ATTEMPT: String(c.attempt || 1) }; })() };
34
34
  const r = spawnSync('bash', [path.join(__dirname, script), a.pipeline, a.state], { encoding: 'utf8', env, maxBuffer: 64 * 1024 * 1024 });
35
- try { result = JSON.parse(r.stdout); } catch { result = { ran: false, passed: false, reason: (r.stderr || `exit ${r.status}`).slice(-400) }; }
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) }; }
36
40
  }
37
41
  process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow',
38
42
  updatedInput: { ...(input.tool_input || {}), step: a.step, result } } })}\n`);
@@ -56,9 +56,13 @@ if (require.main === module) {
56
56
  let input = {};
57
57
  try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* nothing to judge */ }
58
58
  if (input.tool_name !== 'StructuredOutput') process.exit(0);
59
+ // THE HOOK'S STDOUT IS ITS JSON ONLY (R4, the B60 class): the pipeline's plugin runs in this process; whatever it writes to
60
+ // stdout goes to stderr, and only the line below writes stdout
61
+ const stdout = process.stdout.write.bind(process.stdout);
62
+ process.stdout.write = (chunk, ...rest) => process.stderr.write(chunk, ...rest);
59
63
  let validation;
60
64
  try { validation = judge(a, input.tool_input || {}); } catch (e) { validation = { valid: false, committed: false, class: 'error', feedback: '', reason: e.message }; }
61
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow',
65
+ stdout(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow',
62
66
  updatedInput: { ...(input.tool_input || {}), validation } } })}\n`);
63
67
  }
64
68
  module.exports = { judge };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amsd-pipeline-workflow",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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": {
@@ -12,12 +12,35 @@ function fromSchema(schema, overrides) {
12
12
  const out = {};
13
13
  const props = (schema && schema.properties) || {};
14
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}`;
15
+ const p = props[k] || {}; const t = p.type;
16
+ out[k] = Array.isArray(p.enum) ? p.enum[0] : t === 'boolean' ? false : t === 'number' || t === 'integer' ? 0 : t === 'array' ? [] : t === 'object' ? fromSchema(p, {}) : `generated-${k}`;
17
17
  }
18
18
  return { ...out, ...(overrides || {}) };
19
19
  }
20
20
 
21
+ /**
22
+ * WHY A VALUE DOES NOT MATCH ITS SCHEMA, or '' — the subset of JSON Schema the workflows' answers use (type, required,
23
+ * properties, additionalProperties, items, enum). Claude Code refuses a StructuredOutput answer that does not match
24
+ * (deep review R7): the harness refuses it too.
25
+ */
26
+ function schemaError(schema, v, at = '') {
27
+ if (!schema || typeof schema !== 'object') return '';
28
+ const t = schema.type;
29
+ const typeOf = (x) => (Array.isArray(x) ? 'array' : x === null ? 'null' : Number.isInteger(x) ? 'integer' : typeof x);
30
+ if (t && !(t === typeOf(v) || (t === 'number' && typeOf(v) === 'integer'))) return `${at || '/'}: ${typeOf(v)} where ${t} is required`;
31
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => JSON.stringify(e) === JSON.stringify(v))) return `${at || '/'}: ${JSON.stringify(v)} is not one of ${JSON.stringify(schema.enum)}`;
32
+ if (t === 'array' && schema.items) for (let i = 0; i < v.length; i += 1) { const e = schemaError(schema.items, v[i], `${at}/${i}`); if (e) return e; }
33
+ if (t === 'object' || (!t && v && typeof v === 'object' && !Array.isArray(v))) {
34
+ for (const k of schema.required || []) if (!(k in v)) return `${at || '/'}: required '${k}' is missing`;
35
+ const props = schema.properties || {};
36
+ for (const [k, x] of Object.entries(v)) {
37
+ if (props[k]) { const e = schemaError(props[k], x, `${at}/${k}`); if (e) return e; } else if (schema.additionalProperties === false) return `${at || '/'}: '${k}' is not allowed`;
38
+ else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { const e = schemaError(schema.additionalProperties, x, `${at}/${k}`); if (e) return e; }
39
+ }
40
+ }
41
+ return '';
42
+ }
43
+
21
44
  /**
22
45
  * Run a workflow script.
23
46
  * @param {string} source the script text (as emitted)
@@ -43,7 +66,14 @@ async function run(source, args, answer) {
43
66
  calls.push(call);
44
67
  const a = await answer(call);
45
68
  if (a === null || a === undefined) return null;
46
- if (opts.schema) return fromSchema(opts.schema, typeof a === 'object' ? a : {});
69
+ if (opts.schema) {
70
+ // the hook harness has already judged the agent's own answer against its schema (before its hooks, as Claude Code)
71
+ if (call.judged) return a;
72
+ const full = fromSchema(opts.schema, typeof a === 'object' ? a : {});
73
+ const err = schemaError(opts.schema, full);
74
+ if (err) { call.refused = err; return null; }
75
+ return full;
76
+ }
47
77
  return typeof a === 'string' ? a : JSON.stringify(a);
48
78
  };
49
79
  const parallel = async (thunks) => Promise.all(thunks.map((t) => t().catch(() => null)));
@@ -61,4 +91,4 @@ async function run(source, args, answer) {
61
91
  return { meta, result, calls, phases, logs };
62
92
  }
63
93
 
64
- module.exports = { run, fromSchema };
94
+ module.exports = { run, fromSchema, schemaError };
@@ -7,7 +7,7 @@
7
7
  // never answers, so its hook never runs. No AI.
8
8
  const fs = require('fs');
9
9
  const { spawnSync } = require('child_process');
10
- const { run } = require('./harness.js');
10
+ const { run, fromSchema, schemaError } = require('./harness.js');
11
11
 
12
12
  /**
13
13
  * @param {string} source the emitted script
@@ -23,16 +23,27 @@ async function runWithHooks(source, args, agentsFile, answer) {
23
23
  if (a === null || a === undefined) return null;
24
24
  const def = defs[call.opts.agentType];
25
25
  if (!def) throw new Error(`no generated definition for agent type '${call.opts.agentType}'`);
26
- const pre = ((def.hooks || {}).PreToolUse || []).filter((h) => new RegExp(`^(${h.matcher})$`).test('StructuredOutput'));
26
+ // THE AGENT'S OWN ANSWER, judged against its schema before any hook (Claude Code refuses a StructuredOutput that does not
27
+ // match — the tc-writer's live refusals, deep review R7): refused, the agent has no answer and its hook never runs
27
28
  let input = a;
29
+ if (call.opts.schema) {
30
+ input = fromSchema(call.opts.schema, typeof a === 'object' ? a : {});
31
+ const err = schemaError(call.opts.schema, input);
32
+ if (err) { call.refused = err; return null; }
33
+ call.judged = true;
34
+ }
35
+ const pre = ((def.hooks || {}).PreToolUse || []).filter((h) => new RegExp(`^(${h.matcher})$`).test('StructuredOutput'));
28
36
  for (const h of pre) for (const c of h.hooks || []) {
29
37
  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 }),
30
38
  encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
31
- hookRuns.push({ agentType: call.opts.agentType, status: res.status, stderr: (res.stderr || '').slice(-2000) });
32
- if (res.status !== 0) throw new Error(`hook of ${call.opts.agentType} exited ${res.status}: ${(res.stderr || '').slice(-1500)}`);
33
- const outLine = String(res.stdout || '').trim().split('\n').pop();
34
- const o = outLine ? JSON.parse(outLine) : {};
35
- const upd = o.hookSpecificOutput && o.hookSpecificOutput.updatedInput;
39
+ hookRuns.push({ agentType: call.opts.agentType, status: res.status, stderr: (res.stderr || '').slice(-2000), stdout: String(res.stdout || '').slice(0, 2000) });
40
+ // AS CLAUDE CODE (deep review R6): exit 2 blocks the answer; any other non-zero exit is a non-blocking error and the
41
+ // answer goes on unchanged; on exit 0 the hook's JSON is read only when its WHOLE stdout is JSON — a log line ahead of
42
+ // it (B60) makes it plain text, and updatedInput is ignored
43
+ if (res.status === 2) { call.blocked = (res.stderr || '').slice(-500); return null; }
44
+ if (res.status !== 0) continue;
45
+ let o = null; try { o = JSON.parse(String(res.stdout || '').trim() || 'null'); } catch { o = null; }
46
+ const upd = o && o.hookSpecificOutput && o.hookSpecificOutput.updatedInput;
36
47
  if (upd) input = upd;
37
48
  }
38
49
  call.answered = input;