Skill: shawnhan98/weixin-task-workbench (version 1.0.4)

--- name: weixin-task-workbench description: 在单个微信 / OpenClaw 对话窗口中提供“单入口、多任务”的任务工作台体验：用户始终只需和一个助手聊天，就能并行推进多个事项，并把不同任务稳定路由到各自独立会话，减少串话、上下文污染与多线程协作混乱。适用于微信中的长期协作、任务切换、任务总结、任务归档与任务生命周期管理；当用户发送“任务列表 / 新建任务：… / 切到 #id / 切换到 #id / 继续 / 总结 #id / 关闭 #id / 归档 #id / 任务状态”或英文等价命令时，应优先使用此技能。 homepage: https://clawhub.com metadata: { "openclaw": { "requires": { "bins": ["python3"] } } } --- # 微信任务工作台 在一个微信对话窗口中运行**单入口、多任务**工作台。 它面向这样一种典型场景：用户只想和一个助手持续对话，但实际工作里同时存在多个并行事项。这个 skill 会把“单窗口交互体验”和“多会话执行隔离”结合起来，让用户前台感觉始终在同一个聊天里，后台则把不同任务拆分到各自独立会话中执行。 对用户只暴露一组简单、稳定的任务动作：**新建**、**列表**、**切换**、**继续**、**总结**、**关闭**、**归档**。 核心价值： - 用一个微信窗口管理多个并行任务，而不是来回切换不同会话 - 为每个任务绑定独立会话，减少串话、遗忘和上下文污染 - 用 registry 持久化任务状态、当前任务指针与摘要，便于恢复与追踪 - 兼容自然中文命令，适合作为 OpenClaw 的微信任务中枢 任务状态以 `scripts/task_registry.py` 为唯一事实来源。 ## 触发信号（强匹配） 当用户出现以下任一表达时，应优先激活本技能，而不是进入泛化问答澄清： - `任务列表` - `新建任务：...` - `切到 #<id>` / `切换到 #<id>` - `继续`（在任务上下文中） - `总结 #<id>` - `关闭 #<id>` - `归档 #<id>` - `任务状态` 同义英文命令（`task list`、`task new`、`task use` 等）同样适用。 ## 强制能力门禁（必需） 如果缺少以下任一会话工具，本技能必须**阻断执行**： - `sessions_spawn` - `sessions_send` - `sessions_history` 执行规则： 1. 在会话中的**第一次任务命令**时检查能力。 2. 如果缺任何工具，立即停止并返回阻断提示。 3. 阻断状态下不允许创建或更新任务 registry。 4. 明确提示如何修复主机配置（`tools.allow`、`agentToAgent`、`sessions.visibility`）。 禁止静默降级为 registry-only 模式。 ## 主机配置提醒 要获得真实任务隔离，通常需要： - agent allowlist 包含 `sessions_spawn`、`sessions_send`、`sessions_history`，通常还需要 `sessions_list` - `tools.agentToAgent.enabled=true` - `tools.sessions.visibility=all` 参考配置： ```json { "tools": { "agentToAgent": { "enabled": true }, "sessions": { "visibility": "all" } }, "agents": { "list": [ { "id": "main", "tools": { "allow": [ "sessions_spawn", "sessions_send", "sessions_history", "sessions_list" ] } } ] } } ``` ## 核心运行模型 - 一个用户可见的微信聊天窗口 - 每个微信账号+联系人对应一个任务 registry 文件 - 一个当前任务指针 - 每个任务对应一个独立会话 - 非全局命令默认路由到当前任务 除非用户明确要求查看实现细节，否则不要暴露 `sessionKey`。 ## Registry 路径策略 使用“联系人隔离”路径： ```text tasks/weixin/<account>/<peer>.json ``` 规则： - `<account>` 使用当前微信账号 id - `<peer>` 使用当前微信用户 id（`[REDACTED_SECRET]`），并做文件名安全化 - 不同 `account+peer` 严禁共享同一 registry 文件 首次使用先初始化： ```bash python3 scripts/task_registry.py --registry tasks/weixin/<account>/<peer>.json init ``` ## 标准命令（与飞书流程兼容） - `新建任务：<title>` - `任务列表` - `切到 #<id>` - `继续` - `总结 #<id>` - `关闭 #<id>` - `归档 #<id>` - `任务状态` 可兼容英文等价命令（如 `task new`、`task list`、`task use`）。 ## 控制面动作 ### 1. 新建任务 1. 先执行能力门禁（仅首次任务命令） 2. 先校验标题非空；若用户只发送 `新建任务` 或空标题，必须返回补全提示，不得创建空任务 3. 用 `sessions_spawn` 创建任务专属会话 4. 用 registry `add --session-key ... --make-current` 持久化任务 5. 回执任务 id 与当前任务 ### 2. 查看任务列表 执行： ```bash python3 scripts/task_registry.py --registry <path> list ``` 返回任务列表，并标记当前任务。 如果任务数为 0，不要只返回原始 JSON 或泛化提示；必须按“任务列表”样式返回空状态，并给出 1～2 个可直接复制的 `新建任务：...` 示例。 ### 3. 切换任务 执行： ```bash python3 scripts/task_registry.py --registry <path> switch <id> ``` 回执已切换目标。 如果 `id` 不存在，必须返回“任务 #<id> 不存在，请先发送任务列表查看可用任务”，不要直接透出脚本原始错误。 ### 4. 继续当前任务 1. 读取当前任务（`show`） 2. 使用 `sessions_send` 把消息路由到当前任务 `sessionKey` 3. 需要时将状态更新为 `in_progress` 若无当前任务，提示先新建或先查看列表。 若当前任务状态为 `completed` 或 `archived`，不要直接继续执行；先提示用户切换到其他任务，或明确要求恢复该任务后再继续。 ### 5. 总结任务 1. 读取 registry（`summarize <id>`） 2. 需要时调用 `sessions_history` 补充上下文 3. 输出“进展/产出/阻塞/下一步” 4. 可选写回 `update --summary` 如果 `id` 不存在，返回“任务 #<id> 不存在，请先发送任务列表查看可用任务”。 ### 6. 关闭 / 归档任务 执行： ```bash python3 scripts/task_registry.py --registry <path> close <id> --summary "..." python3 scripts/task_registry.py --registry <path> archive <id> --summary "..." ``` 如果 `id` 不存在，返回“任务 #<id> 不存在，请先发送任务列表查看可用任务”。 如果任务已经是 `completed` / `archived`，应返回幂等提示，不要重复执行状态变更。 ### 7. 任务状态（替代 `/status` 的任务视图） 当用户发送 `任务状态` 时： 1. 读取 `show`（当前任务）+ `list`（任务清单） 2. 若存在当前任务，返回当前任务编号、标题、状态 3. 返回当前任务绑定的 `sessionKey`（仅用于排障；无当前任务时省略） 4. 返回 registry 路径（便于定位持久化文件） 5. 若没有任何任务，明确返回“当前任务：无 / 任务总数：0 / 先发送 新建任务：...” 说明：`/status` 是 OpenClaw 全局会话状态，不等价于任务工作台的任务路由状态。 ## 测试与回归 修改以下任一文件后，优先阅读并对照 `references/test-matrix.md` 做回归检查： - `SKILL.md` - `references/protocol.md` - `references/implementation.md` - `scripts/task_registry.py` 至少覆盖：空状态、新建任务、切换、继续、总结、关闭、归档、能力门禁、英文等价命令。 ## 回复样式约束 对控制面命令（如 `任务列表`、`任务状态`、`新建任务`、`切换`、`关闭`、`归档`），优先套用 `references/protocol.md` 中的固定响应模板，避免每次自由发挥。 对于路由到具体任务的非全局回复，必须加任务头： ```text [任务:#2 周报] ``` 该任务头需要在每次任务回复中可见，便于用户核对路由是否正确。

