Skill: lanyasheng/execution-loop (version 2.4.0)

--- name: execution-loop version: 2.0.0 description: Agent 执行循环控制。当 agent 提前停止、偏离任务、或在 headless 模式下需要执行控制时使用。不适用于工具重试（用 tool-governance）。参见 context-memory（阶段边界 handoff）、quality-verification（编辑后检查）。 license: MIT triggers: - agent keeps stopping - ralph - persistent execution - 不要停 - doubt gate - task completion - headless mode - agent drifts - adaptive complexity author: OpenClaw Team --- # Execution Loop 控制 agent 的执行循环：阻止提前停止、检测任务完成、防止任务漂移。 ## When to Use - Agent 只完成一部分就停了 → Ralph persistent loop - Agent 说"可能"就声称完成 → Doubt gate - 长 session 中偏离原始任务 → Drift re-anchoring - 有明确任务清单 → Task completion verifier - 用 `-p` headless 模式 → Headless execution control ## When NOT to Use - 工具反复失败 → 用 `tool-governance` - 上下文快用完 → 用 `context-memory` - Session 挂死 → 用 `error-recovery` --- ## Patterns | # | Pattern | Type | Description | |---|---------|------|-------------| | 1.1 | Ralph persistent loop | [script] | Stop hook 在 agent 尝试 end_turn 时检查 `ralph.json` 状态，未达 max 则 block 并注入续航指令。核心保障是 5 个安全阀（context >=95%、401/403、cancel TTL、闲置 2h、迭代上限），安全阀优先级高于一切 block 逻辑——context 即将溢出时 Ralph 必须让路，否则 session 崩溃。支持 shell/prompt/agent 三种 hook 实现，高价值任务用 agent-type 做语义级完成度验证。 → [详见](references/ralph.md) | | 1.2 | Doubt gate | [script] | Strip 代码块和引用后扫描 agent 回复中的 hedging 词（"likely""maybe""可能""应该是"等），命中则 block 并要求提供日志、测试结果等具体证据。用 `$TMPDIR/doubt-gate-<session>` 守卫文件做单轮去重——同一轮触发过一次后无条件放行，防止 agent 永远无法停止。与 Ralph 正交叠加：Ralph 管"别停"，Doubt Gate 管"别含糊地停"。 → [详见](references/doubt-gate.md) | | 1.3 | Adaptive complexity triage | [design] | 执行前根据文件数、安全/迁移关键词做 triage，将任务分为 Trivial→Express 到 Critical→Full+Gate 五档，每档启用不同的 hook 组合。关键约束：triage 不确定时默认 Standard 而非 Express，因为 Express 跳过验证——安全修复被误判为 Trivial 会不经验证直接提交。文件计数是极简启发式，生产环境建议用一次 Haiku 调用替代。 → [详见](references/adaptive-complexity.md) | | 1.4 | Task completion verifier | [script] | Stop hook 读取 `.harness-tasks.json`（`{tasks: [{name, done}]}`），存在 `done: false` 条目则 block 并将未完成项注入 reason。与 Ralph 互补：Ralph 只做迭代计数，Task Completion 提供语义级的"为什么不能停"。确定性校验、零 API 成本，但 agent 可能只标记完成而实际没做好，需配合 quality-verification 二次验证。 → [详见](references/task-completion.md) | | 1.5 | Drift re-anchoring | [script] | 每 N 轮（默认 10）将原始任务描述通过 `hookSpecificOutput.additionalContext` 重新注入对话，作为防漂移锚点。在 Ralph 持续执行中尤其必要——agent 不停意味着 drift 没有天然修正点，第 5 轮开始"顺手"重构不相关代码，第 10 轮已经在优化 CI 配置。与 Ralph、Task Completion 三者各管一个维度：别停 / 为什么不能停 / 别跑偏。 → [详见](references/drift-reanchor.md) | | 1.6 | Headless execution control | [config] | `-p` 模式没有 Stop 事件循环，Ralph 不生效。替代方案是三维控制：`--max-turns` 限轮数，prompt 结构化（系统指令放前 ~2000 token 利用 prefix cache），budget 注入让 agent 感知资源约束做优先级决策。Pipeline 场景每阶段告知当前位置和剩余预算。 → [详见](references/headless-config.md) | | 1.7 | Iteration-aware messaging | [design] | Ralph 的 block 消息如果每次相同，agent 会进入 compliance mode——表面继续实际反复生成"我已完成大部分"的变体。解法是按迭代阶段动态切换消息角度：早期(1-5)方向引导，中期(6-15)反合理化要求逐条对照，后期(16-30)反循环鼓励换方法，收尾(30+)切换优先级做 graceful handoff。基于 prompt-hardening P5 反推理阻断原则。 → [详见](references/iteration-aware-messaging.md) | 扩展模式（跨 turn 状态对象、多层停止条件、三维预算约束、流式工具执行）→ [详见](references/extended-patterns.md)。Cancel 信号 TTL 机制 → [详见](references/cancel-ttl.md)。 ## Scripts | 脚本 | Hook 类型 | 功能 | |------|----------|------| | `ralph-stop-hook.sh` | Stop | 阻止提前停止，5 安全阀 | | `ralph-init.sh <id> [max]` | CLI | 初始化持续执行 | | `ralph-cancel.sh <id>` | CLI | 发送取消信号 | | `doubt-gate.sh` | Stop | 检测 hedging words | | `task-completion-gate.sh` | Stop | 读 .harness-tasks.json | | `drift-reanchor.sh` | Stop | 每 N 轮注入原始任务提醒 | ## Workflow ``` 任务到达 │ ├─ Interactive 模式？ │ │ │ ├─ 是 ──→ 复杂度 triage（Pattern 1.3） │ │ │ │ │ ├─ Trivial/Low ──→ Express/Light 模式 │ │ │ 不启用 Ralph，agent 完成即停。 │ │ │ 判断不确定？→ 走 Standard，Express 跳过验证不可兜底。 │ │ │ │ │ └─ Medium+ ──→ Standard/Full 模式 │ │ │ │ │ ├─ 1. ralph-init.sh <id> [max] │ │ │ 有残留状态？从上次迭代恢复，不重置计数器。 │ │ │ │ │ ├─ 2. 按复杂度启用 hook 组合 │ │ │ Standard: Ralph + Context 估算 │ │ │ Full: + Handoff + Hook Bracket + Post-Edit 诊断 │ │ │ Critical: + Agent-type 验证门禁 │ │ │ │ │ └─ 3. Agent 执行 → 尝试停止 → Stop hook 链 │ │ │ │ │ ├─ [最高优先级] 安全阀检查 │ │ │ context >= 95%? → 放行（阻止会导致溢出崩溃） │ │ │ 401/403 认证失败? → 放行（继续执行无意义） │ │ │ cancel 信号存在且 TTL 未过期? → 放行 │ │ │ 闲置 > 2h? → 放行（防 zombie） │ │ │ 迭代 >= max? → 放行 │ │ │ ※ 安全阀先于一切 block 逻辑执行，顺序固定 │ │ │ │ │ ├─ Doubt gate（Pattern 1.2） │ │ │ 回复含投机语言? → block，要求证据 │ │ │ 本轮已触发过? → 放行（守卫去重） │ │ │ │ │ ├─ Task completion（Pattern 1.4） │ │ │ .harness-tasks.json 有未完成项? → block，注入缺失项 │ │ │ │ │ ├─ Ralph 迭代检查（Pattern 1.1） │ │ │ active=true 且 iteration < max? → block │ │ │ 注入迭代感知消息（Pattern 1.7） │ │ │ │ │ ├─ Drift re-anchor（Pattern 1.5） │ │ │ 每 N 轮注入原始任务提醒（不 block，仅注入 context） │ │ │ │ │ └─ 所有检查通过 → 放行停止 │ │ │ └─ 否（Headless `-p`）──→ 三维控制（Pattern 1.6） │ --max-turns 限轮数（简单 10-20 / 中等 30-50 / 复杂 80-120） │ prompt 结构化利用 prefix cache │ budget 注入让 agent 感知剩余资源 │ 第 80% 轮次时开始收尾，写 handoff 文档 ``` <example> 场景：7 文件 API 重构 用户：「把 user-service 的 7 个 endpoint 从 REST 迁移到 gRPC」 → Triage: High（7 文件 + 架构变更）→ Full 模式 → ralph-init.sh api-migration 50 Agent 改完 1 个 endpoint → 停 → Ralph block: 「[RALPH 1/50] 检查任务清单，标记已完成项，继续下一项。」 Agent 改完 3 个 → 停 → Task completion block: 「未完成项: endpoint-4, endpoint-5, endpoint-6, endpoint-7」 Agent 改完全部 7 个 → 停 → Doubt gate 通过（无投机语言）→ Task completion 通过（全部 done）→ Ralph 放行 实际迭代: 12/50，自然完成。 </example> <anti-example> 场景：拼写修复误用 Ralph 用户：「README 里 'recieve' 改成 'receive'」 错误：ralph-init.sh typo-fix 50 → 改完 1 行被 Ralph block → 被迫"寻找更多错误" → 引入无关改动 正确：Triage → Trivial → Express → 不启 Ralph → 改完即停 </anti-example> ## Output | 产物 | 路径 | 说明 | |------|------|------| | Ralph 状态文件 | `sessions/<session-id>/ralph.json` | 记录 active、iteration、max_iterations、时间戳。原子写入（write-then-rename） | | Doubt gate 守卫文件 | `$TMPDIR/doubt-gate-<session-id>` | 防止 doubt gate 同一轮重复触发。临时文件，触发后创建，放行后删除 | | 任务清单 | `.harness-tasks.json` | 任务 checklist，格式 `{tasks: [{name, done}]}`。agent 完成子任务后标记 `done: true` | ## Usage 在 `.claude/settings.json` 中配置 Stop hook： ```jsonc { "hooks": { "Stop": [ // Ralph 持续执行（Pattern 1.1） { "hooks": [{ "type": "command", "command": "bash ralph-stop-hook.sh" }] }, // Doubt gate（Pattern 1.2） { "hooks": [{ "type": "command", "command": "bash doubt-gate.sh" }] }, // Task completion（Pattern 1.4） { "hooks": [{ "type": "command", "command": "bash task-completion-gate.sh" }] } ] } } ``` 初始化和取消 Ralph： ```bash # 启动持续执行（50 轮上限） bash ralph-init.sh api-migration 50 # 取消持续执行 bash ralph-cancel.sh api-migration ``` Hook 输出格式（ralph-stop-hook.sh stdout）： ```json {"decision": "block", "hookSpecificOutput": {"reasonForBlocking": "[RALPH 3/50] 检查任务清单，标记已完成项，继续下一项。"}} ``` 安全阀在 block 之前检查，确保不会阻止必要的停止： ```json {"decision": "allow", "hookSpecificOutput": {"reasonForAllowing": "context >= 95%, 必须放行避免溢出"}} ``` ## Related | Skill | 关系 | |-------|------| | `tool-governance` | 上游：提供工具错误数据。工具连续失败时由 tool-governance 处理，不属于 execution-loop 范围 | | `context-memory` | 互补：阶段边界时写 handoff document。Ralph 到达 max 或 context 接近上限时，应触发 context-memory 保存决策 | | `quality-verification` | 下游：agent 完成编辑后由 quality-verification 做 post-edit 检查（lint、type check、测试） |

