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 +2 -1
- package/driver/runtime.js +8 -3
- package/driver/writer-stamps.js +3 -0
- package/generator/writer-workflow.js +6 -1
- package/hooks/pipeline-step.js +5 -1
- package/hooks/repro-checks.js +5 -1
- package/package.json +1 -1
- package/test/layer1/harness.js +34 -4
- package/test/layer1/hook-harness.js +18 -7
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
package/driver/writer-stamps.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/hooks/pipeline-step.js
CHANGED
|
@@ -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
|
-
|
|
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`);
|
package/hooks/repro-checks.js
CHANGED
|
@@ -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
|
-
|
|
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
package/test/layer1/harness.js
CHANGED
|
@@ -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
|
|
16
|
-
out[k] = t === 'boolean' ? false : t === 'number' || t === 'integer' ? 0 : t === 'array' ? [] : t === 'object' ? fromSchema(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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;
|