path: references/implementation.md
content: # 微信任务工作台实现说明

## 与飞书版本的差异

本微信版本沿用飞书版命令语义，但运行策略有三点差异：

- 会话工具缺失时强制阻断（不降级到 registry-only）
- registry 按 `account + peer` 隔离
- 所有任务路由回复都必须带任务头

## 目录结构

```text
skills/public/weixin-task-workbench/
  SKILL.md
  scripts/task_registry.py
  references/protocol.md
  references/implementation.md

tasks/weixin/<account>/<peer>.json
```

## 启动检查

在会话内第一次任务命令时执行：

1. 检查 `sessions_spawn`、`sessions_send`、`sessions_history`
2. 如缺任一能力，返回阻断提示并停止
3. 能力齐全后才进入正常任务流程

## Registry 路径构造

- `account` = 当前微信账号 id
- `peer` = 当前微信用户 id（`[REDACTED_SECRET]`），做文件名安全化
- 最终路径 = `tasks/weixin/<account>/<peer>.json`

## 标准命令流程

## 异常与幂等处理

实现时不要把 `task_registry.py` 的原始错误直接转发给用户。对常见异常统一转成任务工作台话术：

- 空标题创建任务 → 返回补全提示
- 不存在的任务 id → 返回“任务 #<id> 不存在，请先发送任务列表查看可用任务”
- 无当前任务时继续 → 返回“当前没有可继续的任务”
- 已完成任务重复关闭 / 已归档任务重复归档 → 返回幂等提示，不重复写状态
- 当前任务为 `completed` / `archived` 时收到 `继续` → 先提示切换或恢复，不直接路由