path: metadata.json
content: name: execution-loop
version: 2.0.0
description: Agent execution loop control
license: MIT

path: references/adaptive-complexity.md
content: # Pattern 1.3: Adaptive Complexity Scoring（任务复杂度自适应）

## 问题

所有任务使用相同的 harness 配置——简单的拼写修复和复杂的架构重构都走同样的 Ralph + Handoff + Verification 全流程。简单任务 overhead 太高，复杂任务可能保障不足。

来源：`rubenzarroca/sdd-autopilot` — 8 阶段 pipeline 的复杂度分类

## 原理

在执行前做 triage——评估任务复杂度，自动选择执行模式。

### 复杂度分级

| 等级 | 判断标准 | 执行模式 |
|------|---------|---------|
| Trivial | 单文件、< 10 行改动、无依赖 | Express（跳过 plan/verify） |
| Low | 单文件、可能需要测试更新 | Light（plan + implement） |
| Medium | 2-5 文件、涉及跨模块修改 | Standard（plan + implement + verify） |
| High | 5+ 文件、架构变更、新 API | Full（plan + implement + verify + review） |
| Critical | 安全修复、数据迁移、生产变更 | Full + Agent-type 验证门禁（Pattern 1.1 模式 C） |

### 执行模式映射

| 模式 | 启用的 Pattern |
|------|---------------|
| Express | 仅 Pattern 6.5（原子写入） |
| Light | + Pattern 2.1（工具错误升级） |
| Standard | + Pattern 1.1（Ralph）+ Pattern 3.5（Context 估算） |
| Full | + Pattern 3.1（Handoff）+ Pattern 6.3（Hook Bracket）+ Pattern 6.1（Post-Edit 诊断） |
| Full+Gate | + Pattern 1.1 模式 C（Agent-type 验证） + Pattern 2.5（Scoped Hooks） |

## 实现

在 session 启动时（UserPromptSubmit hook + `once: true`）做 triage：

```bash
# 分析 prompt 中的文件数、改动范围、关键词
FILES_MENTIONED=$(echo "$PROMPT" | grep -oE '[a-zA-Z_/]+\.(py|ts|rs|go|cpp|java)' | wc -l)
HAS_SECURITY=$(echo "$PROMPT" | grep -qiE 'security|vulnerability|CVE|auth|密钥|安全' && echo 1 || echo 0)
HAS_MIGRATION=$(echo "$PROMPT" | grep -qiE 'migration|migrate|数据迁移' && echo 1 || echo 0)

if [ "$HAS_SECURITY" = "1" ] || [ "$HAS_MIGRATION" = "1" ]; then
  LEVEL="critical"
elif [ "$FILES_MENTIONED" -gt 5 ]; then
  LEVEL="high"
elif [ "$FILES_MENTIONED" -gt 1 ]; then
  LEVEL="medium"
elif [ "$FILES_MENTIONED" -eq 1 ]; then
  LEVEL="low"
else
  LEVEL="trivial"
fi
```

## 安全规则

**MUST：当 triage 不确定时，默认 Standard，NEVER 默认 Express。** Express 跳过验证——如果一个安全修复被误判为 Trivial，agent 会不经验证直接提交。

**MUST：Express 模式不应用于任何写入生产代码的操作。** Express 适合只读分析、文档更新、配置微调。

## 已知局限

文件计数 triage 是极简启发式。已知失败场景：
- "Fix the auth bug" 提到 0 个文件但可能涉及 10+ 文件
- 带连字符/数字的文件名（`my-component.ts`, `v2.config.js`）匹配不到
- 上下文信息（是否涉及安全、数据库、支付）比文件数更重要

**生产建议**：用 LLM triage（一次 Haiku 调用评估复杂度）替代 regex 启发式。

## Tradeoff

- Triage 本身会消耗一些 token（但远少于在简单任务上运行全流程的 overhead）
- 误判复杂度会导致过度/不足保障——文件计数只是起点，需要持续优化
- 历史数据可以提升 triage 准确度（追踪实际 vs 预估复杂度）


path: references/cancel-ttl.md
content: # Pattern 1.1-sub: Cancel Signal with TTL

## 问题

用户发送取消信号后，如果新 session 复用了同一个 session-id，旧的取消信号会错误地阻止新 session。

## 原理

取消信号带 30 秒过期时间。过期后自动忽略。来自 OMC 的 `CANCEL_SIGNAL_TTL_MS = 30000`。

## 信号格式

```json
{
  "requested_at": "2026-04-05T10:30:00Z",
  "expires_at": "2026-04-05T10:30:30Z",
  "reason": "user_abort"
}
```

## 使用

```bash
# 发送取消信号
scripts/ralph-cancel.sh <session-id> [reason]
# 创建 sessions/<session-id>/cancel.json（30 秒后过期）
```

## Ralph stop hook 的检查逻辑

- 存在且未过期 → 停止 ralph，设置 `deactivation_reason: "cancelled"`，允许 agent 退出
- 存在但已过期 → 删除信号文件，继续 ralph 循环
- 不存在 → 继续 ralph 循环

## 为什么是 30 秒

OMC 选择 30 秒是因为：
- 足够覆盖 Stop hook 的检查周期（通常 1-5 秒内触发）
- 不会太长以至于影响后续 session
- 用户手动取消后 30 秒内必然会触发至少一次 Stop hook 检查


path: references/doubt-gate.md
content: # Pattern 1.2: Doubt Gate（投机检测断路器）

## 问题

Agent 声称任务完成，但回答中包含投机性语言——"可能是"、"我认为"、"大概"——没有提供具体证据。这和 Pattern 2.2（权限否决追踪）解决的是相反的问题：Pattern 2.2 处理 agent 被拒绝后换表述重试，Doubt Gate 处理 agent 以不确定的态度"完成"任务。

来源：`johnlindquist/plugin-doubt-gate`

## 原理

Stop hook 在 agent 尝试结束时，strip 代码块和引用块后扫描剩余文本中的 hedging 关键词（"likely"、"maybe"、"might"、"probably"、"not sure"、"I think"、"I believe"、"可能"、"大概"、"也许"、"应该是"）。如果匹配到，返回 `{"decision":"block"}`，强制 agent 回到活跃状态，必须提供具体证据（日志、断言、测试运行结果）才能再次尝试停止。

### 防止无限循环

设置 `stop_hook_active` 守卫标志。如果本轮已经触发过一次 doubt gate，下次 stop 无条件放行。防止 agent 永远无法停止。

## 实现

```json
{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "command",
        "command": "bash scripts/doubt-gate.sh"
      }]
    }]
  }
}
```

