diff --git a/hooks/force-recall/handler.ts b/hooks/force-recall/handler.ts index 9c229ff..605f342 100644 --- a/hooks/force-recall/handler.ts +++ b/hooks/force-recall/handler.ts @@ -394,14 +394,37 @@ const continuityAdapterModuleCache = new Map { 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); + try { + const stat = await fs.stat(adapterPath); + const cacheKey = `${adapterPath}?mtimeMs=${stat.mtimeMs}`; + let modulePromise = continuityAdapterModuleCache.get(cacheKey); + + if (!modulePromise) { + modulePromise = import(pathToFileURL(adapterPath).href + `?mtimeMs=${stat.mtimeMs}`).catch(() => null); + continuityAdapterModuleCache.set(cacheKey, modulePromise); + } + + return modulePromise; + } catch { + return null; } +} - return modulePromise; +async function readContinuityPluginConfigOverrides(workspaceDir: string): Promise> { + const defaultsPath = path.join(workspaceDir, "plugins", "continuity", "src", "config", "defaults.mjs"); + const source = await safeReadText(defaultsPath); + if (!source) return {}; + + const forceRecallLabel = source.match(/forceRecall:\s*\{[\s\S]*?injectBlockLabel:\s*['"]([^'"]+)['"]/); + const genericPreflightLabel = source.match(/genericPreflight:\s*\{[\s\S]*?injectBlockLabel:\s*['"]([^'"]+)['"]/); + + return { + adapter: { + forceRecall: forceRecallLabel ? { injectBlockLabel: forceRecallLabel[1] } : {}, + genericPreflight: genericPreflightLabel ? { injectBlockLabel: genericPreflightLabel[1] } : {}, + }, + }; } async function evaluateApprovedPlanContinuityViaPlugin(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise<{ input: Record | null; result: ApprovedPlanContinuityResult | null; block: string; } | null> { @@ -409,10 +432,27 @@ async function evaluateApprovedPlanContinuityViaPlugin(workspaceDir: string, wra const runAdapter = adapterModule?.runForceRecallContinuityAdapter; if (typeof runAdapter !== "function") return null; + const configOverrides = await readContinuityPluginConfigOverrides(workspaceDir); + return runAdapter({ wrapperResult, autoChainPlanResult, - config: adapterModule?.defaultConfig ?? {}, + config: { + ...(adapterModule?.defaultConfig ?? {}), + ...configOverrides, + adapter: { + ...(((adapterModule?.defaultConfig ?? {}) as any)?.adapter ?? {}), + ...((configOverrides as any)?.adapter ?? {}), + forceRecall: { + ...((((adapterModule?.defaultConfig ?? {}) as any)?.adapter?.forceRecall) ?? {}), + ...(((configOverrides as any)?.adapter?.forceRecall) ?? {}), + }, + genericPreflight: { + ...((((adapterModule?.defaultConfig ?? {}) as any)?.adapter?.genericPreflight) ?? {}), + ...(((configOverrides as any)?.adapter?.genericPreflight) ?? {}), + }, + }, + }, }); } diff --git a/plugins/continuity/README.md b/plugins/continuity/README.md index 2b99d68..ee1fcd5 100644 --- a/plugins/continuity/README.md +++ b/plugins/continuity/README.md @@ -1,12 +1,16 @@ -# Continuity Plugin (MVP) +# Continuity Plugin (MVP → generalized checkpoint) > 中文版:`README.zh-TW.md` -This package extracts the current approved-plan continuity hard gate into a small installable, testable OpenClaw plugin MVP. +This package extracts the current approved-plan continuity hard gate into a small installable, testable OpenClaw plugin. -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. +The package still preserves the current approved-plan behavior, but it now moves one step closer to a more general **engine + adapter** structure: -## What this MVP currently provides +- a host-agnostic continuity engine input/output contract +- the existing `force-recall` parity adapter +- a new `generic-preflight` adapter and manual runner for non-`force-recall` integration + +## What this package currently provides - continuity config validation - dispatch receipt contract validation @@ -14,6 +18,8 @@ The goal is not to reinvent workflow policy. The goal is to package the existing - 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 +- a `generic-preflight` adapter that accepts host-agnostic continuity input directly +- a manual preflight runner for workspaces that do not use `force-recall` ## Install location @@ -23,7 +29,7 @@ Recommended location inside an OpenClaw workspace: /plugins/continuity ``` -With the current MVP integration, the related files normally look like this: +Possible integration layouts now include both the original `force-recall` path and a more generic path: ```text / @@ -59,10 +65,12 @@ plugins/continuity/ index.mjs adapters/ force-recall.mjs + generic-preflight.mjs config/ defaults.mjs schema.mjs continuity/ + engine.mjs evaluator.mjs receipt-store.mjs receipt-validator.mjs @@ -80,10 +88,12 @@ plugins/continuity/ - `src/config/schema.mjs` - `src/config/defaults.mjs` +- `src/continuity/engine.mjs` - `src/continuity/evaluator.mjs` - `src/continuity/receipt-validator.mjs` - `src/continuity/receipt-store.mjs` - `src/adapters/force-recall.mjs` +- `src/adapters/generic-preflight.mjs` - `src/index.mjs` `src/index.mjs` currently re-exports: @@ -91,11 +101,42 @@ plugins/continuity/ - `defaultConfig` - `cloneDefaultConfig()` - `validateContinuityConfig()` / `normalizeContinuityConfig()` +- `normalizeContinuityEngineInput()` +- `createContinuityEngineResult()` / `createContinuityEngineContract()` - `evaluateContinuity()` / `buildContinuityGateBlock()` - `validateReceipt()` / `isValidReceipt()` - `slugifyReceiptSegment()` / `buildReceiptFilename()` / `writeReceipt()` - `buildApprovedPlanContinuityInput()` - `createForceRecallContinuityAdapter()` / `runForceRecallContinuityAdapter()` +- `buildGenericContinuityInput()` +- `createGenericPreflightContinuityAdapter()` / `runGenericPreflightContinuityAdapter()` +- `runManualContinuityPreflight()` + +## Host-agnostic engine contract + +The generalized engine accepts a normalized continuity input with fields such as: + +- `planId` +- `currentTask` +- `taskState` +- `nextDerivedAction` +- `replyClosureState` +- `dispatchReceipt` +- `nextTaskKnown` +- `sameApprovedPlan` +- `taskBoundaryStop` +- `highRiskStop` + +Generalized adapters return a common contract: + +- `input` +- `result` +- `evaluation` +- `block` +- `meta.adapterName` +- `meta.hostAgnostic` + +See `src/continuity/types.md` for the concise contract notes. ## Example config @@ -118,6 +159,10 @@ Start from `examples/openclaw.continuity.example.json`: "forceRecall": { "enabled": true, "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" + }, + "genericPreflight": { + "enabled": true, + "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" } } } @@ -125,23 +170,9 @@ Start from `examples/openclaw.continuity.example.json`: Defaults are defined in `src/config/defaults.mjs`. -## Hook integration +## Integration path A: `force-recall` -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: +The original MVP integration point remains `hooks/force-recall/handler.ts`. ```js import plugin from './plugins/continuity/src/index.mjs'; @@ -157,7 +188,48 @@ if (out?.block) { } ``` -If you want a custom injected block label, override `adapter.forceRecall.injectBlockLabel`. +## Integration path B: generic/manual preflight + +If your workspace does **not** use `force-recall`, you can still install and use the plugin by calling the generalized adapter or the manual runner directly. + +### Generic preflight adapter + +```js +import plugin from './plugins/continuity/src/index.mjs'; + +const out = plugin.runGenericPreflightContinuityAdapter({ + config: plugin.defaultConfig, + source: { + planId: 'approved-plan-1', + currentTask: 'task-3', + taskState: 'complete', + nextTaskKnown: true, + sameApprovedPlan: true, + taskBoundaryStop: true, + nextTaskId: 'task-4', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }, +}); +``` + +### Manual runner + +```js +import plugin from './plugins/continuity/src/index.mjs'; + +const out = plugin.runManualContinuityPreflight({ + config: plugin.defaultConfig, + planId: 'approved-plan-1', + currentTask: 'task-3', + taskState: 'complete', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'waiting_user', +}); +``` + +If `out.block` is non-empty, prepend it into the prompt/body seen by the agent. ## Receipt contract @@ -171,24 +243,6 @@ Minimum receipt shape: - `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 @@ -202,51 +256,51 @@ await writeReceipt({ ## Smoke test / verification -At minimum, run: +Required plugin verification: ```bash cd plugins/continuity npm test +node test/continuity.smoke.test.mjs +``` +If your workspace uses `force-recall`, also run: + +```bash 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: +2. Choose one integration path: + - `force-recall`: load `runForceRecallContinuityAdapter(...)` + - no `force-recall`: call `runGenericPreflightContinuityAdapter(...)` or `runManualContinuityPreflight(...)` +3. Adjust config as needed, especially: - `planMatchers` - `legalTerminalStates` - `receiptDir` - `adapter.forceRecall.injectBlockLabel` + - `adapter.genericPreflight.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. +5. Run plugin tests and the relevant workspace smoke path. 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. +- This is still centered on the approved-plan continuity hard gate, not a full general workflow engine. +- The generalized engine contract is intentionally minimal and conservative. +- `force-recall` remains the most battle-tested adapter. - 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` +- The new generic path makes the plugin more reusable even without the `force-recall` hook - `HOOK.md` describes the plugin/hook adapter contract boundary, not the full installation guide ## Chinese documentation diff --git a/plugins/continuity/README.zh-TW.md b/plugins/continuity/README.zh-TW.md index 6ffeb5a..0e3026b 100644 --- a/plugins/continuity/README.zh-TW.md +++ b/plugins/continuity/README.zh-TW.md @@ -1,10 +1,14 @@ -# Continuity Plugin(MVP) +# Continuity Plugin(MVP → generalized checkpoint) > English version: `README.md` -這個套件把目前 approved-plan continuity hard gate 抽離成一個可安裝、可測試、可在 hook 內重用的 OpenClaw plugin MVP。 +這個套件把目前 approved-plan continuity hard gate 抽離成一個可安裝、可測試、可在 hook 內重用的 OpenClaw plugin。 -目標不是重新發明規則,而是把既有 continuity 判斷、receipt contract、force-recall adapter 收斂成一個可以被其他 OpenClaw workspace 直接帶走的最小可用包。 +它仍保留既有 approved-plan 行為,但現在往比較通用的 **engine + adapter** 結構前進了一步: + +- 有 host-agnostic 的 continuity engine input/output contract +- 保留既有 `force-recall` parity adapter +- 新增 `generic-preflight` adapter 與 manual runner,讓未使用 `force-recall` 的 workspace 也能接 ## 目前能做什麼 @@ -14,6 +18,8 @@ - 評估 approved-plan continuity gate - 產生可注入 prompt 的 continuity gate block - 透過 `force-recall` adapter,把 hook 端的 wrapper/planner 結果轉成 continuity input +- 透過 `generic-preflight` adapter,直接吃 host-agnostic continuity input +- 透過 manual preflight runner,在沒有 `force-recall` 的情況下也能直接呼叫 ## 安裝位置 @@ -23,7 +29,7 @@ /plugins/continuity ``` -以目前 MVP 慣例,相關檔案位置如下: +現在可支援兩類整合路徑:原本的 `force-recall` 路徑,以及較通用的 generic path。 ```text / @@ -59,10 +65,12 @@ plugins/continuity/ index.mjs adapters/ force-recall.mjs + generic-preflight.mjs config/ defaults.mjs schema.mjs continuity/ + engine.mjs evaluator.mjs receipt-store.mjs receipt-validator.mjs @@ -80,10 +88,12 @@ plugins/continuity/ - `src/config/schema.mjs` - `src/config/defaults.mjs` +- `src/continuity/engine.mjs` - `src/continuity/evaluator.mjs` - `src/continuity/receipt-validator.mjs` - `src/continuity/receipt-store.mjs` - `src/adapters/force-recall.mjs` +- `src/adapters/generic-preflight.mjs` - `src/index.mjs` `src/index.mjs` 目前會 re-export: @@ -91,11 +101,42 @@ plugins/continuity/ - `defaultConfig` - `cloneDefaultConfig()` - `validateContinuityConfig()` / `normalizeContinuityConfig()` +- `normalizeContinuityEngineInput()` +- `createContinuityEngineResult()` / `createContinuityEngineContract()` - `evaluateContinuity()` / `buildContinuityGateBlock()` - `validateReceipt()` / `isValidReceipt()` - `slugifyReceiptSegment()` / `buildReceiptFilename()` / `writeReceipt()` - `buildApprovedPlanContinuityInput()` - `createForceRecallContinuityAdapter()` / `runForceRecallContinuityAdapter()` +- `buildGenericContinuityInput()` +- `createGenericPreflightContinuityAdapter()` / `runGenericPreflightContinuityAdapter()` +- `runManualContinuityPreflight()` + +## Host-agnostic engine contract + +generalized engine 會吃一個正規化後的 continuity input,常用欄位包括: + +- `planId` +- `currentTask` +- `taskState` +- `nextDerivedAction` +- `replyClosureState` +- `dispatchReceipt` +- `nextTaskKnown` +- `sameApprovedPlan` +- `taskBoundaryStop` +- `highRiskStop` + +generalized adapter 會回傳共同 contract: + +- `input` +- `result` +- `evaluation` +- `block` +- `meta.adapterName` +- `meta.hostAgnostic` + +精簡契約請見 `src/continuity/types.md`。 ## Example config @@ -118,6 +159,10 @@ plugins/continuity/ "forceRecall": { "enabled": true, "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" + }, + "genericPreflight": { + "enabled": true, + "injectBlockLabel": "APPROVED_PLAN_CONTINUITY_GATE" } } } @@ -125,23 +170,9 @@ plugins/continuity/ 預設值定義在 `src/config/defaults.mjs`。 -## Hook 接法 +## 整合路徑 A:`force-recall` -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]` - -最小接法示意: +原本的 MVP 整合點仍是 `hooks/force-recall/handler.ts`。 ```js import plugin from './plugins/continuity/src/index.mjs'; @@ -157,7 +188,48 @@ if (out?.block) { } ``` -若要覆蓋 block label,可改 `adapter.forceRecall.injectBlockLabel`。 +## 整合路徑 B:generic / manual preflight + +如果你的 workspace **沒有** 使用 `force-recall`,現在也可以安裝這個 plugin,直接呼叫 generalized adapter 或 manual runner。 + +### Generic preflight adapter + +```js +import plugin from './plugins/continuity/src/index.mjs'; + +const out = plugin.runGenericPreflightContinuityAdapter({ + config: plugin.defaultConfig, + source: { + planId: 'approved-plan-1', + currentTask: 'task-3', + taskState: 'complete', + nextTaskKnown: true, + sameApprovedPlan: true, + taskBoundaryStop: true, + nextTaskId: 'task-4', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }, +}); +``` + +### Manual runner + +```js +import plugin from './plugins/continuity/src/index.mjs'; + +const out = plugin.runManualContinuityPreflight({ + config: plugin.defaultConfig, + planId: 'approved-plan-1', + currentTask: 'task-3', + taskState: 'complete', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'waiting_user', +}); +``` + +若 `out.block` 非空,就把它 prepend 到 agent 會看到的 prompt/body。 ## Receipt contract @@ -171,24 +243,6 @@ if (out?.block) { - `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 @@ -202,51 +256,51 @@ await writeReceipt({ ## Smoke test / 驗證 -至少執行以下驗證: +plugin 本體必要驗證: ```bash cd plugins/continuity npm test +node test/continuity.smoke.test.mjs +``` +若你的 workspace 有使用 `force-recall`,再補跑: + +```bash 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`。 +2. 選一條整合路徑: + - `force-recall`:載入 `runForceRecallContinuityAdapter(...)` + - 沒有 `force-recall`:呼叫 `runGenericPreflightContinuityAdapter(...)` 或 `runManualContinuityPreflight(...)` 3. 視需要調整 continuity config,至少確認: - `planMatchers` - `legalTerminalStates` - `receiptDir` - `adapter.forceRecall.injectBlockLabel` + - `adapter.genericPreflight.injectBlockLabel` 4. 若你的 dispatch 流程會產生 child run/session,請同步寫出 receipt。 -5. 跑 smoke test 與 hook preflight 測試。 +5. 跑 plugin 測試與對應 workspace smoke path。 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 安裝器/註冊流程文件。 +- 它仍以 approved-plan continuity hard gate 為中心,不是完整通用 workflow engine。 +- generalized engine contract 目前刻意保持最小且保守。 +- `force-recall` 仍是目前最成熟的 adapter。 - 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 一致 +- 新增 generic path 後,即使沒有 `force-recall` hook,也比較能重用這個 plugin - `HOOK.md` 說明的是 plugin/hook adapter 契約定位,不是完整安裝說明 ## 英文文件 diff --git a/plugins/continuity/src/adapters/force-recall.mjs b/plugins/continuity/src/adapters/force-recall.mjs index 7392db8..ab6bb73 100644 --- a/plugins/continuity/src/adapters/force-recall.mjs +++ b/plugins/continuity/src/adapters/force-recall.mjs @@ -1,3 +1,7 @@ +import { + normalizeContinuityEngineInput, + createContinuityEngineContract, +} from '../continuity/engine.mjs'; import { evaluateContinuity, buildContinuityGateBlock } from '../continuity/evaluator.mjs'; function isNonEmptyString(value) { @@ -29,7 +33,7 @@ export function buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanRes const taskBoundaryStop = wrapperResult?.taskBoundaryStop === true || replyClosureState === 'completed'; const highRiskStop = wrapperResult?.highRiskStop === true; - return { + return normalizeContinuityEngineInput({ planId: wrapperResult?.planId ?? 'hook-preflight-approved-plan', currentTask: wrapperResult?.currentTask ?? wrapperResult?.requiredNextAction ?? 'hook-preflight-task', taskState: wrapperResult?.taskState ?? (plannerDerivedAction ? 'complete' : null), @@ -40,7 +44,11 @@ export function buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanRes sameApprovedPlan, taskBoundaryStop, highRiskStop, - }; + metadata: { + adapterSource: 'force-recall', + classification: wrapperResult?.classification ?? null, + }, + }); } export function createForceRecallContinuityAdapter(config = {}) { @@ -50,10 +58,22 @@ export function createForceRecallContinuityAdapter(config = {}) { 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 }; + if (!input) { + return createContinuityEngineContract({ + input: null, + evaluation: null, + block: '', + options: { adapterName: 'force-recall', label }, + }); + } + const evaluation = evaluateContinuity(input, { legalTerminalStates }); + const block = buildContinuityGateBlock(evaluation, { legalTerminalStates, label }); + return createContinuityEngineContract({ + input, + evaluation, + block, + options: { adapterName: 'force-recall', label }, + }); }, }; } diff --git a/plugins/continuity/src/adapters/generic-preflight.mjs b/plugins/continuity/src/adapters/generic-preflight.mjs new file mode 100644 index 0000000..837830f --- /dev/null +++ b/plugins/continuity/src/adapters/generic-preflight.mjs @@ -0,0 +1,98 @@ +import { + normalizeContinuityEngineInput, + createContinuityEngineContract, +} from '../continuity/engine.mjs'; +import { evaluateContinuity, buildContinuityGateBlock } from '../continuity/evaluator.mjs'; + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function buildGenericContinuityInput(source = {}) { + const normalized = normalizeContinuityEngineInput(source); + + if (!normalized.planId || !normalized.currentTask) { + return null; + } + + return normalized; +} + +export function createGenericPreflightContinuityAdapter(config = {}) { + const legalTerminalStates = config?.legalTerminalStates; + const label = config?.adapter?.genericPreflight?.injectBlockLabel + ?? config?.adapter?.forceRecall?.injectBlockLabel + ?? 'APPROVED_PLAN_CONTINUITY_GATE'; + + return { + evaluate(source = {}) { + const input = buildGenericContinuityInput(source); + if (!input) { + return createContinuityEngineContract({ + input: null, + evaluation: null, + block: '', + options: { adapterName: 'generic-preflight', label }, + }); + } + + const evaluation = evaluateContinuity(input, { legalTerminalStates }); + const block = buildContinuityGateBlock(evaluation, { legalTerminalStates, label }); + return createContinuityEngineContract({ + input, + evaluation, + block, + options: { adapterName: 'generic-preflight', label }, + }); + }, + }; +} + +export function runGenericPreflightContinuityAdapter({ source = {}, config = {} } = {}) { + return createGenericPreflightContinuityAdapter(config).evaluate(source); +} + +export function runManualContinuityPreflight({ + config = {}, + planId, + currentTask, + taskState = null, + nextDerivedAction = null, + derivedAction = null, + replyClosureState = null, + dispatchReceipt = null, + nextTaskKnown = false, + sameApprovedPlan = false, + taskBoundaryStop = false, + highRiskStop = false, + nextTaskId = null, + nextTaskKey = null, + metadata = {}, +} = {}) { + return runGenericPreflightContinuityAdapter({ + config, + source: { + planId, + currentTask, + taskState, + nextDerivedAction: nextDerivedAction ?? derivedAction, + derivedAction: derivedAction ?? nextDerivedAction, + replyClosureState, + dispatchReceipt, + nextTaskKnown, + sameApprovedPlan, + taskBoundaryStop, + highRiskStop, + nextTaskId, + nextTaskKey, + metadata, + }, + }); +} + +export default { + buildGenericContinuityInput, + createGenericPreflightContinuityAdapter, + runGenericPreflightContinuityAdapter, + runManualContinuityPreflight, +}; diff --git a/plugins/continuity/src/config/defaults.mjs b/plugins/continuity/src/config/defaults.mjs index 0ed8484..d42555f 100644 --- a/plugins/continuity/src/config/defaults.mjs +++ b/plugins/continuity/src/config/defaults.mjs @@ -11,6 +11,10 @@ export const defaultConfig = Object.freeze({ enabled: true, injectBlockLabel: 'APPROVED_PLAN_CONTINUITY_GATE', }, + genericPreflight: { + enabled: true, + injectBlockLabel: 'APPROVED_PLAN_CONTINUITY_GATE', + }, }, }); diff --git a/plugins/continuity/src/config/schema.mjs b/plugins/continuity/src/config/schema.mjs index f6f069d..2ea3f51 100644 --- a/plugins/continuity/src/config/schema.mjs +++ b/plugins/continuity/src/config/schema.mjs @@ -13,6 +13,10 @@ export const continuityConfigSchema = Object.freeze({ enabled: 'boolean', injectBlockLabel: 'string', }, + genericPreflight: { + enabled: 'boolean', + injectBlockLabel: 'string', + }, }, }); @@ -27,8 +31,8 @@ const TOP_LEVEL_KEYS = new Set([ 'adapter', ]); -const ADAPTER_KEYS = new Set(['forceRecall']); -const FORCE_RECALL_KEYS = new Set(['enabled', 'injectBlockLabel']); +const ADAPTER_KEYS = new Set(['forceRecall', 'genericPreflight']); +const ADAPTER_CONFIG_KEYS = new Set(['enabled', 'injectBlockLabel']); function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -59,6 +63,35 @@ function validateStringArray(errors, value, fieldName) { }); } +function normalizeAdapterConfig(baseAdapterConfig, inputAdapterConfig) { + return { + ...baseAdapterConfig, + ...(isPlainObject(inputAdapterConfig) ? inputAdapterConfig : {}), + injectBlockLabel: typeof inputAdapterConfig?.injectBlockLabel === 'string' + ? inputAdapterConfig.injectBlockLabel.trim() + : baseAdapterConfig.injectBlockLabel, + }; +} + +function validateNamedAdapter(errors, adapterInput, adapterKey) { + if (!(adapterKey in adapterInput)) return; + + if (!isPlainObject(adapterInput[adapterKey])) { + errors.push(`adapter.${adapterKey}: expected object`); + return; + } + + pushUnknownKeyErrors(errors, adapterInput[adapterKey], ADAPTER_CONFIG_KEYS, `adapter.${adapterKey}.`); + + if ('enabled' in adapterInput[adapterKey] && typeof adapterInput[adapterKey].enabled !== 'boolean') { + errors.push(`adapter.${adapterKey}.enabled: expected boolean`); + } + + if ('injectBlockLabel' in adapterInput[adapterKey] && !isNonEmptyString(adapterInput[adapterKey].injectBlockLabel)) { + errors.push(`adapter.${adapterKey}.injectBlockLabel: expected non-empty string`); + } +} + export function normalizeContinuityConfig(input = {}) { const base = cloneDefaultConfig(); @@ -79,17 +112,11 @@ export function normalizeContinuityConfig(input = {}) { adapter: { ...base.adapter, ...(isPlainObject(input.adapter) ? input.adapter : {}), - forceRecall: { - ...base.adapter.forceRecall, - ...(isPlainObject(input.adapter?.forceRecall) ? input.adapter.forceRecall : {}), - }, + forceRecall: normalizeAdapterConfig(base.adapter.forceRecall, input.adapter?.forceRecall), + genericPreflight: normalizeAdapterConfig(base.adapter.genericPreflight, input.adapter?.genericPreflight), }, }; - if (typeof normalized.adapter.forceRecall.injectBlockLabel === 'string') { - normalized.adapter.forceRecall.injectBlockLabel = normalized.adapter.forceRecall.injectBlockLabel.trim(); - } - return normalized; } @@ -140,25 +167,8 @@ export function validateContinuityConfig(input = {}) { 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'); - } - } - } + validateNamedAdapter(errors, input.adapter, 'forceRecall'); + validateNamedAdapter(errors, input.adapter, 'genericPreflight'); } } diff --git a/plugins/continuity/src/continuity/engine.mjs b/plugins/continuity/src/continuity/engine.mjs new file mode 100644 index 0000000..4ec1167 --- /dev/null +++ b/plugins/continuity/src/continuity/engine.mjs @@ -0,0 +1,66 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function normalizeContinuityEngineInput(input = {}) { + if (!isPlainObject(input)) return {}; + + const normalized = { + planId: isNonEmptyString(input.planId) ? input.planId.trim() : input.planId ?? null, + currentTask: isNonEmptyString(input.currentTask) ? input.currentTask.trim() : input.currentTask ?? null, + taskState: isNonEmptyString(input.taskState) ? input.taskState.trim() : input.taskState ?? null, + nextTaskId: isNonEmptyString(input.nextTaskId) ? input.nextTaskId.trim() : input.nextTaskId ?? null, + nextTaskKey: isNonEmptyString(input.nextTaskKey) ? input.nextTaskKey.trim() : input.nextTaskKey ?? null, + nextDerivedAction: input.nextDerivedAction ?? input.derivedAction ?? null, + derivedAction: input.derivedAction ?? input.nextDerivedAction ?? null, + replyClosureState: isNonEmptyString(input.replyClosureState) ? input.replyClosureState.trim() : input.replyClosureState ?? null, + dispatchReceipt: input.dispatchReceipt ?? null, + nextTaskKnown: input.nextTaskKnown === true, + sameApprovedPlan: input.sameApprovedPlan === true, + taskBoundaryStop: input.taskBoundaryStop === true, + highRiskStop: input.highRiskStop === true, + metadata: isPlainObject(input.metadata) ? { ...input.metadata } : {}, + }; + + return normalized; +} + +export function createContinuityEngineResult(input, evaluation, options = {}) { + const label = isNonEmptyString(options.label) ? options.label.trim() : 'APPROVED_PLAN_CONTINUITY_GATE'; + + return { + input, + evaluation, + label, + ok: evaluation?.ok === true, + status: evaluation?.status ?? 'pass', + verdict: evaluation?.verdict ?? 'pass', + reason: evaluation?.reason ?? null, + }; +} + +export function createContinuityEngineContract({ input, evaluation, block, options = {} }) { + const result = createContinuityEngineResult(input, evaluation, options); + + return { + input, + result, + evaluation, + block: typeof block === 'string' ? block : '', + meta: { + adapterName: options.adapterName ?? 'unknown', + label: result.label, + hostAgnostic: true, + }, + }; +} + +export default { + normalizeContinuityEngineInput, + createContinuityEngineResult, + createContinuityEngineContract, +}; diff --git a/plugins/continuity/src/continuity/types.md b/plugins/continuity/src/continuity/types.md index 0af2f78..91b11dd 100644 --- a/plugins/continuity/src/continuity/types.md +++ b/plugins/continuity/src/continuity/types.md @@ -1,4 +1,41 @@ -# Continuity Types (MVP) +# Continuity Types + +## Host-agnostic continuity engine input + +The generalized engine operates on a host-agnostic input object. Adapters are responsible for mapping host or hook specific context into this shape. + +Minimum practical fields: + +- `planId`: string +- `currentTask`: string +- `taskState`: string | null +- `nextDerivedAction`: object | null +- `replyClosureState`: string | null +- `dispatchReceipt`: object | null +- `nextTaskKnown`: boolean +- `sameApprovedPlan`: boolean +- `taskBoundaryStop`: boolean +- `highRiskStop`: boolean + +Optional fields: + +- `nextTaskId`: string | null +- `nextTaskKey`: string | null +- `derivedAction`: object | null +- `metadata`: object + +Normalization entrypoint: + +- `normalizeContinuityEngineInput(input)` + +Engine contract returned by generalized adapters: + +- `input`: normalized engine input or `null` +- `result`: summarized engine result object +- `evaluation`: raw evaluator result or `null` +- `block`: injected prompt block string +- `meta.adapterName`: adapter identifier +- `meta.hostAgnostic`: always `true` ## Receipt contract @@ -20,4 +57,6 @@ The MVP receipt validator contract uses this minimum shape: ## 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. +- The engine is host-agnostic; host-specific behavior belongs in adapters. +- `force-recall` remains the parity adapter for the current hook path. +- `generic-preflight` is the minimal generalized adapter/runner for non-`force-recall` integration. diff --git a/plugins/continuity/src/index.mjs b/plugins/continuity/src/index.mjs index 0f34656..6938fd4 100644 --- a/plugins/continuity/src/index.mjs +++ b/plugins/continuity/src/index.mjs @@ -4,6 +4,11 @@ import { validateContinuityConfig, normalizeContinuityConfig, } from './config/schema.mjs'; +import { + normalizeContinuityEngineInput, + createContinuityEngineResult, + createContinuityEngineContract, +} from './continuity/engine.mjs'; import { evaluateContinuity, buildContinuityGateBlock, @@ -24,6 +29,12 @@ import { createForceRecallContinuityAdapter, runForceRecallContinuityAdapter, } from './adapters/force-recall.mjs'; +import { + buildGenericContinuityInput, + createGenericPreflightContinuityAdapter, + runGenericPreflightContinuityAdapter, + runManualContinuityPreflight, +} from './adapters/generic-preflight.mjs'; export { defaultConfig, @@ -31,6 +42,9 @@ export { continuityConfigSchema, validateContinuityConfig, normalizeContinuityConfig, + normalizeContinuityEngineInput, + createContinuityEngineResult, + createContinuityEngineContract, evaluateContinuity, buildContinuityGateBlock, hasValidDispatchReceipt, @@ -43,6 +57,10 @@ export { buildApprovedPlanContinuityInput, createForceRecallContinuityAdapter, runForceRecallContinuityAdapter, + buildGenericContinuityInput, + createGenericPreflightContinuityAdapter, + runGenericPreflightContinuityAdapter, + runManualContinuityPreflight, }; export default { @@ -51,6 +69,9 @@ export default { continuityConfigSchema, validateContinuityConfig, normalizeContinuityConfig, + normalizeContinuityEngineInput, + createContinuityEngineResult, + createContinuityEngineContract, evaluateContinuity, buildContinuityGateBlock, hasValidDispatchReceipt, @@ -63,4 +84,8 @@ export default { buildApprovedPlanContinuityInput, createForceRecallContinuityAdapter, runForceRecallContinuityAdapter, + buildGenericContinuityInput, + createGenericPreflightContinuityAdapter, + runGenericPreflightContinuityAdapter, + runManualContinuityPreflight, }; diff --git a/plugins/continuity/test/continuity.config.test.mjs b/plugins/continuity/test/continuity.config.test.mjs index de1c934..6cc221f 100644 --- a/plugins/continuity/test/continuity.config.test.mjs +++ b/plugins/continuity/test/continuity.config.test.mjs @@ -52,6 +52,19 @@ test('normalizes missing fields from defaults', () => { assert.equal(normalized.receiptDir, defaultConfig.receiptDir); assert.deepEqual(normalized.legalTerminalStates, defaultConfig.legalTerminalStates); assert.notEqual(normalized.legalTerminalStates, defaultConfig.legalTerminalStates); + assert.equal(normalized.adapter.genericPreflight.enabled, true); +}); + +test('normalizes generic preflight adapter block label', () => { + const normalized = normalizeContinuityConfig({ + adapter: { + genericPreflight: { + injectBlockLabel: ' CUSTOM_GENERIC_GATE ', + }, + }, + }); + + assert.equal(normalized.adapter.genericPreflight.injectBlockLabel, 'CUSTOM_GENERIC_GATE'); }); test('rejects non-array legalTerminalStates', () => { @@ -94,6 +107,17 @@ test('rejects malformed adapter.forceRecall shape', () => { assert.match(result.errors.join('\n'), /adapter\.forceRecall/); }); +test('rejects malformed adapter.genericPreflight shape', () => { + const result = validateContinuityConfig({ + adapter: { + genericPreflight: false, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.genericPreflight/); +}); + test('rejects malformed adapter.forceRecall.enabled type', () => { const result = validateContinuityConfig({ adapter: { @@ -120,6 +144,32 @@ test('rejects malformed adapter.forceRecall.injectBlockLabel type', () => { assert.match(result.errors.join('\n'), /injectBlockLabel/); }); +test('rejects malformed adapter.genericPreflight.enabled type', () => { + const result = validateContinuityConfig({ + adapter: { + genericPreflight: { + enabled: 'yes', + }, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.genericPreflight\.enabled/); +}); + +test('rejects malformed adapter.genericPreflight.injectBlockLabel type', () => { + const result = validateContinuityConfig({ + adapter: { + genericPreflight: { + injectBlockLabel: 42, + }, + }, + }); + + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /adapter\.genericPreflight\.injectBlockLabel/); +}); + test('rejects unknown top-level key', () => { const result = validateContinuityConfig({ unexpected: true, diff --git a/plugins/continuity/test/continuity.plugin.test.mjs b/plugins/continuity/test/continuity.plugin.test.mjs index 31a701e..c11e647 100644 --- a/plugins/continuity/test/continuity.plugin.test.mjs +++ b/plugins/continuity/test/continuity.plugin.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import plugin, { createForceRecallContinuityAdapter, + createGenericPreflightContinuityAdapter, defaultConfig, evaluateContinuity, + runManualContinuityPreflight, } from '../src/index.mjs'; function test(name, fn) { @@ -19,6 +21,8 @@ test('index exports plugin surface', () => { assert.equal(plugin.name, '@openclaw/plugin-continuity'); assert.equal(typeof evaluateContinuity, 'function'); assert.equal(defaultConfig.adapter.forceRecall.enabled, true); + assert.equal(defaultConfig.adapter.genericPreflight.enabled, true); + assert.equal(typeof plugin.runGenericPreflightContinuityAdapter, 'function'); }); test('adapter preserves current hook parity for plain wrapper next-action mapping', () => { @@ -36,6 +40,8 @@ test('adapter preserves current hook parity for plain wrapper next-action mappin }); assert.equal(out.result.ok, true); + assert.equal(out.meta.adapterName, 'force-recall'); + assert.equal(out.meta.hostAgnostic, true); assert.match(out.block, /status=pass/); }); @@ -60,4 +66,41 @@ test('adapter fails when planner-derived auto-next boundary exists without dispa assert.match(out.block, /continuity_failure/); }); +test('generic preflight adapter evaluates host-agnostic source payload', () => { + const adapter = createGenericPreflightContinuityAdapter(defaultConfig); + const out = adapter.evaluate({ + planId: 'plan-generic', + currentTask: 'task-generic', + taskState: 'complete', + nextTaskKnown: true, + sameApprovedPlan: true, + taskBoundaryStop: true, + nextTaskId: 'task-next', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }); + + assert.equal(out.result.ok, false); + assert.equal(out.result.reason, 'missing_auto_next_dispatch'); + assert.equal(out.meta.adapterName, 'generic-preflight'); + assert.equal(out.meta.hostAgnostic, true); + assert.equal(out.input.planId, 'plan-generic'); +}); + +test('manual continuity preflight runner works without force-recall hook', () => { + const out = runManualContinuityPreflight({ + config: defaultConfig, + planId: 'plan-manual', + currentTask: 'task-manual', + taskState: 'complete', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'waiting_user', + }); + + assert.equal(out.result.ok, true); + assert.match(out.block, /APPROVED_PLAN_CONTINUITY_GATE/); + assert.equal(out.meta.adapterName, 'generic-preflight'); +}); + console.log('continuity.plugin.test.mjs PASS'); diff --git a/plugins/continuity/test/continuity.smoke.test.mjs b/plugins/continuity/test/continuity.smoke.test.mjs index 2b8bd7d..607f1dd 100644 --- a/plugins/continuity/test/continuity.smoke.test.mjs +++ b/plugins/continuity/test/continuity.smoke.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import plugin, { runForceRecallContinuityAdapter, + runGenericPreflightContinuityAdapter, validateContinuityConfig, } from '../src/index.mjs'; @@ -25,4 +26,26 @@ const smoke = runForceRecallContinuityAdapter({ assert.equal(smoke.result.ok, false); assert.equal(smoke.result.reason, 'missing_auto_next_dispatch'); assert.match(smoke.block, /APPROVED_PLAN_CONTINUITY_GATE/); +assert.equal(smoke.meta.adapterName, 'force-recall'); + +const genericSmoke = runGenericPreflightContinuityAdapter({ + config: plugin.defaultConfig, + source: { + planId: 'plan-generic-smoke', + currentTask: 'task-9', + taskState: 'complete', + nextTaskKnown: true, + sameApprovedPlan: true, + taskBoundaryStop: true, + nextTaskId: 'task-10', + nextDerivedAction: { type: 'message_subagent', task: 'continue' }, + replyClosureState: 'completed', + dispatchReceipt: null, + }, +}); + +assert.equal(genericSmoke.result.ok, false); +assert.equal(genericSmoke.result.reason, 'missing_auto_next_dispatch'); +assert.match(genericSmoke.block, /APPROVED_PLAN_CONTINUITY_GATE/); +assert.equal(genericSmoke.meta.adapterName, 'generic-preflight'); console.log('continuity.smoke.test.mjs PASS');