优先使用 `references/protocol.md` 中的固定响应模板。除任务总结、任务头内正文等确需生成内容的场景外，不要自由改写这些控制面回复。

### 新建任务

1. `sessions_spawn` 创建任务会话
2. `task_registry.py add ... --session-key <sessionKey> --make-current`
3. 回执任务创建成功与当前任务

### 继续任务

1. `task_registry.py show`
2. 通过 `sessions_send` 将用户消息发送到任务 `sessionKey`
3. 需要时更新任务状态

### 总结任务

1. `task_registry.py summarize <id>`
2. 需要更多上下文时调用 `sessions_history`
3. 输出简明阶段总结
4. 可选写回 `update --summary`

### 任务状态

1. `task_registry.py show` 获取当前任务
2. `task_registry.py list` 获取任务列表
3. 返回任务态摘要（当前任务、状态、sessionKey、registry 路径）
4. 用于排障时优先使用 `任务状态`，不要用 `/status` 判断任务是否隔离

## 验收标准

满足以下条件才可认为运行有效：

1. `新建任务` 能写入 registry 并获得真实 `sessionKey`
2. 后续消息能通过 `sessions_send` 持续进入同一任务会话
3. `总结 #id` 可通过 `sessions_history` 获取近期上下文
4. 切换任务后默认路由目标同步变化
5. 非全局任务回复均包含 `[任务:#id 标题]` 任务头


path: references/protocol.md
content: # 单入口多任务工作台协议（微信）

## 目标

在一个微信/OpenClaw 对话窗口中并行管理多个任务，并保证上下文不串味。

## 用户可见概念

- 任务
- 任务 ID
- 当前任务
- 状态
- 摘要

除非用户明确询问实现细节，不主动暴露：

- `sessionKey`
- 路由内部实现
- subagent / session 机制细节

## 标准命令

- `新建任务：<title>`
- `任务列表`
- `切到 #<id>`
- `继续`
- `总结 #<id>`
- `关闭 #<id>`
- `归档 #<id>`
- `任务状态`

## 边界处理（必需）

### 新建任务缺少标题

如果用户只发送 `新建任务`、`新建任务：` 或等价空标题命令，不要创建空任务。必须提示：

```text
新建任务时还缺一个标题。
可直接发送：
- 新建任务：周报
- 新建任务：招聘方案优化
```

### 指定任务不存在

如果用户请求的 `#<id>` 不存在（如 `切到 #9`、`总结 #9`、`关闭 #9`、`归档 #9`），不要返回原始错误栈。必须提示：

```text
任务 #9 不存在。
先发送「任务列表」查看可用任务。
```

### 无当前任务时继续

如果用户发送 `继续`，但当前没有任务：

```text
当前没有可继续的任务。
先发送「任务列表」查看任务，或发送「新建任务：...」开始。
```

### 已完成 / 已归档任务

- `切到 #<id>`：允许切换到已完成任务，但要在回执中标明状态。
- `继续`：默认只路由到当前任务；如果当前任务为 `completed` 或 `archived`，先提醒用户切换或明确恢复。
- `归档 #<id>`：如果任务已归档，应返回“已归档，无需重复归档”。
- `关闭 #<id>`：如果任务已完成，应返回“已完成，无需重复关闭”。

## 强制门禁

在首次任务命令前，必须确认主机支持：