```bash
# doubt-gate.sh — 核心逻辑
INPUT=$(cat)
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
# Strip code blocks and blockquotes
PROSE=$(echo "$LAST_MSG" | sed '/^```/,/^```/d; /^>/d')
# Check for hedging keywords
if echo "$PROSE" | grep -qiE 'likely|maybe|might|probably|not sure|I think|I believe|可能|大概|也许|应该是'; then
  # Check guard: already fired this turn?
  GUARD_FILE="$TMPDIR/doubt-gate-${SESSION_ID}"
  if [ -f "$GUARD_FILE" ]; then
    rm -f "$GUARD_FILE"
    echo '{"continue":true}'
  else
    touch "$GUARD_FILE"
    echo '{"decision":"block","reason":"Your response contains speculative language. Provide concrete evidence (logs, test results, or code verification) before completing."}'
  fi
else
  echo '{"continue":true}'
fi
```

## 与 Ralph 的关系

Ralph 阻止 agent 过早停止（基于迭代计数）。Doubt Gate 阻止 agent 以不确定的方式停止（基于内容分析）。两者可叠加：Ralph 在前（决定是否继续迭代），Doubt Gate 在后（即使 Ralph 允许停止，如果内容有投机语言也会 block）。

## Tradeoff

- 可能误报——agent 在解释可能性时使用"可能"是合理的
- 需要 strip 代码块，否则代码注释中的 hedging 词会误触发
- 守卫标志防止无限循环，但意味着 agent 第二次尝试时即使仍然投机也会被放行


path: references/drift-reanchor.md
content: # Drift Re-anchoring

## Problem

Agent 在长会话中逐渐偏离原始任务。第 1 轮还在做需求分析，第 5 轮开始"顺手"重构不相关代码，第 10 轮已经在优化 CI 配置。这种 drift 在 Ralph 持续执行中更严重——因为 agent 不停，drift 没有天然的修正点。

## Solution

Stop hook 每 N 轮（默认 10 轮）将原始任务描述重新注入对话，作为"锚点"。在 Ralph 持续执行模式中，每次 Stop hook 触发时计数 +1，每 N 次注入一次 re-anchor 消息。

## Implementation

1. 任务启动时将原始 prompt 存入 `sessions/<session-id>/reanchor.json`
2. Stop hook 每次触发时递增 turn_count
3. 当 `turn_count % REANCHOR_INTERVAL == 0` 时通过 additionalContext 注入原始任务提醒
4. 注入结构化消息包含：原始任务、当前迭代数、已完成项（从 .harness-tasks.json 读取）
5. Hook 返回 `{"continue":true}` 放行，re-anchor 通过 hookSpecificOutput 注入

```bash
# Stop hook — 每轮触发，每 N 轮注入 re-anchor
INPUT=$(head -c 20000)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
[ -z "$SESSION_ID" ] && echo '{"continue":true}' && exit 0

STATE_FILE="sessions/${SESSION_ID}/reanchor.json"
[ -f "$STATE_FILE" ] || { jq -n '{"turn_count":1,"original_task":""}' > "$STATE_FILE"; echo '{"continue":true}'; exit 0; }

TURN_COUNT=$(( $(jq -r '.turn_count // 0' "$STATE_FILE") + 1 ))
ORIGINAL_TASK=$(jq -r '.original_task // ""' "$STATE_FILE")
jq -n --arg task "$ORIGINAL_TASK" --argjson count "$TURN_COUNT" \
  '{"original_task":$task,"turn_count":$count}' > "$STATE_FILE"

INTERVAL=${REANCHOR_INTERVAL:-10}
if [ $((TURN_COUNT % INTERVAL)) -eq 0 ] && [ -n "$ORIGINAL_TASK" ]; then
  jq -n --arg ctx "TASK REMINDER (turn ${TURN_COUNT}): ${ORIGINAL_TASK}" \
    '{"hookSpecificOutput":{"additionalContext":$ctx}}'
else
  echo '{"continue":true}'
fi
```

## Tradeoffs

- **Pro**: 低成本防 drift，不需要 LLM 判断
- **Pro**: 与 Ralph、Task Completion 正交——三者各管一个维度
- **Con**: 频率过高会浪费 context（N 太小），频率过低可能来不及纠偏（N 太大）
- **Con**: 对于合理的任务拆分变化（发现 A 前必须先做 B），re-anchor 可能产生误导

## Source

OMC 的多模式持续执行系统中的"任务重注入"机制。Anthropic 博客提到的 coordinator synthesis 原则——agent 需要定期对照原始目标。


path: references/extended-patterns.md
content: # Execution Loop — Extended Patterns

### E1.1: Explicit Cross-Turn State Object
**What it does**: 将跨 turn 的状态封装到一个显式的 State 类型（如 QueryEngine 中的 `{messages, tools, context_usage, turn_count}`），每轮循环接收旧 state、产出新 state，而非依赖闭包或全局变量。
**Why it matters**: Agent 的执行循环在每轮需要做决策（是否继续、是否 compact、是否切换工具集），这些决策依赖跨 turn 的累积状态。隐式状态（散布在多个变量里）导致决策逻辑脆弱——改一个变量忘了改另一个。显式 State 对象让状态转换可追踪、可序列化、可在 crash 后恢复。
**Source evidence**: Claude Code 内部的 QueryEngine 类使用类型化的 state 对象在 `agentic_loop` 的每轮循环中传递。OMC 的 `persistent-mode.mjs` 在每次 Stop hook 触发时读写完整的 `ralph.json` state。

### E1.2: Multi-Layered Stop Conditions
**What it does**: 定义 5 种独立的停止语义，按优先级从高到低评估：(1) 紧急停止（context >=95%、auth 失败）→ 无条件放行；(2) 用户取消（cancel 信号 + TTL）→ 放行并清理；(3) 闲置超时（2h 无活动）→ 放行并标记 stale；(4) 迭代上限（max_iterations）→ 放行或 auto-extend；(5) 任务完成（checklist 全 checked）→ 放行。
**Why it matters**: 单一停止条件不够——只看迭代数会在 context 溢出时崩溃，只看 context 会在认证失败时浪费 token。分层且有优先级的停止条件确保在任何异常场景下都有合理的退出路径，不会出现"该停的时候停不下来"或"不该停的时候停了"。
**Source evidence**: OMC `persistent-mode.mjs` 的 5 个 NEVER-block 安全阀。Claude Code 内部的 `shouldStop()` 多条件评估。

### E1.3: Budget Enforcement Triad
**What it does**: 同时施加三种独立的预算约束：USD 成本上限（`--budget`）、执行轮数上限（`--max-turns`）、token 消耗上限（context window 的 95%）。三者中任一触顶即停止，互为安全网。
**Why it matters**: 单一预算维度有盲区：token 上限不防慢速高轮次任务、轮次上限不防单轮高 token 消耗、USD 上限不防 context 溢出。三维约束覆盖所有资源消耗模式。在 headless 场景尤其重要——没有人类监督，预算是唯一的自动刹车。
**Source evidence**: Claude Code CLI 的 `--max-turns` 和 `--budget` 参数。OMC 的 strict 模式同时检查 iteration count + context usage + cost。

### E1.4: Streaming Tool Execution
**What it does**: 在模型 response 还在流式生成时，一旦检测到完整的 tool_use block，立即开始执行该工具调用，不等整个 response 结束。
**Why it matters**: Agent 一轮交互中经常产生多个工具调用。如果等所有 tool_use block 都生成完再执行，耗时 = 生成时间 + 串行执行时间。Streaming 执行让生成和执行重叠，显著降低端到端延迟——尤其是当第一个 tool_use 是慢操作（如 Bash 编译）时。
**Source evidence**: Claude Code 内部的 tool execution pipeline 在流式 response 中检测完整的 tool_use JSON block 后立即 dispatch。需要 `isConcurrencySafe()` 标记来判断是否可以与其他工具并行执行。


path: references/headless-config.md
content: # Headless Execution Control

## Problem

`claude -p` (headless) 模式没有 Stop 事件循环，Ralph 的 Stop hook 不生效。Headless agent 行为由 `--max-turns`、prompt 结构、budget 注入共同控制，但配置不当会导致：任务做一半就到 turn 上限停了，或者 token 预算耗尽但任务还差最后一步。

## Solution

为 headless 模式提供三维控制：`--max-turns` 限制执行轮数，prompt 结构确保关键指令在 prefix cache 范围内，budget 注入让 agent 感知资源约束并做优先级决策。三者协同替代 interactive 模式下的 Ralph + Stop hook。

## Implementation

1. **--max-turns 估算**：根据任务复杂度设置。简单任务 10-20，中等 30-50，复杂 80-120。设置过低会截断任务，过高浪费资源
2. **Prompt 结构**：系统指令放在 prompt 开头（前 ~2000 token），利用 Anthropic 的 prefix cache。任务细节紧随其后。动态 context（文件内容）放末尾
3. **Budget 注入**：在 prompt 中显式告知 agent 资源约束

```bash
# Headless 执行模板
claude -p \
  --max-turns 50 \
  --allowedTools "Read,Write,Edit,Bash,Glob,Grep" \
  "$(cat <<'PROMPT'
[SYSTEM] 你的执行预算：50 轮 / ~200K token。
在第 40 轮时如果任务未完成，开始做收尾工作（保存进度、写 handoff 文档）。
不要把预算浪费在探索性操作上——先规划再执行。

[TASK]
修改 src/parser.ts 支持新的 AST 节点类型...
PROMPT
)"
```

