From 13bc748a83ec5f409b1b265b9773de3593cb3e1f Mon Sep 17 00:00:00 2001 From: Eve Date: Thu, 23 Apr 2026 22:40:08 +0800 Subject: [PATCH] feat: add execution-layer auto-chain dry-run planner --- hooks/force-recall/handler.ts | 95 ++++++++++++++++++- .../test_force_recall_long_task_preflight.mjs | 25 ++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index d79f9d7..cc1bfd7 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -7,6 +7,16 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const LONG_TASK_WRAPPER_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 = { 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); } +function buildAutoChainPlannerInput(gateLockResult: GateLockResult | null, wrapperResult: any): Record { + const requiredNextAction = typeof wrapperResult?.requiredNextAction === "string" + ? wrapperResult.requiredNextAction.trim() + : ""; + const plannerInput: Record = { + 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 { + 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[] { const lines = [ "- Treat this as ingress preflight guidance from the wrapper MVP.", @@ -327,8 +417,9 @@ const forceRecall = async (event: any) => { runLongTaskWrapper(workspaceDir, ctx), ]); 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 ? [ @@ -353,6 +444,7 @@ const forceRecall = async (event: any) => { : ""; const gateLockBlock = buildGateLockBlock(gateLockResult); + const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult); const recallBlock = [ "[RECALL_GATE] Mandatory recall before ANY technical action/tool use.", @@ -361,6 +453,7 @@ const forceRecall = async (event: any) => { "", wrapperBlock || null, gateLockBlock, + autoChainPlanBlock, rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null, soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null, "[/RECALL_GATE]", diff --git a/scripts/test_force_recall_long_task_preflight.mjs b/scripts/test_force_recall_long_task_preflight.mjs index 05775e7..f503029 100755 --- a/scripts/test_force_recall_long_task_preflight.mjs +++ b/scripts/test_force_recall_long_task_preflight.mjs @@ -11,6 +11,7 @@ const repoRoot = path.resolve(__dirname, '..'); const handlerPath = path.join(repoRoot, 'hooks', 'force-recall', 'handler.ts'); const wrapperPath = path.join(repoRoot, 'scripts', 'long_task_governor_wrapper.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) { const source = await fs.readFile(tsPath, 'utf8'); @@ -55,7 +56,7 @@ function buildWrapperScript(wrapperResult) { } 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); assert.equal(typeof forceRecall, 'function', 'force-recall handler should export default function'); @@ -74,6 +75,13 @@ async function main() { 'handoff.mode=button_path', '[LONG_TASK_GATE_LOCK]', '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=concreteNextAction', 'requiredEvidence=buttonPathMode', @@ -201,6 +209,11 @@ async function main() { handoff: { mode: 'direct_reply' }, }), async () => runScenario(forceRecall, requestText)); 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({ classification: 'long_task', @@ -213,6 +226,11 @@ async function main() { handoff: { mode: 'direct_reply' }, }), 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, /\[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, /requiredEvidence=autoChainDispatchEvidence/, 'hook fail-path should require autoChainDispatchEvidence'); @@ -227,6 +245,11 @@ async function main() { handoff: { mode: 'direct_reply' }, }), 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, /\[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'); const originalGateLock = await fs.readFile(gateLockPath, 'utf8');