- `sessions_spawn`
- `sessions_send`
- `sessions_history`

缺任一能力时，立即阻断并返回配置修复指引。

## Registry 隔离范围

按微信账号 + 联系人隔离：

```text
tasks/weixin/<account>/<peer>.json
```

## 最小数据模型

```json
{
  "currentTaskId": 2,
  "tasks": [
    {
      "id": 1,
      "title": "周报",
      "sessionKey": "sess_a",
      "status": "completed",
      "updatedAt": "2026-03-23T18:00:00+00:00",
      "summary": "已输出周报终稿"
    },
    {
      "id": 2,
      "title": "PPT",
      "sessionKey": "sess_b",
      "status": "in_progress",
      "updatedAt": "2026-03-23T18:15:00+00:00",
      "summary": "已完成结构与前两页"
    }
  ]
}
```

## 输出样式

## 响应模板（低自由度，优先套用）

以下控制面命令优先使用固定模板回复，避免风格漂移。

### 新建任务成功

```text
已新建任务 #<id>：<title>
当前任务已切换到：#<id> <title>
```

### 切换任务成功

```text
已切换到任务 #<id>：<title> [<状态中文名>]
```

### 继续但无当前任务

```text
当前没有可继续的任务。
先发送「任务列表」查看任务，或发送「新建任务：...」开始。
```

### 指定任务不存在

```text
任务 #<id> 不存在。
先发送「任务列表」查看可用任务。
```

### 重复关闭

```text
任务 #<id> 已完成，无需重复关闭。
```

### 重复归档

```text
任务 #<id> 已归档，无需重复归档。
```

### 新建任务缺标题

```text
新建任务时还缺一个标题。
可直接发送：
- 新建任务：周报
- 新建任务：招聘方案优化
```

### 任务列表

有任务时：

```text
任务列表 / Task List
#1 周报 [已完成]
#2 PPT [进行中] ← 当前
```

无任务时：

```text
任务列表 / Task List
当前还没有任务。
可直接发送：
- 新建任务：周报
- 新建任务：产品需求梳理
```

### 任务回复头（必需）

```text
[任务:#2 PPT]
```

### 任务总结

```text
任务 #2：PPT
- 进展：已完成结构与前两页
- 当前状态：进行中
- 阻塞：缺少最新数据图
- 下一步：补图并统一视觉样式
```

### 任务状态（排障命令）

有当前任务时：

```text
任务状态
- 当前任务：#2 PPT [进行中]
- taskSessionKey: agent:main:openclaw-weixin:direct:<peer>:task:t2
- registry: tasks/weixin/<account>/<peer>.json
```

无任务时：

```text
任务状态
- 当前任务：无
- 任务总数：0
- registry: tasks/weixin/<account>/<peer>.json
- 提示：先发送「新建任务：...」开始
```

注意：`/status` 显示的是 OpenClaw 当前全局会话信息，不是任务工作台状态。

## 推荐状态集

- `todo`
- `in_progress`
- `waiting_input`
- `blocked`
- `completed`
- `archived`

## 路由决策树

1. 若消息是任务控制命令，优先走控制面动作。
2. 若消息显式包含 `#<id>`，路由到指定任务。
3. 否则默认路由到 `currentTaskId`。
4. 若不存在当前任务，提示用户新建或切换任务。


path: references/test-matrix.md
content: # Weixin Task Workbench 测试矩阵

用于验证微信任务工作台在正常路径、空状态、异常输入、幂等场景下的表现是否符合协议。

## 1. 空状态

### 1.1 任务列表（无任务）

**输入**

```text
任务列表
```

**期望**

```text
任务列表 / Task List
当前还没有任务。
可直接发送：
- 新建任务：周报
- 新建任务：产品需求梳理
```

### 1.2 任务状态（无任务）

**输入**

```text
任务状态
```

**期望**

```text
任务状态
- 当前任务：无
- 任务总数：0
- registry: tasks/weixin/<account>/<peer>.json
- 提示：先发送「新建任务：...」开始
```

### 1.3 继续（无当前任务）

**输入**

```text
继续
```

**期望**

```text
当前没有可继续的任务。
先发送「任务列表」查看任务，或发送「新建任务：...」开始。
```

## 2. 新建任务

### 2.1 正常新建任务

**输入**

```text
新建任务：整理朋友圈发布文案
```