4. **阶段性 budget 告知**：若有多轮 headless 调用（pipeline），每次调用时告知当前阶段和剩余预算

```bash
# Pipeline 中的第 2 阶段
claude -p --max-turns 30 \
  "$(cat <<PROMPT
[阶段 2/4] 实现阶段。前序阶段（Research）已完成，结果见 .working-state/research-output.md。
本阶段预算：30 轮。完成后将结果写入 .working-state/implementation-output.md。
PROMPT
)"
```

## Tradeoffs

- **Pro**: 可预测的资源消耗，适合 CI/CD 和 automation
- **Pro**: Prompt 结构化利用 prefix cache 降低成本
- **Con**: 没有 Stop hook 的灵活性——不能动态决定是否继续
- **Con**: max-turns 估算依赖经验，没有自动调优

## Source

Claude Code `-p` 模式文档。OMC 的 pipeline 执行系统（多阶段 headless 调用 + handoff 文件传递）。Claude Anthropic prefix caching 文档。


path: references/iteration-aware-messaging.md
content: # Iteration-Aware Block Messaging

## Problem

Ralph 的 block 消息如果每次都一样（"Work is NOT done. Continue working."），agent 会进入 compliance mode——表面上继续工作，实际上反复生成"我已经完成了大部分工作"的变体直到被放行。固定消息在第 3 轮有效，到第 15 轮就被 agent 的内部推理"消化"掉了。

## Solution

Stop hook 的 block 消息根据当前迭代数动态变化。低迭代数用鼓励性消息，中段用对照检查消息，高迭代数用紧迫性消息。关键在于打破 agent 对固定模式的适应——每次消息的角度不同，迫使 agent 重新审视任务状态。

## Implementation

1. Stop hook 从 ralph.json 读取当前 iteration
2. 根据 iteration 范围选择消息模板
3. 消息中嵌入具体的行动指令而非笼统催促

```bash
ITERATION=$(jq -r '.iteration // 0' "$RALPH_FILE")

if [ "$ITERATION" -le 5 ]; then
  MSG="[RALPH ${ITERATION}] 任务进行中。检查你的任务清单，标记已完成项，继续处理下一项。"
elif [ "$ITERATION" -le 15 ]; then
  MSG="[RALPH ${ITERATION}] 不要声称'剩余工作可以后续完成'。
回到原始任务描述，逐条对照：哪些需求已满足？哪些还没动？
先列出未完成项，再继续。"
elif [ "$ITERATION" -le 30 ]; then
  MSG="[RALPH ${ITERATION}] 你已经工作了 ${ITERATION} 轮。
如果你觉得卡住了，换一个方法。
如果你在反复修同一个 bug，停下来重新阅读错误信息。
不要重复之前失败的操作。"
else
  MSG="[RALPH ${ITERATION}] 进入收尾模式。
优先完成核心需求，跳过 nice-to-have。
如果有未完成项无法在 5 轮内解决，写入 .working-state/remaining-work.md 作为 handoff。"
fi
```

4. 每个阶段的消息设计原则（基于 prompt-hardening P5 反推理阻断）：
   - **早期 (1-5)**：方向引导，不施压
   - **中期 (6-15)**：反合理化，要求具体对照
   - **后期 (16-30)**：反循环，鼓励换方法
   - **收尾 (30+)**：资源意识，优先级切换

## Tradeoffs

- **Pro**: 有效对抗 compliance mode 退化
- **Pro**: 后期消息引导 agent 做 graceful handoff 而非强行死磕
- **Con**: 阶段划分和消息内容需要根据经验调优
- **Con**: 消息长度增加了 context 消耗

## Source

OMC persistent-mode 的 Ralph block 消息观察。prompt-hardening P5（反推理阻断）原则——agent 在接收重复指令后会发展出"合理化绕过"策略。


path: references/ralph.md
content: # Pattern 1.1: 持续执行循环（Ralph 模式）

## 问题

Claude Code agent 在复杂任务中经常"觉得自己做完了"就停了，实际上只完成了一部分。这在多文件修改、多步骤任务中尤其常见——agent 修完第一个文件后就发 `end_turn`，剩下的文件被遗忘。

## 原理

Claude Code 的 Stop hook 在 agent 尝试结束会话时触发。通过在 Stop hook 中返回 `{"decision":"block","reason":"..."}` 可以阻止终止并注入续航指令，让 agent 继续工作。

这个机制来自 OMC 的 `persistent-mode.mjs`——OMC 最核心的设计之一。整个 ralph/autopilot/team 持续执行系统都建立在这个 Stop hook 机制上。OMC 的实现中，Stop hook 按优先级检查 9 种模式（Ralph > Autopilot > Ultrapilot > Swarm > Ultrawork > UltraQA > Pipeline > Team > OMC Teams），每种模式有独立的状态文件和停止条件。

**仅适用于 interactive 模式**。Headless（`-p`）模式没有 Stop 事件循环，用 `--max-turns` 代替。

## 工作流程

```
Agent 尝试停止
  → Stop hook 触发
    → 读取 sessions/<session-id>/ralph.json
      → active=true 且 iteration < max?
        → 是: 阻止停止，注入"继续工作"，iteration++
        → 否: 放行停止
```

## 5 个安全阀（NEVER block）

Stop hook 在以下条件下 MUST 放行，不管 ralph 状态如何：

1. **Context 上限**：usage >= 95%。阻止会导致 context 溢出崩溃。Claude Code 内部在 `context_window` 和 `input_tokens` 比值达到 95% 时会触发紧急压缩（reactive compact），此时 Stop hook 必须让路。

2. **认证失败**：401/403 错误。Token 过期或权限被撤销，继续执行无意义。

3. **Cancel 信号**：带 TTL 的取消文件存在且未过期（见 Pattern 1.1-sub: Cancel TTL）。

4. **闲置超时**：2 小时无活动。防止 zombie 状态永远占用资源。OMC 使用 `STALE_STATE_THRESHOLD_MS = 7200000` 作为阈值。

5. **迭代上限**：达到 `max_iterations`。防止无限循环。OMC 默认 max=200（`OMC_SECURITY=strict` 时），到达上限后尝试 auto-extend +10 直到硬上限。

## 状态文件

```json
{
  "session_id": "my-bugfix-task",
  "active": true,
  "iteration": 3,
  "max_iterations": 50,
  "created_at": "2026-04-05T10:00:00Z",
  "last_checked_at": "2026-04-05T10:15:00Z"
}
```

存储在 `sessions/<session-id>/ralph.json`。所有状态文件使用 write-then-rename 原子写入防止并发损坏（见 Pattern 6.5）。

## 初始化与 Crash 恢复

```bash
# 初始化 ralph 状态（session-id, max-iterations）
scripts/ralph-init.sh my-task-001 50

# 如果检测到已有 active 状态（crash 恢复），自动从上次迭代继续
# 输出: "Resuming ralph from iteration 37 (previous state: active=true, reason=stale)"
```

`ralph-init.sh` 在初始化前检查是否存在残留状态。如果发现 `active: true` 或 `deactivation_reason: "stale"` 的状态文件，不会重置迭代计数器，而是从上次位置恢复。这解决了 agent 在第 37 轮 crash 后重启从第 0 轮开始的问题。

## settings.json 配置

```json
{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "command",
        "command": "bash /path/to/execution-loop/scripts/ralph-stop-hook.sh"
      }]
    }]
  }
}
```

## 三种 Stop Hook 实现

### 模式 A: Shell 脚本（已实现，`scripts/ralph-stop-hook.sh`）

基于 JSON 状态文件的确定性检查。读 stdin（Claude Code hook 协议），读状态文件，输出 block/continue JSON。

- **优点**：快速、零 API 成本、可预测
- **缺点**：只能做迭代计数检查，无法判断任务是否"语义上完成"

### 模式 B: Prompt-type hook（推荐用于高价值任务）

用 LLM 本身判断任务是否完成。Claude Code 支持 `type: "prompt"` 的 hook，hook 内容作为 prompt 发给模型，模型返回结构化 JSON 决策。

```json
{
  "hooks": [{
    "type": "prompt",
    "prompt": "检查当前任务是否已全部完成。评估标准：1) 所有需要修改的文件已保存 2) 测试已通过 3) 无遗留 TODO/FIXME 4) 改动覆盖了原始需求的所有要点。如果未完成，返回 {\"decision\":\"block\",\"reason\":\"未完成：<具体缺失项>\"}；如果已完成，返回 {\"decision\":\"allow\"}。"
  }]
}
```

- **优点**：能捕捉"修了 bug 但没写测试"、"改了 3 个文件但还有 2 个没改"等语义级未完成状态
- **缺点**：每次 Stop 都消耗一次 API 调用

### 模式 C: Agent-type hook（最强，用于关键任务）

生成一个有完整工具访问权（Read、Grep、Bash）的 subagent 做多步验证：

