amsd-pipeline-workflow 0.1.10 → 0.1.12

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
@@ -492,7 +492,8 @@ async function phaseRunner(ctx, lane, laneEnv, { prd, phase, args, tag, lineSlug
492
492
  // RAO:4237–4311 — Step 5, THE PHASE GATE, by the pipeline's own check-phase-gate.sh as the orchestrator runs it
493
493
  // (SKIP_TESTS=true): review status, story completion, deliverables, cost variance. RETRY exits 1, ESCALATE exits 2; any
494
494
  // 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;
495
+ // THE GATE RUNS EVEN WHEN NO STORY RAN THIS TIME (R1): RAO has no exit before Step 5 — a resumed lane whose stories are
496
+ // all completed is still judged (an escalated review, a missing deliverable)
496
497
  const g = phaseGate(env, phase, log);
497
498
  if (g !== 0) return g;
498
499
  // 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
 
@@ -17,6 +17,9 @@ function applyGateStamps(story, res) {
17
17
  else if (uit && uit.passed === true) { del('suiteState'); del('suiteStateStep'); }
18
18
  const gate = res && res.gate;
19
19
  if (gate && gate.ran !== false && gate.passed === false) { set('reviewStatus', 'escalated'); set('reproGate', 'failed'); }
20
+ // A REAL APPROVAL CLEARS AN EARLIER ATTEMPT'S ESCALATION (lib/review-cycle.sh ~:180–195, the pipeline's own: reviewStatus
21
+ // "escalated" → null). A Writer result of 'done' comes only after the review approved. B65: the stale stamp blocked the gate.
22
+ else if (res && res.status === 'done' && story.reviewStatus === 'escalated') set('reviewStatus', null);
20
23
  return changed;
21
24
  }
22
25
 
@@ -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.10",
3
+ "version": "0.1.12",
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;