feat: add execution-layer auto-chain dry-run planner

This commit is contained in:
Eve
2026-04-23 22:40:08 +08:00
parent 245f7385d9
commit 13bc748a83
2 changed files with 118 additions and 2 deletions

View File

@@ -7,6 +7,16 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000; const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000;
const LONG_TASK_GATE_LOCK_TIMEOUT_MS = 8000; const LONG_TASK_GATE_LOCK_TIMEOUT_MS = 8000;
const LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS = 8000;
type AutoChainPlanResult = {
plannerStatus: string;
derivedAction: string;
dispatchMode: string;
reason: string;
requiredEvidence?: string[];
autoChainAllowed: boolean;
};
type GateLockResult = { type GateLockResult = {
gateRequired: boolean; gateRequired: boolean;
@@ -210,6 +220,86 @@ async function runLongTaskGateLock(workspaceDir: string, wrapperResult: any): Pr
return runJsonScript(gateLockPath, workspaceDir, input, LONG_TASK_GATE_LOCK_TIMEOUT_MS); return runJsonScript(gateLockPath, workspaceDir, input, LONG_TASK_GATE_LOCK_TIMEOUT_MS);
} }
function buildAutoChainPlannerInput(gateLockResult: GateLockResult | null, wrapperResult: any): Record<string, unknown> {
const requiredNextAction = typeof wrapperResult?.requiredNextAction === "string"
? wrapperResult.requiredNextAction.trim()
: "";
const plannerInput: Record<string, unknown> = {
gateStatus: gateLockResult?.gateStatus ?? "not_applicable",
actorStage: "hook_preflight",
requiredNextAction,
};
if (!requiredNextAction) return plannerInput;
if (requiredNextAction === "dispatch_follow_up_subagent") {
plannerInput.actorStage = "implementer_result";
plannerInput.requiredNextAction = "request_spec_review";
if (wrapperResult?.autoChainDispatchEvidence && typeof wrapperResult.autoChainDispatchEvidence === "object" && !Array.isArray(wrapperResult.autoChainDispatchEvidence)) {
plannerInput.executionEvidence = wrapperResult.autoChainDispatchEvidence;
}
return plannerInput;
}
if (requiredNextAction === "dispatch_code_quality_review") {
plannerInput.actorStage = "spec_review";
plannerInput.requiredNextAction = "request_code_quality_review";
plannerInput.reviewOutcome = "pass";
plannerInput.reviewEvidence = wrapperResult?.reviewEvidence && typeof wrapperResult.reviewEvidence === "object" && !Array.isArray(wrapperResult.reviewEvidence)
? wrapperResult.reviewEvidence
: { source: "hook_preflight", verdict: "pass" };
return plannerInput;
}
if (requiredNextAction === "dispatch_fix_slice") {
plannerInput.actorStage = "review_result";
plannerInput.requiredNextAction = "fix_review_findings";
plannerInput.blocker = typeof wrapperResult?.silentLaunchReason === "string" && wrapperResult.silentLaunchReason.trim()
? wrapperResult.silentLaunchReason.trim()
: "hook_preflight_blocker";
plannerInput.blockerEvidence = wrapperResult?.blockerEvidence && typeof wrapperResult.blockerEvidence === "object" && !Array.isArray(wrapperResult.blockerEvidence)
? wrapperResult.blockerEvidence
: { source: "hook_preflight", blocker: plannerInput.blocker };
return plannerInput;
}
return plannerInput;
}
async function runAutoChainPlanner(workspaceDir: string, gateLockResult: GateLockResult | null, wrapperResult: any): Promise<AutoChainPlanResult | null> {
if (!wrapperResult || wrapperResult.classification !== "long_task") return null;
const plannerPath = path.join(workspaceDir, "scripts", "plan_long_task_auto_chain.mjs");
const input = buildAutoChainPlannerInput(gateLockResult, wrapperResult);
return runJsonScript(plannerPath, workspaceDir, input, LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS);
}
function buildAutoChainPlanBlock(planResult: AutoChainPlanResult | null): string {
if (!planResult) {
return [
"[LONG_TASK_AUTO_CHAIN_PLAN]",
"plannerStatus=degraded",
"derivedAction=none",
"dispatchMode=no_dispatch",
"autoChainAllowed=false",
"reason=auto-chain planner unavailable during hook preflight",
"[/LONG_TASK_AUTO_CHAIN_PLAN]",
"",
].join("\n");
}
return [
"[LONG_TASK_AUTO_CHAIN_PLAN]",
`plannerStatus=${planResult.plannerStatus}`,
`derivedAction=${planResult.derivedAction}`,
`dispatchMode=${planResult.dispatchMode}`,
`autoChainAllowed=${planResult.autoChainAllowed}`,
`reason=${planResult.reason}`,
...((planResult.requiredEvidence ?? []).map((entry) => `requiredEvidence=${entry}`)),
"[/LONG_TASK_AUTO_CHAIN_PLAN]",
"",
].join("\n");
}
function buildWrapperEnforcement(wrapperResult: any): string[] { function buildWrapperEnforcement(wrapperResult: any): string[] {
const lines = [ const lines = [
"- Treat this as ingress preflight guidance from the wrapper MVP.", "- Treat this as ingress preflight guidance from the wrapper MVP.",
@@ -327,8 +417,9 @@ const forceRecall = async (event: any) => {
runLongTaskWrapper(workspaceDir, ctx), runLongTaskWrapper(workspaceDir, ctx),
]); ]);
const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null; const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null;
const autoChainPlanResult = wrapperResult ? await runAutoChainPlanner(workspaceDir, gateLockResult, wrapperResult) : null;
if (!rulebook && !soul && !wrapperResult && !gateLockResult) return; if (!rulebook && !soul && !wrapperResult && !gateLockResult && !autoChainPlanResult) return;
const wrapperBlock = wrapperResult const wrapperBlock = wrapperResult
? [ ? [
@@ -353,6 +444,7 @@ const forceRecall = async (event: any) => {
: ""; : "";
const gateLockBlock = buildGateLockBlock(gateLockResult); const gateLockBlock = buildGateLockBlock(gateLockResult);
const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
const recallBlock = [ const recallBlock = [
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.", "[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
@@ -361,6 +453,7 @@ const forceRecall = async (event: any) => {
"", "",
wrapperBlock || null, wrapperBlock || null,
gateLockBlock, gateLockBlock,
autoChainPlanBlock,
rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null, rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null,
soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null, soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null,
"[/RECALL_GATE]", "[/RECALL_GATE]",

View File

@@ -11,6 +11,7 @@ const repoRoot = path.resolve(__dirname, '..');
const handlerPath = path.join(repoRoot, 'hooks', 'force-recall', 'handler.ts'); const handlerPath = path.join(repoRoot, 'hooks', 'force-recall', 'handler.ts');
const wrapperPath = path.join(repoRoot, 'scripts', 'long_task_governor_wrapper.mjs'); const wrapperPath = path.join(repoRoot, 'scripts', 'long_task_governor_wrapper.mjs');
const gateLockPath = path.join(repoRoot, 'scripts', 'long_task_gate_lock.mjs'); const gateLockPath = path.join(repoRoot, 'scripts', 'long_task_gate_lock.mjs');
const plannerPath = path.join(repoRoot, 'scripts', 'plan_long_task_auto_chain.mjs');
async function importTsModule(tsPath) { async function importTsModule(tsPath) {
const source = await fs.readFile(tsPath, 'utf8'); const source = await fs.readFile(tsPath, 'utf8');
@@ -55,7 +56,7 @@ function buildWrapperScript(wrapperResult) {
} }
async function main() { async function main() {
await Promise.all([fs.access(wrapperPath), fs.access(gateLockPath)]); await Promise.all([fs.access(wrapperPath), fs.access(gateLockPath), fs.access(plannerPath)]);
const { default: forceRecall } = await importTsModule(handlerPath); const { default: forceRecall } = await importTsModule(handlerPath);
assert.equal(typeof forceRecall, 'function', 'force-recall handler should export default function'); assert.equal(typeof forceRecall, 'function', 'force-recall handler should export default function');
@@ -74,6 +75,13 @@ async function main() {
'handoff.mode=button_path', 'handoff.mode=button_path',
'[LONG_TASK_GATE_LOCK]', '[LONG_TASK_GATE_LOCK]',
'gateStatus=fail', 'gateStatus=fail',
'[LONG_TASK_AUTO_CHAIN_PLAN]',
'plannerStatus=blocked_by_gate',
'derivedAction=none',
'dispatchMode=no_dispatch',
'autoChainAllowed=false',
'reason=gateStatus must pass before auto-chain planning can proceed',
'requiredEvidence=gateStatus=pass',
'requiredEvidence=externalizedCheckpoint', 'requiredEvidence=externalizedCheckpoint',
'requiredEvidence=concreteNextAction', 'requiredEvidence=concreteNextAction',
'requiredEvidence=buttonPathMode', 'requiredEvidence=buttonPathMode',
@@ -201,6 +209,11 @@ async function main() {
handoff: { mode: 'direct_reply' }, handoff: { mode: 'direct_reply' },
}), async () => runScenario(forceRecall, requestText)); }), async () => runScenario(forceRecall, requestText));
assert.match(passInjected, /gateStatus=pass/, 'hook pass-path should pass when wrapper provides concrete progressEvidence'); assert.match(passInjected, /gateStatus=pass/, 'hook pass-path should pass when wrapper provides concrete progressEvidence');
assert.match(passInjected, /\[LONG_TASK_AUTO_CHAIN_PLAN\]/, 'hook pass-path should emit auto-chain plan block');
assert.match(passInjected, /plannerStatus=pass/, 'hook pass-path should expose planner pass result');
assert.match(passInjected, /derivedAction=dispatch_spec_review/, 'hook pass-path should derive dry-run spec review dispatch');
assert.match(passInjected, /dispatchMode=dry_run_dispatch/, 'hook pass-path should stay in dry-run dispatch mode');
assert.match(passInjected, /autoChainAllowed=true/, 'hook pass-path should allow auto-chain in dry-run planner output');
const failInjected = await withPatchedWrapper(buildWrapperScript({ const failInjected = await withPatchedWrapper(buildWrapperScript({
classification: 'long_task', classification: 'long_task',
@@ -213,6 +226,11 @@ async function main() {
handoff: { mode: 'direct_reply' }, handoff: { mode: 'direct_reply' },
}), async () => runScenario(forceRecall, requestText)); }), async () => runScenario(forceRecall, requestText));
assert.match(failInjected, /gateStatus=fail/, 'hook fail-path should fail when wrapper exposes explicit auto-chain action without dispatch evidence'); assert.match(failInjected, /gateStatus=fail/, 'hook fail-path should fail when wrapper exposes explicit auto-chain action without dispatch evidence');
assert.match(failInjected, /\[LONG_TASK_AUTO_CHAIN_PLAN\]/, 'hook fail-path should emit auto-chain plan block');
assert.match(failInjected, /plannerStatus=blocked_by_gate/, 'hook fail-path should report planner blocked by gate');
assert.match(failInjected, /derivedAction=none/, 'hook fail-path should not derive a dry-run action');
assert.match(failInjected, /dispatchMode=no_dispatch/, 'hook fail-path should remain no-dispatch');
assert.match(failInjected, /autoChainAllowed=false/, 'hook fail-path should not allow auto-chain');
assert.match(failInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook fail-path should mention missing dispatched-action evidence'); assert.match(failInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook fail-path should mention missing dispatched-action evidence');
assert.match(failInjected, /requiredEvidence=autoChainDispatchEvidence/, 'hook fail-path should require autoChainDispatchEvidence'); assert.match(failInjected, /requiredEvidence=autoChainDispatchEvidence/, 'hook fail-path should require autoChainDispatchEvidence');
@@ -227,6 +245,11 @@ async function main() {
handoff: { mode: 'direct_reply' }, handoff: { mode: 'direct_reply' },
}), async () => runScenario(forceRecall, requestText)); }), async () => runScenario(forceRecall, requestText));
assert.match(neutralInjected, /gateStatus=pass/, 'hook neutral-path should pass when wrapper does not expose an explicit auto-chain action'); assert.match(neutralInjected, /gateStatus=pass/, 'hook neutral-path should pass when wrapper does not expose an explicit auto-chain action');
assert.match(neutralInjected, /\[LONG_TASK_AUTO_CHAIN_PLAN\]/, 'hook neutral-path should emit auto-chain plan block');
assert.match(neutralInjected, /plannerStatus=none/, 'hook neutral-path should report no derived auto-chain action');
assert.match(neutralInjected, /derivedAction=none/, 'hook neutral-path should keep derivedAction as none');
assert.match(neutralInjected, /dispatchMode=no_dispatch/, 'hook neutral-path should remain no-dispatch');
assert.match(neutralInjected, /autoChainAllowed=false/, 'hook neutral-path should keep auto-chain disabled');
assert.doesNotMatch(neutralInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook neutral-path should not fail on auto-chain evidence when no explicit tool action exists'); assert.doesNotMatch(neutralInjected, /reason=explicit auto-chain next action requires dispatched-action evidence/, 'hook neutral-path should not fail on auto-chain evidence when no explicit tool action exists');
const originalGateLock = await fs.readFile(gateLockPath, 'utf8'); const originalGateLock = await fs.readFile(gateLockPath, 'utf8');