```json
{
  "hooks": [{
    "type": "agent",
    "agent": "验证当前任务完成质量：1) 读取所有修改的文件确认改动正确 2) 运行 grep 检查无遗留 TODO/FIXME 3) 如果有测试文件，运行 bash 确认测试通过。返回结构化验证结果。"
  }]
}
```

- **优点**：能实际读文件、跑测试、检查编译
- **缺点**：最慢、消耗最多 token
- **建议**：配合 `maxTurns: 10` 限制验证 agent 的执行轮数，防止验证本身无限循环

## Block 消息设计

OMC 的 Ralph 注入的 block 消息很简单："Work is NOT done. Continue working."。根据 prompt-hardening 的 P5（反推理阻断）原则，更有效的消息应该预判 agent 的"合理化"倾向：

```
[RALPH LOOP 5/50] Task is NOT done.
Do NOT rationalize that "the remaining work can be done in a follow-up."
Do NOT claim completion with caveats.
Check your original task description and verify EVERY requirement is met.
Continue working on the original task.
```

## OMC 内部实现参考

OMC 的 `persistent-mode.mjs` 有几个值得注意的设计决策：

1. **状态闲置检查**：`STALE_STATE_THRESHOLD_MS = 7200000`（2 小时），超过此时间的状态被视为 zombie
2. **Auto-extend**：到达 `max_iterations` 后自动 +10 而非直接停止，直到硬上限（strict 模式 200）
3. **Cancel 信号 TTL**：`CANCEL_SIGNAL_TTL_MS = 30000`（30 秒），过期信号自动忽略
4. **Context-aware 放行**：读取 transcript 尾部 4KB 估算 context 使用率，>= 95% 时无条件放行
5. **优先级排序**：9 种模式按优先级检查，Ralph 最高，确保不同模式不冲突


path: references/task-completion.md
content: # Task Completion Verifier

## Problem

Agent 在复杂多步骤任务中"觉得做完了"就停止，但实际上 checklist 里还有未完成的项。Ralph 的迭代计数只能防止过早停止，无法验证任务语义完成度。需要一个确定性的完成度校验机制。

## Solution

Stop hook 读取 `.harness-tasks.json` checklist 文件，逐项检查完成状态。只要存在 `done: false` 的条目，就 block 停止并将未完成项注入 block reason，让 agent 知道还差什么。与 Ralph 互补：Ralph 管"别停"，Task Completion 管"为什么不能停"。

## Implementation

1. 任务启动时创建 `.harness-tasks.json`，格式为 `{tasks: [{name, done}]}`
2. Agent 完成子任务后通过 Bash 调用 `jq` 将对应条目设为 `done: true`
3. Stop hook 读取文件，过滤 `done == false` 的条目
4. 若存在未完成项，返回 `{"decision":"block","reason":"未完成：<具体条目>"}`
5. 所有条目 done 后，放行停止

```json
// .harness-tasks.json
{
  "tasks": [
    {"name": "修改 parser.ts 支持新语法", "done": true},
    {"name": "更新测试用例", "done": false},
    {"name": "更新 README 文档", "done": false}
  ]
}
```

```bash
# Stop hook 核心逻辑
TASK_FILE=".harness-tasks.json"
[ -f "$TASK_FILE" ] || exit 0  # 无 checklist 则放行

REMAINING=$(jq '[.tasks[] | select(.done == false)] | length' "$TASK_FILE")
if [ "$REMAINING" -gt 0 ]; then
  ITEMS=$(jq -r '[.tasks[] | select(.done == false) | .name] | join(", ")' "$TASK_FILE")
  echo "{\"decision\":\"block\",\"reason\":\"未完成项: ${ITEMS}\"}"
else
  echo '{"decision":"allow"}'
fi
```

## Tradeoffs

- **Pro**: 确定性校验，不依赖 LLM 判断，零 API 成本
- **Pro**: Agent 能看到具体缺失项，不会盲目"继续工作"
- **Con**: 需要预先定义 checklist，不适合探索性任务
- **Con**: Agent 可能只是把条目标记为完成但实际没做好（需配合 quality-verification 的 hook 做二次验证）

## Source

Claude Code Stop hook 机制 + OMC persistent-mode 的语义完成度检查思路。与 Pattern 1.1 (Ralph) 叠加使用。


path: scripts/doubt-gate.sh
content: #!/usr/bin/env bash
# doubt-gate.sh — Stop hook: block completion when agent uses speculative language
# Forces agent to provide concrete evidence before stopping.
# Uses a one-shot guard to prevent infinite loops.

set -euo pipefail

SESSIONS_DIR="${HOME}/.openclaw/shared-context/sessions"

INPUT=$(head -c 20000)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
[ -z "$SESSION_ID" ] && SESSION_ID="${NC_SESSION:-}"
[ -z "$SESSION_ID" ] && echo '{"continue":true}' && exit 0

SESSION_DIR="${SESSIONS_DIR}/${SESSION_ID}"
GUARD_FILE="${SESSION_DIR}/.doubt-gate-fired"

# If guard already fired this turn, unconditionally allow stop
if [ -f "$GUARD_FILE" ]; then
  rm -f "$GUARD_FILE"
  echo '{"continue":true}'
  exit 0
fi

# Extract last assistant message from hook input
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
[ -z "$LAST_MSG" ] && echo '{"continue":true}' && exit 0

# Strip code blocks and blockquotes to avoid false positives
PROSE=$(echo "$LAST_MSG" | sed '/^```/,/^```/d; /^>/d; /^    /d')

# Check for hedging keywords (EN + ZH)
if echo "$PROSE" | grep -qiE \
  'I think|I believe|likely|maybe|might|probably|not sure|not certain|presumably|I assume|should be|could be|possibly|可能|大概|也许|应该是|我认为|我猜|不太确定|估计是'; then

  mkdir -p "$SESSION_DIR"
  touch "$GUARD_FILE"

  echo '{"decision":"block","reason":"Your response contains speculative language (\"maybe\", \"probably\", \"I think\", etc.). Before completing, you MUST provide concrete evidence: run a test, check a log, read the actual file, or verify with a command. Do not guess — verify."}'
else
  echo '{"continue":true}'
fi


path: scripts/drift-reanchor.sh
content: #!/usr/bin/env bash
# drift-reanchor.sh — Stop hook: re-inject original task every N turns
# Prevents long-session drift by anchoring to the original task description.
# Wired as Stop hook (fires every turn). Counts turns via state file.

set -euo pipefail

SESSIONS_DIR="${HOME}/.openclaw/shared-context/sessions"
INTERVAL="${REANCHOR_INTERVAL:-10}"

INPUT=$(head -c 20000)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
[ -z "$SESSION_ID" ] && SESSION_ID="${NC_SESSION:-}"
[ -z "$SESSION_ID" ] && echo '{"continue":true}' && exit 0

SESSION_DIR="${SESSIONS_DIR}/${SESSION_ID}"
STATE_FILE="${SESSION_DIR}/reanchor.json"
mkdir -p "$SESSION_DIR"

# Get last assistant message (may contain clues about the task)
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null | head -c 500)

# First call: save context as anchor and init turn count
if [ ! -f "$STATE_FILE" ]; then
  # On first stop, we don't have the original task yet — just init counter
  TMP="${STATE_FILE}.${$}.tmp"
  jq -n --argjson count 1 --arg task "" \
    '{"turn_count":$count,"original_task":$task}' > "$TMP"
  mv "$TMP" "$STATE_FILE"
  echo '{"continue":true}'
  exit 0
fi

# Increment turn count
TURN_COUNT=$(jq -r '.turn_count // 0' "$STATE_FILE" 2>/dev/null)
TURN_COUNT=$((TURN_COUNT + 1))
ORIGINAL_TASK=$(jq -r '.original_task // ""' "$STATE_FILE" 2>/dev/null)

# Update state
TMP="${STATE_FILE}.${$}.tmp"
jq -n --arg task "$ORIGINAL_TASK" --argjson count "$TURN_COUNT" \
  '{"original_task":$task,"turn_count":$count}' > "$TMP"
mv "$TMP" "$STATE_FILE"

# Every N turns, inject reminder (only if we have an anchor)
if [ $((TURN_COUNT % INTERVAL)) -eq 0 ] && [ -n "$ORIGINAL_TASK" ]; then
  jq -n --arg ctx "TASK REMINDER (turn ${TURN_COUNT}): ${ORIGINAL_TASK}" \
    '{"hookSpecificOutput":{"additionalContext":$ctx}}'
else
  echo '{"continue":true}'
fi


path: scripts/ralph-cancel.sh
content: #!/usr/bin/env bash
# ralph-cancel.sh — Send cancel signal with 30s TTL
# Usage: ralph-cancel.sh <session-id> [reason]
#
# State layout: ~/.openclaw/shared-context/sessions/<session-id>/cancel.json

set -euo pipefail

SESSION_ID="${1:?Usage: ralph-cancel.sh <session-id> [reason]}"
REASON="${2:-user_abort}"

SESSION_DIR="${HOME}/.openclaw/shared-context/sessions/${SESSION_ID}"
mkdir -p "$SESSION_DIR"

NOW=$(date -u +%FT%TZ)
EXPIRES=$(date -u -v+30S +%FT%TZ 2>/dev/null || date -u -d '+30 seconds' +%FT%TZ)

