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 +35 -1
- package/driver/runtime.js +8 -3
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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;
|