From 37f26727c54fab8b9b91e05f84d941555bc3e3cc Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 24 Apr 2026 14:17:29 +0800 Subject: [PATCH] fix: reject dry-run dispatch as continuity receipt --- hooks/force-recall/handler.ts | 7 +------ scripts/test_force_recall_long_task_preflight.mjs | 10 +++++++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index 6199db6..3a51abd 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -355,12 +355,7 @@ function buildApprovedPlanContinuityInput(wrapperResult: any, autoChainPlanResul ? 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); + const dispatchReceipt = wrapperResult?.dispatchReceipt ?? null; return { planId: wrapperResult?.planId ?? "hook-preflight-approved-plan", diff --git a/scripts/test_force_recall_long_task_preflight.mjs b/scripts/test_force_recall_long_task_preflight.mjs index 5e1e8cf..8a48e4f 100755 --- a/scripts/test_force_recall_long_task_preflight.mjs +++ b/scripts/test_force_recall_long_task_preflight.mjs @@ -14,6 +14,7 @@ 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'); +const continuityGatePath = path.join(repoRoot, 'scripts', 'approved_plan_continuity_gate.mjs'); const execFileAsync = promisify(execFileCallback); async function importTsModule(tsPath) { @@ -54,6 +55,7 @@ async function prepareTempWorkspace() { [wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')], [gateLockPath, path.join(tempWorkspace, 'scripts', 'long_task_gate_lock.mjs')], [plannerPath, path.join(tempWorkspace, 'scripts', 'plan_long_task_auto_chain.mjs')], + [continuityGatePath, path.join(tempWorkspace, 'scripts', 'approved_plan_continuity_gate.mjs')], [handlerPath, path.join(tempWorkspace, 'hooks', 'force-recall', 'handler.ts')], [path.join(repoRoot, 'docs', 'RULEBOOK.md'), path.join(tempWorkspace, 'docs', 'RULEBOOK.md')], [path.join(repoRoot, 'SOUL.md'), path.join(tempWorkspace, 'SOUL.md')], @@ -104,7 +106,7 @@ function buildWrapperScript(wrapperResult) { } async function main() { - await Promise.all([fs.access(wrapperPath), fs.access(gateLockPath), fs.access(plannerPath)]); + await Promise.all([fs.access(wrapperPath), fs.access(gateLockPath), fs.access(plannerPath), fs.access(continuityGatePath)]); const { default: forceRecall } = await importTsModule(handlerPath); assert.equal(typeof forceRecall, 'function', 'force-recall handler should export default function'); @@ -307,6 +309,12 @@ async function main() { 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'); + assert.match(passInjected, /\[APPROVED_PLAN_CONTINUITY_GATE\]/, 'hook pass-path should emit approved-plan continuity gate block'); + assert.match(passInjected, /status=continuity_failure/, 'hook pass-path should fail continuity when planner only returns dry-run dispatch without a real receipt'); + assert.match(passInjected, /verdict=continuity_failure/, 'hook pass-path should expose continuity failure verdict when no real dispatch receipt exists'); + assert.match(passInjected, /reason=missing_dispatch_receipt/, 'hook pass-path should require a real dispatch receipt instead of treating dry-run dispatch as one'); + assert.match(passInjected, /Route back to continuity failure until a real next dispatch receipt exists/, 'hook pass-path should hard-gate normal closeout until a real receipt exists'); + assert.doesNotMatch(passInjected, /\[APPROVED_PLAN_CONTINUITY_GATE\][\s\S]*status=pass/, 'hook pass-path should not let approved-plan continuity pass on dry-run dispatch alone'); const failInjected = await withPatchedWrapper(buildWrapperScript({ classification: 'long_task',