**期望检查点**

- 已通过能力门禁
- 已创建独立会话
- 已写入 registry
- 当前任务指向新任务
- 回执中包含任务 id 和标题

### 2.2 空标题

**输入**

```text
新建任务：
```

或

```text
新建任务
```

**期望**

```text
新建任务时还缺一个标题。
可直接发送：
- 新建任务：周报
- 新建任务：招聘方案优化
```

**额外检查**

- 不创建空任务
- registry 不发生变更

## 3. 任务列表与切换

### 3.1 任务列表（有任务）

**前置条件**

存在至少 2 个任务，其中 1 个为当前任务。

**输入**

```text
任务列表
```

**期望**

```text
任务列表 / Task List
#1 周报 [已完成]
#2 PPT [进行中] ← 当前
```

### 3.2 切换到存在的任务

**输入**

```text
切到 #2
```

**期望检查点**

- currentTaskId 更新为 2
- 回执明确“已切换到 #2”
- 如该任务为已完成/已归档，回执中仍应标注状态

### 3.3 切换到不存在的任务

**输入**

```text
切到 #99
```

**期望**

```text
任务 #99 不存在。
先发送「任务列表」查看可用任务。
```

## 4. 继续任务

### 4.1 继续当前进行中任务

**前置条件**

当前任务状态为 `todo` 或 `in_progress`。

**输入**

```text
继续
```

**期望检查点**

- 消息被路由到当前任务 sessionKey
- 必要时任务状态更新为 `in_progress`
- 后续任务回复包含任务头：

```text
[任务:#<id> <title>]
```

### 4.2 继续已完成任务

**前置条件**

当前任务状态为 `completed`。

**输入**

```text
继续
```

**期望**

应先提示切换或恢复，而不是直接继续路由。

### 4.3 继续已归档任务

**前置条件**

当前任务状态为 `archived`。

**输入**

```text
继续
```

**期望**

应先提示切换或恢复，而不是直接继续路由。

## 5. 总结任务

### 5.1 总结存在的任务

**输入**

```text
总结 #2
```

**期望**

```text
任务 #2：PPT
- 进展：...
- 当前状态：...
- 阻塞：...
- 下一步：...
```

### 5.2 总结不存在的任务

**输入**

```text
总结 #99
```

**期望**

```text
任务 #99 不存在。
先发送「任务列表」查看可用任务。
```

## 6. 关闭与归档

### 6.1 关闭存在的任务

**输入**

```text
关闭 #2
```

**期望检查点**

- 任务状态变为 `completed`
- 如当前任务被关闭，currentTaskId 按规则切换到下一个活跃任务或置空

### 6.2 重复关闭已完成任务

**前置条件**

任务 #2 已为 `completed`。

**输入**

```text
关闭 #2
```

**期望**

```text
任务 #2 已完成，无需重复关闭。
```

### 6.3 归档存在的任务

**输入**

```text
归档 #2
```

**期望检查点**

- 任务状态变为 `archived`
- 如当前任务被归档，currentTaskId 按规则切换到下一个活跃任务或置空

### 6.4 重复归档已归档任务

**前置条件**

任务 #2 已为 `archived`。

**输入**

```text
归档 #2
```

**期望**

```text
任务 #2 已归档，无需重复归档。
```

### 6.5 关闭/归档不存在的任务

**输入**

```text
关闭 #99
```

或

```text
归档 #99
```

**期望**

```text
任务 #99 不存在。
先发送「任务列表」查看可用任务。
```

## 7. 能力门禁

### 7.1 首次任务命令时缺少 sessions 工具

**前置条件**

缺少 `sessions_spawn`、`sessions_send`、`sessions_history` 之一。

**输入**

```text
新建任务：周报
```

**期望检查点**

- 立即阻断
- 返回配置修复提示
- 不创建 registry
- 不创建任务

## 8. 展示与隐私

### 8.1 普通任务回复

**输入**

路由到具体任务的普通消息。

**期望**

- 必须包含任务头：`[任务:#<id> <title>]`
- 不主动暴露 `sessionKey`

### 8.2 排障命令任务状态

**输入**

```text
任务状态
```

**期望**

- 可包含 `taskSessionKey`
- 可包含 registry 路径
- 仅用于排障视图

## 9. 响应模板一致性