CANCEL_FILE="${SESSION_DIR}/cancel.json"
TMP="${CANCEL_FILE}.${$}.$(date +%s).tmp"
jq -n --arg at "$NOW" --arg exp "$EXPIRES" --arg r "$REASON" \
  '{requested_at: $at, expires_at: $exp, reason: $r}' > "$TMP"
mv "$TMP" "$CANCEL_FILE"
echo "Cancel signal sent for ${SESSION_ID} (expires in 30s)"


path: scripts/ralph-init.sh
content: #!/usr/bin/env bash
# ralph-init.sh — Initialize ralph persistent execution state for a session
# Usage: ralph-init.sh <session-id> [max-iterations]
#
# Creates session-scoped state directory and ralph state file.
# If a prior session state exists (crash recovery), resumes from last iteration.
# Call before starting a Claude Code session that needs persistent execution.

set -euo pipefail

SESSION_ID="${1:?Usage: ralph-init.sh <session-id> [max-iterations]}"
MAX_ITERATIONS="${2:-50}"

SESSION_DIR="${HOME}/.openclaw/shared-context/sessions/${SESSION_ID}"
mkdir -p "$SESSION_DIR/handoffs"

STATE_FILE="${SESSION_DIR}/ralph.json"
NOW=$(date -u +%FT%TZ)

# --- Crash recovery: resume from existing state ---
if [ -f "$STATE_FILE" ]; then
  PREV_ACTIVE=$(jq -r '.active // false' "$STATE_FILE")
  PREV_ITER=$(jq -r '.iteration // 0' "$STATE_FILE")
  PREV_REASON=$(jq -r '.deactivation_reason // ""' "$STATE_FILE")

  if [ "$PREV_ACTIVE" = "true" ] || [ "$PREV_REASON" = "stale" ]; then
    # Previous session was active or stale-timed-out → resume
    echo "Resuming ralph from iteration ${PREV_ITER} (previous state: active=${PREV_ACTIVE}, reason=${PREV_REASON})"
    TMP="${STATE_FILE}.${$}.$(date +%s).tmp"
    jq --argjson max "$MAX_ITERATIONS" --arg ts "$NOW" \
      '.active = true | .max_iterations = $max | .last_checked_at = $ts | .deactivation_reason = null | .resumed_at = $ts' \
      "$STATE_FILE" > "$TMP"
    mv "$TMP" "$STATE_FILE"
    echo "Ralph resumed: ${STATE_FILE} (iteration ${PREV_ITER}/${MAX_ITERATIONS})"
    exit 0
  fi
fi

# --- Fresh init ---
TMP="${STATE_FILE}.${$}.$(date +%s).tmp"
jq -n --arg sid "$SESSION_ID" --argjson max "$MAX_ITERATIONS" --arg ts "$NOW" \
  '{session_id: $sid, active: true, iteration: 0, max_iterations: $max, created_at: $ts, last_checked_at: $ts}' \
  > "$TMP"
mv "$TMP" "$STATE_FILE"
echo "Ralph initialized: ${STATE_FILE} (max ${MAX_ITERATIONS} iterations)"


path: scripts/ralph-stop-hook.sh
content: #!/usr/bin/env bash
# ralph-stop-hook.sh — Stop hook for persistent execution loops
# Blocks Claude Code stop events when ralph mode is active.
# Reads JSON from stdin (Claude Code hook protocol).
# Outputs JSON via jq (no string interpolation — injection-safe).
#
# 4 Safety invariants (NEVER block):
#   1. authentication errors (401/403 detected in last_assistant_message)
#   2. cancel signal (with TTL)
#   3. stale state (>2 hours idle)
#   4. max iterations reached
#
# NOTE: Context usage >= 95% safety valve is NOT implemented because
# Claude Code transcripts do not contain context_window_size. That data
# is only available via the statusLine stdin pipe (HUD plugins), not
# accessible to hook scripts. Claude Code's own reactive compaction
# handles context overflow independently.

set -euo pipefail

SESSIONS_DIR="${HOME}/.openclaw/shared-context/sessions"
STALE_THRESHOLD_S=7200  # 2 hours
allow() { echo '{"continue":true}'; exit 0; }

block_with_reason() {
  jq -n --arg reason "$1" '{"decision":"block","reason":$reason}'
  exit 0
}

write_atomic() {
  local target="$1" content="$2"
  local tmp="${target}.${$}.$(date +%s).tmp"
  echo "$content" > "$tmp"
  mv "$tmp" "$target"
}

parse_utc_epoch() {
  local ts="$1"
  local clean=$(echo "$ts" | sed 's/T/ /;s/Z//')
  TZ=UTC date -j -f "%Y-%m-%d %H:%M:%S" "$clean" +%s 2>/dev/null || \
  date -u -d "$ts" +%s 2>/dev/null || \
  python3 -c "import datetime; print(int(datetime.datetime.fromisoformat('$ts'.replace('Z','+00:00')).timestamp()))" 2>/dev/null || \
  echo 0
}

# Read hook input
INPUT=$(head -c 20000)

# Determine session ID: prefer stdin JSON, fallback to env var
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
[ -z "$SESSION_ID" ] && SESSION_ID="${NC_SESSION:-}"
[ -z "$SESSION_ID" ] && allow

# Session directory
SESSION_DIR="${SESSIONS_DIR}/${SESSION_ID}"

# Check ralph state file exists
STATE_FILE="${SESSION_DIR}/ralph.json"
[ -f "$STATE_FILE" ] || allow

# Parse state
STATE=$(cat "$STATE_FILE")
ACTIVE=$(echo "$STATE" | jq -r '.active // false')
[ "$ACTIVE" = "true" ] || allow

ITERATION=$(echo "$STATE" | jq -r '.iteration // 0')
MAX=$(echo "$STATE" | jq -r '.max_iterations // 50')
LAST_CHECKED=$(echo "$STATE" | jq -r '.last_checked_at // ""')

# === Safety valve 1: authentication errors ===
# Note: Stop hook does not receive a stop_reason field. Check last_assistant_message
# for API error indicators that suggest auth failure.
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
if echo "$LAST_MSG" | grep -qiE '401|403|unauthorized|forbidden|authentication.*failed|token.*expired|APIAuthenticationError'; then
  write_atomic "$STATE_FILE" "$(echo "$STATE" | jq '.active = false | .deactivation_reason = "auth_error"')"
  allow
fi

# === Safety valve 2: stale (>2 hours since last activity) ===
if [ -n "$LAST_CHECKED" ]; then
  LAST_EPOCH=$(parse_utc_epoch "$LAST_CHECKED")
  NOW_EPOCH=$(date +%s)
  IDLE_S=$(( NOW_EPOCH - LAST_EPOCH ))
  if [ "$IDLE_S" -gt "$STALE_THRESHOLD_S" ]; then
    write_atomic "$STATE_FILE" "$(echo "$STATE" | jq '.active = false | .deactivation_reason = "stale"')"
    allow
  fi
fi

# === Safety valve 3: cancel signal ===
CANCEL_FILE="${SESSION_DIR}/cancel.json"
if [ -f "$CANCEL_FILE" ]; then
  EXPIRES=$(jq -r '.expires_at // ""' "$CANCEL_FILE")
  if [ -n "$EXPIRES" ]; then
    EXPIRES_EPOCH=$(parse_utc_epoch "$EXPIRES")
    NOW_EPOCH=$(date +%s)
    if [ "$NOW_EPOCH" -lt "$EXPIRES_EPOCH" ]; then
      write_atomic "$STATE_FILE" "$(echo "$STATE" | jq '.active = false | .deactivation_reason = "cancelled"')"
      rm -f "$CANCEL_FILE"
      allow
    else
      rm -f "$CANCEL_FILE"  # expired, clean up
    fi
  fi
fi

# === Safety valve 4: max iterations ===
if [ "$ITERATION" -ge "$MAX" ]; then
  write_atomic "$STATE_FILE" "$(echo "$STATE" | jq '.active = false | .deactivation_reason = "max_iterations"')"
  allow
fi

# Block stop and increment iteration
NEW_ITERATION=$(( ITERATION + 1 ))
NOW=$(date -u +%FT%TZ)
UPDATED=$(echo "$STATE" | jq \
  --argjson iter "$NEW_ITERATION" \
  --arg ts "$NOW" \
  '.iteration = $iter | .last_checked_at = $ts')
write_atomic "$STATE_FILE" "$UPDATED"

# Block message uses prompt-hardening P5 (anti-reasoning) to counter
# the agent's tendency to rationalize premature completion
block_with_reason "[RALPH LOOP ${NEW_ITERATION}/${MAX}] Task is NOT done. Do NOT rationalize that the remaining work can be done in a follow-up. Do NOT claim completion with caveats like 'mostly done' or 'should work'. Check your original task and verify EVERY requirement is met before attempting to stop again."


path: scripts/task-completion-gate.sh
content: #!/usr/bin/env bash
# task-completion-gate.sh — Stop hook: block stop if task checklist has unchecked items
# Reads .harness-tasks.json from working directory or session state.
# Stale checklists (>24h) auto-allow.

