feat: add execution-layer auto-chain dry-run planner
This commit is contained in:
182
scripts/plan_long_task_auto_chain.mjs
Normal file
182
scripts/plan_long_task_auto_chain.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
|
||||
function fail(code, message) {
|
||||
process.stderr.write(`${code}: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { input: '', pretty: true };
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--input') {
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith('--')) fail('CLI_ERROR', '--input requires a value');
|
||||
args.input = value;
|
||||
i += 1;
|
||||
} else if (arg === '--compact') {
|
||||
args.pretty = false;
|
||||
} else {
|
||||
fail('CLI_ERROR', `unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readInput(path) {
|
||||
if (!path || path === '-') return fs.readFileSync(0, 'utf8');
|
||||
return fs.readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
function parseJson(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
fail('INVALID_JSON', 'input must be valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasEvidenceObject(value) {
|
||||
if (!value) return false;
|
||||
if (hasNonEmptyString(value)) return true;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === 'object') return Object.keys(value).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizedAction(value) {
|
||||
return hasNonEmptyString(value) ? value.trim() : '';
|
||||
}
|
||||
|
||||
function evaluatePlan(input) {
|
||||
const gateStatus = normalizedAction(input?.gateStatus);
|
||||
const actorStage = normalizedAction(input?.actorStage);
|
||||
const requiredNextAction = normalizedAction(input?.requiredNextAction || input?.concreteNextAction || input?.nextStep);
|
||||
const reviewOutcome = normalizedAction(input?.reviewOutcome).toLowerCase();
|
||||
const blocker = normalizedAction(input?.blocker);
|
||||
const executionEvidence = input?.executionEvidence;
|
||||
const reviewEvidence = input?.reviewEvidence;
|
||||
const blockerEvidence = input?.blockerEvidence;
|
||||
|
||||
if (gateStatus !== 'pass') {
|
||||
return {
|
||||
plannerStatus: 'blocked_by_gate',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
reason: 'gateStatus must pass before auto-chain planning can proceed',
|
||||
requiredEvidence: ['gateStatus=pass'],
|
||||
autoChainAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!requiredNextAction) {
|
||||
return {
|
||||
plannerStatus: 'none',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
reason: 'no concrete next action available for auto-chain planning',
|
||||
requiredEvidence: ['concreteNextAction'],
|
||||
autoChainAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (actorStage === 'implementer_result' && requiredNextAction === 'request_spec_review') {
|
||||
if (!hasEvidenceObject(executionEvidence)) {
|
||||
return {
|
||||
plannerStatus: 'blocked_by_evidence',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
reason: 'implementation evidence missing for review-required next action',
|
||||
requiredEvidence: ['executionEvidence'],
|
||||
autoChainAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plannerStatus: 'pass',
|
||||
derivedAction: 'dispatch_spec_review',
|
||||
dispatchMode: 'dry_run_dispatch',
|
||||
reason: 'implementation evidence present; derived spec review dispatch in dry-run mode',
|
||||
requiredEvidence: ['executionEvidence'],
|
||||
autoChainAllowed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (actorStage === 'spec_review' && reviewOutcome === 'pass' && requiredNextAction === 'request_code_quality_review') {
|
||||
if (!hasEvidenceObject(reviewEvidence)) {
|
||||
return {
|
||||
plannerStatus: 'blocked_by_evidence',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
reason: 'review pass evidence missing for code quality review transition',
|
||||
requiredEvidence: ['reviewEvidence'],
|
||||
autoChainAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plannerStatus: 'pass',
|
||||
derivedAction: 'dispatch_code_quality_review',
|
||||
dispatchMode: 'dry_run_dispatch',
|
||||
reason: 'review pass evidence present; derived code quality review dispatch in dry-run mode',
|
||||
requiredEvidence: ['reviewEvidence'],
|
||||
autoChainAllowed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (requiredNextAction === 'fix_review_findings' || hasNonEmptyString(blocker)) {
|
||||
if (!hasEvidenceObject(blockerEvidence)) {
|
||||
return {
|
||||
plannerStatus: 'blocked_by_evidence',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
reason: 'blocker evidence missing for retry/fix transition',
|
||||
requiredEvidence: ['blockerEvidence'],
|
||||
autoChainAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plannerStatus: 'pass',
|
||||
derivedAction: 'dispatch_fix_slice',
|
||||
dispatchMode: 'dry_run_dispatch',
|
||||
reason: 'blocker evidence present; derived retry/fix dispatch in dry-run mode',
|
||||
requiredEvidence: ['blockerEvidence'],
|
||||
autoChainAllowed: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plannerStatus: 'none',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
reason: 'no concrete next action matched a dry-run auto-chain transition',
|
||||
requiredEvidence: ['matchedTransitionEvidence'],
|
||||
autoChainAllowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const raw = readInput(args.input);
|
||||
const input = parseJson(raw);
|
||||
const output = evaluatePlan(input);
|
||||
process.stdout.write(JSON.stringify(output, null, args.pretty ? 2 : 0) + '\n');
|
||||
}
|
||||
|
||||
export { evaluatePlan };
|
||||
|
||||
const isDirectRun = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url));
|
||||
|
||||
if (isDirectRun) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail('CLI_ERROR', error && error.message ? error.message : 'unexpected error');
|
||||
}
|
||||
}
|
||||
206
scripts/test_plan_long_task_auto_chain.mjs
Normal file
206
scripts/test_plan_long_task_auto_chain.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const plannerScript = path.join(__dirname, 'plan_long_task_auto_chain.mjs');
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'implementer result with review-required next action -> review dispatch',
|
||||
input: {
|
||||
gateStatus: 'pass',
|
||||
actorStage: 'implementer_result',
|
||||
requiredNextAction: 'request_spec_review',
|
||||
executionEvidence: {
|
||||
modifiedFiles: ['scripts/example.mjs'],
|
||||
verificationResult: 'tests pass',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
plannerStatus: 'pass',
|
||||
derivedAction: 'dispatch_spec_review',
|
||||
dispatchMode: 'dry_run_dispatch',
|
||||
autoChainAllowed: true,
|
||||
reasonIncludes: 'implementation evidence present',
|
||||
requiredEvidenceIncludes: 'executionEvidence',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'spec review PASS -> code quality review dispatch',
|
||||
input: {
|
||||
gateStatus: 'pass',
|
||||
actorStage: 'spec_review',
|
||||
reviewOutcome: 'pass',
|
||||
requiredNextAction: 'request_code_quality_review',
|
||||
reviewEvidence: {
|
||||
reviewer: 'spec-reviewer',
|
||||
verdict: 'pass',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
plannerStatus: 'pass',
|
||||
derivedAction: 'dispatch_code_quality_review',
|
||||
dispatchMode: 'dry_run_dispatch',
|
||||
autoChainAllowed: true,
|
||||
reasonIncludes: 'review pass evidence present',
|
||||
requiredEvidenceIncludes: 'reviewEvidence',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'explicit blocker -> retry/fix action',
|
||||
input: {
|
||||
gateStatus: 'pass',
|
||||
actorStage: 'review_result',
|
||||
blocker: 'tests failed in review',
|
||||
requiredNextAction: 'fix_review_findings',
|
||||
blockerEvidence: {
|
||||
reviewer: 'qa-reviewer',
|
||||
finding: 'tests failed',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
plannerStatus: 'pass',
|
||||
derivedAction: 'dispatch_fix_slice',
|
||||
dispatchMode: 'dry_run_dispatch',
|
||||
autoChainAllowed: true,
|
||||
reasonIncludes: 'blocker evidence present',
|
||||
requiredEvidenceIncludes: 'blockerEvidence',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'no concrete next action -> none',
|
||||
input: {
|
||||
gateStatus: 'pass',
|
||||
actorStage: 'implementer_result',
|
||||
executionEvidence: {
|
||||
modifiedFiles: ['scripts/example.mjs'],
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
plannerStatus: 'none',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
autoChainAllowed: false,
|
||||
reasonIncludes: 'no concrete next action',
|
||||
requiredEvidenceIncludes: 'concreteNextAction',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gate fail refuses auto-chain',
|
||||
input: {
|
||||
gateStatus: 'fail',
|
||||
actorStage: 'implementer_result',
|
||||
requiredNextAction: 'request_spec_review',
|
||||
executionEvidence: {
|
||||
modifiedFiles: ['scripts/example.mjs'],
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
plannerStatus: 'blocked_by_gate',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
autoChainAllowed: false,
|
||||
reasonIncludes: 'gateStatus must pass',
|
||||
requiredEvidenceIncludes: 'gateStatus=pass',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'textual review request without implementation evidence -> blocked_by_evidence',
|
||||
input: {
|
||||
gateStatus: 'pass',
|
||||
actorStage: 'implementer_result',
|
||||
requiredNextAction: 'request_spec_review',
|
||||
},
|
||||
expected: {
|
||||
plannerStatus: 'blocked_by_evidence',
|
||||
derivedAction: 'none',
|
||||
dispatchMode: 'no_dispatch',
|
||||
autoChainAllowed: false,
|
||||
reasonIncludes: 'implementation evidence missing',
|
||||
requiredEvidenceIncludes: 'executionEvidence',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function runPlanner(input) {
|
||||
const result = spawnSync(process.execPath, [plannerScript, '--compact'], {
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`planner script failed with status=${result.status}: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch (error) {
|
||||
throw new Error(`planner script returned invalid JSON: ${error.message}\nstdout=${result.stdout}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireCoreFields(output) {
|
||||
assert.equal(typeof output.plannerStatus, 'string', 'plannerStatus should be string');
|
||||
assert.equal(typeof output.derivedAction, 'string', 'derivedAction should be string');
|
||||
assert.equal(typeof output.dispatchMode, 'string', 'dispatchMode should be string');
|
||||
assert.equal(typeof output.reason, 'string', 'reason should be string');
|
||||
assert.ok(Array.isArray(output.requiredEvidence), 'requiredEvidence should be an array');
|
||||
assert.equal(typeof output.autoChainAllowed, 'boolean', 'autoChainAllowed should be boolean');
|
||||
}
|
||||
|
||||
function assertScenario(output, expected) {
|
||||
assert.equal(output.plannerStatus, expected.plannerStatus, 'plannerStatus mismatch');
|
||||
assert.equal(output.derivedAction, expected.derivedAction, 'derivedAction mismatch');
|
||||
assert.equal(output.dispatchMode, expected.dispatchMode, 'dispatchMode mismatch');
|
||||
assert.equal(output.autoChainAllowed, expected.autoChainAllowed, 'autoChainAllowed mismatch');
|
||||
assert.match(output.reason, new RegExp(expected.reasonIncludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.ok(
|
||||
output.requiredEvidence.includes(expected.requiredEvidenceIncludes),
|
||||
`expected requiredEvidence to include: ${expected.requiredEvidenceIncludes}`,
|
||||
);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let failed = false;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
try {
|
||||
const output = runPlanner(scenario.input);
|
||||
requireCoreFields(output);
|
||||
assertScenario(output, scenario.expected);
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
ok: true,
|
||||
plannerStatus: output.plannerStatus,
|
||||
derivedAction: output.derivedAction,
|
||||
dispatchMode: output.dispatchMode,
|
||||
autoChainAllowed: output.autoChainAllowed,
|
||||
reason: output.reason,
|
||||
requiredEvidence: output.requiredEvidence,
|
||||
});
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
results.push({
|
||||
scenario: scenario.name,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
passed: results.filter((entry) => entry.ok).length,
|
||||
failed: results.filter((entry) => !entry.ok).length,
|
||||
};
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ summary, results }, null, 2)}\n`);
|
||||
|
||||
if (failed) process.exit(1);
|
||||
Reference in New Issue
Block a user