### 9.1 新建任务成功模板

**输入**

```text
新建任务：整理周报
```

**期望**

必须尽量贴近固定模板：

```text
已新建任务 #<id>：整理周报
当前任务已切换到：#<id> 整理周报
```

### 9.2 切换任务成功模板

**输入**

```text
切到 #2
```

**期望**

必须尽量贴近固定模板：

```text
已切换到任务 #2：<title> [<状态中文名>]
```

### 9.3 重复关闭模板

**前置条件**

任务 #2 已为 `completed`。

**输入**

```text
关闭 #2
```

**期望**

```text
任务 #2 已完成，无需重复关闭。
```

### 9.4 重复归档模板

**前置条件**

任务 #2 已为 `archived`。

**输入**

```text
归档 #2
```

**期望**

```text
任务 #2 已归档，无需重复归档。
```

## 10. 英文等价命令

### 9.1 task list

**输入**

```text
task list
```

**期望**

等价于 `任务列表`。

### 9.2 task new <title>

**输入**

```text
task new Weekly report
```

**期望**

等价于 `新建任务：Weekly report`。

### 9.3 task use #id

**输入**

```text
task use #2
```

**期望**

等价于 `切到 #2`。

---

建议每次修改以下任一部分后，至少重跑本测试矩阵：

- `SKILL.md`
- `references/protocol.md`
- `references/implementation.md`
- `scripts/task_registry.py`


path: scripts/task_registry.py
content: #!/usr/bin/env python3
import argparse
import json
import shutil
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import sys

STATUS_CHOICES = [
    "todo",
    "in_progress",
    "waiting_input",
    "blocked",
    "completed",
    "archived",
]


def now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def default_registry() -> dict:
    return {
        "version": 1,
        "currentTaskId": None,
        "nextTaskId": 1,
        "tasks": [],
    }


def alias_registry_paths(path: Path) -> list[Path]:
    name = path.name
    aliases = {path}
    if "@" in name:
        aliases.add(path.with_name(name.replace("@", "_")))
    if "_im.wechat" in name:
        aliases.add(path.with_name(name.replace("_im.wechat", "@im.wechat")))
    return sorted(aliases)


def choose_registry_path(path: Path) -> Path:
    aliases = alias_registry_paths(path)
    existing = [p for p in aliases if p.exists()]
    if not existing:
        return path
    populated = []
    for p in existing:
        try:
            with p.open("r", encoding="utf-8") as f:
                data = json.load(f)
            tasks = data.get("tasks", [])
            populated.append((len(tasks), data.get("currentTaskId") is not None, p))
        except Exception:
            populated.append((0, False, p))
    populated.sort(key=lambda item: (item[0], item[1], str(item[2]) == str(path)), reverse=True)
    return populated[0][2]


