feat: route force-recall continuity via plugin adapter
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
@@ -39,6 +40,19 @@ type ApprovedPlanContinuityResult = {
|
|||||||
gate?: string;
|
gate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ForceRecallContinuityAdapterModule = {
|
||||||
|
defaultConfig?: Record<string, unknown>;
|
||||||
|
runForceRecallContinuityAdapter?: (args: {
|
||||||
|
wrapperResult: any;
|
||||||
|
autoChainPlanResult?: AutoChainPlanResult | null;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
input: Record<string, unknown> | null;
|
||||||
|
result: ApprovedPlanContinuityResult | null;
|
||||||
|
block: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function clamp(s: string, max = 1200): string {
|
function clamp(s: string, max = 1200): string {
|
||||||
if (!s) return s;
|
if (!s) return s;
|
||||||
if (s.length <= max) return s;
|
if (s.length <= max) return s;
|
||||||
@@ -376,14 +390,45 @@ function buildApprovedPlanContinuityInput(wrapperResult: any, autoChainPlanResul
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const continuityAdapterModuleCache = new Map<string, Promise<ForceRecallContinuityAdapterModule | null>>();
|
||||||
|
|
||||||
|
async function loadForceRecallContinuityAdapterModule(workspaceDir: string): Promise<ForceRecallContinuityAdapterModule | null> {
|
||||||
|
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<string, unknown> | 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<ApprovedPlanContinuityResult | null> {
|
async function runApprovedPlanContinuityGate(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise<ApprovedPlanContinuityResult | null> {
|
||||||
|
const evaluated = await evaluateApprovedPlanContinuityViaPlugin(workspaceDir, wrapperResult, autoChainPlanResult);
|
||||||
|
if (evaluated) return evaluated.result;
|
||||||
|
|
||||||
const continuityPath = path.join(workspaceDir, "scripts", "approved_plan_continuity_gate.mjs");
|
const continuityPath = path.join(workspaceDir, "scripts", "approved_plan_continuity_gate.mjs");
|
||||||
const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult);
|
const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult);
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
return runJsonScript(continuityPath, workspaceDir, input, APPROVED_PLAN_CONTINUITY_TIMEOUT_MS);
|
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<string> {
|
||||||
|
const evaluated = await evaluateApprovedPlanContinuityViaPlugin(workspaceDir, wrapperResult, autoChainPlanResult);
|
||||||
|
if (evaluated?.block) return evaluated.block;
|
||||||
if (!result) return "";
|
if (!result) return "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -583,7 +628,7 @@ const forceRecall = async (event: any) => {
|
|||||||
|
|
||||||
const gateLockBlock = buildGateLockBlock(gateLockResult);
|
const gateLockBlock = buildGateLockBlock(gateLockResult);
|
||||||
const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
|
const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
|
||||||
const approvedPlanContinuityBlock = buildApprovedPlanContinuityBlock(approvedPlanContinuityResult);
|
const approvedPlanContinuityBlock = await buildApprovedPlanContinuityBlock(workspaceDir, wrapperResult, autoChainPlanResult, approvedPlanContinuityResult);
|
||||||
|
|
||||||
const recallBlock = [
|
const recallBlock = [
|
||||||
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
||||||
|
|||||||
@@ -1,7 +1,63 @@
|
|||||||
export function createForceRecallContinuityAdapter() {
|
import { evaluateContinuity, buildContinuityGateBlock } from '../continuity/evaluator.mjs';
|
||||||
throw new Error('Not implemented: force-recall continuity adapter contract placeholder');
|
|
||||||
|
function isNonEmptyString(value) {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runForceRecallContinuityAdapter() {
|
export function buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult = null) {
|
||||||
throw new Error('Not implemented: force-recall continuity adapter contract placeholder');
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,120 @@
|
|||||||
export function evaluateContinuity() {
|
function isPlainObject(value) {
|
||||||
throw new Error('Not implemented: continuity evaluator contract placeholder');
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildContinuityGateBlock() {
|
function isNonEmptyString(value) {
|
||||||
throw new Error('Not implemented: continuity gate block contract placeholder');
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import {
|
|||||||
import {
|
import {
|
||||||
evaluateContinuity,
|
evaluateContinuity,
|
||||||
buildContinuityGateBlock,
|
buildContinuityGateBlock,
|
||||||
|
hasValidDispatchReceipt,
|
||||||
|
receiptMatchesPayload,
|
||||||
} from './continuity/evaluator.mjs';
|
} from './continuity/evaluator.mjs';
|
||||||
import {
|
import {
|
||||||
validateReceipt,
|
validateReceipt,
|
||||||
isValidReceipt,
|
isValidReceipt,
|
||||||
} from './continuity/receipt-validator.mjs';
|
} from './continuity/receipt-validator.mjs';
|
||||||
import {
|
import {
|
||||||
|
slugifyReceiptSegment,
|
||||||
|
buildReceiptFilename,
|
||||||
|
writeReceipt,
|
||||||
|
} from './continuity/receipt-store.mjs';
|
||||||
|
import {
|
||||||
|
buildApprovedPlanContinuityInput,
|
||||||
createForceRecallContinuityAdapter,
|
createForceRecallContinuityAdapter,
|
||||||
runForceRecallContinuityAdapter,
|
runForceRecallContinuityAdapter,
|
||||||
} from './adapters/force-recall.mjs';
|
} from './adapters/force-recall.mjs';
|
||||||
@@ -25,8 +33,14 @@ export {
|
|||||||
normalizeContinuityConfig,
|
normalizeContinuityConfig,
|
||||||
evaluateContinuity,
|
evaluateContinuity,
|
||||||
buildContinuityGateBlock,
|
buildContinuityGateBlock,
|
||||||
|
hasValidDispatchReceipt,
|
||||||
|
receiptMatchesPayload,
|
||||||
validateReceipt,
|
validateReceipt,
|
||||||
isValidReceipt,
|
isValidReceipt,
|
||||||
|
slugifyReceiptSegment,
|
||||||
|
buildReceiptFilename,
|
||||||
|
writeReceipt,
|
||||||
|
buildApprovedPlanContinuityInput,
|
||||||
createForceRecallContinuityAdapter,
|
createForceRecallContinuityAdapter,
|
||||||
runForceRecallContinuityAdapter,
|
runForceRecallContinuityAdapter,
|
||||||
};
|
};
|
||||||
@@ -39,8 +53,14 @@ export default {
|
|||||||
normalizeContinuityConfig,
|
normalizeContinuityConfig,
|
||||||
evaluateContinuity,
|
evaluateContinuity,
|
||||||
buildContinuityGateBlock,
|
buildContinuityGateBlock,
|
||||||
|
hasValidDispatchReceipt,
|
||||||
|
receiptMatchesPayload,
|
||||||
validateReceipt,
|
validateReceipt,
|
||||||
isValidReceipt,
|
isValidReceipt,
|
||||||
|
slugifyReceiptSegment,
|
||||||
|
buildReceiptFilename,
|
||||||
|
writeReceipt,
|
||||||
|
buildApprovedPlanContinuityInput,
|
||||||
createForceRecallContinuityAdapter,
|
createForceRecallContinuityAdapter,
|
||||||
runForceRecallContinuityAdapter,
|
runForceRecallContinuityAdapter,
|
||||||
};
|
};
|
||||||
|
|||||||
97
plugins/continuity/test/continuity.evaluator.test.mjs
Normal file
97
plugins/continuity/test/continuity.evaluator.test.mjs
Normal file
@@ -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');
|
||||||
63
plugins/continuity/test/continuity.plugin.test.mjs
Normal file
63
plugins/continuity/test/continuity.plugin.test.mjs
Normal file
@@ -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');
|
||||||
28
plugins/continuity/test/continuity.smoke.test.mjs
Normal file
28
plugins/continuity/test/continuity.smoke.test.mjs
Normal file
@@ -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');
|
||||||
@@ -51,6 +51,10 @@ async function prepareTempWorkspace() {
|
|||||||
await fs.mkdir(path.join(tempWorkspace, 'hooks', 'force-recall'), { recursive: true });
|
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, '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 = [
|
const copies = [
|
||||||
[wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')],
|
[wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')],
|
||||||
[gateLockPath, path.join(tempWorkspace, 'scripts', 'long_task_gate_lock.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')],
|
[handlerPath, path.join(tempWorkspace, 'hooks', 'force-recall', 'handler.ts')],
|
||||||
[path.join(repoRoot, 'docs', 'RULEBOOK.md'), path.join(tempWorkspace, 'docs', 'RULEBOOK.md')],
|
[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, '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) {
|
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.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');
|
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({
|
const passInjected = await withPatchedWrapperWorkspace({
|
||||||
classification: 'long_task',
|
classification: 'long_task',
|
||||||
silentCandidate: true,
|
silentCandidate: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user