diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index 13df26a..6199db6 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -8,6 +8,7 @@ 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; +const APPROVED_PLAN_CONTINUITY_TIMEOUT_MS = 8000; type AutoChainPlanResult = { plannerStatus: string; @@ -30,6 +31,14 @@ type GateLockResult = { allowedResponseModes?: string[]; }; +type ApprovedPlanContinuityResult = { + ok: boolean; + status: string; + verdict: string; + reason?: string; + gate?: string; +}; + function clamp(s: string, max = 1200): string { if (!s) return s; if (s.length <= max) return s; @@ -328,6 +337,68 @@ async function runAutoChainPlanner(workspaceDir: string, gateLockResult: GateLoc return runJsonScript(plannerPath, workspaceDir, input, LONG_TASK_AUTO_CHAIN_PLANNER_TIMEOUT_MS); } +function buildApprovedPlanContinuityInput(wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Record | null { + if (!wrapperResult || wrapperResult.classification !== "long_task") return null; + + const wrapperNextAction = wrapperResult?.nextDerivedAction ?? wrapperResult?.derivedAction ?? null; + const plannerDerivedAction = autoChainPlanResult?.derivedAction && autoChainPlanResult.derivedAction !== "none" + ? { + type: autoChainPlanResult.dispatchMode ?? "no_dispatch", + action: autoChainPlanResult.derivedAction, + } + : null; + const nextDerivedAction = wrapperNextAction ?? plannerDerivedAction; + + if (nextDerivedAction == null) return null; + + const replyClosureState = typeof wrapperResult?.replyClosureState === "string" + ? wrapperResult.replyClosureState + : (wrapperResult?.handoff?.mode === "button_path" ? "waiting_user" : "completed"); + + const dispatchReceipt = wrapperResult?.dispatchReceipt ?? (autoChainPlanResult?.dispatchMode && autoChainPlanResult.dispatchMode !== "no_dispatch" + ? { + dispatchMode: autoChainPlanResult.dispatchMode, + derivedAction: autoChainPlanResult.derivedAction, + } + : null); + + return { + planId: wrapperResult?.planId ?? "hook-preflight-approved-plan", + currentTask: wrapperResult?.currentTask ?? wrapperResult?.requiredNextAction ?? "hook-preflight-task", + taskState: wrapperResult?.taskState ?? (plannerDerivedAction ? "complete" : null), + nextDerivedAction, + replyClosureState, + dispatchReceipt, + }; +} + +async function runApprovedPlanContinuityGate(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise { + const continuityPath = path.join(workspaceDir, "scripts", "approved_plan_continuity_gate.mjs"); + const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult); + if (!input) return null; + return runJsonScript(continuityPath, workspaceDir, input, APPROVED_PLAN_CONTINUITY_TIMEOUT_MS); +} + +function buildApprovedPlanContinuityBlock(result: ApprovedPlanContinuityResult | null): string { + if (!result) return ""; + + const lines = [ + "[APPROVED_PLAN_CONTINUITY_GATE]", + `status=${result.status}`, + `verdict=${result.verdict}`, + ]; + + if (result.reason) lines.push(`reason=${result.reason}`); + + if (result.ok === false) { + lines.push("- HARD_GATE: Do not close out this reply as normal completion."); + lines.push("- HARD_GATE: Route back to continuity failure until a real next dispatch receipt exists, unless closure state is waiting_user, blocked, or pending_verification."); + } + + lines.push("[/APPROVED_PLAN_CONTINUITY_GATE]", ""); + return lines.join("\n"); +} + function buildAutoChainPlanBlock(planResult: AutoChainPlanResult | null): string { if (!planResult) { return [ @@ -473,8 +544,11 @@ const forceRecall = async (event: any) => { ]); const gateLockResult = wrapperResult ? await runLongTaskGateLock(workspaceDir, wrapperResult) : null; const autoChainPlanResult = wrapperResult ? await runAutoChainPlanner(workspaceDir, gateLockResult, wrapperResult) : null; + const approvedPlanContinuityResult = wrapperResult + ? await runApprovedPlanContinuityGate(workspaceDir, wrapperResult, autoChainPlanResult) + : null; - if (!rulebook && !soul && !wrapperResult && !gateLockResult && !autoChainPlanResult) return; + if (!rulebook && !soul && !wrapperResult && !gateLockResult && !autoChainPlanResult && !approvedPlanContinuityResult) return; const wrapperBlock = wrapperResult ? [ @@ -500,6 +574,7 @@ const forceRecall = async (event: any) => { const gateLockBlock = buildGateLockBlock(gateLockResult); const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult); + const approvedPlanContinuityBlock = buildApprovedPlanContinuityBlock(approvedPlanContinuityResult); const recallBlock = [ "[RECALL_GATE] Mandatory recall before ANY technical action/tool use.", @@ -509,6 +584,7 @@ const forceRecall = async (event: any) => { wrapperBlock || null, gateLockBlock, autoChainPlanBlock, + approvedPlanContinuityBlock || null, rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null, soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null, "[/RECALL_GATE]",