def load_json_file(path: Path) -> Optional[dict]:
    try:
        with path.open("r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):
            return None
        data.setdefault("version", 1)
        data.setdefault("currentTaskId", None)
        data.setdefault("nextTaskId", 1)
        data.setdefault("tasks", [])
        return data
    except Exception:
        return None


def merge_registries(base: dict, incoming: dict) -> dict:
    def status_rank(status: str) -> int:
        return {
            "todo": 0,
            "in_progress": 1,
            "waiting_input": 2,
            "blocked": 3,
            "completed": 4,
            "archived": 5,
        }.get(status or "todo", 0)

    def task_score(task: dict) -> tuple:
        return (
            1 if task.get("summary") else 0,
            status_rank(task.get("status", "todo")),
            task.get("updatedAt", ""),
        )

    merged = {}
    order = []
    for source in (base, incoming):
        for task in source.get("tasks", []):
            key = (task.get("title"), task.get("sessionKey"))
            if key not in merged:
                merged[key] = deepcopy(task)
                order.append(key)
            elif task_score(task) > task_score(merged[key]):
                merged[key] = deepcopy(task)

    tasks = [merged[key] for key in order]
    tasks.sort(key=lambda t: (t.get("createdAt", ""), t.get("id", 0), t.get("title", "")))
    title_to_new_id = {}
    for i, task in enumerate(tasks, start=1):
        task["id"] = i
        title_to_new_id[task.get("title")] = i

    current_title = None
    for candidate in (incoming, base):
        current_id = candidate.get("currentTaskId")
        if current_id is None:
            continue
        for task in candidate.get("tasks", []):
            if task.get("id") == current_id:
                current_title = task.get("title")
                break
        if current_title:
            break

    current_task_id = title_to_new_id.get(current_title)
    return {
        "version": 1,
        "currentTaskId": current_task_id,
        "nextTaskId": len(tasks) + 1,
        "tasks": tasks,
    }


def find_cross_account_candidates(path: Path) -> list[Path]:
    try:
        account_dir = path.parent
        root = account_dir.parent
        peer_name = path.name
    except Exception:
        return []
    if not root.exists():
        return []
    candidates = []
    peer_aliases = {p.name for p in alias_registry_paths(path)}
    for other_account_dir in root.iterdir():
        if not other_account_dir.is_dir() or other_account_dir == account_dir:
            continue
        for peer_alias in peer_aliases:
            candidate = other_account_dir / peer_alias
            if candidate.exists():
                candidates.append(candidate)
    return sorted(set(candidates))


def restore_cross_account_registry(path: Path, data: dict) -> tuple[dict, list[str]]:
    restored_from = []
    merged = data
    for candidate in find_cross_account_candidates(path):
        other = load_json_file(candidate)
        if not other or not other.get("tasks"):
            continue
        if not merged.get("tasks") or len(other.get("tasks", [])) > len(merged.get("tasks", [])):
            merged = merge_registries(other, merged)
            restored_from.append(str(candidate))
    return merged, restored_from


def sync_registry_aliases(primary: Path) -> None:
    for alias in alias_registry_paths(primary):
        if alias == primary:
            continue
        alias.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(primary, alias)


def load_registry(path: Path) -> dict:
    resolved = choose_registry_path(path)
    if not resolved.exists():
        data = default_registry()
        data, restored_from = restore_cross_account_registry(path, data)
        save_registry(path, data)
        if restored_from:
            data["_restoredFrom"] = restored_from
        return data
    data = load_json_file(resolved) or default_registry()
    restored_from = []
    if not data.get("tasks"):
        data, restored_from = restore_cross_account_registry(path, data)
    if resolved != path or restored_from:
        save_registry(path, data)
    else:
        sync_registry_aliases(path)
    if restored_from:
        data["_restoredFrom"] = restored_from
    return data


def save_registry(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")
    sync_registry_aliases(path)


def find_task(data: dict, task_id: int) -> dict:
    for task in data["tasks"]:
        if task["id"] == task_id:
            return task
    raise SystemExit(f"Task #{task_id} not found")


def emit(payload: dict) -> None:
    json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
    sys.stdout.write("\n")


def cmd_init(args):
    path = Path(args.registry)
    data = load_registry(path)
    payload = {"ok": True, "registry": str(path), "data": data}
    if data.get("_restoredFrom"):
        payload["restoredFrom"] = data["_restoredFrom"]
    save_registry(path, {k: v for k, v in data.items() if not k.startswith("_")})
    emit(payload)


def cmd_add(args):
    path = Path(args.registry)
    data = load_registry(path)
    task_id = data["nextTaskId"]
    task = {
        "id": task_id,
        "title": args.title,
        "status": args.status,
        "sessionKey": args.session_key,
        "summary": args.summary or "",
        "createdAt": now_iso(),
        "updatedAt": now_iso(),
        "tags": args.tags or [],
    }
    data["tasks"].append(task)
    data["nextTaskId"] = task_id + 1
    if args.make_current or data["currentTaskId"] is None:
        data["currentTaskId"] = task_id
    save_registry(path, data)
    emit({"ok": True, "action": "add", "task": task, "currentTaskId": data["currentTaskId"]})


def cmd_list(args):
    path = Path(args.registry)
    data = load_registry(path)
    tasks = data["tasks"]
    if not args.include_archived:
        tasks = [t for t in tasks if t.get("status") != "archived"]
    emit({"ok": True, "action": "list", "currentTaskId": data["currentTaskId"], "tasks": tasks})


def cmd_switch(args):
    path = Path(args.registry)
    data = load_registry(path)
    task = find_task(data, args.task_id)
    data["currentTaskId"] = task["id"]
    task["updatedAt"] = now_iso()
    save_registry(path, data)
    emit({"ok": True, "action": "switch", "currentTaskId": data["currentTaskId"], "task": task})


def cmd_show(args):
    path = Path(args.registry)
    data = load_registry(path)
    task_id = args.task_id if args.task_id is not None else data.get("currentTaskId")
    if task_id is None:
        emit({"ok": True, "action": "show", "task": None, "currentTaskId": None})
        return
    task = find_task(data, task_id)
    emit({"ok": True, "action": "show", "task": task, "currentTaskId": data["currentTaskId"]})


def cmd_update(args):
    path = Path(args.registry)
    data = load_registry(path)
    task = find_task(data, args.task_id)
    changed = {}
    if args.title is not None:
        task["title"] = args.title
        changed["title"] = args.title
    if args.status is not None:
        task["status"] = args.status
        changed["status"] = args.status
    if args.session_key is not None:
        task["sessionKey"] = args.session_key
        changed["sessionKey"] = args.session_key
    if args.summary is not None:
        task["summary"] = args.summary
        changed["summary"] = args.summary
    if args.tags is not None:
        task["tags"] = args.tags
        changed["tags"] = args.tags
    task["updatedAt"] = now_iso()
    save_registry(path, data)
    emit({"ok": True, "action": "update", "changed": changed, "task": task})


def cmd_close_like(args, status: str, action: str):
    path = Path(args.registry)
    data = load_registry(path)
    task = find_task(data, args.task_id)
    task["status"] = status
    if args.summary is not None:
        task["summary"] = args.summary
    task["updatedAt"] = now_iso()
    if data.get("currentTaskId") == task["id"] and status in {"completed", "archived"}:
        active = [t for t in data["tasks"] if t["id"] != task["id"] and t.get("status") not in {"completed", "archived"}]
        data["currentTaskId"] = active[-1]["id"] if active else None
    save_registry(path, data)
    emit({"ok": True, "action": action, "task": task, "currentTaskId": data["currentTaskId"]})


def cmd_summarize(args):
    path = Path(args.registry)
    data = load_registry(path)
    task = find_task(data, args.task_id)
    emit({
        "ok": True,
        "action": "summarize",
        "task": {
            "id": task["id"],
            "title": task["title"],
            "status": task["status"],
            "summary": task.get("summary", ""),
            "updatedAt": task["updatedAt"],
            "sessionKey": task.get("sessionKey"),
        },
        "currentTaskId": data["currentTaskId"],
    })


def build_parser():
    parser = argparse.ArgumentParser(description="Manage Weixin Task Workbench JSON registry")
    parser.add_argument("--registry", required=True, help="Path to registry JSON file")
    sub = parser.add_subparsers(dest="command", required=True)

    p = sub.add_parser("init")
    p.set_defaults(func=cmd_init)

    p = sub.add_parser("add")
    p.add_argument("title")
    p.add_argument("--status", choices=STATUS_CHOICES, default="todo")
    p.add_argument("--session-key", required=True)
    p.add_argument("--summary")
    p.add_argument("--make-current", action="store_true")
    p.add_argument("--tags", nargs="*")
    p.set_defaults(func=cmd_add)

    p = sub.add_parser("list")
    p.add_argument("--include-archived", action="store_true")
    p.set_defaults(func=cmd_list)

    p = sub.add_parser("switch")
    p.add_argument("task_id", type=int)
    p.set_defaults(func=cmd_switch)

    p = sub.add_parser("show")
    p.add_argument("task_id", type=int, nargs="?")
    p.set_defaults(func=cmd_show)

    p = sub.add_parser("update")
    p.add_argument("task_id", type=int)
    p.add_argument("--title")
    p.add_argument("--status", choices=STATUS_CHOICES)
    p.add_argument("--session-key")
    p.add_argument("--summary")
    p.add_argument("--tags", nargs="*")
    p.set_defaults(func=cmd_update)

    p = sub.add_parser("close")
    p.add_argument("task_id", type=int)
    p.add_argument("--summary")
    p.set_defaults(func=lambda a: cmd_close_like(a, "completed", "close"))

    p = sub.add_parser("archive")
    p.add_argument("task_id", type=int)
    p.add_argument("--summary")
    p.set_defaults(func=lambda a: cmd_close_like(a, "archived", "archive"))

    p = sub.add_parser("summarize")
    p.add_argument("task_id", type=int)
    p.set_defaults(func=cmd_summarize)

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


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