set -euo pipefail

SESSIONS_DIR="${HOME}/.openclaw/shared-context/sessions"

INPUT=$(head -c 20000)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
[ -z "$SESSION_ID" ] && SESSION_ID="${NC_SESSION:-}"
[ -z "$SESSION_ID" ] && echo '{"continue":true}' && exit 0

# Look for task file in CWD first, then session dir
TASK_FILE=""
if [ -f ".harness-tasks.json" ]; then
  TASK_FILE=".harness-tasks.json"
elif [ -f "${SESSIONS_DIR}/${SESSION_ID}/.harness-tasks.json" ]; then
  TASK_FILE="${SESSIONS_DIR}/${SESSION_ID}/.harness-tasks.json"
fi

# No task file = no gate
if [ -z "$TASK_FILE" ]; then
  echo '{"continue":true}'
  exit 0
fi

# Check staleness (>24h = auto-allow)
if command -v stat &>/dev/null; then
  FILE_AGE=$(( $(date +%s) - $(stat -f %m "$TASK_FILE" 2>/dev/null || stat -c %Y "$TASK_FILE" 2>/dev/null || echo "0") ))
  if [ "$FILE_AGE" -gt 86400 ]; then
    echo '{"continue":true}'
    exit 0
  fi
fi

# Count remaining tasks
REMAINING=$(jq '[.tasks[] | select(.done == false)] | length' "$TASK_FILE" 2>/dev/null || echo "0")
REMAINING_NAMES=$(jq -r '[.tasks[] | select(.done == false) | .name] | join(", ")' "$TASK_FILE" 2>/dev/null || echo "")

if [ "$REMAINING" -gt 0 ]; then
  jq -n --arg reason "Task checklist has ${REMAINING} unchecked item(s): ${REMAINING_NAMES}. Complete these before stopping." \
    '{"decision":"block","reason":$reason}'
else
  echo '{"continue":true}'
fi


path: tests/test_doubt_gate.py
content: """Tests for doubt gate stop hook."""

import json
import os
import subprocess
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


@pytest.fixture
def env(tmp_path):
    e = os.environ.copy()
    e["HOME"] = str(tmp_path)
    sessions = tmp_path / ".openclaw" / "shared-context" / "sessions"
    sessions.mkdir(parents=True)
    return e, tmp_path


def run_doubt_gate(env, last_msg="", session_id="test-sess"):
    env["NC_SESSION"] = session_id
    inp = json.dumps({"last_assistant_message": last_msg})
    result = subprocess.run(
        ["bash", str(SCRIPTS_DIR / "doubt-gate.sh")],
        capture_output=True, text=True, env=env, input=inp, timeout=10,
    )
    return json.loads(result.stdout.strip()), result.returncode


class TestDoubtGate:
    def test_allows_concrete_response(self, env):
        e, _ = env
        result, rc = run_doubt_gate(e, "I have verified the fix by running pytest. All 12 tests pass.")
        assert result.get("continue") is True

    def test_blocks_speculative_english(self, env):
        e, _ = env
        result, _ = run_doubt_gate(e, "I think this should fix the bug. It will probably work.")
        assert result.get("decision") == "block"
        assert "evidence" in result.get("reason", "").lower() or "verify" in result.get("reason", "").lower()

    def test_blocks_speculative_chinese(self, env):
        e, _ = env
        result, _ = run_doubt_gate(e, "这个修改应该是可以解决问题的，大概不会有副作用。")
        assert result.get("decision") == "block"

    def test_allows_after_guard_fires(self, env):
        e, tmp = env
        # First call blocks
        result1, _ = run_doubt_gate(e, "I think this might work.")
        assert result1.get("decision") == "block"
        # Second call with same speculative text — guard fired, allows through
        result2, _ = run_doubt_gate(e, "I think this might work.")
        assert result2.get("continue") is True

    def test_ignores_code_blocks(self, env):
        e, _ = env
        msg = "The fix is applied.\n```python\n# I think this might need review\nresult = maybe_func()\n```\nAll tests pass."
        result, _ = run_doubt_gate(e, msg)
        assert result.get("continue") is True

    def test_allows_empty_message(self, env):
        e, _ = env
        result, _ = run_doubt_gate(e, "")
        assert result.get("continue") is True

    def test_no_session_allows(self, env):
        e, _ = env
        e.pop("NC_SESSION", None)
        e["NC_SESSION"] = ""
        inp = json.dumps({"last_assistant_message": "I think maybe this works"})
        result = subprocess.run(
            ["bash", str(SCRIPTS_DIR / "doubt-gate.sh")],
            capture_output=True, text=True, env=e, input=inp, timeout=10,
        )
        out = json.loads(result.stdout.strip())
        assert out.get("continue") is True


path: tests/test_drift_reanchor.py
content: """Tests for drift-reanchor stop hook."""

import json
import os
import subprocess
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


@pytest.fixture
def env(tmp_path):
    e = os.environ.copy()
    e["HOME"] = str(tmp_path)
    sessions = tmp_path / ".openclaw" / "shared-context" / "sessions"
    sessions.mkdir(parents=True)
    return e, tmp_path


def run(env, stdin_data="", session_id="test-sess"):
    env["NC_SESSION"] = session_id
    result = subprocess.run(
        ["bash", str(SCRIPTS_DIR / "drift-reanchor.sh")],
        capture_output=True, text=True, env=env, input=stdin_data, timeout=10,
    )
    return result.stdout.strip(), result.returncode


