From c51bd84449ef792387a8174d47bea7ddec4a705c Mon Sep 17 00:00:00 2001 From: Eve Date: Mon, 4 May 2026 11:55:05 +0800 Subject: [PATCH] feat: enforce proactive report gate during force-recall preflight --- hooks/force-recall/handler.ts | 96 ++++++++++++++++++- scripts/long_task_governor_wrapper.mjs | 16 ++++ .../test_force_recall_long_task_preflight.mjs | 78 ++++++++++++++- scripts/test_long_task_governor_wrapper.mjs | 43 +++++++++ 4 files changed, 230 insertions(+), 3 deletions(-) diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index 654b3db..dfe4b0f 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -7,6 +7,7 @@ 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; @@ -20,6 +21,20 @@ type AutoChainPlanResult = { 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"; @@ -130,6 +145,10 @@ async function runLongTaskWrapper(workspaceDir: string, ctx: any): Promise { + 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); @@ -568,6 +623,42 @@ function buildWrapperHardGate(wrapperResult: any): string[] { 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 [ @@ -638,13 +729,14 @@ const forceRecall = async (event: any) => { 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 && !gateLockResult && !autoChainPlanResult && !approvedPlanContinuityResult) return; + if (!rulebook && !soul && !wrapperResult && !proactiveReportGateResult && !gateLockResult && !autoChainPlanResult && !approvedPlanContinuityResult) return; const wrapperBlock = wrapperResult ? [ @@ -668,6 +760,7 @@ const forceRecall = async (event: any) => { .join("\n") : ""; + const proactiveReportGateBlock = buildProactiveReportGateBlock(proactiveReportGateResult); const gateLockBlock = buildGateLockBlock(gateLockResult); const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult); const approvedPlanContinuityBlock = await buildApprovedPlanContinuityBlock(workspaceDir, wrapperResult, autoChainPlanResult, approvedPlanContinuityResult); @@ -678,6 +771,7 @@ const forceRecall = async (event: any) => { "- 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, diff --git a/scripts/long_task_governor_wrapper.mjs b/scripts/long_task_governor_wrapper.mjs index ec6c79b..2388cde 100644 --- a/scripts/long_task_governor_wrapper.mjs +++ b/scripts/long_task_governor_wrapper.mjs @@ -54,6 +54,10 @@ function normalizeRequest(raw) { checkpointTrigger: data.checkpointTrigger || '', externalizedTrigger: data.externalizedTrigger || '', triggerKind: data.triggerKind || '', + firstReportTrigger: data.firstReportTrigger || '', + fallbackState: data.fallbackState || '', + reportMode: data.reportMode || '', + ownerVisibleIfStalled: data.ownerVisibleIfStalled === true, }; } @@ -76,6 +80,9 @@ function inferFromRequestText(input) { if (!input.checkpointTrigger && inferred.needsSubagent) { inferred.checkpointTrigger = 'when delegated work returns or the next checkpoint fires'; } + if (!input.firstReportTrigger && inferred.checkpointTrigger) { + inferred.firstReportTrigger = inferred.checkpointTrigger; + } if (!input.externalizedTrigger && inferred.needsSubagent) { inferred.externalizedTrigger = 'wrapper-derived checkpoint artifact'; } @@ -114,6 +121,10 @@ function bootstrapTaskState(input, classificationResult) { waiting_on: input.waitingOn, blocker: input.blocker, silent: classificationResult.silentCandidate, + first_report_trigger: input.firstReportTrigger, + fallback_state: input.fallbackState, + report_mode: input.reportMode, + owner_visible_if_stalled: input.ownerVisibleIfStalled, }; } @@ -244,6 +255,11 @@ function main() { progressEvidence, externalizedCheckpointPath, checkpointArtifact, + firstReportTrigger: input.firstReportTrigger || '', + nextReportCondition: input.nextReportCondition || '', + fallbackState: input.fallbackState || '', + reportMode: input.reportMode || '', + ownerVisibleIfStalled: input.ownerVisibleIfStalled === true, silentLaunchOk: silentLaunch.ok, silentLaunchReason: silentLaunch.reason, recommendedFallback: silentLaunch.recommendedFallback, diff --git a/scripts/test_force_recall_long_task_preflight.mjs b/scripts/test_force_recall_long_task_preflight.mjs index ec9d265..abeafb7 100755 --- a/scripts/test_force_recall_long_task_preflight.mjs +++ b/scripts/test_force_recall_long_task_preflight.mjs @@ -12,6 +12,7 @@ const __dirname = path.dirname(new URL(import.meta.url).pathname); 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 proactiveGatePath = path.join(repoRoot, 'scripts', 'proactive_report_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'); const continuityGatePath = path.join(repoRoot, 'scripts', 'approved_plan_continuity_gate.mjs'); @@ -57,12 +58,11 @@ async function prepareTempWorkspace() { const copies = [ [wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')], + [proactiveGatePath, path.join(tempWorkspace, 'scripts', 'proactive_report_gate_lock.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')], [path.join(repoRoot, 'plugins', 'continuity', 'src', 'index.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'index.mjs')], [path.join(repoRoot, 'plugins', 'continuity', 'src', 'adapters', 'force-recall.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters', 'force-recall.mjs')], [path.join(repoRoot, 'plugins', 'continuity', 'src', 'adapters', 'generic-preflight.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters', 'generic-preflight.mjs')], @@ -78,6 +78,17 @@ async function prepareTempWorkspace() { await fs.copyFile(src, dest); } + await fs.writeFile( + path.join(tempWorkspace, 'docs', 'RULEBOOK.md'), + '# Test Fixture RULEBOOK\n\nMinimal clean-room fixture generated by scripts/test_force_recall_long_task_preflight.mjs.\n', + 'utf8', + ); + await fs.writeFile( + path.join(tempWorkspace, 'SOUL.md'), + '# Test Fixture SOUL\n\nMinimal clean-room fixture generated by scripts/test_force_recall_long_task_preflight.mjs.\n', + 'utf8', + ); + return tempWorkspace; } @@ -166,6 +177,9 @@ async function main() { } finally { await fs.rm(checkpointWorkspace, { recursive: true, force: true }); } + assert.match(realWrapperInjected, /\[PROACTIVE_REPORT_GATE\]/, 'real wrapper integration should inject proactive report gate block'); + assert.match(realWrapperInjected, /reportBindingStatus=bound/, 'real wrapper integration should bind proactive report fields before silent continuation'); + assert.match(realWrapperInjected, /allowedResponseMode=silent_continuation/, 'real wrapper integration should allow silent continuation only after proactive binding'); assert.match(realWrapperInjected, /classification=long_task/, 'real wrapper integration should classify subagent wait as long_task'); assert.match(realWrapperInjected, /gateStatus=pass/, 'real wrapper integration should pass gate with real progress evidence'); assert.match(realWrapperInjected, /allowedResponseMode=silent_continuation/, 'real wrapper integration should preserve silent continuation allowance'); @@ -177,6 +191,10 @@ async function main() { const expectedSnippets = [ '[LONG_TASK_GOVERNOR_PREFLIGHT]', + '[PROACTIVE_REPORT_GATE]', + 'gateStatus=fail', + 'reason=missing first proactive report trigger', + 'requiredEvidence=firstReportTrigger', 'classification=long_task', 'silentLaunchOk=false', 'handoff.mode=button_path', @@ -505,6 +523,62 @@ async function main() { assert.match(specReviewWithoutImplementationEvidenceInjected, /reason=implementation evidence missing for review-required next action/, 'hook implementation missing-evidence path should mention missing implementation evidence'); assert.match(specReviewWithoutImplementationEvidenceInjected, /requiredEvidence=executionEvidence/, 'hook implementation missing-evidence path should require executionEvidence'); + const continuityCannotMaskReportBindingFailureInjected = await withPatchedWrapperWorkspace({ + classification: 'long_task', + silentCandidate: true, + needsCheckpoint: true, + needsSubagent: false, + needsOwnerDecision: false, + silentLaunchOk: true, + requiredNextAction: 'dispatch_follow_up_subagent', + autoChainDispatchEvidence: { + action: 'dispatch_follow_up_subagent', + dispatched: true, + event: 'dispatch', + }, + progressEvidence: { sessionKey: 'task-proactive-missing' }, + externalizedCheckpointPath: 'checkpoints/task-proactive-missing.json', + nextReportCondition: 'after verifier output arrives', + reportMode: 'watchdog', + handoff: { mode: 'direct_reply' }, + dispatchReceipt: { + planId: 'plan-proactive-missing', + currentTask: 'task-proactive-missing', + nextDerivedAction: { type: 'dry_run_dispatch', action: 'dispatch_spec_review' }, + dispatchedAt: '2026-05-04T11:30:00+08:00', + }, + }, async (workspaceDir) => runScenario(forceRecall, requestText, workspaceDir)); + assert.match(continuityCannotMaskReportBindingFailureInjected, /\[PROACTIVE_REPORT_GATE\]/, 'continuity regression should include proactive report gate block'); + assert.match(continuityCannotMaskReportBindingFailureInjected, /reportBindingStatus=missing/, 'continuity regression should fail early on missing proactive report binding'); + assert.match(continuityCannotMaskReportBindingFailureInjected, /reason=missing first proactive report trigger/, 'continuity regression should expose missing first report trigger'); + assert.match(continuityCannotMaskReportBindingFailureInjected, /reason=missing fallback state for stalled reporting/, 'continuity regression should expose missing fallback state'); + + const continuityStillSeparateWhenProactiveBindingPassesInjected = await withPatchedWrapperWorkspace({ + classification: 'long_task', + silentCandidate: true, + needsCheckpoint: true, + needsSubagent: false, + needsOwnerDecision: false, + silentLaunchOk: true, + requiredNextAction: 'dispatch_follow_up_subagent', + autoChainDispatchEvidence: { + action: 'dispatch_follow_up_subagent', + dispatched: true, + event: 'dispatch', + }, + progressEvidence: { sessionKey: 'task-proactive-pass' }, + externalizedCheckpointPath: 'checkpoints/task-proactive-pass.json', + firstReportTrigger: 'when delegated scan returns or at 10 minutes, whichever comes first', + nextReportCondition: 'report again only after new verifier output or blocker-state change', + fallbackState: 'blocked', + reportMode: 'watchdog', + ownerVisibleIfStalled: true, + handoff: { mode: 'direct_reply' }, + }, async (workspaceDir) => runScenario(forceRecall, requestText, workspaceDir)); + assert.match(continuityStillSeparateWhenProactiveBindingPassesInjected, /\[PROACTIVE_REPORT_GATE\]/, 'pass regression should include proactive report gate block'); + assert.match(continuityStillSeparateWhenProactiveBindingPassesInjected, /reportBindingStatus=bound/, 'pass regression should show bound proactive report state'); + assert.match(continuityStillSeparateWhenProactiveBindingPassesInjected, /\[APPROVED_PLAN_CONTINUITY_GATE\]/, 'pass regression should still show continuity block separately'); + const originalGateLock = await fs.readFile(gateLockPath, 'utf8'); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'force-recall-gate-lock-')); const backupPath = path.join(tempDir, path.basename(gateLockPath)); diff --git a/scripts/test_long_task_governor_wrapper.mjs b/scripts/test_long_task_governor_wrapper.mjs index 7c52afe..da69270 100644 --- a/scripts/test_long_task_governor_wrapper.mjs +++ b/scripts/test_long_task_governor_wrapper.mjs @@ -73,6 +73,49 @@ const fixtures = [ assert.equal(JSON.stringify(output.progressEvidence).includes('Wait for delegated log survey'), false, 'subagent wait: progressEvidence must not derive from taskRecord.task_name'); }, }, + { + name: 'report binding fields preserved', + input: { + requestText: 'Dispatch a subagent to inspect logs and wait for the result.', + canReplyNow: false, + needsSubagent: true, + needsWaiting: true, + checkpointTrigger: 'when delegated work returns or the next checkpoint fires', + externalizedTrigger: 'wrapper-derived checkpoint artifact', + triggerKind: 'artifact', + firstReportTrigger: 'when delegated work returns or at 10 minutes, whichever comes first', + nextReportCondition: 'report again only after new verifier output or blocker-state change', + fallbackState: 'blocked', + reportMode: 'watchdog', + ownerVisibleIfStalled: true, + }, + assert(output) { + assert.equal(output.firstReportTrigger, 'when delegated work returns or at 10 minutes, whichever comes first'); + assert.equal(output.nextReportCondition, 'report again only after new verifier output or blocker-state change'); + assert.equal(output.fallbackState, 'blocked'); + assert.equal(output.reportMode, 'watchdog'); + assert.equal(output.ownerVisibleIfStalled, true); + }, + }, + { + name: 'wrapper does not fabricate hard report binding fields', + input: { + requestText: 'Dispatch a subagent to inspect logs and wait for the result.', + canReplyNow: false, + needsSubagent: true, + needsWaiting: true, + checkpointTrigger: 'when delegated work returns or the next checkpoint fires', + externalizedTrigger: 'wrapper-derived checkpoint artifact', + triggerKind: 'artifact', + }, + assert(output) { + assert.equal(output.firstReportTrigger, 'when delegated work returns or the next checkpoint fires'); + assert.equal(output.nextReportCondition, 'After next meaningful milestone'); + assert.equal(output.fallbackState, ''); + assert.equal(output.reportMode, ''); + assert.equal(output.ownerVisibleIfStalled, false); + }, + }, ]; function runFixture(fixture) {