feat: export continuity plugin MVP packaging
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
@@ -39,6 +40,19 @@ type ApprovedPlanContinuityResult = {
|
|||||||
gate?: string;
|
gate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ForceRecallContinuityAdapterModule = {
|
||||||
|
defaultConfig?: Record<string, unknown>;
|
||||||
|
runForceRecallContinuityAdapter?: (args: {
|
||||||
|
wrapperResult: any;
|
||||||
|
autoChainPlanResult?: AutoChainPlanResult | null;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
input: Record<string, unknown> | null;
|
||||||
|
result: ApprovedPlanContinuityResult | null;
|
||||||
|
block: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function clamp(s: string, max = 1200): string {
|
function clamp(s: string, max = 1200): string {
|
||||||
if (!s) return s;
|
if (!s) return s;
|
||||||
if (s.length <= max) return s;
|
if (s.length <= max) return s;
|
||||||
@@ -376,14 +390,45 @@ function buildApprovedPlanContinuityInput(wrapperResult: any, autoChainPlanResul
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const continuityAdapterModuleCache = new Map<string, Promise<ForceRecallContinuityAdapterModule | null>>();
|
||||||
|
|
||||||
|
async function loadForceRecallContinuityAdapterModule(workspaceDir: string): Promise<ForceRecallContinuityAdapterModule | null> {
|
||||||
|
const adapterPath = path.join(workspaceDir, "plugins", "continuity", "src", "index.mjs");
|
||||||
|
let modulePromise = continuityAdapterModuleCache.get(adapterPath);
|
||||||
|
|
||||||
|
if (!modulePromise) {
|
||||||
|
modulePromise = import(pathToFileURL(adapterPath).href).catch(() => null);
|
||||||
|
continuityAdapterModuleCache.set(adapterPath, modulePromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
return modulePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateApprovedPlanContinuityViaPlugin(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise<{ input: Record<string, unknown> | null; result: ApprovedPlanContinuityResult | null; block: string; } | null> {
|
||||||
|
const adapterModule = await loadForceRecallContinuityAdapterModule(workspaceDir);
|
||||||
|
const runAdapter = adapterModule?.runForceRecallContinuityAdapter;
|
||||||
|
if (typeof runAdapter !== "function") return null;
|
||||||
|
|
||||||
|
return runAdapter({
|
||||||
|
wrapperResult,
|
||||||
|
autoChainPlanResult,
|
||||||
|
config: adapterModule?.defaultConfig ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function runApprovedPlanContinuityGate(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise<ApprovedPlanContinuityResult | null> {
|
async function runApprovedPlanContinuityGate(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null): Promise<ApprovedPlanContinuityResult | null> {
|
||||||
|
const evaluated = await evaluateApprovedPlanContinuityViaPlugin(workspaceDir, wrapperResult, autoChainPlanResult);
|
||||||
|
if (evaluated) return evaluated.result;
|
||||||
|
|
||||||
const continuityPath = path.join(workspaceDir, "scripts", "approved_plan_continuity_gate.mjs");
|
const continuityPath = path.join(workspaceDir, "scripts", "approved_plan_continuity_gate.mjs");
|
||||||
const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult);
|
const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult);
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
return runJsonScript(continuityPath, workspaceDir, input, APPROVED_PLAN_CONTINUITY_TIMEOUT_MS);
|
return runJsonScript(continuityPath, workspaceDir, input, APPROVED_PLAN_CONTINUITY_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApprovedPlanContinuityBlock(result: ApprovedPlanContinuityResult | null): string {
|
async function buildApprovedPlanContinuityBlock(workspaceDir: string, wrapperResult: any, autoChainPlanResult: AutoChainPlanResult | null, result: ApprovedPlanContinuityResult | null): Promise<string> {
|
||||||
|
const evaluated = await evaluateApprovedPlanContinuityViaPlugin(workspaceDir, wrapperResult, autoChainPlanResult);
|
||||||
|
if (evaluated?.block) return evaluated.block;
|
||||||
if (!result) return "";
|
if (!result) return "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -583,7 +628,7 @@ const forceRecall = async (event: any) => {
|
|||||||
|
|
||||||
const gateLockBlock = buildGateLockBlock(gateLockResult);
|
const gateLockBlock = buildGateLockBlock(gateLockResult);
|
||||||
const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
|
const autoChainPlanBlock = buildAutoChainPlanBlock(autoChainPlanResult);
|
||||||
const approvedPlanContinuityBlock = buildApprovedPlanContinuityBlock(approvedPlanContinuityResult);
|
const approvedPlanContinuityBlock = await buildApprovedPlanContinuityBlock(workspaceDir, wrapperResult, autoChainPlanResult, approvedPlanContinuityResult);
|
||||||
|
|
||||||
const recallBlock = [
|
const recallBlock = [
|
||||||
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
"[RECALL_GATE] Mandatory recall before ANY technical action/tool use.",
|
||||||
|
|||||||
20
plugins/continuity/HOOK.md
Normal file
20
plugins/continuity/HOOK.md
Normal file
@@ -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
|
||||||
254
plugins/continuity/README.md
Normal file
254
plugins/continuity/README.md
Normal file
@@ -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
|
||||||
|
<workspace>/plugins/continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
With the current MVP integration, the related files normally look like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace>/
|
||||||
|
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.
|
||||||
254
plugins/continuity/README.zh-TW.md
Normal file
254
plugins/continuity/README.zh-TW.md
Normal file
@@ -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
|
||||||
|
<workspace>/plugins/continuity
|
||||||
|
```
|
||||||
|
|
||||||
|
以目前 MVP 慣例,相關檔案位置如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace>/
|
||||||
|
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`。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
21
plugins/continuity/examples/openclaw.continuity.example.json
Normal file
21
plugins/continuity/examples/openclaw.continuity.example.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
plugins/continuity/package.json
Normal file
13
plugins/continuity/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
63
plugins/continuity/src/adapters/force-recall.mjs
Normal file
63
plugins/continuity/src/adapters/force-recall.mjs
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
21
plugins/continuity/src/config/defaults.mjs
Normal file
21
plugins/continuity/src/config/defaults.mjs
Normal file
@@ -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;
|
||||||
176
plugins/continuity/src/config/schema.mjs
Normal file
176
plugins/continuity/src/config/schema.mjs
Normal file
@@ -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;
|
||||||
120
plugins/continuity/src/continuity/evaluator.mjs
Normal file
120
plugins/continuity/src/continuity/evaluator.mjs
Normal file
@@ -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');
|
||||||
|
}
|
||||||
44
plugins/continuity/src/continuity/receipt-store.mjs
Normal file
44
plugins/continuity/src/continuity/receipt-store.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
71
plugins/continuity/src/continuity/receipt-validator.mjs
Normal file
71
plugins/continuity/src/continuity/receipt-validator.mjs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
plugins/continuity/src/continuity/types.md
Normal file
23
plugins/continuity/src/continuity/types.md
Normal file
@@ -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.
|
||||||
66
plugins/continuity/src/index.mjs
Normal file
66
plugins/continuity/src/index.mjs
Normal file
@@ -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,
|
||||||
|
};
|
||||||
1
plugins/continuity/test/.gitkeep
Normal file
1
plugins/continuity/test/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
133
plugins/continuity/test/continuity.config.test.mjs
Normal file
133
plugins/continuity/test/continuity.config.test.mjs
Normal file
@@ -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');
|
||||||
97
plugins/continuity/test/continuity.evaluator.test.mjs
Normal file
97
plugins/continuity/test/continuity.evaluator.test.mjs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildContinuityGateBlock,
|
||||||
|
evaluateContinuity,
|
||||||
|
hasValidDispatchReceipt,
|
||||||
|
receiptMatchesPayload,
|
||||||
|
} from '../src/continuity/evaluator.mjs';
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(`ok - ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`not ok - ${name}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validReceipt = {
|
||||||
|
planId: 'plan-1',
|
||||||
|
currentTask: 'task-7',
|
||||||
|
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
|
||||||
|
dispatchedAt: '2026-04-24T09:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('recognizes minimum valid dispatch receipt', () => {
|
||||||
|
assert.equal(hasValidDispatchReceipt(validReceipt), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches payload against valid receipt', () => {
|
||||||
|
const payload = {
|
||||||
|
planId: 'plan-1',
|
||||||
|
currentTask: 'task-7',
|
||||||
|
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(receiptMatchesPayload(payload, validReceipt), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails when completion has next action and no receipt', () => {
|
||||||
|
const result = evaluateContinuity({
|
||||||
|
planId: 'plan-1',
|
||||||
|
currentTask: 'task-7',
|
||||||
|
taskState: 'complete',
|
||||||
|
nextDerivedAction: { type: 'message_subagent' },
|
||||||
|
replyClosureState: 'completed',
|
||||||
|
dispatchReceipt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.reason, 'missing_dispatch_receipt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails auto-next boundary without dispatch', () => {
|
||||||
|
const result = evaluateContinuity({
|
||||||
|
planId: 'plan-1',
|
||||||
|
currentTask: 'task-8',
|
||||||
|
taskState: 'complete',
|
||||||
|
nextTaskKnown: true,
|
||||||
|
sameApprovedPlan: true,
|
||||||
|
taskBoundaryStop: true,
|
||||||
|
nextTaskId: 'task-9',
|
||||||
|
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
|
||||||
|
replyClosureState: 'completed',
|
||||||
|
dispatchReceipt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.reason, 'missing_auto_next_dispatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes allowed terminal state', () => {
|
||||||
|
const result = evaluateContinuity({
|
||||||
|
planId: 'plan-1',
|
||||||
|
currentTask: 'task-7',
|
||||||
|
taskState: 'complete',
|
||||||
|
nextDerivedAction: { type: 'message_subagent' },
|
||||||
|
replyClosureState: 'waiting_user',
|
||||||
|
dispatchReceipt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders hard-gate block', () => {
|
||||||
|
const text = buildContinuityGateBlock({
|
||||||
|
ok: false,
|
||||||
|
status: 'continuity_failure',
|
||||||
|
verdict: 'continuity_failure',
|
||||||
|
reason: 'missing_dispatch_receipt',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(text, /APPROVED_PLAN_CONTINUITY_GATE/);
|
||||||
|
assert.match(text, /HARD_GATE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('continuity.evaluator.test.mjs PASS');
|
||||||
63
plugins/continuity/test/continuity.plugin.test.mjs
Normal file
63
plugins/continuity/test/continuity.plugin.test.mjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import plugin, {
|
||||||
|
createForceRecallContinuityAdapter,
|
||||||
|
defaultConfig,
|
||||||
|
evaluateContinuity,
|
||||||
|
} from '../src/index.mjs';
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(`ok - ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`not ok - ${name}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('index exports plugin surface', () => {
|
||||||
|
assert.equal(plugin.name, '@openclaw/plugin-continuity');
|
||||||
|
assert.equal(typeof evaluateContinuity, 'function');
|
||||||
|
assert.equal(defaultConfig.adapter.forceRecall.enabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adapter preserves current hook parity for plain wrapper next-action mapping', () => {
|
||||||
|
const adapter = createForceRecallContinuityAdapter(defaultConfig);
|
||||||
|
const out = adapter.evaluate({
|
||||||
|
wrapperResult: {
|
||||||
|
classification: 'long_task',
|
||||||
|
planId: 'plan-1',
|
||||||
|
currentTask: 'task-7',
|
||||||
|
taskState: 'complete',
|
||||||
|
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
|
||||||
|
replyClosureState: 'completed',
|
||||||
|
dispatchReceipt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(out.result.ok, true);
|
||||||
|
assert.match(out.block, /status=pass/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adapter fails when planner-derived auto-next boundary exists without dispatch receipt', () => {
|
||||||
|
const adapter = createForceRecallContinuityAdapter(defaultConfig);
|
||||||
|
const out = adapter.evaluate({
|
||||||
|
wrapperResult: {
|
||||||
|
classification: 'long_task',
|
||||||
|
planId: 'plan-2',
|
||||||
|
currentTask: 'task-8',
|
||||||
|
replyClosureState: 'completed',
|
||||||
|
dispatchReceipt: null,
|
||||||
|
},
|
||||||
|
autoChainPlanResult: {
|
||||||
|
derivedAction: 'continue_task_9',
|
||||||
|
dispatchMode: 'message_subagent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(out.result.ok, false);
|
||||||
|
assert.equal(out.result.reason, 'missing_auto_next_dispatch');
|
||||||
|
assert.match(out.block, /continuity_failure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('continuity.plugin.test.mjs PASS');
|
||||||
38
plugins/continuity/test/continuity.receipt-store.test.mjs
Normal file
38
plugins/continuity/test/continuity.receipt-store.test.mjs
Normal file
@@ -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');
|
||||||
@@ -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');
|
||||||
28
plugins/continuity/test/continuity.smoke.test.mjs
Normal file
28
plugins/continuity/test/continuity.smoke.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import plugin, {
|
||||||
|
runForceRecallContinuityAdapter,
|
||||||
|
validateContinuityConfig,
|
||||||
|
} from '../src/index.mjs';
|
||||||
|
|
||||||
|
const configResult = validateContinuityConfig(plugin.defaultConfig);
|
||||||
|
assert.equal(configResult.ok, true);
|
||||||
|
|
||||||
|
const smoke = runForceRecallContinuityAdapter({
|
||||||
|
config: plugin.defaultConfig,
|
||||||
|
wrapperResult: {
|
||||||
|
classification: 'long_task',
|
||||||
|
planId: 'plan-smoke',
|
||||||
|
currentTask: 'task-8',
|
||||||
|
replyClosureState: 'completed',
|
||||||
|
dispatchReceipt: null,
|
||||||
|
},
|
||||||
|
autoChainPlanResult: {
|
||||||
|
derivedAction: 'continue_task_9',
|
||||||
|
dispatchMode: 'message_subagent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(smoke.result.ok, false);
|
||||||
|
assert.equal(smoke.result.reason, 'missing_auto_next_dispatch');
|
||||||
|
assert.match(smoke.block, /APPROVED_PLAN_CONTINUITY_GATE/);
|
||||||
|
console.log('continuity.smoke.test.mjs PASS');
|
||||||
@@ -51,6 +51,10 @@ async function prepareTempWorkspace() {
|
|||||||
await fs.mkdir(path.join(tempWorkspace, 'hooks', 'force-recall'), { recursive: true });
|
await fs.mkdir(path.join(tempWorkspace, 'hooks', 'force-recall'), { recursive: true });
|
||||||
await fs.mkdir(path.join(tempWorkspace, 'docs'), { recursive: true });
|
await fs.mkdir(path.join(tempWorkspace, 'docs'), { recursive: true });
|
||||||
|
|
||||||
|
await fs.mkdir(path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity'), { recursive: true });
|
||||||
|
|
||||||
const copies = [
|
const copies = [
|
||||||
[wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')],
|
[wrapperPath, path.join(tempWorkspace, 'scripts', 'long_task_governor_wrapper.mjs')],
|
||||||
[gateLockPath, path.join(tempWorkspace, 'scripts', 'long_task_gate_lock.mjs')],
|
[gateLockPath, path.join(tempWorkspace, 'scripts', 'long_task_gate_lock.mjs')],
|
||||||
@@ -59,6 +63,13 @@ async function prepareTempWorkspace() {
|
|||||||
[handlerPath, path.join(tempWorkspace, 'hooks', 'force-recall', 'handler.ts')],
|
[handlerPath, path.join(tempWorkspace, 'hooks', 'force-recall', 'handler.ts')],
|
||||||
[path.join(repoRoot, 'docs', 'RULEBOOK.md'), path.join(tempWorkspace, 'docs', 'RULEBOOK.md')],
|
[path.join(repoRoot, 'docs', 'RULEBOOK.md'), path.join(tempWorkspace, 'docs', 'RULEBOOK.md')],
|
||||||
[path.join(repoRoot, 'SOUL.md'), path.join(tempWorkspace, 'SOUL.md')],
|
[path.join(repoRoot, 'SOUL.md'), path.join(tempWorkspace, 'SOUL.md')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'index.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'index.mjs')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'adapters', 'force-recall.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'adapters', 'force-recall.mjs')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'config', 'schema.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'config', 'schema.mjs')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'evaluator.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'evaluator.mjs')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'receipt-validator.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'receipt-validator.mjs')],
|
||||||
|
[path.join(repoRoot, 'plugins', 'continuity', 'src', 'continuity', 'receipt-store.mjs'), path.join(tempWorkspace, 'plugins', 'continuity', 'src', 'continuity', 'receipt-store.mjs')],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [src, dest] of copies) {
|
for (const [src, dest] of copies) {
|
||||||
@@ -285,6 +296,49 @@ async function main() {
|
|||||||
assert.equal(neutralSnakeCaseResult.gateStatus, 'pass', 'neutral snake_case non-dispatch action should not trigger dispatch-evidence requirement');
|
assert.equal(neutralSnakeCaseResult.gateStatus, 'pass', 'neutral snake_case non-dispatch action should not trigger dispatch-evidence requirement');
|
||||||
assert.doesNotMatch(JSON.stringify(neutralSnakeCaseResult), /autoChainDispatchEvidence/, 'neutral snake_case non-dispatch action should not mention dispatch-evidence requirement');
|
assert.doesNotMatch(JSON.stringify(neutralSnakeCaseResult), /autoChainDispatchEvidence/, 'neutral snake_case non-dispatch action should not mention dispatch-evidence requirement');
|
||||||
|
|
||||||
|
const pluginPathInjected = await withPatchedWrapperWorkspace({
|
||||||
|
classification: 'long_task',
|
||||||
|
silentCandidate: true,
|
||||||
|
needsCheckpoint: true,
|
||||||
|
needsSubagent: false,
|
||||||
|
needsOwnerDecision: false,
|
||||||
|
silentLaunchOk: true,
|
||||||
|
planId: 'plan-plugin-path',
|
||||||
|
currentTask: 'task-plugin-path',
|
||||||
|
taskState: 'complete',
|
||||||
|
replyClosureState: 'completed',
|
||||||
|
requiredNextAction: 'dispatch_follow_up_subagent',
|
||||||
|
autoChainDispatchEvidence: {
|
||||||
|
action: 'dispatch_follow_up_subagent',
|
||||||
|
dispatched: true,
|
||||||
|
event: 'dispatch',
|
||||||
|
},
|
||||||
|
progressEvidence: { sessionKey: 'task-plugin-path' },
|
||||||
|
externalizedCheckpointPath: 'checkpoints/task-plugin-path.json',
|
||||||
|
handoff: { mode: 'direct_reply' },
|
||||||
|
dispatchReceipt: {
|
||||||
|
planId: 'plan-plugin-path',
|
||||||
|
currentTask: 'task-plugin-path',
|
||||||
|
nextDerivedAction: {
|
||||||
|
type: 'dry_run_dispatch',
|
||||||
|
action: 'dispatch_spec_review',
|
||||||
|
},
|
||||||
|
dispatchedAt: '2026-04-24T17:00:00+08:00',
|
||||||
|
},
|
||||||
|
}, async (workspaceDir) => {
|
||||||
|
const defaultsPath = path.join(workspaceDir, 'plugins', 'continuity', 'src', 'config', 'defaults.mjs');
|
||||||
|
const defaultsSource = await fs.readFile(defaultsPath, 'utf8');
|
||||||
|
await fs.writeFile(
|
||||||
|
defaultsPath,
|
||||||
|
defaultsSource.replace('APPROVED_PLAN_CONTINUITY_GATE', 'PLUGIN_CONTINUITY_GATE'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
return runScenario(forceRecall, requestText, workspaceDir);
|
||||||
|
});
|
||||||
|
assert.match(pluginPathInjected, /\[PLUGIN_CONTINUITY_GATE\]/, 'hook should inject continuity block from plugin adapter path, not only local fallback builder');
|
||||||
|
assert.match(pluginPathInjected, /status=pass/, 'plugin adapter path should still pass when a bound dispatch receipt exists');
|
||||||
|
assert.doesNotMatch(pluginPathInjected, /\[APPROVED_PLAN_CONTINUITY_GATE\]/, 'plugin adapter label override should replace the legacy fallback block label when plugin path is active');
|
||||||
|
|
||||||
const passInjected = await withPatchedWrapperWorkspace({
|
const passInjected = await withPatchedWrapperWorkspace({
|
||||||
classification: 'long_task',
|
classification: 'long_task',
|
||||||
silentCandidate: true,
|
silentCandidate: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user