class TestDriftReanchor:
    def test_first_call_inits_state(self, env):
        e, tmp = env
        stdout, rc = run(e, json.dumps({"last_assistant_message": "hello"}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True
        # State file should exist with turn_count=1
        state = tmp / ".openclaw/shared-context/sessions/test-sess/reanchor.json"
        assert state.exists()
        data = json.loads(state.read_text())
        assert data["turn_count"] == 1

    def test_increments_count(self, env):
        e, tmp = env
        inp = json.dumps({"last_assistant_message": "working"})
        run(e, inp)  # turn 1 (init)
        run(e, inp)  # turn 2
        run(e, inp)  # turn 3
        state = tmp / ".openclaw/shared-context/sessions/test-sess/reanchor.json"
        data = json.loads(state.read_text())
        assert data["turn_count"] == 3

    def test_injects_at_interval(self, env):
        e, tmp = env
        e["REANCHOR_INTERVAL"] = "3"
        # Set up state with original_task so reminder fires
        session_dir = tmp / ".openclaw/shared-context/sessions/test-sess"
        session_dir.mkdir(parents=True, exist_ok=True)
        state_file = session_dir / "reanchor.json"
        state_file.write_text(json.dumps({
            "turn_count": 2,
            "original_task": "Fix the login bug",
        }))
        inp = json.dumps({"last_assistant_message": "still working"})
        stdout, rc = run(e, inp)  # turn 3, interval=3, should fire
        assert rc == 0
        result = json.loads(stdout)
        ctx = result.get("hookSpecificOutput", {}).get("additionalContext", "")
        assert "Fix the login bug" in ctx
        assert "TASK REMINDER" in ctx

    def test_no_inject_without_original_task(self, env):
        e, tmp = env
        e["REANCHOR_INTERVAL"] = "3"
        inp = json.dumps({"last_assistant_message": "working"})
        # First call inits (turn 1)
        run(e, inp)
        # turn 2
        run(e, inp)
        # turn 3 — interval hit but no original_task set
        stdout, rc = run(e, inp)
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_no_session_allows(self, env):
        e, _ = env
        e["NC_SESSION"] = ""
        stdout, rc = run(e, json.dumps({"last_assistant_message": "test"}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True


path: tests/test_ralph.py
content: """Tests for Ralph persistent execution loop scripts."""

import json
import os
import subprocess
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


@pytest.fixture
def ralph_env(tmp_path):
    """Create isolated session-scoped state directories."""
    env = os.environ.copy()
    env["HOME"] = str(tmp_path)
    # Create the expected directory structure
    sessions = tmp_path / ".openclaw" / "shared-context" / "sessions"
    sessions.mkdir(parents=True)
    return env, tmp_path


def run_script(script_name, env, stdin_data="", args=None):
    """Run a bash script and return (stdout, returncode)."""
    cmd = ["bash", str(SCRIPTS_DIR / script_name)]
    if args:
        cmd.extend(args)
    result = subprocess.run(
        cmd, capture_output=True, text=True, env=env,
        input=stdin_data, timeout=10,
    )
    return result.stdout.strip(), result.returncode


def session_dir(tmp, session_id):
    return tmp / ".openclaw" / "shared-context" / "sessions" / session_id


class TestRalphInit:
    def test_creates_state_file(self, ralph_env):
        env, tmp = ralph_env
        stdout, rc = run_script("ralph-init.sh", env, args=["test-sess", "20"])
        assert rc == 0
        state_file = session_dir(tmp, "test-sess") / "ralph.json"
        assert state_file.exists()
        state = json.loads(state_file.read_text())
        assert state["session_id"] == "test-sess"
        assert state["active"] is True
        assert state["iteration"] == 0
        assert state["max_iterations"] == 20

    def test_default_max_iterations(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["test-sess"])
        state_file = session_dir(tmp, "test-sess") / "ralph.json"
        state = json.loads(state_file.read_text())
        assert state["max_iterations"] == 50

    def test_missing_session_id_fails(self, ralph_env):
        env, _ = ralph_env
        _, rc = run_script("ralph-init.sh", env, args=[])
        assert rc != 0

    def test_crash_recovery_resumes(self, ralph_env):
        """Simulate crash: init, run 5 iterations, then re-init should resume."""
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["crash-test", "50"])
        # Simulate 5 iterations by modifying state
        state_file = session_dir(tmp, "crash-test") / "ralph.json"
        state = json.loads(state_file.read_text())
        state["iteration"] = 5
        state_file.write_text(json.dumps(state))
        # Re-init (crash recovery)
        stdout, rc = run_script("ralph-init.sh", env, args=["crash-test", "50"])
        assert rc == 0
        assert "Resuming" in stdout
        state = json.loads(state_file.read_text())
        assert state["iteration"] == 5  # Preserved, not reset
        assert state["active"] is True


class TestRalphStopHook:
    def test_allows_when_no_session(self, ralph_env):
        env, _ = ralph_env
        env.pop("NC_SESSION", None)
        stdout, rc = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_allows_when_no_state_file(self, ralph_env):
        env, _ = ralph_env
        env["NC_SESSION"] = "nonexistent-session"
        stdout, rc = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_blocks_when_active(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["block-test", "10"])
        env["NC_SESSION"] = "block-test"
        stdout, rc = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("decision") == "block"
        assert "RALPH LOOP 1/10" in result.get("reason", "")

    def test_increments_iteration(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["inc-test", "10"])
        env["NC_SESSION"] = "inc-test"
        run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        result = json.loads(stdout)
        assert "RALPH LOOP 2/10" in result.get("reason", "")

    def test_allows_after_max_iterations(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["max-test", "3"])
        env["NC_SESSION"] = "max-test"
        for _ in range(3):
            run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_allows_when_inactive(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["inactive-test", "10"])
        state_file = session_dir(tmp, "inactive-test") / "ralph.json"
        state = json.loads(state_file.read_text())
        state["active"] = False
        state_file.write_text(json.dumps(state))
        env["NC_SESSION"] = "inactive-test"
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        result = json.loads(stdout)
        assert result.get("continue") is True


    def test_allows_on_auth_error(self, ralph_env):
        """Safety valve: auth errors in last_assistant_message should allow stop."""
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["auth-test", "50"])
        env["NC_SESSION"] = "auth-test"
        hook_input = json.dumps({
            "session_id": "auth-test",
            "last_assistant_message": "Error: 401 Unauthorized - API key expired",
        })
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data=hook_input)
        result = json.loads(stdout)
        assert result.get("continue") is True
        state = json.loads((session_dir(tmp, "auth-test") / "ralph.json").read_text())
        assert state["deactivation_reason"] == "auth_error"

    def test_allows_on_stale_timeout(self, ralph_env):
        """Safety valve: >2 hours idle should allow stop."""
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["stale-test", "50"])
        # Manually set last_checked_at to 3 hours ago
        state_file = session_dir(tmp, "stale-test") / "ralph.json"
        state = json.loads(state_file.read_text())
        from datetime import datetime, timedelta, timezone
        three_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%SZ")
        state["last_checked_at"] = three_hours_ago
        state_file.write_text(json.dumps(state))
        env["NC_SESSION"] = "stale-test"
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        result = json.loads(stdout)
        assert result.get("continue") is True
        state = json.loads(state_file.read_text())
        assert state["deactivation_reason"] == "stale"

    def test_expired_cancel_is_ignored(self, ralph_env):
        """Expired cancel signal should be cleaned up, ralph continues."""
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["expire-test", "50"])
        # Create an expired cancel signal (expires_at in the past)
        cancel_file = session_dir(tmp, "expire-test") / "cancel.json"
        cancel_file.write_text(json.dumps({
            "requested_at": "2020-01-01T00:00:00Z",
            "expires_at": "2020-01-01T00:00:30Z",
            "reason": "old_cancel",
        }))
        env["NC_SESSION"] = "expire-test"
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        result = json.loads(stdout)
        # Ralph should block (expired cancel ignored, ralph still active)
        assert result.get("decision") == "block"
        # Expired cancel file should be cleaned up
        assert not cancel_file.exists()


class TestRalphCancel:
    def test_creates_cancel_signal(self, ralph_env):
        env, tmp = ralph_env
        stdout, rc = run_script("ralph-cancel.sh", env, args=["cancel-test"])
        assert rc == 0
        cancel_file = session_dir(tmp, "cancel-test") / "cancel.json"
        assert cancel_file.exists()
        signal = json.loads(cancel_file.read_text())
        assert "requested_at" in signal
        assert "expires_at" in signal
        assert signal["reason"] == "user_abort"

    def test_custom_reason(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-cancel.sh", env, args=["cancel-test", "timeout"])
        cancel_file = session_dir(tmp, "cancel-test") / "cancel.json"
        signal = json.loads(cancel_file.read_text())
        assert signal["reason"] == "timeout"

    def test_cancel_stops_ralph(self, ralph_env):
        env, tmp = ralph_env
        run_script("ralph-init.sh", env, args=["cancel-flow", "50"])
        run_script("ralph-cancel.sh", env, args=["cancel-flow"])
        env["NC_SESSION"] = "cancel-flow"
        stdout, _ = run_script("ralph-stop-hook.sh", env, stdin_data="{}")
        result = json.loads(stdout)
        assert result.get("continue") is True
        state_file = session_dir(tmp, "cancel-flow") / "ralph.json"
        state = json.loads(state_file.read_text())
        assert state["active"] is False
        assert state["deactivation_reason"] == "cancelled"


path: tests/test_task_completion.py
content: """Tests for task-completion-gate stop hook."""

import json
import os
import subprocess
import time
from pathlib import Path

import pytest

SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"


@pytest.fixture
def env(tmp_path):
    e = os.environ.copy()
    e["HOME"] = str(tmp_path)
    sessions = tmp_path / ".openclaw" / "shared-context" / "sessions"
    sessions.mkdir(parents=True)
    return e, tmp_path


def run(env, stdin_data="", session_id="test-sess"):
    env["NC_SESSION"] = session_id
    result = subprocess.run(
        ["bash", str(SCRIPTS_DIR / "task-completion-gate.sh")],
        capture_output=True, text=True, env=env, input=stdin_data, timeout=10,
    )
    return result.stdout.strip(), result.returncode


def make_task_file(path, tasks):
    """Create a .harness-tasks.json file at the given path."""
    data = {"tasks": tasks}
    path.write_text(json.dumps(data))


class TestTaskCompletionGate:
    def test_no_checklist_allows(self, env):
        e, _ = env
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_all_done_allows(self, env):
        e, tmp = env
        session_dir = tmp / ".openclaw" / "shared-context" / "sessions" / "test-sess"
        session_dir.mkdir(parents=True, exist_ok=True)
        make_task_file(session_dir / ".harness-tasks.json", [
            {"name": "Write tests", "done": True},
            {"name": "Fix bug", "done": True},
        ])
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_items_remaining_blocks(self, env):
        e, tmp = env
        session_dir = tmp / ".openclaw" / "shared-context" / "sessions" / "test-sess"
        session_dir.mkdir(parents=True, exist_ok=True)
        make_task_file(session_dir / ".harness-tasks.json", [
            {"name": "Write tests", "done": True},
            {"name": "Fix lint", "done": False},
            {"name": "Update docs", "done": False},
        ])
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("decision") == "block"
        assert "Fix lint" in result.get("reason", "")
        assert "Update docs" in result.get("reason", "")

    def test_stale_checklist_allows(self, env):
        e, tmp = env
        session_dir = tmp / ".openclaw" / "shared-context" / "sessions" / "test-sess"
        session_dir.mkdir(parents=True, exist_ok=True)
        task_file = session_dir / ".harness-tasks.json"
        make_task_file(task_file, [
            {"name": "Old task", "done": False},
        ])
        # Set mtime to 25 hours ago
        old_time = time.time() - (25 * 3600)
        os.utime(str(task_file), (old_time, old_time))
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_no_session_allows(self, env):
        e, _ = env
        e["NC_SESSION"] = ""
        stdout, rc = run(e, json.dumps({}))
        assert rc == 0
        result = json.loads(stdout)
        assert result.get("continue") is True

    def test_single_remaining_blocks_with_name(self, env):
        e, tmp = env
        session_dir = tmp / ".openclaw" / "shared-context" / "sessions" / "test-sess"
        session_dir.mkdir(parents=True, exist_ok=True)
        make_task_file(session_dir / ".harness-tasks.json", [
            {"name": "Run integration tests", "done": False},
        ])
        stdout, rc = run(e, json.dumps({}))
        result = json.loads(stdout)
        assert result.get("decision") == "block"
        assert "1 unchecked" in result.get("reason", "")
        assert "Run integration tests" in result.get("reason", "")


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)