From b9ccc3d096c081d355f3e1fb20bea7d8a52c98ac Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 24 Apr 2026 17:32:47 +0800 Subject: [PATCH] feat: package continuity plugin MVP docs and receipt store --- hooks/force-recall/HOOK.md | 29 +++ plugins/continuity/README.md | 233 +++++++++++++++-- plugins/continuity/README.zh-TW.md | 235 ++++++++++++++++-- .../src/continuity/receipt-store.mjs | 44 ++++ .../test/continuity.receipt-store.test.mjs | 38 +++ .../continuity.receipt-validator.test.mjs | 44 ++++ 6 files changed, 580 insertions(+), 43 deletions(-) create mode 100644 hooks/force-recall/HOOK.md create mode 100644 plugins/continuity/src/continuity/receipt-store.mjs create mode 100644 plugins/continuity/test/continuity.receipt-store.test.mjs create mode 100644 plugins/continuity/test/continuity.receipt-validator.test.mjs diff --git a/hooks/force-recall/HOOK.md b/hooks/force-recall/HOOK.md new file mode 100644 index 0000000..6722b48 --- /dev/null +++ b/hooks/force-recall/HOOK.md @@ -0,0 +1,29 @@ +--- +name: force-recall +description: "Prepend mandatory RULEBOOK/SOUL recall block before the agent sees inbound messages" +homepage: https://docs.openclaw.ai/automation/hooks +metadata: + { "openclaw": { "emoji": "🧠", "events": ["message:preprocessed"], "always": true } } +--- + +# Force Recall Hook (MVP) + +This hook enforces a **recall gate** by prepending a short, high-salience block to every inbound message *after* media/link enrichment and *before* the agent sees it. + +Goal: **Before any technical action/tooling**, the agent must recall key rules from `docs/RULEBOOK.md` + `SOUL.md`. + +## Behavior + +- Listens on `message:preprocessed` +- Injects a `RECALL_GATE` prefix into `context.bodyForAgent` +- Optional debug: set `OPENCLAW_FORCE_RECALL_DEBUG=1` to append a one-line marker (visible in the agent prompt) + +## Why this MVP + +OpenClaw hooks currently provide reliable interception at the message boundary (`message:preprocessed`). This is the earliest stable point to force rules into the model's working context without patching core. + +## Disable + +```bash +openclaw hooks disable force-recall +``` diff --git a/plugins/continuity/README.md b/plugins/continuity/README.md index 990bb78..2b99d68 100644 --- a/plugins/continuity/README.md +++ b/plugins/continuity/README.md @@ -1,45 +1,167 @@ # Continuity Plugin (MVP) -This package is the skeleton for extracting the current approved-plan continuity hard gate into an installable plugin. +> 中文版:`README.zh-TW.md` -## MVP status +This package extracts the current approved-plan continuity hard gate into a small installable, testable OpenClaw plugin MVP. -- Task 2: package skeleton created -- Task 3: config schema contract scaffolded -- Task 4: config validation tests added -- Task 5: minimal config validator implemented -- Task 6: receipt validator contract extracted -- Plugin evaluator / adapter logic intentionally not implemented yet +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. -## Layout +## 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.md 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 ``` -## Planned public surface +## 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 -See `examples/openclaw.continuity.example.json`. +Start from `examples/openclaw.continuity.example.json`: -## Receipt validator contract +```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" + } + } +} +``` -The MVP receipt validator currently defines this minimum shape: +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` @@ -49,15 +171,84 @@ The MVP receipt validator currently defines this minimum shape: - `childSessionKey` - `replyClosureState` -API surface: +Example from `examples/approved-plan-receipt.example.json`: -- `validateReceipt(receipt)` -- `isValidReceipt(receipt)` +```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" +} +``` -See `src/continuity/types.md` for the extracted contract note. +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 -- Current terminal states preserved by default: `waiting_user`, `blocked`, `pending_verification` -- Default receipt directory target: `state/approved-plan-continuity` -- `npm test` is reserved for the full plugin test suite defined by the implementation plan +- 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 index 57acbab..6ffeb5a 100644 --- a/plugins/continuity/README.zh-TW.md +++ b/plugins/continuity/README.zh-TW.md @@ -1,45 +1,167 @@ # Continuity Plugin(MVP) -這個套件目前是把既有 approved-plan continuity hard gate 抽離成可安裝 plugin 的骨架。 +> English version: `README.md` -## MVP 狀態 +這個套件把目前 approved-plan continuity hard gate 抽離成一個可安裝、可測試、可在 hook 內重用的 OpenClaw plugin MVP。 -- Task 2:已建立 package skeleton -- Task 3:已先放入 config schema contract 骨架 -- Task 4:已補 config validation 測試 -- Task 5:已實作 minimal config validator -- Task 6:已抽出 receipt validator contract -- evaluator / adapter 邏輯目前仍未實作 +目標不是重新發明規則,而是把既有 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.md 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: -請參考 `examples/openclaw.continuity.example.json`。 +- `defaultConfig` +- `cloneDefaultConfig()` +- `validateContinuityConfig()` / `normalizeContinuityConfig()` +- `evaluateContinuity()` / `buildContinuityGateBlock()` +- `validateReceipt()` / `isValidReceipt()` +- `slugifyReceiptSegment()` / `buildReceiptFilename()` / `writeReceipt()` +- `buildApprovedPlanContinuityInput()` +- `createForceRecallContinuityAdapter()` / `runForceRecallContinuityAdapter()` -## Receipt validator contract +## Example config -目前 MVP receipt validator 最小欄位如下: +請從 `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` @@ -49,15 +171,84 @@ plugins/continuity/ - `childSessionKey` - `replyClosureState` -API 介面: +範例可參考 `examples/approved-plan-receipt.example.json`: -- `validateReceipt(receipt)` -- `isValidReceipt(receipt)` +```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" +} +``` -抽出的 contract 說明見 `src/continuity/types.md`。 +若要把 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。 ## 備註 -- 預設保留目前 terminal states:`waiting_user`、`blocked`、`pending_verification` -- 預設 receipt 目錄:`state/approved-plan-continuity` -- `npm test` 先保留給後續依計畫補上的完整測試流程 +- 預設 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/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/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');