diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index b526635..9c229ff 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -1,6 +1,7 @@ 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"; @@ -39,6 +40,19 @@ type ApprovedPlanContinuityResult = { 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; @@ -376,14 +390,45 @@ function buildApprovedPlanContinuityInput(wrapperResult: any, autoChainPlanResul }; } +const continuityAdapterModuleCache = new Map>(); + +async function loadForceRecallContinuityAdapterModule(workspaceDir: string): Promise { + const adapterPath = path.join(workspaceDir, "plugins", "continuity", "src", "index.mjs"); + let modulePromise = continuityAdapterModuleCache.get(adapterPath); + + if (!modulePromise) { + modulePromise = import(pathToFileURL(adapterPath).href).catch(() => null); + continuityAdapterModuleCache.set(adapterPath, modulePromise); + } + + return modulePromise; +} + +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; + + return runAdapter({ + wrapperResult, + autoChainPlanResult, + config: adapterModule?.defaultConfig ?? {}, + }); +} + 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); } -function buildApprovedPlanContinuityBlock(result: ApprovedPlanContinuityResult | null): string { +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 = [ @@ -583,7 +628,7 @@ const forceRecall = async (event: any) => { const gateLockBlock = buildGateLockBlock(gateLockResult); const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult); - const approvedPlanContinuityBlock = buildApprovedPlanContinuityBlock(approvedPlanContinuityResult); + const approvedPlanContinuityBlock = await buildApprovedPlanContinuityBlock(workspaceDir, wrapperResult, autoChainPlanResult, approvedPlanContinuityResult); const recallBlock = [ "[RECALL_GATE] Mandatory recall before ANY technical action/tool use.", diff --git a/plugins/continuity/src/adapters/force-recall.mjs b/plugins/continuity/src/adapters/force-recall.mjs index 6c5d8c7..7392db8 100644 --- a/plugins/continuity/src/adapters/force-recall.mjs +++ b/plugins/continuity/src/adapters/force-recall.mjs @@ -1,7 +1,63 @@ -export function createForceRecallContinuityAdapter() { - throw new Error('Not implemented: force-recall continuity adapter contract placeholder'); +import { evaluateContinuity, buildContinuityGateBlock } from '../continuity/evaluator.mjs'; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; } -export function runForceRecallContinuityAdapter() { - throw new Error('Not implemented: force-recall continuity adapter contract placeholder'); +export function buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult = 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 = isNonEmptyString(wrapperResult?.replyClosureState) + ? wrapperResult.replyClosureState + : (wrapperResult?.handoff?.mode === 'button_path' ? 'waiting_user' : 'completed'); + + const dispatchReceipt = wrapperResult?.dispatchReceipt ?? null; + const nextTaskKnown = wrapperResult?.nextTaskKnown === true + || (plannerDerivedAction != null && isNonEmptyString(autoChainPlanResult?.derivedAction) && 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, + }; +} + +export function createForceRecallContinuityAdapter(config = {}) { + const legalTerminalStates = config?.legalTerminalStates; + const label = config?.adapter?.forceRecall?.injectBlockLabel ?? 'APPROVED_PLAN_CONTINUITY_GATE'; + + return { + evaluate({ wrapperResult, autoChainPlanResult = null }) { + const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult); + if (!input) return { input: null, result: null, block: '' }; + const result = evaluateContinuity(input, { legalTerminalStates }); + const block = buildContinuityGateBlock(result, { legalTerminalStates, label }); + return { input, result, block }; + }, + }; +} + +export function runForceRecallContinuityAdapter({ wrapperResult, autoChainPlanResult = null, config = {} } = {}) { + return createForceRecallContinuityAdapter(config).evaluate({ wrapperResult, autoChainPlanResult }); } diff --git a/plugins/continuity/src/continuity/evaluator.mjs b/plugins/continuity/src/continuity/evaluator.mjs index 00ba3bb..f2f4f87 100644 --- a/plugins/continuity/src/continuity/evaluator.mjs +++ b/plugins/continuity/src/continuity/evaluator.mjs @@ -1,7 +1,120 @@ -export function evaluateContinuity() { - throw new Error('Not implemented: continuity evaluator contract placeholder'); +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -export function buildContinuityGateBlock() { - throw new Error('Not implemented: continuity gate block contract placeholder'); +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeAction(action) { + return JSON.stringify(action ?? null); +} + +export function hasValidDispatchReceipt(receipt) { + if (!isPlainObject(receipt)) return false; + if (!isNonEmptyString(receipt.planId)) return false; + if (!isNonEmptyString(receipt.currentTask)) return false; + if (!isPlainObject(receipt.nextDerivedAction)) return false; + if (!isNonEmptyString(receipt.dispatchedAt)) return false; + return true; +} + +export function receiptMatchesPayload(payload, receipt) { + if (!hasValidDispatchReceipt(receipt)) return false; + + const expectedPlanId = payload?.planId; + if (isNonEmptyString(expectedPlanId) && receipt.planId !== expectedPlanId) return false; + + const expectedCurrentTask = payload?.currentTask; + if (isNonEmptyString(expectedCurrentTask) && receipt.currentTask !== expectedCurrentTask) return false; + + const expectedNextTask = payload?.nextTaskId ?? payload?.nextTaskKey ?? null; + const receiptNextTask = receipt?.nextTaskId ?? receipt?.nextTaskKey ?? null; + if (isNonEmptyString(expectedNextTask) && receiptNextTask !== expectedNextTask) return false; + + const expectedNextAction = payload?.nextDerivedAction ?? payload?.derivedAction ?? null; + if (expectedNextAction != null && normalizeAction(receipt.nextDerivedAction) !== normalizeAction(expectedNextAction)) { + return false; + } + + return true; +} + +export function evaluateContinuity(payload, options = {}) { + const legalTerminalStates = new Set(options.legalTerminalStates ?? ['waiting_user', 'blocked', 'pending_verification']); + const taskComplete = payload?.taskState === 'complete'; + const nextAction = payload?.nextDerivedAction ?? payload?.derivedAction ?? null; + const nextActionKnown = nextAction != null; + const explicitNextTaskKnown = payload?.nextTaskKnown === true; + const sameApprovedPlan = payload?.sameApprovedPlan === true; + const taskBoundaryStop = payload?.taskBoundaryStop === true; + const highRiskStop = payload?.highRiskStop === true; + const closureState = payload?.replyClosureState ?? null; + const isLegalTerminalState = legalTerminalStates.has(closureState); + const hasDispatchReceipt = receiptMatchesPayload(payload, payload?.dispatchReceipt ?? null); + const autoNextObligatory = taskComplete + && explicitNextTaskKnown + && sameApprovedPlan + && taskBoundaryStop + && !isLegalTerminalState + && !highRiskStop; + + if (autoNextObligatory && !hasDispatchReceipt) { + return { + ok: false, + status: 'continuity_failure', + verdict: 'continuity_failure', + reason: 'missing_auto_next_dispatch', + }; + } + + if (taskComplete && nextActionKnown && !hasDispatchReceipt && !isLegalTerminalState && !highRiskStop && !('sameApprovedPlan' in (payload ?? {}))) { + return { + ok: false, + status: 'continuity_failure', + verdict: 'continuity_failure', + reason: 'missing_dispatch_receipt', + }; + } + + if (taskComplete && nextActionKnown && !hasDispatchReceipt && !isLegalTerminalState && !highRiskStop && sameApprovedPlan && !taskBoundaryStop && !explicitNextTaskKnown) { + return { + ok: false, + status: 'continuity_failure', + verdict: 'continuity_failure', + reason: 'missing_dispatch_receipt', + }; + } + + return { + ok: true, + status: 'pass', + verdict: 'pass', + }; +} + +export function buildContinuityGateBlock(result, options = {}) { + if (!result) return ''; + const label = isNonEmptyString(options.label) ? options.label.trim() : 'APPROVED_PLAN_CONTINUITY_GATE'; + const terminalStates = options.legalTerminalStates ?? ['waiting_user', 'blocked', 'pending_verification']; + const lines = [ + `[${label}]`, + `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 ${terminalStates.join(', ')}, or high-risk stop applies.`); + } else { + lines.push(`- HARD_GATE: Route back to continuity failure until a real next dispatch receipt exists, unless closure state is ${terminalStates.join(', ')}.`); + } + } + + lines.push(`[/${label}]`, ''); + return lines.join('\n'); } diff --git a/plugins/continuity/src/index.mjs b/plugins/continuity/src/index.mjs index 84636ab..0f34656 100644 --- a/plugins/continuity/src/index.mjs +++ b/plugins/continuity/src/index.mjs @@ -7,12 +7,20 @@ import { import { evaluateContinuity, buildContinuityGateBlock, + hasValidDispatchReceipt, + receiptMatchesPayload, } from './continuity/evaluator.mjs'; import { validateReceipt, isValidReceipt, } from './continuity/receipt-validator.mjs'; import { + slugifyReceiptSegment, + buildReceiptFilename, + writeReceipt, +} from './continuity/receipt-store.mjs'; +import { + buildApprovedPlanContinuityInput, createForceRecallContinuityAdapter, runForceRecallContinuityAdapter, } from './adapters/force-recall.mjs'; @@ -25,8 +33,14 @@ export { normalizeContinuityConfig, evaluateContinuity, buildContinuityGateBlock, + hasValidDispatchReceipt, + receiptMatchesPayload, validateReceipt, isValidReceipt, + slugifyReceiptSegment, + buildReceiptFilename, + writeReceipt, + buildApprovedPlanContinuityInput, createForceRecallContinuityAdapter, runForceRecallContinuityAdapter, }; @@ -39,8 +53,14 @@ export default { normalizeContinuityConfig, evaluateContinuity, buildContinuityGateBlock, + hasValidDispatchReceipt, + receiptMatchesPayload, validateReceipt, isValidReceipt, + slugifyReceiptSegment, + buildReceiptFilename, + writeReceipt, + buildApprovedPlanContinuityInput, createForceRecallContinuityAdapter, runForceRecallContinuityAdapter, }; diff --git a/plugins/continuity/test/continuity.evaluator.test.mjs b/plugins/continuity/test/continuity.evaluator.test.mjs new file mode 100644 index 0000000..b3b5111 --- /dev/null +++ b/plugins/continuity/test/continuity.evaluator.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { + buildContinuityGateBlock, + evaluateContinuity, + hasValidDispatchReceipt, + receiptMatchesPayload, +} from '../src/continuity/evaluator.mjs'; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +const validReceipt = { + planId: 'plan-1', + currentTask: 'task-7', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + dispatchedAt: '2026-04-24T09:00:00.000Z', +}; + +test('recognizes minimum valid dispatch receipt', () => { + assert.equal(hasValidDispatchReceipt(validReceipt), true); +}); + +test('matches payload against valid receipt', () => { + const payload = { + planId: 'plan-1', + currentTask: 'task-7', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + }; + + assert.equal(receiptMatchesPayload(payload, validReceipt), true); +}); + +test('fails when completion has next action and no receipt', () => { + const result = evaluateContinuity({ + planId: 'plan-1', + currentTask: 'task-7', + taskState: 'complete', + nextDerivedAction: { type: 'message_subagent' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }); + + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing_dispatch_receipt'); +}); + +test('fails auto-next boundary without dispatch', () => { + const result = evaluateContinuity({ + planId: 'plan-1', + currentTask: 'task-8', + taskState: 'complete', + nextTaskKnown: true, + sameApprovedPlan: true, + taskBoundaryStop: true, + nextTaskId: 'task-9', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }); + + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing_auto_next_dispatch'); +}); + +test('passes allowed terminal state', () => { + const result = evaluateContinuity({ + planId: 'plan-1', + currentTask: 'task-7', + taskState: 'complete', + nextDerivedAction: { type: 'message_subagent' }, + replyClosureState: 'waiting_user', + dispatchReceipt: null, + }); + + assert.equal(result.ok, true); +}); + +test('renders hard-gate block', () => { + const text = buildContinuityGateBlock({ + ok: false, + status: 'continuity_failure', + verdict: 'continuity_failure', + reason: 'missing_dispatch_receipt', + }); + + assert.match(text, /APPROVED_PLAN_CONTINUITY_GATE/); + assert.match(text, /HARD_GATE/); +}); + +console.log('continuity.evaluator.test.mjs PASS'); diff --git a/plugins/continuity/test/continuity.plugin.test.mjs b/plugins/continuity/test/continuity.plugin.test.mjs new file mode 100644 index 0000000..31a701e --- /dev/null +++ b/plugins/continuity/test/continuity.plugin.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import plugin, { + createForceRecallContinuityAdapter, + defaultConfig, + evaluateContinuity, +} from '../src/index.mjs'; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +test('index exports plugin surface', () => { + assert.equal(plugin.name, '@openclaw/plugin-continuity'); + assert.equal(typeof evaluateContinuity, 'function'); + assert.equal(defaultConfig.adapter.forceRecall.enabled, true); +}); + +test('adapter preserves current hook parity for plain wrapper next-action mapping', () => { + const adapter = createForceRecallContinuityAdapter(defaultConfig); + const out = adapter.evaluate({ + wrapperResult: { + classification: 'long_task', + planId: 'plan-1', + currentTask: 'task-7', + taskState: 'complete', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }, + }); + + assert.equal(out.result.ok, true); + assert.match(out.block, /status=pass/); +}); + +test('adapter fails when planner-derived auto-next boundary exists without dispatch receipt', () => { + const adapter = createForceRecallContinuityAdapter(defaultConfig); + const out = adapter.evaluate({ + wrapperResult: { + classification: 'long_task', + planId: 'plan-2', + currentTask: 'task-8', + replyClosureState: 'completed', + dispatchReceipt: null, + }, + autoChainPlanResult: { + derivedAction: 'continue_task_9', + dispatchMode: 'message_subagent', + }, + }); + + assert.equal(out.result.ok, false); + assert.equal(out.result.reason, 'missing_auto_next_dispatch'); + assert.match(out.block, /continuity_failure/); +}); + +console.log('continuity.plugin.test.mjs PASS'); diff --git a/plugins/continuity/test/continuity.smoke.test.mjs b/plugins/continuity/test/continuity.smoke.test.mjs new file mode 100644 index 0000000..2b8bd7d --- /dev/null +++ b/plugins/continuity/test/continuity.smoke.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import plugin, { + runForceRecallContinuityAdapter, + validateContinuityConfig, +} from '../src/index.mjs'; + +const configResult = validateContinuityConfig(plugin.defaultConfig); +assert.equal(configResult.ok, true); + +const smoke = runForceRecallContinuityAdapter({ + config: plugin.defaultConfig, + wrapperResult: { + classification: 'long_task', + planId: 'plan-smoke', + currentTask: 'task-8', + replyClosureState: 'completed', + dispatchReceipt: null, + }, + autoChainPlanResult: { + derivedAction: 'continue_task_9', + dispatchMode: 'message_subagent', + }, +}); + +assert.equal(smoke.result.ok, false); +assert.equal(smoke.result.reason, 'missing_auto_next_dispatch'); +assert.match(smoke.block, /APPROVED_PLAN_CONTINUITY_GATE/); +console.log('continuity.smoke.test.mjs PASS'); diff --git a/scripts/test_force_recall_long_task_preflight.mjs b/scripts/test_force_recall_long_task_preflight.mjs index dc67ae9..f275ce1 100755 --- a/scripts/test_force_recall_long_task_preflight.mjs +++ b/scripts/test_force_recall_long_task_preflight.mjs @@ -51,6 +51,10 @@ async function prepareTempWorkspace() { await fs.mkdir(path.join(tempWorkspace, 'hooks', 'force-recall'), { recursive: true }); await fs.mkdir(path.join(tempWorkspace, 'docs'), { recursive: true }); + await fs.mkdir(path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters'), { recursive: true }); + await fs.mkdir(path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config'), { recursive: true }); + await fs.mkdir(path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity'), { recursive: true }); + const copies = [ [wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')], [gateLockPath, path.join(tempWorkspace, 'scripts', 'long_task_gate_lock.mjs')], @@ -59,6 +63,13 @@ async function prepareTempWorkspace() { [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', 'config', 'defaults.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs')], + [path.join(repoRoot, 'plugins', 'continuity', 'src', 'config', 'schema.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config', 'schema.mjs')], + [path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'evaluator.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'evaluator.mjs')], + [path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'receipt-validator.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'receipt-validator.mjs')], + [path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'receipt-store.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'receipt-store.mjs')], ]; for (const [src, dest] of copies) { @@ -285,6 +296,49 @@ async function main() { assert.equal(neutralSnakeCaseResult.gateStatus, 'pass', 'neutral snake_case non-dispatch action should not trigger dispatch-evidence requirement'); assert.doesNotMatch(JSON.stringify(neutralSnakeCaseResult), /autoChainDispatchEvidence/, 'neutral snake_case non-dispatch action should not mention dispatch-evidence requirement'); + const pluginPathInjected = await withPatchedWrapperWorkspace({ + classification: 'long_task', + silentCandidate: true, + needsCheckpoint: true, + needsSubagent: false, + needsOwnerDecision: false, + silentLaunchOk: true, + planId: 'plan-plugin-path', + currentTask: 'task-plugin-path', + taskState: 'complete', + replyClosureState: 'completed', + requiredNextAction: 'dispatch_follow_up_subagent', + autoChainDispatchEvidence: { + action: 'dispatch_follow_up_subagent', + dispatched: true, + event: 'dispatch', + }, + progressEvidence: { sessionKey: 'task-plugin-path' }, + externalizedCheckpointPath: 'checkpoints/task-plugin-path.json', + handoff: { mode: 'direct_reply' }, + dispatchReceipt: { + planId: 'plan-plugin-path', + currentTask: 'task-plugin-path', + nextDerivedAction: { + type: 'dry_run_dispatch', + action: 'dispatch_spec_review', + }, + dispatchedAt: '2026-04-24T17:00:00+08:00', + }, + }, async (workspaceDir) => { + const defaultsPath = path.join(workspaceDir, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs'); + const defaultsSource = await fs.readFile(defaultsPath, 'utf8'); + await fs.writeFile( + defaultsPath, + defaultsSource.replace('APPROVED_PLAN_CONTINUITY_GATE', 'PLUGIN_CONTINUITY_GATE'), + 'utf8', + ); + return runScenario(forceRecall, requestText, workspaceDir); + }); + assert.match(pluginPathInjected, /\[PLUGIN_CONTINUITY_GATE\]/, 'hook should inject continuity block from plugin adapter path, not only local fallback builder'); + assert.match(pluginPathInjected, /status=pass/, 'plugin adapter path should still pass when a bound dispatch receipt exists'); + assert.doesNotMatch(pluginPathInjected, /\[APPROVED_PLAN_CONTINUITY_GATE\]/, 'plugin adapter label override should replace the legacy fallback block label when plugin path is active'); + const passInjected = await withPatchedWrapperWorkspace({ classification: 'long_task', silentCandidate: true,