diff --git a/scripts/plan_long_task_auto_chain.mjs b/scripts/plan_long_task_auto_chain.mjs new file mode 100644 index 0000000..85320ce --- /dev/null +++ b/scripts/plan_long_task_auto_chain.mjs @@ -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'); + } +} diff --git a/scripts/test_plan_long_task_auto_chain.mjs b/scripts/test_plan_long_task_auto_chain.mjs new file mode 100644 index 0000000..fbf1d05 --- /dev/null +++ b/scripts/test_plan_long_task_auto_chain.mjs @@ -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);