import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const LONG_TASK_WRAPPER_TIMEOUT_MS = 8000; const PROACTIVE_REPORT_GATE_LOCK_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; derivedAction: string; dispatchMode: string; reason: string; requiredEvidence?: string[]; autoChainAllowed: boolean; }; type ProactiveReportGateResult = { gateRequired: boolean; gateStatus: "not_applicable" | "pass" | "fail"; ok: boolean; reasons?: string[]; requiredEvidence?: Array<{ evidenceKey?: string; acceptedFields?: string[]; requiredValue?: string; }>; allowedResponseModes?: string[]; reportBindingStatus?: string; }; type GateLockResult = { gateRequired: boolean; gateStatus: "not_applicable" | "pass" | "fail"; reasons?: string[]; requiredEvidence?: Array<{ evidenceKey?: string; acceptedFields?: string[]; requiredValue?: string; }>; allowedResponseModes?: string[]; }; type ApprovedPlanContinuityResult = { ok: boolean; status: string; verdict: string; reason?: string; gate?: string; }; type ForceRecallContinuityAdapterModule = { defaultConfig?: Record; runForceRecallContinuityAdapter?: (args: { wrapperResult: any; autoChainPlanResult?: AutoChainPlanResult | null; config?: Record; }) => { input: Record | null; result: ApprovedPlanContinuityResult | null; block: string; }; }; function clamp(s: string, max = 1200): string { if (!s) return s; if (s.length <= max) return s; return s.slice(0, max) + "\n…(truncated)…"; } async function safeReadText(filePath: string): Promise { try { const raw = await fs.readFile(filePath, "utf-8"); const trimmed = raw.trim(); return trimmed ? trimmed : null; } catch { return null; } } async function getReadableCheckpointArtifact(workspaceDir: string, wrapperResult: any): Promise<{ relativePath: string; absolutePath: string; content: string; } | null> { const relativePath = typeof wrapperResult?.externalizedCheckpointPath === "string" ? wrapperResult.externalizedCheckpointPath.trim() : ""; if (!relativePath) return null; const absolutePath = path.resolve(workspaceDir, relativePath); try { const raw = await fs.readFile(absolutePath, "utf-8"); const content = raw.trim(); if (!content) return null; return { relativePath, absolutePath, content }; } catch { return null; } } async function runJsonScript(scriptPath: string, workspaceDir: string, input: Record, timeout: number): Promise { let tempInputPath: string | null = null; try { tempInputPath = path.join( os.tmpdir(), `openclaw-hook-${path.basename(scriptPath, path.extname(scriptPath))}-${process.pid}-${Date.now()}.json`, ); await fs.writeFile(tempInputPath, JSON.stringify(input), "utf-8"); const { stdout } = await execFileAsync("node", [scriptPath, "--compact", "--input", tempInputPath], { cwd: workspaceDir, maxBuffer: 1024 * 1024, timeout, }); return JSON.parse(stdout); } catch { return null; } finally { if (tempInputPath) { await fs.unlink(tempInputPath).catch(() => {}); } } } async function runLongTaskWrapper(workspaceDir: string, ctx: any): Promise { const wrapperPath = path.join(workspaceDir, "scripts", "long_task_governor_wrapper.mjs"); const input = { requestText: (ctx.body ?? ctx.content ?? ctx.bodyForAgent ?? "") as string, hasFilesOrSystems: false, needsWaiting: false, needsSubagent: false, needsOwnerDecision: false, canReplyNow: false, taskName: "Hook preflight classification", currentStep: "Classifying request at preprocessed hook", nextStep: "Carry governor recommendation into prompt context", nextReportCondition: "At next meaningful milestone", waitingOn: "none", blocker: "none", checkpointTrigger: "", externalizedTrigger: "", triggerKind: "", firstReportTrigger: "", fallbackState: "pending_verification", reportMode: "watchdog", ownerVisibleIfStalled: true, }; return runJsonScript(wrapperPath, workspaceDir, input, LONG_TASK_WRAPPER_TIMEOUT_MS); } function buildProgressEvidence(wrapperResult: any, readableCheckpointArtifact: { relativePath: string; absolutePath: string; content: string; } | null): Record | null { const candidate = wrapperResult?.progressEvidence; if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { return null; } const progressEvidence: Record = {}; const sessionKey = typeof candidate.sessionKey === "string" ? candidate.sessionKey.trim() : ""; if (sessionKey) { progressEvidence.sessionKey = sessionKey; } const runId = typeof candidate.runId === "string" ? candidate.runId.trim() : ""; if (runId) { progressEvidence.runId = runId; } if (Array.isArray(candidate.modified_files) && candidate.modified_files.length > 0) { progressEvidence.modified_files = candidate.modified_files; } const verificationResult = typeof candidate.verificationResult === "string" ? candidate.verificationResult.trim() : ""; if (verificationResult) { progressEvidence.verificationResult = verificationResult; } if (readableCheckpointArtifact) { progressEvidence.checkpointPath = readableCheckpointArtifact.relativePath; if (!progressEvidence.verificationResult) { progressEvidence.verificationResult = `checkpoint artifact readable at ${readableCheckpointArtifact.relativePath}`; } } return Object.keys(progressEvidence).length > 0 ? progressEvidence : null; } function shouldClaimProgression(wrapperResult: any, progressEvidence: Record | null): boolean { if (!wrapperResult || wrapperResult.classification !== "long_task") return false; if (progressEvidence && Object.keys(progressEvidence).length > 0) return true; const requiredNextAction = typeof wrapperResult.requiredNextAction === "string" ? wrapperResult.requiredNextAction.trim() : ""; const progressingActionPrefixes = [ "dispatch_", "handoff_", "launch_", "resume_", "continue_", "queue_", "schedule_", "run_", "start_", "spawn_", ]; if (requiredNextAction && progressingActionPrefixes.some((prefix) => requiredNextAction.startsWith(prefix))) { return true; } return wrapperResult.silentLaunchOk === true; } function buildGateLockInput(wrapperResult: any, readableCheckpointArtifact: { relativePath: string; absolutePath: string; content: string; } | null): Record { if (!wrapperResult || wrapperResult.classification !== "long_task") { return { classification: wrapperResult?.classification ?? "general_chat" }; } const needsOwnerDecision = wrapperResult.needsOwnerDecision === true; const silentCandidate = wrapperResult.silentCandidate === true; const progressEvidence = buildProgressEvidence(wrapperResult, readableCheckpointArtifact); const requiredNextAction = typeof wrapperResult.requiredNextAction === "string" ? wrapperResult.requiredNextAction.trim() : ""; const hasConcreteExecutionEvidence = Boolean( requiredNextAction && ![ "", "proceed_with_normal_long_task_flow", "proceed_with_silent_launch", "define_first_checkpoint_trigger_before_silent_launch", "bind_externalized_checkpoint_path_or_abort_silent_launch", ].includes(requiredNextAction), ); const autoChainNextAction = hasConcreteExecutionEvidence ? requiredNextAction : ""; const executionEvidence = hasConcreteExecutionEvidence ? { concreteNextAction: requiredNextAction, } : null; const autoChainDispatchEvidence = hasConcreteExecutionEvidence && wrapperResult.autoChainDispatchEvidence && typeof wrapperResult.autoChainDispatchEvidence === "object" && !Array.isArray(wrapperResult.autoChainDispatchEvidence) ? wrapperResult.autoChainDispatchEvidence : null; const claimedProgression = shouldClaimProgression(wrapperResult, progressEvidence) ? "already progressing to the next step in background" : ""; const progressEvidenceReason = claimedProgression && !progressEvidence ? "progression claim requires concrete evidence such as sessionKey, runId, modified_files, or verification result" : ""; const hasExternalizedCheckpointEvidence = Boolean(readableCheckpointArtifact); const hasButtonPathClosureEvidence = needsOwnerDecision && wrapperResult.silentLaunchOk === true; return { classification: wrapperResult.classification, silentContinuation: silentCandidate, claimedExecution: hasConcreteExecutionEvidence || (silentCandidate && wrapperResult.silentLaunchOk !== true), needsOwnerDecision, nextStep: hasConcreteExecutionEvidence ? requiredNextAction : "", requiredNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "", concreteNextAction: hasConcreteExecutionEvidence ? requiredNextAction : "", autoChainNextAction, autoChainDispatchEvidence, progressionClaim: claimedProgression, claimedProgression: claimedProgression, statusSummary: claimedProgression, executionEvidence, progressEvidence, autoChainDispatchEvidenceReason: hasConcreteExecutionEvidence && !autoChainDispatchEvidence ? "explicit auto-chain next action requires dispatched-action evidence" : "", progressEvidenceReason, sessionKey: typeof progressEvidence?.sessionKey === "string" ? progressEvidence.sessionKey : "", runId: typeof progressEvidence?.runId === "string" ? progressEvidence.runId : "", modified_files: Array.isArray(progressEvidence?.modified_files) ? progressEvidence.modified_files : [], verificationResult: typeof progressEvidence?.verificationResult === "string" ? progressEvidence.verificationResult : "", toolCallEvidence: "", dispatchEvidence: "", fileChangeEvidence: "", verificationEvidence: "", checkpointArtifactEvidence: hasExternalizedCheckpointEvidence ? readableCheckpointArtifact.relativePath : "", externalizedCheckpointPath: hasExternalizedCheckpointEvidence ? readableCheckpointArtifact.relativePath : "", externalizedTrigger: hasExternalizedCheckpointEvidence ? "hook-preflight-checkpoint" : "", handoffMode: hasButtonPathClosureEvidence ? (wrapperResult.handoff?.mode ?? "button_path") : "direct_reply", replyClosureMode: hasButtonPathClosureEvidence ? (wrapperResult.handoff?.mode ?? "button_path") : "direct_reply", }; } function buildProactiveReportGateInput(wrapperResult: any, readableCheckpointArtifact: { relativePath: string; absolutePath: string; content: string; } | null): Record { if (!wrapperResult || wrapperResult.classification !== "long_task") { return { classification: wrapperResult?.classification ?? "general_chat" }; } const needsOwnerDecision = wrapperResult.needsOwnerDecision === true; const silentCandidate = wrapperResult.silentCandidate === true; const firstReportTrigger = typeof wrapperResult.firstReportTrigger === "string" ? wrapperResult.firstReportTrigger.trim() : ""; const nextReportCondition = typeof wrapperResult.nextReportCondition === "string" ? wrapperResult.nextReportCondition.trim() : ""; const fallbackState = typeof wrapperResult.fallbackState === "string" ? wrapperResult.fallbackState.trim() : ""; const reportMode = typeof wrapperResult.reportMode === "string" ? wrapperResult.reportMode.trim() : ""; return { classification: wrapperResult.classification, silentCandidate, needsSubagent: wrapperResult.needsSubagent === true, needsWaiting: silentCandidate, needsOwnerDecision, firstReportTrigger, nextReportCondition, fallbackState, reportMode, ownerVisibleIfStalled: wrapperResult.ownerVisibleIfStalled === true, handoffMode: typeof wrapperResult?.handoff?.mode === "string" ? wrapperResult.handoff.mode : "direct_reply", externalizedCheckpointPath: readableCheckpointArtifact?.relativePath ?? (typeof wrapperResult.externalizedCheckpointPath === "string" ? wrapperResult.externalizedCheckpointPath : ""), checkpointTrigger: typeof wrapperResult.checkpointTrigger === "string" ? wrapperResult.checkpointTrigger : "", }; } async function runProactiveReportGateLock(workspaceDir: string, wrapperResult: any): Promise { const gateLockPath = path.join(workspaceDir, "scripts", "proactive_report_gate_lock.mjs"); const readableCheckpointArtifact = await getReadableCheckpointArtifact(workspaceDir, wrapperResult); const input = buildProactiveReportGateInput(wrapperResult, readableCheckpointArtifact); return runJsonScript(gateLockPath, workspaceDir, input, PROACTIVE_REPORT_GATE_LOCK_TIMEOUT_MS); } async function runLongTaskGateLock(workspaceDir: string, wrapperResult: any): Promise { const gateLockPath = path.join(workspaceDir, "scripts", "long_task_gate_lock.mjs"); const readableCheckpointArtifact = await getReadableCheckpointArtifact(workspaceDir, wrapperResult); const input = buildGateLockInput(wrapperResult, readableCheckpointArtifact); 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"; if (wrapperResult?.reviewEvidence && typeof wrapperResult.reviewEvidence === "object" && !Array.isArray(wrapperResult.reviewEvidence)) { plannerInput.reviewEvidence = wrapperResult.reviewEvidence; } 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"; if (wrapperResult?.blockerEvidence && typeof wrapperResult.blockerEvidence === "object" && !Array.isArray(wrapperResult.blockerEvidence)) { plannerInput.blockerEvidence = wrapperResult.blockerEvidence; } return plannerInput; } if (requiredNextAction === "dispatch_spec_review") { plannerInput.actorStage = "implementer_result"; plannerInput.requiredNextAction = "request_spec_review"; if (wrapperResult?.implementationEvidence && typeof wrapperResult.implementationEvidence === "object" && !Array.isArray(wrapperResult.implementationEvidence)) { plannerInput.executionEvidence = wrapperResult.implementationEvidence; } 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 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 ?? null; const nextTaskKnown = wrapperResult?.nextTaskKnown === true || (plannerDerivedAction != null && typeof autoChainPlanResult?.derivedAction === 'string' && autoChainPlanResult.derivedAction !== 'none'); const sameApprovedPlan = wrapperResult?.sameApprovedPlan === true || plannerDerivedAction != null; const taskBoundaryStop = wrapperResult?.taskBoundaryStop === true || replyClosureState === 'completed'; const highRiskStop = wrapperResult?.highRiskStop === true; 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, nextTaskKnown, sameApprovedPlan, taskBoundaryStop, highRiskStop, }; } const continuityAdapterModuleCache = new Map>(); async function loadForceRecallContinuityAdapterModule(workspaceDir: string): Promise { const adapterPath = path.join(workspaceDir, "plugins", "continuity", "src", "index.mjs"); try { const stat = await fs.stat(adapterPath); const cacheKey = `${adapterPath}?mtimeMs=${stat.mtimeMs}`; let modulePromise = continuityAdapterModuleCache.get(cacheKey); if (!modulePromise) { modulePromise = import(pathToFileURL(adapterPath).href + `?mtimeMs=${stat.mtimeMs}`).catch(() => null); continuityAdapterModuleCache.set(cacheKey, modulePromise); } return modulePromise; } catch { return null; } } async function readContinuityPluginConfigOverrides(workspaceDir: string): Promise> { const defaultsPath = path.join(workspaceDir, "plugins", "continuity", "src", "config", "defaults.mjs"); const source = await safeReadText(defaultsPath); if (!source) return {}; const forceRecallLabel = source.match(/forceRecall:\s*\{[\s\S]*?injectBlockLabel:\s*['"]([^'"]+)['"]/); const genericPreflightLabel = source.match(/genericPreflight:\s*\{[\s\S]*?injectBlockLabel:\s*['"]([^'"]+)['"]/); return { adapter: { forceRecall: forceRecallLabel ? { injectBlockLabel: forceRecallLabel[1] } : {}, genericPreflight: genericPreflightLabel ? { injectBlockLabel: genericPreflightLabel[1] } : {}, }, }; } async function evaluateApprovedPlanContinuityViaPlugin(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise<{ input: Record | null; result: ApprovedPlanContinuityResult | null; block: string; } | null> { const adapterModule = await loadForceRecallContinuityAdapterModule(workspaceDir); const runAdapter = adapterModule?.runForceRecallContinuityAdapter; if (typeof runAdapter !== "function") return null; const configOverrides = await readContinuityPluginConfigOverrides(workspaceDir); return runAdapter({ wrapperResult, autoChainPlanResult, config: { ...(adapterModule?.defaultConfig ?? {}), ...configOverrides, adapter: { ...(((adapterModule?.defaultConfig ?? {}) as any)?.adapter ?? {}), ...((configOverrides as any)?.adapter ?? {}), forceRecall: { ...((((adapterModule?.defaultConfig ?? {}) as any)?.adapter?.forceRecall) ?? {}), ...(((configOverrides as any)?.adapter?.forceRecall) ?? {}), }, genericPreflight: { ...((((adapterModule?.defaultConfig ?? {}) as any)?.adapter?.genericPreflight) ?? {}), ...(((configOverrides as any)?.adapter?.genericPreflight) ?? {}), }, }, }, }); } async function runApprovedPlanContinuityGate(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise { const evaluated = await evaluateApprovedPlanContinuityViaPlugin(workspaceDir, wrapperResult, autoChainPlanResult); if (evaluated) return evaluated.result; 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); } async function buildApprovedPlanContinuityBlock(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null, result: ApprovedPlanContinuityResult | null): Promise { const evaluated = await evaluateApprovedPlanContinuityViaPlugin(workspaceDir, wrapperResult, autoChainPlanResult); if (evaluated?.block) return evaluated.block; 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."); if (result.reason === 'missing_auto_next_dispatch') { lines.push("- HARD_GATE: Do not stop at this completed-task boundary."); lines.push("- HARD_GATE: Auto-dispatch the next task in the same approved plan, unless waiting_user, blocked, pending_verification, or high-risk stop applies."); lines.push("- HARD_GATE: Do not hand control back to the user with an ordinary progress update while auto-next is still obligatory."); lines.push("- HARD_GATE: If you cannot prove the next dispatch, convert this into an explicit continuity failure instead of a normal status report."); } else { 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 [ "[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.", ]; if (wrapperResult.classification === "long_task") { lines.push("- ENFORCEMENT: This request defaults to long-task governance; do not treat it as ordinary single-turn chat unless you can clearly justify overriding the classifier."); lines.push("- ENFORCEMENT: If you proceed, prefer explicit task state and checkpoint discipline over ad-hoc continuation."); } if (wrapperResult.handoff?.mode === "button_path") { lines.push("- ENFORCEMENT: Owner decision is expected; plan Telegram button-path early instead of ending with a plain-text menu."); } if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === false) { lines.push("- ENFORCEMENT: Silent launch is NOT allowed in the current form."); lines.push("- ENFORCEMENT: Use the recommended fallback before proceeding."); if (wrapperResult.requiredNextAction) { lines.push(`- ENFORCEMENT: Required next action = ${wrapperResult.requiredNextAction}`); } } else if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === true) { lines.push("- ENFORCEMENT: Silent launch is only acceptable if you preserve externalized checkpoint discipline and do not rely on memory alone."); } return lines; } function buildWrapperHardGate(wrapperResult: any): string[] { const lines: string[] = []; if (wrapperResult.classification === "long_task") { lines.push("- HARD_GATE: If you intend to proceed as ordinary chat, you must explicitly justify why long-task governance does not apply."); } if (wrapperResult.handoff?.mode === "button_path") { lines.push("- HARD_GATE: Do not end this flow with a plain-text choice menu. Use Telegram inline buttons or execute the most reasonable next step directly."); } if (wrapperResult.silentCandidate === true && wrapperResult.silentLaunchOk === false) { lines.push("- HARD_GATE: Do NOT launch or continue this task in silent mode in its current form."); lines.push("- HARD_GATE: Before any silent execution, satisfy the required next action or downgrade to non-silent follow-up."); } return lines; } function buildProactiveReportGateBlock(gateResult: ProactiveReportGateResult | null): string { if (!gateResult) { return [ "[PROACTIVE_REPORT_GATE]", "gateStatus=degraded", "gateRequired=unknown", "- ENFORCEMENT: Proactive-report gate unavailable; do not treat this as permission to launch silent progression.", "- HARD_GATE: Fall back to non-silent follow-up unless firstReportTrigger, nextReportCondition, and fallbackState are explicitly bound.", "[/PROACTIVE_REPORT_GATE]", "", ].join("\n"); } const lines = [ "[PROACTIVE_REPORT_GATE]", `gateRequired=${gateResult.gateRequired}`, `gateStatus=${gateResult.gateStatus}`, `reportBindingStatus=${gateResult.reportBindingStatus ?? "unknown"}`, ...((gateResult.reasons ?? []).map((reason) => `reason=${reason}`)), ...((gateResult.requiredEvidence ?? []).map((requirement) => { const fields = (requirement.acceptedFields ?? []).join(","); return `requiredEvidence=${requirement.evidenceKey ?? "unknown"};fields=${fields};requiredValue=${requirement.requiredValue ?? "unknown"}`; })), ...((gateResult.allowedResponseModes ?? []).map((mode) => `allowedResponseMode=${mode}`)), ]; if (gateResult.gateStatus === "fail") { lines.push("- ENFORCEMENT: Silent long-task cannot enter silent progression yet."); lines.push("- ENFORCEMENT: Bind firstReportTrigger, nextReportCondition, and fallbackState before claiming background continuation."); lines.push("- HARD_GATE: Downgrade to non-silent follow-up if proactive report binding remains incomplete."); } lines.push("[/PROACTIVE_REPORT_GATE]", ""); return lines.join("\n"); } function buildGateLockBlock(gateLockResult: GateLockResult | null): string { if (!gateLockResult) { return [ "[LONG_TASK_GATE_LOCK]", "gateStatus=degraded", "gateRequired=unknown", "- ENFORCEMENT: Gate-lock evaluator unavailable; keep existing long-task safeguards in force.", "- ENFORCEMENT: Do not claim you have progressed into the next task or are already pushing the next step unless you have concrete progress evidence such as a sessionKey, runId, modified_files record, verification result, actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.", "- ENFORCEMENT: Hook inputs for any progression claim should carry progressEvidence (or equivalent concrete fields) so the gate can verify the claim.", "- HARD_GATE: Evaluator unavailable is not permission to claim silent continuation or next-task progression without verifiable progress evidence.", "- HARD_GATE: Fall back to a non-silent, evidence-preserving follow-up if you cannot prove checkpoint state or concrete execution.", "[/LONG_TASK_GATE_LOCK]", "", ].join("\n"); } const lines = [ "[LONG_TASK_GATE_LOCK]", `gateRequired=${gateLockResult.gateRequired}`, `gateStatus=${gateLockResult.gateStatus}`, ...(gateLockResult.reasons ?? []).map((reason) => `reason=${reason}`), ...((gateLockResult.requiredEvidence ?? []).map((requirement) => { const fields = (requirement.acceptedFields ?? []).join(","); return `requiredEvidence=${requirement.evidenceKey ?? "unknown"};fields=${fields};requiredValue=${requirement.requiredValue ?? "unknown"}`; })), ...((gateLockResult.allowedResponseModes ?? []).map((mode) => `allowedResponseMode=${mode}`)), "- ENFORCEMENT: Do not claim you have progressed into the next task or are already pushing the next step unless you have concrete progress evidence such as a sessionKey, runId, modified_files record, verification result, actual dispatch, tool calls, file changes, or a persisted checkpoint artifact.", "- ENFORCEMENT: Hook input should include progressEvidence (or equivalent concrete fields) whenever a progression claim is present.", "- ENFORCEMENT: Forbidden path: plain-text handoff that pretends the long task is already continuing without an externalized checkpoint.", "- ENFORCEMENT: Forbidden path: stating you have already entered the next task/step when the record only contains planning language and no concrete execution evidence.", "- ENFORCEMENT: If hook input carries autoChainNextAction, it must also carry matching autoChainDispatchEvidence before the gate may pass that auto-chain step.", ]; if (gateLockResult.gateStatus === "fail") { lines.push("- HARD_GATE: Block any plain-text handoff or silent-continuation claim when externalized checkpoint evidence is missing."); lines.push("- HARD_GATE: Block any reply path that says you already moved into the next task or are advancing the next step without concrete progress evidence."); lines.push("- HARD_GATE: If a progression claim exists, the hook input must supply progressEvidence (or equivalent concrete fields) before the claim can pass gate."); lines.push("- HARD_GATE: Do not say you are already on the next task, already dispatched follow-up work, or already progressing in background unless you can point to a sessionKey, runId, modified_files record, verification result, actual tool execution, file changes, emitted messages, or checkpoint records."); lines.push("- HARD_GATE: If required evidence is missing, ask for/produce the checkpoint or downgrade to a non-silent, evidence-preserving follow-up."); lines.push("- HARD_GATE: If autoChainNextAction is explicit, you must actually dispatch it and surface autoChainDispatchEvidence; otherwise the gate fails."); lines.push("- HARD_GATE: If owner decision is involved, do not replace button-path closure with plain-text handoff."); } lines.push("[/LONG_TASK_GATE_LOCK]", ""); return lines.join("\n"); } /** * Force Recall hook handler * * Event: message:preprocessed * - Reads docs/RULEBOOK.md and SOUL.md from the resolved workspace * - Prepends a recall gate block to context.bodyForAgent * - Optionally injects wrapper MVP classification hints when available */ const forceRecall = async (event: any) => { if (event?.type !== "message" || event?.action !== "preprocessed") return; const ctx = event.context ?? {}; const workspaceDir: string | undefined = ctx.workspaceDir; if (!workspaceDir) return; const rulebookPath = path.join(workspaceDir, "docs", "RULEBOOK.md"); const soulPath = path.join(workspaceDir, "SOUL.md"); const [rulebook, soul, wrapperResult] = await Promise.all([ safeReadText(rulebookPath), safeReadText(soulPath), runLongTaskWrapper(workspaceDir, ctx), ]); const proactiveReportGateResult = wrapperResult ? await runProactiveReportGateLock(workspaceDir, wrapperResult) : null; 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 && !proactiveReportGateResult && !gateLockResult && !autoChainPlanResult && !approvedPlanContinuityResult) return; const wrapperBlock = wrapperResult ? [ "[LONG_TASK_GOVERNOR_PREFLIGHT]", `classification=${wrapperResult.classification}`, `silentCandidate=${wrapperResult.silentCandidate}`, `needsCheckpoint=${wrapperResult.needsCheckpoint}`, `needsSubagent=${wrapperResult.needsSubagent}`, `needsOwnerDecision=${wrapperResult.needsOwnerDecision}`, `silentLaunchOk=${wrapperResult.silentLaunchOk}`, wrapperResult.silentLaunchReason ? `silentLaunchReason=${wrapperResult.silentLaunchReason}` : null, wrapperResult.recommendedFallback ? `recommendedFallback=${wrapperResult.recommendedFallback}` : null, wrapperResult.requiredNextAction ? `requiredNextAction=${wrapperResult.requiredNextAction}` : null, wrapperResult.handoff?.mode ? `handoff.mode=${wrapperResult.handoff.mode}` : null, ...buildWrapperEnforcement(wrapperResult), ...buildWrapperHardGate(wrapperResult), "[/LONG_TASK_GOVERNOR_PREFLIGHT]", "", ] .filter(Boolean) .join("\n") : ""; const proactiveReportGateBlock = buildProactiveReportGateBlock(proactiveReportGateResult); const gateLockBlock = buildGateLockBlock(gateLockResult); const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult); const approvedPlanContinuityBlock = await buildApprovedPlanContinuityBlock(workspaceDir, wrapperResult, autoChainPlanResult, approvedPlanContinuityResult); const recallBlock = [ "[RECALL_GATE] Mandatory recall before ANY technical action/tool use.", "- You MUST consult and follow the key rules from RULEBOOK + SOUL.", "- If you are about to run tools, change configs, modify code, or delegate agents: restate the applicable rules first.", "", wrapperBlock || null, proactiveReportGateBlock, gateLockBlock, autoChainPlanBlock, approvedPlanContinuityBlock || null, rulebook ? `RULEBOOK (source: ${rulebookPath}):\n${clamp(rulebook, 1200)}` : null, soul ? `SOUL (source: ${soulPath}):\n${clamp(soul, 1200)}` : null, "[/RECALL_GATE]", "", ] .filter(Boolean) .join("\n"); const prior = (ctx.bodyForAgent ?? ctx.body ?? ctx.content ?? "") as string; const injected = `${recallBlock}${prior ? "\n" + prior : ""}`; ctx.bodyForAgent = injected; event.context = ctx; if (process.env.OPENCLAW_FORCE_RECALL_DEBUG === "1") { ctx.bodyForAgent += "\n\n[force-recall:debug] injected"; console.log(`[force-recall:debug] injected for chat=${ctx.chatId ?? "?"} msg=${ctx.messageId ?? "?"}`); } }; export default forceRecall;