From 7d62b1b84e7d7c1765310f386e8e6c13e9f7a6b9 Mon Sep 17 00:00:00 2001 From: "openclaw@cowbay.org" Date: Fri, 24 Apr 2026 17:33:01 +0800 Subject: [PATCH] feat: export continuity plugin MVP packaging --- hooks/force-recall/handler.ts | 49 +++- plugins/continuity/HOOK.md | 20 ++ plugins/continuity/README.md | 254 ++++++++++++++++++ plugins/continuity/README.zh-TW.md | 254 ++++++++++++++++++ .../approved-plan-receipt.example.json | 13 + .../examples/openclaw.continuity.example.json | 21 ++ plugins/continuity/package.json | 13 + .../continuity/src/adapters/force-recall.mjs | 63 +++++ plugins/continuity/src/config/defaults.mjs | 21 ++ plugins/continuity/src/config/schema.mjs | 176 ++++++++++++ .../continuity/src/continuity/evaluator.mjs | 120 +++++++++ .../src/continuity/receipt-store.mjs | 44 +++ .../src/continuity/receipt-validator.mjs | 71 +++++ plugins/continuity/src/continuity/types.md | 23 ++ plugins/continuity/src/index.mjs | 66 +++++ plugins/continuity/test/.gitkeep | 1 + .../test/continuity.config.test.mjs | 133 +++++++++ .../test/continuity.evaluator.test.mjs | 97 +++++++ .../test/continuity.plugin.test.mjs | 63 +++++ .../test/continuity.receipt-store.test.mjs | 38 +++ .../continuity.receipt-validator.test.mjs | 44 +++ .../continuity/test/continuity.smoke.test.mjs | 28 ++ .../test_force_recall_long_task_preflight.mjs | 54 ++++ 23 files changed, 1664 insertions(+), 2 deletions(-) create mode 100644 plugins/continuity/HOOK.md create mode 100644 plugins/continuity/README.md create mode 100644 plugins/continuity/README.zh-TW.md create mode 100644 plugins/continuity/examples/approved-plan-receipt.example.json create mode 100644 plugins/continuity/examples/openclaw.continuity.example.json create mode 100644 plugins/continuity/package.json create mode 100644 plugins/continuity/src/adapters/force-recall.mjs create mode 100644 plugins/continuity/src/config/defaults.mjs create mode 100644 plugins/continuity/src/config/schema.mjs create mode 100644 plugins/continuity/src/continuity/evaluator.mjs create mode 100644 plugins/continuity/src/continuity/receipt-store.mjs create mode 100644 plugins/continuity/src/continuity/receipt-validator.mjs create mode 100644 plugins/continuity/src/continuity/types.md create mode 100644 plugins/continuity/src/index.mjs create mode 100644 plugins/continuity/test/.gitkeep create mode 100644 plugins/continuity/test/continuity.config.test.mjs create mode 100644 plugins/continuity/test/continuity.evaluator.test.mjs create mode 100644 plugins/continuity/test/continuity.plugin.test.mjs create mode 100644 plugins/continuity/test/continuity.receipt-store.test.mjs create mode 100644 plugins/continuity/test/continuity.receipt-validator.test.mjs create mode 100644 plugins/continuity/test/continuity.smoke.test.mjs 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/HOOK.md b/plugins/continuity/HOOK.md new file mode 100644 index 0000000..b0646db --- /dev/null +++ b/plugins/continuity/HOOK.md @@ -0,0 +1,20 @@ +# HOOK.md + +This document reserves the hook adapter contract for the continuity plugin MVP. + +## Target adapter + +Primary MVP integration target: + +- `force-recall` + +## Planned responsibilities + +- derive continuity input from hook context +- invoke the plugin evaluator +- return a prompt block / gate result without duplicating continuity rules + +## Current status + +- contract placeholder only +- implementation deferred to later plan tasks diff --git a/plugins/continuity/README.md b/plugins/continuity/README.md new file mode 100644 index 0000000..2b99d68 --- /dev/null +++ b/plugins/continuity/README.md @@ -0,0 +1,254 @@ +# Continuity Plugin (MVP) + +> 中文版:`README.zh-TW.md` + +This package extracts the current approved-plan continuity hard gate into a small installable, testable OpenClaw plugin MVP. + +The goal is not to reinvent workflow policy. The goal is to package the existing continuity evaluator, receipt contract, and force-recall adapter so other OpenClaw workspaces can reuse the same minimum integration. + +## What this MVP currently provides + +- continuity config validation +- dispatch receipt contract validation +- receipt file writing +- approved-plan continuity gate evaluation +- prompt block generation for the continuity gate +- a `force-recall` adapter that maps hook wrapper/planner output into continuity input + +## Install location + +Recommended location inside an OpenClaw workspace: + +```text +/plugins/continuity +``` + +With the current MVP integration, the related files normally look like this: + +```text +/ + hooks/ + force-recall/ + handler.ts + HOOK.md + plugins/ + continuity/ + README.zh-TW.md + README.md + HOOK.md + package.json + examples/ + src/ + test/ + scripts/ + test_force_recall_long_task_preflight.mjs +``` + +## Directory structure + +```text +plugins/continuity/ + README.zh-TW.md + README.md + HOOK.md + package.json + examples/ + approved-plan-receipt.example.json + openclaw.continuity.example.json + src/ + index.mjs + adapters/ + force-recall.mjs + config/ + defaults.mjs + schema.mjs + continuity/ + evaluator.mjs + receipt-store.mjs + receipt-validator.mjs + types.md + test/ + continuity.config.test.mjs + continuity.evaluator.test.mjs + continuity.plugin.test.mjs + continuity.receipt-store.test.mjs + continuity.receipt-validator.test.mjs + continuity.smoke.test.mjs +``` + +## Public surface + +- `src/config/schema.mjs` +- `src/config/defaults.mjs` +- `src/continuity/evaluator.mjs` +- `src/continuity/receipt-validator.mjs` +- `src/continuity/receipt-store.mjs` +- `src/adapters/force-recall.mjs` +- `src/index.mjs` + +`src/index.mjs` currently re-exports: + +- `defaultConfig` +- `cloneDefaultConfig()` +- `validateContinuityConfig()` / `normalizeContinuityConfig()` +- `evaluateContinuity()` / `buildContinuityGateBlock()` +- `validateReceipt()` / `isValidReceipt()` +- `slugifyReceiptSegment()` / `buildReceiptFilename()` / `writeReceipt()` +- `buildApprovedPlanContinuityInput()` +- `createForceRecallContinuityAdapter()` / `runForceRecallContinuityAdapter()` + +## Example config + +Start from `examples/openclaw.continuity.example.json`: + +```json +{ + "enabled": true, + "planMatchers": ["approved-plan"], + "legalTerminalStates": [ + "waiting_user", + "blocked", + "pending_verification" + ], + "receiptDir": "state/approved-plan-continuity", + "requireRealDispatchReceipt": true, + "allowReplyClosureWithoutDispatch": false, + "debug": false, + "adapter": { + "forceRecall": { + "enabled": true, + "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" + } + } +} +``` + +Defaults are defined in `src/config/defaults.mjs`. + +## Hook integration + +The primary MVP integration point is `hooks/force-recall/handler.ts`. + +The current hook path is: + +1. run long-task preflight / gate lock / auto-chain planner +2. dynamically load `plugins/continuity/src/index.mjs` +3. call `runForceRecallContinuityAdapter({ wrapperResult, autoChainPlanResult, config })` +4. prepend the returned block into `bodyForAgent` + +The handler already contains the plugin path integration points. The key symbols are: + +- `runForceRecallContinuityAdapter` +- `[APPROVED_PLAN_CONTINUITY_GATE]` + +Minimal integration example: + +```js +import plugin from './plugins/continuity/src/index.mjs'; + +const out = plugin.runForceRecallContinuityAdapter({ + config: plugin.defaultConfig, + wrapperResult, + autoChainPlanResult, +}); + +if (out?.block) { + context.bodyForAgent = `${out.block}\n${context.bodyForAgent}`; +} +``` + +If you want a custom injected block label, override `adapter.forceRecall.injectBlockLabel`. + +## Receipt contract + +Minimum receipt shape: + +- `planId` +- `currentTask` +- `nextDerivedAction` +- `dispatchedAt` +- `dispatchRunId` +- `childSessionKey` +- `replyClosureState` + +Example from `examples/approved-plan-receipt.example.json`: + +```json +{ + "planId": "example-plan", + "currentTask": "task-01", + "nextDerivedAction": { + "kind": "delegate", + "target": "subagent", + "task": "placeholder" + }, + "dispatchedAt": "2026-04-24T16:43:00+08:00", + "dispatchRunId": "example-run", + "childSessionKey": "session-placeholder", + "replyClosureState": "pending_verification" +} +``` + +To persist a receipt: + +```js +import { writeReceipt } from './src/index.mjs'; + +await writeReceipt({ + receiptDir: 'state/approved-plan-continuity', + receipt, +}); +``` + +## Smoke test / verification + +At minimum, run: + +```bash +cd plugins/continuity +npm test + +cd /path/to/workspace +node scripts/test_force_recall_long_task_preflight.mjs +node --check hooks/force-recall/handler.ts +``` + +For a minimal plugin-only check, you can also run: + +```bash +cd plugins/continuity +node test/continuity.smoke.test.mjs +``` + +## Install and apply steps for another OpenClaw workspace + +1. Copy `plugins/continuity` into your workspace. +2. Ensure `hooks/force-recall/handler.ts` loads `plugins/continuity/src/index.mjs`. +3. Adjust the continuity config as needed, especially: + - `planMatchers` + - `legalTerminalStates` + - `receiptDir` + - `adapter.forceRecall.injectBlockLabel` +4. If your dispatch flow creates child runs/sessions, persist a real receipt. +5. Run the smoke test and the force-recall preflight test. +6. Confirm the agent prompt contains the continuity gate block and that dry-run dispatch alone does not pass the gate. + +## Current limitations + +- This is an MVP extraction of the **approved-plan continuity hard gate**, not a general workflow engine. +- The main adapter is `force-recall`; the package is not yet generalized into a multi-hook / multi-event integration layer. +- Config is still passed as module defaults plus caller input; there is not yet a full OpenClaw plugin installer/registration guide. +- The receipt store only writes files; it does not manage retention, cleanup, or indexing. +- The receipt validator checks the minimum contract only; it does not deeply validate every `nextDerivedAction` subtype. +- The documented install path assumes the existing `force-recall` preflight chain; if your workspace does not use that chain, you still need your own glue code. + +## Notes + +- Default legal terminal states are `waiting_user`, `blocked`, and `pending_verification` +- The evaluator preserves current behavior, including `missing_dispatch_receipt` and `missing_auto_next_dispatch` +- The adapter mirrors the continuity input mapping used by `hooks/force-recall/handler.ts` +- `HOOK.md` describes the plugin/hook adapter contract boundary, not the full installation guide + +## Chinese documentation + +See `README.zh-TW.md` for the Traditional Chinese version. diff --git a/plugins/continuity/README.zh-TW.md b/plugins/continuity/README.zh-TW.md new file mode 100644 index 0000000..6ffeb5a --- /dev/null +++ b/plugins/continuity/README.zh-TW.md @@ -0,0 +1,254 @@ +# Continuity Plugin(MVP) + +> English version: `README.md` + +這個套件把目前 approved-plan continuity hard gate 抽離成一個可安裝、可測試、可在 hook 內重用的 OpenClaw plugin MVP。 + +目標不是重新發明規則,而是把既有 continuity 判斷、receipt contract、force-recall adapter 收斂成一個可以被其他 OpenClaw workspace 直接帶走的最小可用包。 + +## 目前能做什麼 + +- 驗證 continuity config +- 驗證 dispatch receipt contract +- 寫出 receipt 檔案 +- 評估 approved-plan continuity gate +- 產生可注入 prompt 的 continuity gate block +- 透過 `force-recall` adapter,把 hook 端的 wrapper/planner 結果轉成 continuity input + +## 安裝位置 + +建議直接放在 OpenClaw workspace 內: + +```text +/plugins/continuity +``` + +以目前 MVP 慣例,相關檔案位置如下: + +```text +/ + hooks/ + force-recall/ + handler.ts + HOOK.md + plugins/ + continuity/ + README.zh-TW.md + README.md + HOOK.md + package.json + examples/ + src/ + test/ + scripts/ + test_force_recall_long_task_preflight.mjs +``` + +## 目錄結構 + +```text +plugins/continuity/ + README.zh-TW.md + README.md + HOOK.md + package.json + examples/ + approved-plan-receipt.example.json + openclaw.continuity.example.json + src/ + index.mjs + adapters/ + force-recall.mjs + config/ + defaults.mjs + schema.mjs + continuity/ + evaluator.mjs + receipt-store.mjs + receipt-validator.mjs + types.md + test/ + continuity.config.test.mjs + continuity.evaluator.test.mjs + continuity.plugin.test.mjs + continuity.receipt-store.test.mjs + continuity.receipt-validator.test.mjs + continuity.smoke.test.mjs +``` + +## 公開介面 + +- `src/config/schema.mjs` +- `src/config/defaults.mjs` +- `src/continuity/evaluator.mjs` +- `src/continuity/receipt-validator.mjs` +- `src/continuity/receipt-store.mjs` +- `src/adapters/force-recall.mjs` +- `src/index.mjs` + +`src/index.mjs` 目前會 re-export: + +- `defaultConfig` +- `cloneDefaultConfig()` +- `validateContinuityConfig()` / `normalizeContinuityConfig()` +- `evaluateContinuity()` / `buildContinuityGateBlock()` +- `validateReceipt()` / `isValidReceipt()` +- `slugifyReceiptSegment()` / `buildReceiptFilename()` / `writeReceipt()` +- `buildApprovedPlanContinuityInput()` +- `createForceRecallContinuityAdapter()` / `runForceRecallContinuityAdapter()` + +## Example config + +請從 `examples/openclaw.continuity.example.json` 開始: + +```json +{ + "enabled": true, + "planMatchers": ["approved-plan"], + "legalTerminalStates": [ + "waiting_user", + "blocked", + "pending_verification" + ], + "receiptDir": "state/approved-plan-continuity", + "requireRealDispatchReceipt": true, + "allowReplyClosureWithoutDispatch": false, + "debug": false, + "adapter": { + "forceRecall": { + "enabled": true, + "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" + } + } +} +``` + +預設值定義在 `src/config/defaults.mjs`。 + +## Hook 接法 + +MVP 的主要整合點是 `hooks/force-recall/handler.ts`。 + +目前 hook 端做法是: + +1. 先完成 long-task preflight / gate lock / auto-chain planner +2. 再動態載入 `plugins/continuity/src/index.mjs` +3. 呼叫 `runForceRecallContinuityAdapter({ wrapperResult, autoChainPlanResult, config })` +4. 把 adapter 產出的 block 注入 `bodyForAgent` + +`handler.ts` 內已有 plugin 路徑接點,關鍵符號是: + +- `runForceRecallContinuityAdapter` +- `[APPROVED_PLAN_CONTINUITY_GATE]` + +最小接法示意: + +```js +import plugin from './plugins/continuity/src/index.mjs'; + +const out = plugin.runForceRecallContinuityAdapter({ + config: plugin.defaultConfig, + wrapperResult, + autoChainPlanResult, +}); + +if (out?.block) { + context.bodyForAgent = `${out.block}\n${context.bodyForAgent}`; +} +``` + +若要覆蓋 block label,可改 `adapter.forceRecall.injectBlockLabel`。 + +## Receipt contract + +最小 receipt shape 如下: + +- `planId` +- `currentTask` +- `nextDerivedAction` +- `dispatchedAt` +- `dispatchRunId` +- `childSessionKey` +- `replyClosureState` + +範例可參考 `examples/approved-plan-receipt.example.json`: + +```json +{ + "planId": "example-plan", + "currentTask": "task-01", + "nextDerivedAction": { + "kind": "delegate", + "target": "subagent", + "task": "placeholder" + }, + "dispatchedAt": "2026-04-24T16:43:00+08:00", + "dispatchRunId": "example-run", + "childSessionKey": "session-placeholder", + "replyClosureState": "pending_verification" +} +``` + +若要把 receipt 落盤,可用: + +```js +import { writeReceipt } from './src/index.mjs'; + +await writeReceipt({ + receiptDir: 'state/approved-plan-continuity', + receipt, +}); +``` + +## Smoke test / 驗證 + +至少執行以下驗證: + +```bash +cd plugins/continuity +npm test + +cd /path/to/workspace +node scripts/test_force_recall_long_task_preflight.mjs +node --check hooks/force-recall/handler.ts +``` + +若只想先做 plugin 本體最小檢查,也可以: + +```bash +cd plugins/continuity +node test/continuity.smoke.test.mjs +``` + +## 安裝與套用步驟(給其他 OpenClaw 使用者) + +1. 把 `plugins/continuity` 複製到你的 workspace。 +2. 確認 `hooks/force-recall/handler.ts` 會載入 `plugins/continuity/src/index.mjs`。 +3. 視需要調整 continuity config,至少確認: + - `planMatchers` + - `legalTerminalStates` + - `receiptDir` + - `adapter.forceRecall.injectBlockLabel` +4. 若你的 dispatch 流程會產生 child run/session,請同步寫出 receipt。 +5. 跑 smoke test 與 hook preflight 測試。 +6. 確認 agent prompt 內可見 continuity gate block,且 dry-run dispatch 不會被誤判為 pass。 + +## 目前限制 + +- 目前是 **approved-plan continuity hard gate** 的 MVP 抽離,不是通用 workflow engine。 +- 主要 adapter 只有 `force-recall`,尚未抽象成多 hook / 多事件通用介面。 +- config 目前是模組內預設 + 呼叫端傳入,還沒有完整的 OpenClaw plugin 安裝器/註冊流程文件。 +- receipt store 只負責寫檔,不含 retention、cleanup、indexing。 +- receipt validator 目前只檢查最小 contract,不驗證每個 `nextDerivedAction` 子欄位語意。 +- 文件描述的是「依現有 hook 整合」的安裝方式;若未採用 `force-recall` preflight 鏈,仍需自行補 glue code。 + +## 備註 + +- 預設 legal terminal states:`waiting_user`、`blocked`、`pending_verification` +- evaluator 保留既有行為,包括 `missing_dispatch_receipt` 與 `missing_auto_next_dispatch` +- adapter 維持與 `hooks/force-recall/handler.ts` 的 continuity input mapping 一致 +- `HOOK.md` 說明的是 plugin/hook adapter 契約定位,不是完整安裝說明 + +## 英文文件 + +英文版請見 `README.md`。 diff --git a/plugins/continuity/examples/approved-plan-receipt.example.json b/plugins/continuity/examples/approved-plan-receipt.example.json new file mode 100644 index 0000000..a69c106 --- /dev/null +++ b/plugins/continuity/examples/approved-plan-receipt.example.json @@ -0,0 +1,13 @@ +{ + "planId": "example-plan", + "currentTask": "task-01", + "nextDerivedAction": { + "kind": "delegate", + "target": "subagent", + "task": "placeholder" + }, + "dispatchedAt": "2026-04-24T16:43:00+08:00", + "dispatchRunId": "example-run", + "childSessionKey": "session-placeholder", + "replyClosureState": "pending_verification" +} diff --git a/plugins/continuity/examples/openclaw.continuity.example.json b/plugins/continuity/examples/openclaw.continuity.example.json new file mode 100644 index 0000000..4ab8db8 --- /dev/null +++ b/plugins/continuity/examples/openclaw.continuity.example.json @@ -0,0 +1,21 @@ +{ + "enabled": true, + "planMatchers": [ + "approved-plan" + ], + "legalTerminalStates": [ + "waiting_user", + "blocked", + "pending_verification" + ], + "receiptDir": "state/approved-plan-continuity", + "requireRealDispatchReceipt": true, + "allowReplyClosureWithoutDispatch": false, + "debug": false, + "adapter": { + "forceRecall": { + "enabled": true, + "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" + } + } +} diff --git a/plugins/continuity/package.json b/plugins/continuity/package.json new file mode 100644 index 0000000..e9a0e01 --- /dev/null +++ b/plugins/continuity/package.json @@ -0,0 +1,13 @@ +{ + "name": "@openclaw/plugin-continuity", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Continuity plugin MVP skeleton for approved-plan dispatch gating.", + "exports": { + ".": "./src/index.mjs" + }, + "scripts": { + "test": "node test/continuity.config.test.mjs && node test/continuity.receipt-validator.test.mjs && node test/continuity.receipt-store.test.mjs && node test/continuity.evaluator.test.mjs && node test/continuity.plugin.test.mjs && node test/continuity.smoke.test.mjs" + } +} diff --git a/plugins/continuity/src/adapters/force-recall.mjs b/plugins/continuity/src/adapters/force-recall.mjs new file mode 100644 index 0000000..7392db8 --- /dev/null +++ b/plugins/continuity/src/adapters/force-recall.mjs @@ -0,0 +1,63 @@ +import { evaluateContinuity, buildContinuityGateBlock } from '../continuity/evaluator.mjs'; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +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/config/defaults.mjs b/plugins/continuity/src/config/defaults.mjs new file mode 100644 index 0000000..0ed8484 --- /dev/null +++ b/plugins/continuity/src/config/defaults.mjs @@ -0,0 +1,21 @@ +export const defaultConfig = Object.freeze({ + enabled: true, + planMatchers: ['approved-plan'], + legalTerminalStates: ['waiting_user', 'blocked', 'pending_verification'], + receiptDir: 'state/approved-plan-continuity', + requireRealDispatchReceipt: true, + allowReplyClosureWithoutDispatch: false, + debug: false, + adapter: { + forceRecall: { + enabled: true, + injectBlockLabel: 'APPROVED_PLAN_CONTINUITY_GATE', + }, + }, +}); + +export function cloneDefaultConfig() { + return structuredClone(defaultConfig); +} + +export default defaultConfig; diff --git a/plugins/continuity/src/config/schema.mjs b/plugins/continuity/src/config/schema.mjs new file mode 100644 index 0000000..f6f069d --- /dev/null +++ b/plugins/continuity/src/config/schema.mjs @@ -0,0 +1,176 @@ +import defaultConfig, { cloneDefaultConfig } from './defaults.mjs'; + +export const continuityConfigSchema = Object.freeze({ + enabled: 'boolean', + planMatchers: 'string[]', + legalTerminalStates: 'string[]', + receiptDir: 'string', + requireRealDispatchReceipt: 'boolean', + allowReplyClosureWithoutDispatch: 'boolean', + debug: 'boolean', + adapter: { + forceRecall: { + enabled: 'boolean', + injectBlockLabel: 'string', + }, + }, +}); + +const TOP_LEVEL_KEYS = new Set([ + 'enabled', + 'planMatchers', + 'legalTerminalStates', + 'receiptDir', + 'requireRealDispatchReceipt', + 'allowReplyClosureWithoutDispatch', + 'debug', + 'adapter', +]); + +const ADAPTER_KEYS = new Set(['forceRecall']); +const FORCE_RECALL_KEYS = new Set(['enabled', 'injectBlockLabel']); + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function pushUnknownKeyErrors(errors, input, allowedKeys, pathPrefix = '') { + for (const key of Object.keys(input)) { + if (!allowedKeys.has(key)) { + errors.push(`${pathPrefix}${key}: unknown config key`); + } + } +} + +function validateStringArray(errors, value, fieldName) { + if (!Array.isArray(value)) { + errors.push(`${fieldName}: expected array of strings`); + return; + } + + value.forEach((entry, index) => { + if (!isNonEmptyString(entry)) { + errors.push(`${fieldName}[${index}]: expected non-empty string`); + } + }); +} + +export function normalizeContinuityConfig(input = {}) { + const base = cloneDefaultConfig(); + + if (!isPlainObject(input)) { + return base; + } + + const normalized = { + ...base, + ...input, + planMatchers: Array.isArray(input.planMatchers) + ? input.planMatchers.map((entry) => (typeof entry === 'string' ? entry.trim() : entry)) + : [...base.planMatchers], + legalTerminalStates: Array.isArray(input.legalTerminalStates) + ? input.legalTerminalStates.map((entry) => (typeof entry === 'string' ? entry.trim() : entry)) + : [...base.legalTerminalStates], + receiptDir: typeof input.receiptDir === 'string' ? input.receiptDir.trim() : base.receiptDir, + adapter: { + ...base.adapter, + ...(isPlainObject(input.adapter) ? input.adapter : {}), + forceRecall: { + ...base.adapter.forceRecall, + ...(isPlainObject(input.adapter?.forceRecall) ? input.adapter.forceRecall : {}), + }, + }, + }; + + if (typeof normalized.adapter.forceRecall.injectBlockLabel === 'string') { + normalized.adapter.forceRecall.injectBlockLabel = normalized.adapter.forceRecall.injectBlockLabel.trim(); + } + + return normalized; +} + +export function validateContinuityConfig(input = {}) { + const errors = []; + + if (!isPlainObject(input)) { + errors.push('config: expected plain object'); + return { + ok: false, + errors, + normalizedConfig: cloneDefaultConfig(), + }; + } + + pushUnknownKeyErrors(errors, input, TOP_LEVEL_KEYS); + + if ('enabled' in input && typeof input.enabled !== 'boolean') { + errors.push('enabled: expected boolean'); + } + + if ('planMatchers' in input) { + validateStringArray(errors, input.planMatchers, 'planMatchers'); + } + + if ('legalTerminalStates' in input) { + validateStringArray(errors, input.legalTerminalStates, 'legalTerminalStates'); + } + + if ('receiptDir' in input && !isNonEmptyString(input.receiptDir)) { + errors.push('receiptDir: expected non-empty string'); + } + + if ('requireRealDispatchReceipt' in input && typeof input.requireRealDispatchReceipt !== 'boolean') { + errors.push('requireRealDispatchReceipt: expected boolean'); + } + + if ('allowReplyClosureWithoutDispatch' in input && typeof input.allowReplyClosureWithoutDispatch !== 'boolean') { + errors.push('allowReplyClosureWithoutDispatch: expected boolean'); + } + + if ('debug' in input && typeof input.debug !== 'boolean') { + errors.push('debug: expected boolean'); + } + + if ('adapter' in input) { + if (!isPlainObject(input.adapter)) { + errors.push('adapter: expected object'); + } else { + pushUnknownKeyErrors(errors, input.adapter, ADAPTER_KEYS, 'adapter.'); + + if ('forceRecall' in input.adapter) { + if (!isPlainObject(input.adapter.forceRecall)) { + errors.push('adapter.forceRecall: expected object'); + } else { + pushUnknownKeyErrors(errors, input.adapter.forceRecall, FORCE_RECALL_KEYS, 'adapter.forceRecall.'); + + if ('enabled' in input.adapter.forceRecall && typeof input.adapter.forceRecall.enabled !== 'boolean') { + errors.push('adapter.forceRecall.enabled: expected boolean'); + } + + if ( + 'injectBlockLabel' in input.adapter.forceRecall + && !isNonEmptyString(input.adapter.forceRecall.injectBlockLabel) + ) { + errors.push('adapter.forceRecall.injectBlockLabel: expected non-empty string'); + } + } + } + } + } + + const normalizedConfig = normalizeContinuityConfig(input); + + return { + ok: errors.length === 0, + errors, + normalizedConfig, + }; +} + +export { defaultConfig }; + +export default continuityConfigSchema; diff --git a/plugins/continuity/src/continuity/evaluator.mjs b/plugins/continuity/src/continuity/evaluator.mjs new file mode 100644 index 0000000..f2f4f87 --- /dev/null +++ b/plugins/continuity/src/continuity/evaluator.mjs @@ -0,0 +1,120 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +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/continuity/receipt-store.mjs b/plugins/continuity/src/continuity/receipt-store.mjs new file mode 100644 index 0000000..ab3f16b --- /dev/null +++ b/plugins/continuity/src/continuity/receipt-store.mjs @@ -0,0 +1,44 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function slugifyReceiptSegment(value) { + if (!isNonEmptyString(value)) return ''; + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function buildReceiptFilename({ planId, dispatchRunId }) { + const planIdSafe = slugifyReceiptSegment(planId); + const dispatchRunIdSafe = slugifyReceiptSegment(dispatchRunId); + return { + planIdSafe, + dispatchRunIdSafe, + filename: (planIdSafe && dispatchRunIdSafe) + ? `receipt-${planIdSafe}-${dispatchRunIdSafe}.json` + : '', + }; +} + +export async function writeReceipt({ receiptDir, receipt }) { + if (!isNonEmptyString(receiptDir)) { + throw new Error('receiptDir: expected non-empty string'); + } + + const { filename, planIdSafe, dispatchRunIdSafe } = buildReceiptFilename(receipt ?? {}); + if (!filename) { + throw new Error(`receipt filename segments invalid: planId=${JSON.stringify(planIdSafe)}, dispatchRunId=${JSON.stringify(dispatchRunIdSafe)}`); + } + + await fs.mkdir(receiptDir, { recursive: true }); + const receiptPath = path.join(receiptDir, filename); + await fs.writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8'); + return receiptPath; +} diff --git a/plugins/continuity/src/continuity/receipt-validator.mjs b/plugins/continuity/src/continuity/receipt-validator.mjs new file mode 100644 index 0000000..0b45fe4 --- /dev/null +++ b/plugins/continuity/src/continuity/receipt-validator.mjs @@ -0,0 +1,71 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeString(value) { + return typeof value === 'string' ? value.trim() : value; +} + +export function validateReceipt(receipt) { + const errors = []; + + if (!isPlainObject(receipt)) { + return { + ok: false, + errors: ['receipt: expected object'], + normalizedReceipt: null, + }; + } + + const normalizedReceipt = { + planId: normalizeString(receipt.planId), + currentTask: normalizeString(receipt.currentTask), + nextDerivedAction: isPlainObject(receipt.nextDerivedAction) ? receipt.nextDerivedAction : receipt.nextDerivedAction, + dispatchedAt: normalizeString(receipt.dispatchedAt), + dispatchRunId: normalizeString(receipt.dispatchRunId), + childSessionKey: normalizeString(receipt.childSessionKey), + replyClosureState: normalizeString(receipt.replyClosureState), + }; + + if (!isNonEmptyString(normalizedReceipt.planId)) { + errors.push('planId: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.currentTask)) { + errors.push('currentTask: expected non-empty string'); + } + + if (!isPlainObject(normalizedReceipt.nextDerivedAction)) { + errors.push('nextDerivedAction: expected object'); + } + + if (!isNonEmptyString(normalizedReceipt.dispatchedAt)) { + errors.push('dispatchedAt: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.dispatchRunId)) { + errors.push('dispatchRunId: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.childSessionKey)) { + errors.push('childSessionKey: expected non-empty string'); + } + + if (!isNonEmptyString(normalizedReceipt.replyClosureState)) { + errors.push('replyClosureState: expected non-empty string'); + } + + return { + ok: errors.length === 0, + errors, + normalizedReceipt, + }; +} + +export function isValidReceipt(receipt) { + return validateReceipt(receipt).ok; +} diff --git a/plugins/continuity/src/continuity/types.md b/plugins/continuity/src/continuity/types.md new file mode 100644 index 0000000..0af2f78 --- /dev/null +++ b/plugins/continuity/src/continuity/types.md @@ -0,0 +1,23 @@ +# Continuity Types (MVP) + +## Receipt contract + +The MVP receipt validator contract uses this minimum shape: + +- `planId`: string +- `currentTask`: string +- `nextDerivedAction`: object +- `dispatchedAt`: string +- `dispatchRunId`: string +- `childSessionKey`: string +- `replyClosureState`: string + +## Validator API + +- `validateReceipt(receipt)` → `{ ok, errors, normalizedReceipt }` +- `isValidReceipt(receipt)` → `boolean` + +## Notes + +- This contract is intentionally minimal and keeps file I/O separate. +- It mirrors the current approved-plan dispatch receipt fields used by the existing continuity scripts. diff --git a/plugins/continuity/src/index.mjs b/plugins/continuity/src/index.mjs new file mode 100644 index 0000000..0f34656 --- /dev/null +++ b/plugins/continuity/src/index.mjs @@ -0,0 +1,66 @@ +import { defaultConfig, cloneDefaultConfig } from './config/defaults.mjs'; +import { + continuityConfigSchema, + validateContinuityConfig, + normalizeContinuityConfig, +} from './config/schema.mjs'; +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'; + +export { + defaultConfig, + cloneDefaultConfig, + continuityConfigSchema, + validateContinuityConfig, + normalizeContinuityConfig, + evaluateContinuity, + buildContinuityGateBlock, + hasValidDispatchReceipt, + receiptMatchesPayload, + validateReceipt, + isValidReceipt, + slugifyReceiptSegment, + buildReceiptFilename, + writeReceipt, + buildApprovedPlanContinuityInput, + createForceRecallContinuityAdapter, + runForceRecallContinuityAdapter, +}; + +export default { + name: '@openclaw/plugin-continuity', + defaultConfig, + continuityConfigSchema, + validateContinuityConfig, + normalizeContinuityConfig, + evaluateContinuity, + buildContinuityGateBlock, + hasValidDispatchReceipt, + receiptMatchesPayload, + validateReceipt, + isValidReceipt, + slugifyReceiptSegment, + buildReceiptFilename, + writeReceipt, + buildApprovedPlanContinuityInput, + createForceRecallContinuityAdapter, + runForceRecallContinuityAdapter, +}; diff --git a/plugins/continuity/test/.gitkeep b/plugins/continuity/test/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plugins/continuity/test/.gitkeep @@ -0,0 +1 @@ + diff --git a/plugins/continuity/test/continuity.config.test.mjs b/plugins/continuity/test/continuity.config.test.mjs new file mode 100644 index 0000000..de1c934 --- /dev/null +++ b/plugins/continuity/test/continuity.config.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; + +import defaultConfig from '../src/config/defaults.mjs'; +import { + normalizeContinuityConfig, + validateContinuityConfig, +} from '../src/config/schema.mjs'; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +test('accepts default config', () => { + const result = validateContinuityConfig(defaultConfig); + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.normalizedConfig, defaultConfig); +}); + +test('accepts custom receipt directory', () => { + const result = validateContinuityConfig({ + receiptDir: 'tmp/continuity-receipts', + }); + + assert.equal(result.ok, true); + assert.equal(result.normalizedConfig.receiptDir, 'tmp/continuity-receipts'); + assert.deepEqual(result.errors, []); +}); + +test('accepts custom legal terminal states list', () => { + const result = validateContinuityConfig({ + legalTerminalStates: ['waiting_user', 'blocked', 'done_elsewhere'], + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.normalizedConfig.legalTerminalStates, ['waiting_user', 'blocked', 'done_elsewhere']); +}); + +test('normalizes missing fields from defaults', () => { + const normalized = normalizeContinuityConfig({ + debug: true, + }); + + assert.equal(normalized.debug, true); + assert.equal(normalized.enabled, defaultConfig.enabled); + assert.equal(normalized.receiptDir, defaultConfig.receiptDir); + assert.deepEqual(normalized.legalTerminalStates, defaultConfig.legalTerminalStates); + assert.notEqual(normalized.legalTerminalStates, defaultConfig.legalTerminalStates); +}); + +test('rejects non-array legalTerminalStates', () => { + const result = validateContinuityConfig({ + legalTerminalStates: 'waiting_user', + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /legalTerminalStates/); + assert.match(result.errors.join('\n'), /array/i); +}); + +test('rejects invalid legalTerminalStates entry types', () => { + const result = validateContinuityConfig({ + legalTerminalStates: ['waiting_user', 123], + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /legalTerminalStates\[1\]/); + assert.match(result.errors.join('\n'), /string/i); +}); + +test('rejects empty receiptDir', () => { + const result = validateContinuityConfig({ + receiptDir: ' ', + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /receiptDir/); +}); + +test('rejects malformed adapter.forceRecall shape', () => { + const result = validateContinuityConfig({ + adapter: { + forceRecall: false, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.forceRecall/); +}); + +test('rejects malformed adapter.forceRecall.enabled type', () => { + const result = validateContinuityConfig({ + adapter: { + forceRecall: { + enabled: 'yes', + }, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.forceRecall\.enabled/); +}); + +test('rejects malformed adapter.forceRecall.injectBlockLabel type', () => { + const result = validateContinuityConfig({ + adapter: { + forceRecall: { + injectBlockLabel: 42, + }, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /injectBlockLabel/); +}); + +test('rejects unknown top-level key', () => { + const result = validateContinuityConfig({ + unexpected: true, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /unknown/i); + assert.match(result.errors.join('\n'), /unexpected/); +}); + +console.log('continuity.config.test.mjs PASS'); 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.receipt-store.test.mjs b/plugins/continuity/test/continuity.receipt-store.test.mjs new file mode 100644 index 0000000..3cf1c16 --- /dev/null +++ b/plugins/continuity/test/continuity.receipt-store.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildReceiptFilename, slugifyReceiptSegment, writeReceipt } from '../src/continuity/receipt-store.mjs'; + +function test(name, fn) { + Promise.resolve() + .then(fn) + .then(() => console.log(`ok - ${name}`)) + .catch((error) => { + console.error(`not ok - ${name}`); + throw error; + }); +} + +assert.equal(slugifyReceiptSegment(' Plan ID / 01 '), 'plan-id-01'); +const built = buildReceiptFilename({ planId: 'Plan ID', dispatchRunId: 'Run 01' }); +assert.equal(built.filename, 'receipt-plan-id-run-01.json'); + +test('writes receipt using canonical filename', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'continuity-receipt-store-')); + const receipt = { + planId: 'Plan ID', + currentTask: 'task-7', + nextDerivedAction: { type: 'message_subagent' }, + dispatchedAt: '2026-04-24T09:00:00.000Z', + dispatchRunId: 'Run 01', + childSessionKey: 'child-1', + replyClosureState: 'completed', + }; + + const receiptPath = await writeReceipt({ receiptDir: dir, receipt }); + assert.match(receiptPath, /receipt-plan-id-run-01\.json$/); + assert.equal(fs.existsSync(receiptPath), true); +}); + +console.log('continuity.receipt-store.test.mjs PASS'); diff --git a/plugins/continuity/test/continuity.receipt-validator.test.mjs b/plugins/continuity/test/continuity.receipt-validator.test.mjs new file mode 100644 index 0000000..cc98abb --- /dev/null +++ b/plugins/continuity/test/continuity.receipt-validator.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { isValidReceipt, validateReceipt } from '../src/continuity/receipt-validator.mjs'; + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +test('accepts full receipt contract', () => { + const receipt = { + planId: 'plan-1', + currentTask: 'task-7', + nextDerivedAction: { type: 'message_subagent' }, + dispatchedAt: '2026-04-24T09:00:00.000Z', + dispatchRunId: 'run-1', + childSessionKey: 'child-1', + replyClosureState: 'completed', + }; + + const result = validateReceipt(receipt); + assert.equal(result.ok, true); + assert.equal(isValidReceipt(receipt), true); +}); + +test('rejects non-object receipt', () => { + const result = validateReceipt(null); + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /expected object/); +}); + +test('rejects missing required fields', () => { + const result = validateReceipt({ planId: 'plan-1' }); + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /currentTask/); + assert.match(result.errors.join('\n'), /nextDerivedAction/); + assert.match(result.errors.join('\n'), /replyClosureState/); +}); + +console.log('continuity.receipt-validator.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,