
Cross Agent Coordination
- 26 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Coordinate tasks across agents on different platforms: assign from a project task source, tag agent ownership in commits/PRs, and preserve handoff context.
About
A cross-platform agent task-coordination hub that assigns tasks from a project task source, tags agent ownership in commits and PRs, and preserves handoff context across sessions. A developer uses it when multiple agents on different platforms collaborate in one repo, not for single-platform parallel execution or Git safety rules.
- Ownership-first: sets Git author and PR attribution
- Traceable handoff context in issue/README/handoff.md
Cross Agent Coordination by the numbers
- 26 all-time installs (skills.sh)
- Ranked #9,702 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill cross-agent-coordinationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Coordinate tasks across agents on different platforms: assign from a project task source, tag agent ownership in commits/PRs, and preserve handoff context.
Files
Cross-Agent Coordination
跨平台 Agent 任务协调枢纽。核心职责是让不同平台的 Agent 围绕项目配置或项目上下文指定的任务源分配任务、保留交接上下文,并在提交/PR 中标记 Agent 归属。
1. 何时使用
使用本 Skill:
- 多个不同平台的 Agent 需要在同一仓库协作
- 需要追溯某个提交/PR 是哪个 Agent 完成的
- 需要把任务派给 Manus、AnyGen、Coze 等外部 Agent
- 需要跨会话保留任务上下文、依赖和交接记录
不使用本 Skill:
- 单一平台内的并行执行:使用
multi-agent-orchestration - 纯 Git 安全规范:使用
git-workflow
2. 核心规则
1. 任务源由项目定义:常规任务状态以项目配置或项目上下文指定的任务源为准;任务文件夹只是材料包、产物包和交接包。 2. 归属优先:提交前必须设置 Git Author;PR 正文应包含 Agent Attribution。 3. 任务按分配执行:默认只处理项目任务源中负责人/委托对象匹配当前 Agent 的任务。 4. 项目可配置:任务类型、模板、状态映射、认领策略由项目级配置决定,不改 Skill 源码。 5. 交接可追溯:Issue、任务 README 或 .agent-context/handoff.md 必须记录目标、来源、决策、阻塞和下一步。
3. 项目配置
项目根目录使用本地忽略配置:
cp .claude/skills/cross-agent-coordination/config/collab.yaml.example config/collab.yaml关键字段(示例;具体任务源由项目决定):
project:
mode: task_folders # task_folders | single_repo
default_agent: codex
issue_file: project-tasks.md
task_context_mode: issues_primary
status_map:
"⬜": pending_confirmation
"✅": ready
"🟢": created
available_statuses: [ready, created, todo]
dependency_done_statuses: [ready, created, done, resolved, closed]
claim_policy: assigned_only # assigned_only | claim_pool
task_types_file: config/task-types.yaml
template_dir: templates/tasks
agents:
codex:
name: "Codex"
email: "codex@agents.local"
github_user: "codex-bot"
token_env: "CODEX_GITHUB_TOKEN"配置路径固定为 config/collab.yaml。旧的 github-monorepo-collab 和 config/monorepo.yaml 不再读取。
4. 任务模型
项目配置中的 project.issue_file 或项目上下文指定的任务文件,是脚本读取任务的默认入口。脚本可解析如下格式:
### ✅ Issue #9: 触发 Manus 调研任务
- **类型**: 研究(委托 Manus)
- **依赖**: Issue #7 确定后
- **目标**: 为 ch03 法律 AI 基础设施章节提供调研支撑
#### 验收标准
- [ ] 形成产品对比表解析字段包括 Issue 编号、标题、状态标记、类型、负责人/委托 Agent、依赖、目标、素材来源和验收标准。状态由 project.status_map 映射;find_task.py --available 只返回状态可执行、依赖满足、负责人匹配的 Issue。
任务文件夹模式仍可使用 {YYMMDDNNN}-{type}-{title}/README.md,但它是重任务材料包/产物包/交接包,不覆盖主任务源状态:
---
id: 260517001
slug: 260517001-研究-法律AI产品生态调查
title: 法律AI产品生态调查
type: 研究
status: todo
assignee: manus
dependencies: []
artifact_paths: []
progress: 0
created: 2026-05-17
updated: 2026-05-17
---assignee 是材料包负责人字段;agent 只作为旧任务兼容读取,不再作为新任务必填字段。任务文件夹依赖仍按 done / resolved / closed 判断;Issue 依赖按 dependency_done_statuses 判断。
项目可在 config/task-types.yaml 扩展任务类型:
task_types:
整合:
aliases: [integration]
description: 多来源材料融合
output_hint: 统一稿/整合报告
审阅:
aliases: [review]
description: 质量审查/事实核查
output_hint: 审阅意见/修订稿项目可在 templates/tasks/{type}.md 或 templates/tasks/default.md 定义任务 README 模板。模板变量使用 {{ id }}、{{ slug }}、{{ title }}、{{ type }}、{{ assignee }}、{{ created }}、{{ updated }}。
5. 依赖
Python 包
| 包名 | 用途 | 安装命令 |
|---|---|---|
PyYAML | 读取 collab.yaml、task-types.yaml 和 frontmatter | python3 -m pip install -r scripts/requirements.txt |
安装依赖(仅在脚本提示缺失时):
python3 -m pip install -r scripts/requirements.txt6. 常用脚本
| 脚本 | 用途 |
|---|---|
scripts/task_scaffold.py | 创建任务文件夹,分配稳定 Task ID,按项目模板写 README |
scripts/find_task.py | 按主题搜索项目任务源和任务文件夹,支持 --available 依赖/分配过滤 |
scripts/gh_git.py | 带 Agent 归属的 clone/branch/commit/push/PR/merge |
scripts/email_trigger.py | 为外部 Agent 生成标准邮件触发草稿 |
scripts/audit_repo.py | 审计任务 metadata 与项目类型注册表是否一致 |
示例:
python3 scripts/task_scaffold.py create --root . --type 研究 --topic "法律AI产品生态调查" --assignee manus
python3 scripts/task_scaffold.py create --root . --type 写作 --topic "ch01 Agent发展阶段" --field chapter=ch01 --field target_words=15000
python3 scripts/find_task.py . --available --agent manus
python3 scripts/gh_git.py commit --dest . --agent manus --message "docs: update handoff"
python3 scripts/gh_git.py pr --dest . --agent manus --title "docs: Manus handoff update"
python3 scripts/email_trigger.py . --agent manus --issue 97. Agent 归属
每次提交涉及两层身份:
| 层级 | 控制方式 | 证明什么 |
|---|---|---|
| Git Author | git config user.name / user.email | 谁写了提交内容 |
| GitHub Actor / PR Opener | 使用的 Token 或 GitHub App | 哪个账号执行平台操作 |
脚本会设置 Git Author。PR Opener 由 token 决定;如果要让 PR actor 区分 Agent,需要给该 Agent 配置独立 token 环境变量。详细规则见 references/agent-identity.md。
8. 工作流
Issue / Task 主状态模式
1. 读取 config/collab.yaml、config/task-types.yaml、项目模板和项目上下文。 2. 用 find_task.py --available --agent <agent-id> 从项目任务源领取可执行任务。 3. 只有任务需要较多材料、产物或交接记录时,才用 task_scaffold.py create 创建或复用任务文件夹。 4. 在 agent/{agent-id}/{slug} 分支上工作。 5. 提交时用 gh_git.py commit 标记 Git Author。 6. 完成后更新项目任务源状态;若有任务文件夹,同时更新 README 交接记录,并开 PR。
若某个 Issue / Task 不通过 PR,而是在当前分支或 main 上直接解决,提交信息仍必须保留任务来源。GitHub Issue、项目本地任务条目或任务文件夹 ID 的具体提交格式、引用格式和关闭规则遵循 git-workflow;本 Skill 只负责确认任务来源和交接上下文。
单 Repo 模式
不创建任务文件夹;仍需用 Agent 分支、Git Author、PR Attribution 和 .agent-context/handoff.md 保留交接上下文。
邮箱触发
对支持邮箱入口的外部 Agent,使用 email_trigger.py 生成草稿。邮件可通过 --issue 绑定项目任务源中的任务,并包含目标、验收标准、来源材料、依赖、查重命令、分支和 PR 要求。默认不发送真实邮件。
外部 Agent Adapter
外部 Agent(如 Manus、AnyGen、Coze)只作为执行通道或能力 adapter,不拥有任务状态。项目应在 config/collab.yaml 的 agents.<id> 中声明该 Agent 的能力和触发方式:
agents:
manus:
name: "Manus Bot"
email: "manus@agents.local"
github_user: "manus-bot"
token_env: "MANUS_GITHUB_TOKEN"
trigger_email: "manus-agent-inbox@example.com"
capabilities: [web_research, citation_collection]
trigger_modes: [email_draft]
handoff_format: pull_requestAdapter 选择规则:
- 复杂网络搜索、资料收集、网页操作:优先分配给具备
web_research/browser_ops能力的 Agent。 - 图片生成、视觉资产:优先分配给具备
image_generation能力的 Agent。 - 代码修改、本地测试、worktree 执行:交给本地 Agent,并由
multi-agent-orchestration管 session。 - 任何 adapter 都必须绑定项目任务源中的任务或先查重;结果通过分支、PR、handoff note 回到仓库。
9. 参考文档
references/naming.md:任务命名、frontmatter、归档规则references/agent-guide.md:Agent 启动、交接和完成检查清单references/agent-identity.md:Git Author 与 GitHub Actor 归属references/email-trigger.md:邮箱触发协议references/legacy-migration.md:旧任务迁移提示
10. Related Skills
| 维度 | cross-agent-coordination | multi-agent-orchestration | git-workflow |
|---|---|---|---|
| 定位 | 任务协调层 | 本地执行层 | Git 安全层 |
| 主责 | 项目任务源状态、Agent 归属、交接上下文 | Agent Teams / tmux 会话、worktree、PM 巡检 | 分支、PR、diff、review、merge 安全规则 |
| 不负责 | 本地会话管理、Git 合并策略 | 任务主状态、外部 Agent 邮件触发 | 任务分配、本地 Agent 调度 |
协作模式:先由 cross-agent-coordination 从项目任务源确定任务;同平台需要并行执行时使用 multi-agent-orchestration;涉及分支、PR、review 或 merge 时遵循 git-workflow。
变更记录
[1.0.0] - 2026-06-01
文档完善
- 将版本定为正式发布候选版本,保留任务协调、Agent 归属、能力路由和交接上下文等核心边界。
- 收口发布包参考文档,只保留任务命名、Agent 指南、身份归属、邮件触发、旧任务迁移和任务类型注册表。
移除
- 移除弱相关的 Git LFS 策略和 Profile 模板参考文档,避免把 Git 存储策略或项目偏好模板混入任务协调 Skill。
- 清理旧 monorepo 模板空目录、书籍写作项目 adapter 样例、macOS 缓存文件和脚本冲突副本。
- 移除通用 project-starter 目录,避免公开发布包包含会被仓库忽略规则挡住的嵌套 config/templates 资源。
[0.7.0] - 2026-05-20
重构
- 重命名 Skill:
cross-agent-collab→cross-agent-coordination,标题改为 Cross-Agent Coordination,以突出“跨平台任务协调”而非泛化协作。 - 同步更新脚本提示、邮件主题前缀、测试文件名和相关参考文档中的 Skill 名称。
- 保留
config/collab.yaml文件名,避免破坏既有项目配置。
[0.6.4] - 2026-05-20
改进
- 同步相关 Skill 引用:
multi-agent-workflow定稿为multi-agent-orchestration后,更新边界说明、Related Skills 和 Agent guide。
[0.6.3] - 2026-05-20
改进
- 同步相关 Skill 引用:
parallel-agent-workflow更名为multi-agent-workflow后,更新边界说明、Related Skills 和 Agent guide。
[0.6.2] - 2026-05-19
改进
- 将任务源表述改为“项目配置或项目上下文指定的任务源”,不再在 Skill 中把固定文件路径写成唯一标准。
- 保留
project.issue_file作为可配置字段示例,具体路径由项目决定。
[0.6.0] - 2026-05-17
新增
- 外部 Agent Adapter 声明:
config/collab.yaml.example增加capabilities、trigger_modes、handoff_format示例,用于声明 Manus、AnyGen 等外部 Agent 的能力边界。 - Adapter 能力路由:文档补充
web_research、citation_collection、image_generation、browser_ops等能力如何映射到外部 Agent。
改进
- 明确外部 Agent 只是执行通道,不拥有任务状态;所有常规任务仍以
docs/TASKS.md为主状态源。 - 邮件触发协议要求外部 Agent 通过 branch、PR 或 durable handoff note 返回结果,不接受无仓库回写的 chat-only 结果作为默认完成。
[0.5.0] - 2026-05-17
新增
- TASKS.md 主状态源:新增
docs/TASKS.md解析能力,支持 Issue 编号、标题、状态标记、类型、负责人/Lead Author、依赖、目标、素材来源和验收标准。 - Task 可执行过滤:
find_task.py --available默认基于docs/TASKS.md返回状态可执行、依赖满足、负责人匹配的任务。 - Task 邮件触发:
email_trigger.py --issue N可从docs/TASKS.md注入目标、验收标准、依赖、调研任务和交接要求。 - 书籍写作 adapter 状态映射:示例配置将
⬜、✅、🟢映射为pending_confirmation、ready、created,默认只有ready/created/todo可执行。
改进
- 任务文件夹明确降级为材料包、产物包和交接包,不再作为常规任务状态源。
- 对未显式声明类型的章节类 Issue 增加保守推断,支持书籍项目中
chXX写作任务的现有格式。 - 邮件草稿不再引用已删除 dashboard 脚本,交接要求统一要求更新
docs/TASKS.md。 - 相关文档补充三层边界:
cross-agent-coordination管任务状态,multi-agent-orchestration管本地会话,git-workflow管 Git 安全。
[0.4.0] - 2026-05-17
新增
- 项目适配层:新增
project.mode、task_types_file、template_dir、claim_policy、default_agent配置,支持不同项目通过配置和模板适配。 - 动态任务类型:脚本读取项目级
config/task-types.yaml并回退到 Skill 默认注册表,支持整合、审阅等泛化任务类型。 - 任务模板:新增
templates/tasks/default.md,支持项目级templates/tasks/{type}.md和--field key=value写入自定义 frontmatter。 - 依赖过滤:
find_task.py --available支持按dependencies、assignee和claim_policy过滤可执行任务。 - 回归测试:新增
scripts/test_cross_agent_coordination.py,覆盖配置读取、任务类型、模板字段、依赖过滤和邮件触发。
改进
task_scaffold.py、find_task.py、email_trigger.py、gh_git.py、audit_repo.py统一使用共享 helper,避免多处硬编码配置和任务类型。email_trigger.py自动注入任务 README 中的目标、验收标准、来源材料和交接要求,并统一使用python3命令示例。gh_git.py在无 remote 场景下给出清晰错误,保留本地 Git author 归属能力。- 新增
assets/project-starter/通用 starter 和书籍写作适配样例。
移除
- 移除旧 dashboard workflow 与个人化模板残留。
- 脚本不再读取旧
github-monorepo-collab或config/monorepo.yaml路径。
安全
- 在私有技能仓库
.gitignore中忽略**/config/collab.yaml,避免本地 Agent token 配置误提交。
[0.3.0] - 2026-05-16
重构
- 重命名:
github-monorepo-collab→cross-agent-collab,去除 GitHub + Monorepo 绑定 - 重命名:
config/monorepo.yaml.example→config/collab.yaml.example - 重命名:
scripts/monorepo_scaffold.py→scripts/task_scaffold.py - 重命名:
scripts/audit_monorepo.py→scripts/audit_repo.py
新增
- Agent 归属提升为第一优先级:明确提交时必须标记 Agent 身份的规则
- 任务来源与分配机制:支持
docs/TASKS.md(assignee字段)和 GitHub Issues 双通道 - 单 Repo 模式:不需要任务文件夹体系,通过 Git author + branch prefix + PR body 实现归属
- Related Skills 章节:明确与
multi-agent-orchestration、git-workflow、git-batch-commit的边界 - AnyGen 正式列入支持的 Agent 平台
移除
- 删除
scripts/generate_dashboard.py(已过时) - 删除
scripts/sync_to_obsidian.py(已过时) - 移除 SKILL.md 中 Dashboard 和 Obsidian 相关描述
改进
- 配置文件注释去除
monorepo字眼,改为通用描述 references/agent-identity.md更新脚本路径引用
[0.2.0] - 2026-04-23
新增
- 增加
scripts/audit_monorepo.py,用于审计云端仓库中的历史 metadata 残留与不一致。 - 增加
scripts/find_task.py,用于在创建或上传前检索相似既有任务。 - 增加
references/legacy-migration.md,说明旧版本任务的低风险迁移顺序。 - 增加
references/agent-identity.md,说明 Git author 与 GitHub actor 的区别及配置方式。 - 增加
scripts/email_trigger.py和references/email-trigger.md,用于生成外部 Agent 的邮件触发草稿。 - 在配置模板中补充 Manus、AnyGen、OpenClaw、Codex、Claude Code、Coze 等 Agent 身份字段。
修复
- 修复
gh_git.py子命令参数注册错误,恢复 CLI 可用性。 - 修复任务 ID 生成逻辑,按
YYMMDDNNN扫描根目录和archive/中已有任务。 monorepo_scaffold.py create默认执行相似主题查重,命中既有任务时阻止新建,避免重复研究同一内容。- 删除
SKILL.md中不存在的scripts/gh_pr.py引用。
安全
- 将
config/monorepo.yaml从 Git 跟踪中移除,并加入私有仓库.gitignore,允许本地保留私有 token。 - 脚本优先通过
GITHUB_TOKEN读取 GitHub 凭据,其次读取本地忽略配置,不再把 token 写入 git remote。
改进
- 读取云端
Monorepo-Collab现状后,将任务模型统一为{YYMMDDNNN}-{中文分类}-{title},脚本兼容英文别名输入。 - dashboard 生成时优先使用任务文件夹名,避免旧 frontmatter slug 覆盖真实路径。
gh_git.py与monorepo_scaffold.py支持按 Agent 设置 repository-local Git author,并优先使用 Agent 专属 token 环境变量。gh_git.py新增pr子命令,创建 PR 时自动写入 Agent ID、Git author 和预期 GitHub actor。- Agent 配置新增
trigger_email、reply_to、from_alias字段,可为支持邮箱入口的外部 Agent 生成标准触发邮件草稿。 - 精简
SKILL.md,把细节规则下沉到references/。 - 增加
scripts/requirements.txt和 PyYAML 缺失时的清晰安装提示。
待办事项
- 如泄露提交已推送到远端,评估是否需要撤销 token 或清理 Git 历史。
- 为脚本补充自动化测试。
[0.1.0] - 2026-04-23
新增
- 初始版本:提供多 Agent GitHub monorepo 协作流程、任务脚手架、dashboard 生成和 Obsidian 同步脚本。
# 复制为项目仓库的 config/collab.yaml 并填入实际值。
#
# 注意:collab.yaml 是本地配置,禁止提交。
# GitHub PAT 推荐通过环境变量提供;如确需写入本地 collab.yaml,
# 必须确认该文件已被 .gitignore 忽略。
project:
# task_folders: 在仓库根目录使用 {YYMMDDNNN}-{type}-{title}/README.md 管理任务
# single_repo: 不创建任务文件夹,仅用 Git author + branch + PR 记录归属
mode: task_folders
default_agent: codex
# 项目任务源文件;按项目约定填写。任务文件夹只作为材料包/交接包。
issue_file: project-tasks.md
task_context_mode: issues_primary # issues_primary | task_folders_primary | task_folders_only
status_map:
"⬜": pending_confirmation
"✅": ready
"🟢": created
"[ ]": todo
"[x]": done
available_statuses: [ready, created, todo]
dependency_done_statuses: [ready, created, done, resolved, closed]
# assigned_only: 只处理明确 assignee 为当前 agent 的 todo 任务
# claim_pool: 允许当前 agent 认领未分配的 todo 任务
claim_policy: assigned_only
task_types_file: config/task-types.yaml
template_dir: templates/tasks
github:
repo_url: "https://github.com/<owner>/<repo>.git"
owner: "<owner>"
repo: "<repo>"
# token: "" # local ignored fallback only; prefer environment variables
local:
repo_root: "~/work/cross-agent-project"
agents:
codex:
name: "Codex"
email: "codex@agents.local"
github_user: "codex-bot"
token_env: "CODEX_GITHUB_TOKEN"
trigger_email: "codex-agent-inbox@example.com"
reply_to: "collab-dispatch@example.com"
from_alias: "Collab Dispatcher <collab-dispatch@example.com>"
claude_code:
name: "Claude Code"
email: "claude-code@agents.local"
github_user: "claude-code-bot"
token_env: "CLAUDE_CODE_GITHUB_TOKEN"
trigger_email: "claude-code-agent-inbox@example.com"
reply_to: "collab-dispatch@example.com"
from_alias: "Collab Dispatcher <collab-dispatch@example.com>"
manus:
name: "Manus Bot"
email: "manus@agents.local"
github_user: "manus-bot"
token_env: "MANUS_GITHUB_TOKEN"
trigger_email: "manus-agent-inbox@example.com"
reply_to: "collab-dispatch@example.com"
from_alias: "Collab Dispatcher <collab-dispatch@example.com>"
capabilities: [web_research, citation_collection]
trigger_modes: [email_draft]
handoff_format: pull_request
openclaw:
name: "OpenClaw"
email: "openclaw@agents.local"
github_user: "openclaw-bot"
token_env: "OPENCLAW_GITHUB_TOKEN"
anygen:
name: "AnyGen Bot"
email: "anygen@agents.local"
github_user: "anygen-bot"
token_env: "ANYGEN_GITHUB_TOKEN"
capabilities: [image_generation, visual_assets]
trigger_modes: [email_draft]
handoff_format: pull_request
coze:
name: "Coze"
email: "coze@agents.local"
github_user: "coze-bot"
token_env: "COZE_GITHUB_TOKEN"
hermes:
name: "Hermes"
email: "hermes@agents.local"
github_user: "hermes-bot"
token_env: "HERMES_GITHUB_TOKEN"
MIT License
Copyright (c) 2025 杨卫薪律师(微信ywxlaw)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Agent Collaboration Guide
Use this guide when an agent starts, resumes, hands off, or finishes work in a cross-agent-coordination project.
Repository Shape
/
config/
collab.yaml
task-types.yaml
templates/
tasks/
default.md
写作.md
project-tasks.md
260517001-研究-法律AI产品生态调查/
README.mdThe project task source is configured by project.issue_file or project context. Task folders stay flat at the repository root only when a workstream needs a material package, artifact package, or durable handoff notes. Project-specific task files, task types, and templates live in the project, not inside the Skill source.
Start A Work Session
1. Pull the latest default branch. 2. Read config/collab.yaml and the configured task source. 3. For executable work, run python3 scripts/find_task.py . --available --agent <agent-id>. 4. If the user gives a topic, run python3 scripts/find_task.py . --topic "<topic>" before creating any task folder. 5. Create an agent branch named agent/{agent-id}/{slug} or agent/{agent-id}/issue-{n}-{slug} before committing.
Default claim_policy is assigned_only; only work on Issues or task folders whose responsible field matches your Agent ID. Projects may set claim_policy: claim_pool to allow unassigned todo tasks to be claimed.
Create A Task
Create or edit the configured task source first. Only create a task folder when the workstream needs a durable material package:
python3 scripts/task_scaffold.py create --root . --type 研究 --topic "LegalSkill架构设计" --assignee codexThe script allocates the next YYMMDDNNN ID, creates {id}-{type}-{title}, and writes README frontmatter using the project template. Use --field key=value to add project-specific metadata:
python3 scripts/task_scaffold.py create --root . --type 写作 --topic "ch01 Agent发展阶段" --field chapter=ch01 --field target_words=15000Before creating, the script searches existing tasks and stops when it finds likely duplicates. Integrate new findings into the existing task README instead of creating another folder. Use --dry-run to inspect the generated ID and slug without writing files. Use --force-new only after confirming the topic is not a duplicate.
Update A Task README
Keep the configured task source as the status source. Keep the task README useful for the next agent when a task folder exists. Include:
- Objective and acceptance criteria
- Current progress
- Source files, links, or evidence already reviewed
- Decisions and rationale
- Blockers and open questions
- Next concrete step
Do not rely on chat history alone. The README is the durable handoff record.
Commit And PR
Use an agent-specific branch and let scripts set the Git author:
python3 scripts/gh_git.py branch --dest . --agent codex --name agent/codex/260517001-研究-法律AI产品生态调查
python3 scripts/gh_git.py commit --dest . --agent codex --message "docs: update task handoff"
python3 scripts/gh_git.py push --dest . --agent codex
python3 scripts/gh_git.py pr --dest . --agent codex --title "docs: Codex handoff update"Prefer an agent-specific token environment variable, such as CODEX_GITHUB_TOKEN, when the PR opener should appear as that agent's GitHub account or app. The pr subcommand writes Agent ID, Git author, and expected GitHub actor into the PR body. If a token is stored in config/collab.yaml, first confirm that file is ignored and never stage it.
Trigger External Agents By Email
When an external agent supports inbound email, compose a trigger draft:
python3 scripts/email_trigger.py . --agent manus --task-id 260517001 --topic "法律AI产品生态调查"Prefer binding to an existing Issue in the configured task source:
python3 scripts/email_trigger.py . --agent manus --issue 9Use this only after binding the work to an existing Issue/task or confirming the email instructs the recipient to run duplicate search first. See references/email-trigger.md.
Route To External Adapters
Before sending work to an external Agent, check config/collab.yaml:
1. Match the task need against agents.<id>.capabilities. 2. Prefer --issue N so the adapter receives the project task-source context. 3. Include acceptance criteria and handoff requirements in the trigger. 4. Require branch + PR output when the result changes repository files. 5. Keep local execution sessions in multi-agent-orchestration; do not use email adapters to manage tmux or worktree state.
Finish A Work Session
1. Update the configured task source status and notes. 2. If a task folder exists, update README progress, updated, assignee, and add a short handoff note with next steps. 3. Run python3 scripts/find_task.py . --topic "<topic>" before uploading newly researched material. 4. Run python3 scripts/audit_repo.py . when touching legacy or externally created tasks. 5. Commit README and artifact changes. 6. Open or update the PR.
Merge Duplicate Tasks
When two folders cover the same workstream:
1. Choose the richest task folder as the target unless the user chooses differently. 2. Copy useful material into the target. 3. Record source task IDs in the target README. 4. Add merge notices to source READMEs. 5. Move source folders to archive/ only after preserving their README frontmatter.
Agent Identity And Git Attribution
Use this guide when commits and PRs need to show which agent did the work.
Two Identities
| Layer | Controlled by | What it proves |
|---|---|---|
| Commit author | git config user.name and git config user.email | Who authored the commit content |
| GitHub actor / PR opener | The token or GitHub App used for API/push/PR actions | Which GitHub account or app performed the platform action |
Scripts can reliably set the commit author for each agent. The PR opener cannot be faked with Git config. If every PR is created with the user's PAT, GitHub will show the user as the PR actor even when commits are authored by Manus, AnyGen, OpenClaw, Codex, Claude Code, or Coze.
To make PR actors differ, provide a separate PAT or GitHub App token for that agent.
Config Format
In local ignored config/collab.yaml:
project:
default_agent: codex
agents:
codex:
name: "Codex"
email: "codex@agents.local"
github_user: "codex-bot"
token_env: "CODEX_GITHUB_TOKEN"
manus:
name: "Manus Bot"
email: "manus@agents.local"
github_user: "manus-bot"
token_env: "MANUS_GITHUB_TOKEN"
trigger_email: "manus-agent-inbox@example.com"
reply_to: "collab-dispatch@example.com"Fields:
name: Git author name.email: Git author email. Use a unique stable email per agent.github_user: Expected GitHub actor. Used for PR body and audit notes.token_env: Environment variable that stores the agent-specific token.trigger_email: Dedicated mailbox or inbound email address used to trigger this agent.reply_to: Optional mailbox for agent responses/status updates.
Do not use trigger_email as the Git author email unless that is intentionally the same identity. email controls commit attribution; trigger_email controls where task instructions are sent.
Token Priority
Scripts resolve tokens in this order:
1. Agent-specific token_env, such as MANUS_GITHUB_TOKEN. 2. Generic GITHUB_TOKEN. 3. Local ignored github.token in config/collab.yaml.
Current Agent Priority
Scripts resolve the current agent in this order:
1. Explicit --agent. 2. AGENT_ID environment variable. 3. project.default_agent. 4. agent.current if present. 5. Built-in fallback openclaw.
Recommended Agent IDs
| Agent ID | Typical name |
|---|---|
manus | Manus Bot |
anygen | AnyGen Bot |
openclaw | OpenClaw |
codex | Codex |
claude_code | Claude Code |
coze | Coze |
hermes | Hermes |
Workflow
Create a branch or commit as a specific agent:
python3 scripts/gh_git.py branch --dest . --agent manus --name agent/manus/260517001-研究-示例任务
python3 scripts/gh_git.py commit --dest . --agent manus --message "docs: update handoff"
python3 scripts/gh_git.py push --dest . --agent manus
python3 scripts/gh_git.py pr --dest . --agent manus --title "docs: Manus handoff update"Create a task assigned to a specific agent:
python3 scripts/task_scaffold.py create --root . --assignee manus --type 研究 --topic "示例任务" --auto-commitAudit Rule
For traceability, every task README should include:
assignee: stable agent ID responsible for the task.- PR body:
Agent ID,Git Author, andExpected GitHub Actor.
The pr command always writes these fields into the PR body so attribution remains visible even when the platform actor is a shared token:
## Agent Attribution
- Agent ID: manus
- Git Author: Manus Bot <manus@agents.local>
- Expected GitHub Actor: manus-botEmail Trigger Protocol
Use this guide when an external agent can be triggered by email, such as Manus, AnyGen, Coze, or another hosted agent.
Purpose
Email triggering lets the user start an agent task without opening that agent's web UI. The email must still bind the work back to the collaboration repository so the result is submitted as a branch and PR.
Safety Rules
- Generate an email draft first; do not send automatically unless the user explicitly asks.
- Prefer binding to an existing Issue in the configured task source with
--issue. - Task folders are context packages only. They do not override the configured task source status.
- If the email is for a new topic, instruct the recipient agent to run duplicate search before creating a folder.
- Never include secret tokens in the email body.
- Require the agent to open a PR, not push directly to
main. - Treat the email recipient as an adapter with specific capabilities, not as a task status owner.
Config
In local ignored config/collab.yaml:
agents:
manus:
name: "Manus Bot"
email: "manus@agents.local"
github_user: "manus-bot"
token_env: "MANUS_GITHUB_TOKEN"
trigger_email: "manus-agent-inbox@example.com"
reply_to: "collab-dispatch@example.com"
from_alias: "Collab Dispatcher <collab-dispatch@example.com>"
capabilities: [web_research, citation_collection]
trigger_modes: [email_draft]
handoff_format: pull_requestDo not confuse trigger_email with email, which is the Git author email written into commits.
Compose A Trigger
Bind to an existing issue:
python3 scripts/email_trigger.py . \
--agent manus \
--issue 9 \
--instruction "补充最新资料,并提交 PR"Bind to an existing task folder:
python3 scripts/email_trigger.py . \
--agent manus \
--task-id 260517001 \
--topic "法律AI产品生态调查" \
--instruction "补充最新资料,并提交 PR"Create a .eml draft:
python3 scripts/email_trigger.py . \
--agent manus \
--task-id 260517001 \
--topic "法律AI产品生态调查" \
--output /tmp/manus-task.emlOverride the recipient address:
python3 scripts/email_trigger.py . --agent anygen --to agent@example.com --topic "律师AI指南"Email Format
Subject:
[Cross-Agent-Coordination][260517001][manus] 法律AI产品生态调查Body includes:
- repository URL
- bound Issue number, or task ID and slug when using a task folder
- configured task-source fields and sections: target, acceptance criteria, dependencies, source material, handoff requirements
- README sections as supplemental context when a task folder exists
- assignment instructions
- duplicate search command
- branch name
- commit, push, and PR commands
- expected Git author and GitHub actor
- handoff requirements
Expected Recipient Behavior
The receiving agent should:
1. Clone or pull the repository. 2. Run scripts/find_task.py before adding new material. 3. Treat the configured task source as the task status source. 4. Integrate into an existing task folder when matched; otherwise create one only when durable materials or handoff notes are needed. 5. Commit with its configured Agent ID. 6. Open a PR with scripts/gh_git.py pr. 7. Update the configured task source; leave a handoff note in the task README only when a task folder exists.
Adapter Capability Routing
Use agents.<id>.capabilities to decide where the work goes:
| Capability | Typical Agent | Suitable work |
|---|---|---|
web_research | Manus / browser-capable hosted agent | complex online research, source collection, competitive scans |
citation_collection | Manus / research agent | gathering links, quotes, evidence tables |
image_generation | AnyGen / image agent | visual assets, image variants, illustration drafts |
browser_ops | browser automation agent | website interaction, form workflows, screenshot evidence |
The adapter must return results through a branch, PR, or durable handoff note. Do not accept a chat-only result as complete unless the user explicitly asked for a one-off answer.
Legacy Migration Guide
Use this guide when an existing collaboration project contains older task metadata.
What Changed In v0.4.0
- Skill path is
.claude/skills/cross-agent-coordination/. - Project config is
config/collab.yaml. - Task types are read from
config/task-types.yaml, then the Skill default registry. - New task README frontmatter uses
assignee;agentis only read as a legacy fallback. - Dashboard and Obsidian sync helpers were removed from this Skill.
The scripts intentionally do not read old github-monorepo-collab or config/monorepo.yaml paths.
Low-Risk Migration Order
1. Copy old local configuration into config/collab.yaml. 2. Add config/task-types.yaml for project-specific task categories. 3. Add templates/tasks/default.md and type-specific templates if needed. 4. Run python3 scripts/audit_repo.py .. 5. Fix only the task README files that block current work. 6. For new tasks, use task_scaffold.py so metadata is written in the v0.4.0 format.
Common Fixes
- Rename
agenttoassigneewhen touching an active task. - Add
dependencies: []andartifact_paths: []if missing. - Replace old script references with
task_scaffold.pyandaudit_repo.py. - Remove any workflow that invokes a removed dashboard generator.
Naming And Archive Rules
Use the project-configured task source as the task status source across all agents. Task folders are optional context packages for heavy workstreams.
Issue Record
Default issue heading format:
### ✅ Issue #9: 触发 Manus 调研任务Recommended fields:
类型/Type: task type, optionally with delegation text such as研究(委托 Manus).负责人/Lead Author/assignee: human or Agent responsible for execution.依赖: dependency text.Issue #Nreferences are parsed as dependencies.目标: objective.素材来源/来源材料: source material.验收标准: acceptance criteria section.
Default status mapping:
| Marker | Status |
|---|---|
⬜ | pending_confirmation |
✅ | ready |
🟢 | created |
[ ] | todo |
[x] | done |
Projects may override mappings through project.status_map, available_statuses, and dependency_done_statuses in config/collab.yaml.
Task Folder ID
Format: YYMMDDNNN
YYMMDD: task creation date.NNN: three-digit sequence for that date, starting from001.- The ID never changes after creation, even if the task is renamed, merged, or archived.
Folder Slug
Format: {id}-{type}-{title}
Examples:
260305001-法律-CodingPlan数据条款综合260305002-研究-LegalSkill架构设计260305003-整合-多来源资料合并
Rules:
idmust be the stable task ID.typemust exist in the task type registry. Projects extend it throughconfig/task-types.yaml.- Scripts accept aliases from the registry and normalize them to the canonical type.
titlemay use Chinese or English, but must not contain path separators or shell-sensitive characters.- Keep titles concise enough to scan in GitHub branch and PR lists.
Branch Name
Format: agent/{agent-id}/{slug}
Examples:
agent/codex/260305001-法律-CodingPlan数据条款综合agent/manus/260305002-研究-LegalSkill架构设计
README Frontmatter
README frontmatter belongs to the task folder context package. It must not override the configured task source status.
Each task folder must contain README.md with frontmatter:
---
id: 260305001
slug: 260305001-法律-CodingPlan数据条款综合
title: CodingPlan 数据条款综合研究
type: 法律
status: doing
assignee: codex
dependencies: []
artifact_paths: []
progress: 30
created: 2026-03-05
updated: 2026-03-05
---agent may appear in legacy tasks, but new tasks use assignee.
Status
| Status | Meaning |
|---|---|
todo | 待开始 |
doing | 进行中 |
done | 已完成 |
blocked | 阻塞中 |
archived | 已归档 |
deprecated | 已废弃 |
Dependencies are considered satisfied when referenced tasks are done, resolved, or closed.
Merge And Archive
When merging similar tasks:
1. Compare task folders and choose the richest folder as the main task, unless the user chooses a different target. 2. Keep every original task ID in the main README under a merge history section. 3. Add a merge notice to each source README that points to the target task. 4. Move merged source folders under archive/ only after preserving their README frontmatter.
# Default task type registry for cross-agent-coordination.
# Projects can extend or override these values with config/task-types.yaml.
task_types:
研究:
aliases: [research]
description: 信息收集/调研
output_hint: 报告/笔记/清单
examples:
- 法律案例检索
- AI 工具调研
- 政策法规梳理
写作:
aliases: [writing]
description: 内容创作
output_hint: 文章/脚本/章节
examples:
- 章节初稿
- 公众号文章
- 项目说明
整合:
aliases: [integration, integrate]
description: 多来源材料融合
output_hint: 统一稿/整合报告
examples:
- 多人稿件合并
- 调研材料整合
审阅:
aliases: [review]
description: 质量审查/事实核查
output_hint: 审阅意见/修订稿
examples:
- 事实核查
- 术语一致性检查
- 交叉审阅
课程:
aliases: [course]
description: 课程学习
output_hint: 课程笔记/学习记录
examples:
- 课程笔记
- 学习记录
- 课后作业
代码:
aliases: [code]
description: 代码项目
output_hint: 可运行的代码/工具
examples:
- 小工具开发
- 脚本编写
- PoC 验证
法律:
aliases: [legal]
description: 律师业务
output_hint: 方案/文档/分析
examples:
- 案件材料整理
- 诉讼方案
- 合同审查
实验:
aliases: [exp]
description: 实验性尝试
output_hint: 验证结果/经验总结
examples:
- 不确定方向的探索
- 新方法测试
同步:
aliases: [sync]
description: 知识同步/归档
output_hint: 整理后的文件
examples:
- 知识库归档
- 协作材料整理
naming:
pattern: "{id}-{type}-{title}"
id_pattern: "YYMMDDNNN"
branch_pattern: "agent/{agent-id}/{slug}"
examples:
- "260305001-法律-CodingPlan数据条款综合"
- "260305002-研究-LegalSkill架构设计"
- "260305003-整合-多来源资料合并"
statuses:
todo: 待开始
doing: 进行中
done: 已完成
blocked: 阻塞中
archived: 已归档
deprecated: 已废弃
#!/usr/bin/env python3
"""Audit task metadata in a cross-agent-coordination project."""
from __future__ import annotations
import argparse
from pathlib import Path
from collab_lib import iter_task_dirs, load_config, load_task_type_registry, parse_frontmatter, parse_slug
def audit_task(task_dir, registry):
issues = []
parsed = parse_slug(task_dir.name)
readme = task_dir / "README.md"
fm = parse_frontmatter(readme)
if not parsed:
issues.append("目录名不符合 {YYMMDDNNN}-{type}-{title}")
parsed = {}
folder_id = parsed.get("id", "")
folder_type = parsed.get("type", "")
fm_id = str(fm.get("id", ""))
fm_slug = str(fm.get("slug", ""))
fm_type = str(fm.get("type", ""))
if not readme.exists():
issues.append("缺少 README.md")
elif not fm:
issues.append("README 缺少 frontmatter")
if folder_id and fm_id and folder_id != fm_id:
issues.append(f"id 不一致: 目录={folder_id}, frontmatter={fm_id}")
elif folder_id and not fm_id:
issues.append("frontmatter 缺少 id")
if fm_slug and fm_slug != task_dir.name:
issues.append(f"slug 不一致: 目录={task_dir.name}, frontmatter={fm_slug}")
elif not fm_slug:
issues.append("frontmatter 缺少 slug")
if folder_type:
try:
normalized_folder_type = registry.normalize(folder_type)
except SystemExit:
normalized_folder_type = folder_type
issues.append(f"目录分类不在任务类型注册表中: {folder_type}")
else:
normalized_folder_type = ""
if fm_type:
try:
normalized_fm_type = registry.normalize(fm_type)
except SystemExit:
normalized_fm_type = fm_type
issues.append(f"frontmatter type 不在任务类型注册表中: {fm_type}")
if normalized_folder_type and normalized_fm_type != normalized_folder_type:
issues.append(f"type 不一致: 目录={folder_type}, frontmatter={fm_type}")
elif folder_type:
issues.append("frontmatter 缺少 type")
if "assignee" not in fm:
issues.append("frontmatter 缺少 assignee")
if "dependencies" not in fm:
issues.append("frontmatter 缺少 dependencies")
if "artifact_paths" not in fm:
issues.append("frontmatter 缺少 artifact_paths")
return {
"path": str(task_dir),
"id": folder_id or fm_id or "-",
"folder_type": folder_type or "-",
"frontmatter_type": fm_type or "-",
"issues": issues,
}
def render_markdown(results, show_ok=False):
filtered = [item for item in results if item["issues"] or show_ok]
lines = [
"# Cross-Agent-Coordination 元数据审计",
"",
f"- 扫描任务数: {len(results)}",
f"- 发现问题任务数: {sum(1 for item in results if item['issues'])}",
"",
"| Task | ID | 目录分类 | Frontmatter Type | Issues |",
"|---|---:|---|---|---|",
]
if not filtered:
lines.append("| - | - | - | - | 未发现问题 |")
for item in filtered:
issues = "<br>".join(item["issues"]) if item["issues"] else "OK"
lines.append(
f"| `{item['path']}` | `{item['id']}` | `{item['folder_type']}` | "
f"`{item['frontmatter_type']}` | {issues} |"
)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("root", help="cross-agent-coordination project root")
parser.add_argument("--show-ok", action="store_true", help="include tasks with no issues")
args = parser.parse_args()
root = Path(args.root).expanduser()
config = load_config(root)
registry = load_task_type_registry(root, config)
results = [audit_task(task_dir, registry) for task_dir in iter_task_dirs(root)]
print(render_markdown(results, show_ok=args.show_ok))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Shared helpers for cross-agent-coordination scripts."""
from __future__ import annotations
import difflib
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError: # pragma: no cover - exercised by users without PyYAML
yaml = None
DEFAULT_AGENT = "openclaw"
DEFAULT_AGENT_EMAIL_DOMAIN = "agents.local"
DONE_STATUSES = {"done", "resolved", "closed"}
ISSUE_DEPENDENCY_DONE_STATUSES = {"done", "resolved", "closed", "ready", "created"}
DEFAULT_TASK_SOURCE_CANDIDATES = ("docs/TASKS.md", "docs/ISSUES.md", "TASKS.md", "ISSUES.md")
DEFAULT_ISSUE_FILE = DEFAULT_TASK_SOURCE_CANDIDATES[0]
DEFAULT_STATUS_MAP = {
"⬜": "pending_confirmation",
"✅": "ready",
"🟢": "created",
"[ ]": "todo",
"[x]": "done",
"[X]": "done",
}
DEFAULT_AVAILABLE_STATUSES = {"todo", "ready", "created"}
CLAIM_POOL_EMPTY = {"", "-", "unassigned", "pool", "none", "null"}
IGNORED_DIRS = {
".claude",
".git",
".github",
"assets",
"config",
"docs",
"meta",
"scripts",
"skill",
"source-material",
"templates",
}
DEFAULT_TASK_TYPES = {
"研究": {
"aliases": ["research"],
"description": "信息收集/调研",
"output_hint": "报告/笔记/清单",
},
"写作": {
"aliases": ["writing"],
"description": "内容创作",
"output_hint": "文章/脚本/章节",
},
"整合": {
"aliases": ["integration", "integrate"],
"description": "多来源材料融合",
"output_hint": "统一稿/整合报告",
},
"审阅": {
"aliases": ["review"],
"description": "质量审查/事实核查",
"output_hint": "审阅意见/修订稿",
},
"课程": {
"aliases": ["course"],
"description": "课程学习",
"output_hint": "课程笔记/学习记录",
},
"代码": {
"aliases": ["code"],
"description": "代码项目",
"output_hint": "可运行的代码/工具",
},
"法律": {
"aliases": ["legal"],
"description": "律师业务",
"output_hint": "方案/文档/分析",
},
"实验": {
"aliases": ["exp"],
"description": "实验性尝试",
"output_hint": "验证结果/经验总结",
},
"同步": {
"aliases": ["sync"],
"description": "知识同步/归档",
"output_hint": "整理后的文件",
},
}
def skill_root() -> Path:
return Path(__file__).resolve().parents[1]
def require_yaml(path: Path) -> None:
if yaml is None:
print("❌ 缺少依赖: PyYAML")
print(" 请运行: python3 -m pip install -r scripts/requirements.txt")
print(" 或运行: python3 -m pip install PyYAML")
raise SystemExit(f"无法读取 YAML 文件: {path}")
def read_yaml(path: Path, *, required: bool = False) -> dict[str, Any]:
if not path.exists():
if required:
raise SystemExit(f"找不到配置文件: {path}")
return {}
require_yaml(path)
with path.open(encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise SystemExit(f"YAML 顶层必须是对象: {path}")
return data
def load_config(root: Path | str) -> dict[str, Any]:
root = Path(root).expanduser()
return read_yaml(root / "config" / "collab.yaml")
def project_config(config: dict[str, Any]) -> dict[str, Any]:
value = config.get("project", {})
return value if isinstance(value, dict) else {}
def resolve_project_path(root: Path, value: str, default: str) -> Path:
raw = value or default
path = Path(raw).expanduser()
return path if path.is_absolute() else root / path
def issue_file_path(root: Path, config: dict[str, Any]) -> Path:
project = project_config(config)
configured = str(project.get("issue_file") or project.get("task_source_file") or "")
if configured:
return resolve_project_path(root, configured, DEFAULT_ISSUE_FILE)
for candidate in DEFAULT_TASK_SOURCE_CANDIDATES:
path = resolve_project_path(root, candidate, DEFAULT_ISSUE_FILE)
if path.exists():
return path
return resolve_project_path(root, DEFAULT_ISSUE_FILE, DEFAULT_ISSUE_FILE)
def issue_status_map(config: dict[str, Any]) -> dict[str, str]:
project = project_config(config)
configured = project.get("status_map", {})
result = dict(DEFAULT_STATUS_MAP)
if isinstance(configured, dict):
result.update({str(k): str(v) for k, v in configured.items()})
return result
def available_statuses(config: dict[str, Any]) -> set[str]:
project = project_config(config)
configured = project.get("available_statuses", [])
if isinstance(configured, list) and configured:
return {str(item) for item in configured}
if isinstance(configured, str) and configured.strip():
return {item.strip() for item in configured.split(",") if item.strip()}
return set(DEFAULT_AVAILABLE_STATUSES)
def dependency_done_statuses(config: dict[str, Any]) -> set[str]:
project = project_config(config)
configured = project.get("dependency_done_statuses", [])
if isinstance(configured, list) and configured:
return {str(item) for item in configured}
if isinstance(configured, str) and configured.strip():
return {item.strip() for item in configured.split(",") if item.strip()}
return set(ISSUE_DEPENDENCY_DONE_STATUSES)
def task_context_mode(config: dict[str, Any]) -> str:
return str(project_config(config).get("task_context_mode", "issues_primary"))
def get_current_agent(config: dict[str, Any], explicit_agent: str = "") -> str:
if explicit_agent:
return explicit_agent
if os.getenv("AGENT_ID"):
return os.getenv("AGENT_ID", "")
project = project_config(config)
if project.get("default_agent"):
return str(project["default_agent"])
agent_cfg = config.get("agent", {})
if isinstance(agent_cfg, dict) and agent_cfg.get("current"):
return str(agent_cfg["current"])
return DEFAULT_AGENT
def get_agent_info(config: dict[str, Any], agent: str) -> dict[str, Any]:
agents = config.get("agents", {})
return agents.get(agent, {}) if isinstance(agents, dict) else {}
def get_agent_identity(config: dict[str, Any], agent: str) -> dict[str, str]:
info = get_agent_info(config, agent)
git_name = info.get("git_name") or info.get("name") or agent
git_email = info.get("git_email") or info.get("email") or f"{agent}@{DEFAULT_AGENT_EMAIL_DOMAIN}"
return {
"id": agent,
"git_name": str(git_name),
"git_email": str(git_email),
"github_user": str(info.get("github_user", "")),
"token_env": str(info.get("token_env", "")),
}
def get_token(config: dict[str, Any], agent: str = "") -> str:
current_agent = get_current_agent(config, agent)
identity = get_agent_identity(config, current_agent)
token_env = identity.get("token_env", "")
if token_env and os.getenv(token_env):
return os.getenv(token_env, "")
return os.getenv("GITHUB_TOKEN", "") or str(config.get("github", {}).get("token", ""))
class TaskTypeRegistry:
def __init__(self, task_types: dict[str, Any]):
self.task_types = task_types
self.aliases: dict[str, str] = {}
for canonical, info in task_types.items():
self.aliases[canonical] = canonical
if isinstance(info, dict):
for alias in info.get("aliases", []) or []:
self.aliases[str(alias)] = canonical
@property
def canonical_types(self) -> set[str]:
return set(self.task_types)
@property
def alias_values(self) -> set[str]:
return set(self.aliases)
def normalize(self, task_type: str) -> str:
if task_type in self.aliases:
return self.aliases[task_type]
allowed = ", ".join(sorted(self.aliases))
raise SystemExit(f"未知任务类型: {task_type};可用类型: {allowed}")
def load_task_type_registry(root: Path, config: dict[str, Any] | None = None) -> TaskTypeRegistry:
config = config or load_config(root)
project = project_config(config)
configured = project.get("task_types_file", "config/task-types.yaml")
project_file = resolve_project_path(root, str(configured), "config/task-types.yaml")
default_file = skill_root() / "references" / "task-types.yaml"
task_types = dict(DEFAULT_TASK_TYPES)
source = project_file if project_file.exists() else default_file
data = read_yaml(source)
if isinstance(data.get("task_types"), dict):
task_types.update(data["task_types"])
return TaskTypeRegistry(task_types)
def parse_frontmatter(readme: Path) -> dict[str, Any]:
if not readme.exists():
return {}
text = readme.read_text(encoding="utf-8", errors="ignore")
if not text.startswith("---"):
return {}
parts = text.split("---", 2)
if len(parts) < 3:
return {}
raw = parts[1]
if yaml is not None:
data = yaml.safe_load(raw) or {}
return data if isinstance(data, dict) else {}
data: dict[str, Any] = {}
for line in raw.splitlines():
match = re.match(r"\s*([A-Za-z_][\w-]*):\s*(.*?)\s*(?:#.*)?$", line)
if match:
data[match.group(1)] = match.group(2).strip().strip("\"'")
return data
def split_frontmatter(content: str) -> tuple[dict[str, Any], str]:
if not content.startswith("---"):
return {}, content
parts = content.split("---", 2)
if len(parts) < 3:
return {}, content
data: dict[str, Any] = {}
if yaml is not None:
parsed = yaml.safe_load(parts[1]) or {}
if isinstance(parsed, dict):
data = parsed
return data, parts[2].lstrip("\n")
def yaml_scalar(value: Any) -> str:
if value is None:
return '""'
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
text = str(value)
if text == "":
return '""'
if re.search(r"[:#\[\]{}]|^\s|\s$|^[-?]|^(true|false|null|none)$", text, re.I):
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
return text
def dump_frontmatter(data: dict[str, Any]) -> str:
lines = ["---"]
for key, value in data.items():
if isinstance(value, list):
if not value:
lines.append(f"{key}: []")
else:
lines.append(f"{key}:")
for item in value:
lines.append(f" - {yaml_scalar(item)}")
else:
lines.append(f"{key}: {yaml_scalar(value)}")
lines.append("---")
return "\n".join(lines) + "\n"
def merge_frontmatter(content: str, fields: dict[str, Any]) -> str:
existing, body = split_frontmatter(content)
merged = dict(existing)
merged.update(fields)
return dump_frontmatter(merged) + "\n" + body.lstrip("\n")
def render_template(content: str, values: dict[str, Any]) -> str:
rendered = content
for key, value in values.items():
rendered = re.sub(r"{{\s*" + re.escape(key) + r"\s*}}", str(value), rendered)
return rendered
def parse_field(value: str) -> tuple[str, Any]:
if "=" not in value:
raise SystemExit(f"--field 必须使用 key=value 格式: {value}")
key, raw = value.split("=", 1)
key = key.strip()
raw = raw.strip()
if not re.match(r"^[A-Za-z_][\w-]*$", key):
raise SystemExit(f"非法字段名: {key}")
if raw == "[]":
parsed: Any = []
elif raw.startswith("[") and raw.endswith("]"):
inner = raw[1:-1].strip()
parsed = [item.strip() for item in inner.split(",") if item.strip()] if inner else []
elif raw.isdigit():
parsed = int(raw)
else:
parsed = raw
return key, parsed
def parse_slug(slug: str) -> dict[str, str] | None:
match = re.match(r"^(?P<id>\d{9})-(?P<type>[^-]+)-(?P<title>.+)$", slug)
return match.groupdict() if match else None
def strip_slug_prefix(text: str) -> str:
parts = text.split("-", 2)
if len(parts) == 3 and re.match(r"^\d{6,9}$", parts[0]):
return parts[2]
return text
def sanitize_slug_part(text: str) -> str:
text = re.sub(r'[\\/:*?"<>|#]+', "-", text.strip())
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-{2,}", "-", text)
return text.strip("-")
def is_task_dir(path: Path) -> bool:
if not path.is_dir() or path.name in IGNORED_DIRS:
return False
if parse_slug(path.name):
return True
fm = parse_frontmatter(path / "README.md")
return bool(fm.get("id") or fm.get("slug"))
def iter_task_dirs(root: Path):
for parent in [root, root / "archive"]:
if not parent.exists():
continue
for item in parent.iterdir():
if is_task_dir(item):
yield item
def extract_task_id(readme: Path) -> str:
fm = parse_frontmatter(readme)
value = fm.get("id", "")
return str(value) if re.match(r"^\d{9}$", str(value)) else ""
def get_next_task_id(root: Path, date_str: str | None = None) -> str:
date_str = date_str or datetime.now().strftime("%y%m%d")
date_prefix = date_str[2:] if len(date_str) == 8 else date_str
max_num = 0
for task_dir in iter_task_dirs(root):
task_id = extract_task_id(task_dir / "README.md")
if not task_id:
match = re.match(rf"^{re.escape(date_prefix)}(\d{{3}})(?:-|$)", task_dir.name)
task_id = f"{date_prefix}{match.group(1)}" if match else ""
match = re.match(rf"^{re.escape(date_prefix)}(\d{{3}})$", task_id)
if match:
max_num = max(max_num, int(match.group(1)))
return f"{date_prefix}{max_num + 1:03d}"
def normalize_topic(text: str, registry: TaskTypeRegistry) -> str:
text = strip_slug_prefix(text)
for task_type in registry.canonical_types:
text = text.replace(task_type, "")
for alias in registry.alias_values:
text = re.sub(rf"\b{re.escape(alias)}\b", "", text, flags=re.IGNORECASE)
text = re.sub(r"[\W_]+", "", text, flags=re.UNICODE)
return text.lower()
def char_ngrams(text: str, registry: TaskTypeRegistry, width: int = 2) -> set[str]:
text = normalize_topic(text, registry)
if len(text) <= width:
return {text} if text else set()
return {text[i : i + width] for i in range(len(text) - width + 1)}
def similarity(query: str, candidate: str, registry: TaskTypeRegistry) -> float:
query_norm = normalize_topic(query, registry)
candidate_norm = normalize_topic(candidate, registry)
if not query_norm or not candidate_norm:
return 0.0
sequence_score = difflib.SequenceMatcher(None, query_norm, candidate_norm).ratio()
query_grams = char_ngrams(query_norm, registry)
candidate_grams = char_ngrams(candidate_norm, registry)
overlap_score = 0.0
if query_grams and candidate_grams:
overlap_score = len(query_grams & candidate_grams) / len(query_grams | candidate_grams)
return max(sequence_score, overlap_score)
def as_list(value: Any) -> list[str]:
if value is None or value == "":
return []
if isinstance(value, list):
return [str(item) for item in value if str(item)]
if isinstance(value, str):
stripped = value.strip()
if stripped == "[]":
return []
return [item.strip() for item in stripped.split(",") if item.strip()]
return [str(value)]
def markdown_heading(line: str) -> tuple[int, str] | None:
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
if not match:
return None
return len(match.group(1)), match.group(2).strip()
def normalize_field_name(name: str) -> str:
return re.sub(r"\s+", " ", name.strip().strip("*")).lower()
def parse_markdown_fields(lines: list[str]) -> dict[str, str]:
fields: dict[str, str] = {}
for line in lines:
match = re.match(r"^\s*[-*]\s+(?:\*\*)?([^:*:]+?)(?:\*\*)?\s*[::]\s*(.+?)\s*$", line)
if not match:
continue
key = normalize_field_name(match.group(1))
value = match.group(2).strip()
fields[key] = value
return fields
def parse_markdown_sections(lines: list[str]) -> dict[str, str]:
sections: dict[str, list[str]] = {}
current = ""
buffer: list[str] = []
for line in lines:
heading = markdown_heading(line)
if heading:
level, title = heading
if level >= 4:
if current:
sections[current] = buffer
current = title
buffer = []
continue
if current:
buffer.append(line)
if current:
sections[current] = buffer
return {key: "\n".join(value).strip() for key, value in sections.items() if "\n".join(value).strip()}
def detect_agent_id(config: dict[str, Any], text: str) -> str:
if not text:
return ""
haystack = text.lower()
known_agents: dict[str, set[str]] = {
"codex": {"codex"},
"claude_code": {"claude code", "claude-code", "claude_code"},
"manus": {"manus"},
"openclaw": {"openclaw", "openclaw"},
"anygen": {"anygen"},
"coze": {"coze"},
"hermes": {"hermes"},
}
agents = config.get("agents", {})
if isinstance(agents, dict):
for agent_id, info in agents.items():
tokens = known_agents.setdefault(str(agent_id), {str(agent_id).replace("_", " ").lower(), str(agent_id).lower()})
if isinstance(info, dict):
for field in ["name", "github_user", "email", "git_email"]:
value = str(info.get(field, "")).strip()
if value:
tokens.add(value.lower())
for agent_id, tokens in known_agents.items():
for token in tokens:
if token and token in haystack:
return agent_id
return ""
def field_value(fields: dict[str, str], names: list[str]) -> str:
wanted = {normalize_field_name(name) for name in names}
for key, value in fields.items():
if key in wanted:
return value
return ""
def normalize_issue_type(raw: str, registry: TaskTypeRegistry) -> str:
if not raw:
return "-"
cleaned = re.split(r"[((]", raw, 1)[0].strip()
if not cleaned:
cleaned = raw.strip()
try:
return registry.normalize(cleaned)
except SystemExit:
return cleaned
def infer_issue_type(title: str, fields: dict[str, str], sections: dict[str, str], registry: TaskTypeRegistry) -> str:
keys = set(sections)
lead = field_value(fields, ["lead author", "负责人", "负责"])
if "调研任务" in keys:
candidate = "研究"
elif "审阅维度" in keys or "审阅" in title:
candidate = "审阅"
elif "需确定事项" in keys or "术语" in title or "一致性" in title:
candidate = "整合"
elif re.search(r"\bch\d+\b", title, flags=re.IGNORECASE) or field_value(fields, ["目标字数"]) or lead:
candidate = "写作"
else:
return "-"
try:
return registry.normalize(candidate)
except SystemExit:
return candidate
def parse_issue_dependencies(text: str) -> list[str]:
deps: list[str] = []
for match in re.finditer(r"(?:Issue\s*)?#\s*(\d+)", text, flags=re.IGNORECASE):
value = match.group(1)
if value not in deps:
deps.append(value)
return deps
def issue_heading_parts(title: str) -> tuple[str, str, str] | None:
match = re.match(
r"^(?:(?P<marker>[\u2610-\u2611\u2705\U0001f7e2⬜✅🟢]|\[[ xX]\])\s+)?"
r"Issue\s*#(?P<num>\d+)\s*[::]\s*(?P<title>.+?)\s*$",
title,
flags=re.IGNORECASE,
)
if not match:
return None
return match.group("marker") or "", match.group("num"), match.group("title").strip()
def issue_summary_from_block(
root: Path,
issue_file: Path,
heading_marker: str,
number: str,
title: str,
lines: list[str],
config: dict[str, Any],
registry: TaskTypeRegistry,
) -> dict[str, Any]:
fields = parse_markdown_fields(lines)
sections = parse_markdown_sections(lines)
status = issue_status_map(config).get(heading_marker, heading_marker or "todo")
raw_type = field_value(fields, ["type", "类型", "task type"])
task_type = normalize_issue_type(raw_type, registry)
if task_type == "-":
task_type = infer_issue_type(title, fields, sections, registry)
explicit_owner = field_value(fields, ["assignee", "owner", "负责人", "负责", "lead author", "执行者", "委托"])
agent_from_owner = detect_agent_id(config, explicit_owner)
agent_from_type = detect_agent_id(config, raw_type)
agent_from_text = detect_agent_id(config, title)
assignee = agent_from_owner or agent_from_type or agent_from_text or explicit_owner
dependency_text = field_value(fields, ["依赖", "dependencies", "depends_on", "depends on"])
dependencies = parse_issue_dependencies(dependency_text)
objective = field_value(fields, ["目标", "objective"]) or title
source_material = field_value(fields, ["素材来源", "来源材料", "source material", "sources"])
block_text = "\n".join(lines).strip()
searchable = " ".join([title, task_type, explicit_owner, raw_type, dependency_text, objective, source_material, block_text])
rel_issue_file = str(issue_file.relative_to(root)) if issue_file.is_relative_to(root) else str(issue_file)
return {
"source": "issue",
"path": str(issue_file),
"issue_file": rel_issue_file,
"slug": f"Issue #{number}",
"id": number,
"issue_number": number,
"title": title,
"status": status,
"status_marker": heading_marker,
"type": task_type,
"assignee": assignee,
"owner": explicit_owner,
"dependencies": dependencies,
"dependency_text": dependency_text,
"objective": objective,
"source_material": source_material,
"sections": sections,
"fields": fields,
"searchable": searchable,
}
def iter_issue_summaries(root: Path, registry: TaskTypeRegistry, config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
config = config or load_config(root)
issue_file = issue_file_path(root, config)
if not issue_file.exists():
return []
lines = issue_file.read_text(encoding="utf-8", errors="ignore").splitlines()
starts: list[tuple[int, str, str, str]] = []
for idx, line in enumerate(lines):
heading = markdown_heading(line)
if not heading:
continue
parts = issue_heading_parts(heading[1])
if parts:
marker, number, title = parts
starts.append((idx, marker, number, title))
summaries: list[dict[str, Any]] = []
for pos, (idx, marker, number, title) in enumerate(starts):
end = starts[pos + 1][0] if pos + 1 < len(starts) else len(lines)
block = lines[idx + 1 : end]
summaries.append(issue_summary_from_block(root, issue_file, marker, number, title, block, config, registry))
return summaries
def task_summary(task_dir: Path, registry: TaskTypeRegistry) -> dict[str, Any]:
readme = task_dir / "README.md"
fm = parse_frontmatter(readme)
parsed = parse_slug(task_dir.name) or {}
title = str(fm.get("title") or parsed.get("title") or strip_slug_prefix(task_dir.name))
raw_type = str(fm.get("type") or parsed.get("type") or "-")
try:
task_type = registry.normalize(raw_type)
except SystemExit:
task_type = raw_type
task_id = str(fm.get("id") or parsed.get("id") or "-")
assignee = str(fm.get("assignee") or fm.get("agent") or "")
content_sample = ""
if readme.exists():
content_sample = "\n".join(readme.read_text(encoding="utf-8", errors="ignore").splitlines()[:100])
searchable = " ".join([task_dir.name, str(fm.get("slug", "")), title, task_type, content_sample])
return {
"source": "task_folder",
"path": str(task_dir),
"slug": task_dir.name,
"id": task_id,
"title": title,
"status": str(fm.get("status", "-")),
"type": task_type,
"assignee": assignee,
"dependencies": as_list(fm.get("dependencies")),
"searchable": searchable,
}
def iter_project_tasks(root: Path, registry: TaskTypeRegistry, config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
config = config or load_config(root)
folder_tasks = [task_summary(task_dir, registry) for task_dir in iter_task_dirs(root)]
issue_tasks = iter_issue_summaries(root, registry, config)
mode = task_context_mode(config)
if mode == "task_folders_only":
return folder_tasks
if mode == "task_folders_primary":
return folder_tasks + issue_tasks
return issue_tasks + folder_tasks
def find_task_by_ref(root: Path, registry: TaskTypeRegistry, task_ref: str, config: dict[str, Any] | None = None) -> dict[str, Any] | None:
normalized = str(task_ref).strip()
normalized = re.sub(r"^Issue\s*#?", "", normalized, flags=re.IGNORECASE).strip()
normalized = normalized.lstrip("#")
for summary in iter_project_tasks(root, registry, config):
if (
summary["id"] == normalized
or summary["id"] == task_ref
or summary["slug"] == task_ref
or summary["slug"].startswith(task_ref)
or (summary.get("issue_number") and summary.get("issue_number") == normalized)
):
return summary
return None
def dependencies_satisfied(root: Path, registry: TaskTypeRegistry, dependencies: list[str], config: dict[str, Any] | None = None) -> bool:
config = config or load_config(root)
for dep in dependencies:
task = find_task_by_ref(root, registry, dep, config)
if not task:
return False
done_statuses = dependency_done_statuses(config) if task.get("source") == "issue" else DONE_STATUSES
if task["status"] not in done_statuses:
return False
return True
def assignee_matches(config: dict[str, Any], assignee: str, agent: str, claim_policy: str) -> bool:
assignee = (assignee or "").strip()
if claim_policy == "claim_pool" and assignee.lower() in CLAIM_POOL_EMPTY:
return True
if assignee == agent:
return True
if not assignee or not agent:
return False
if assignee.lower() == agent.lower() or agent.lower() in assignee.lower():
return True
detected = detect_agent_id(config, assignee)
return detected == agent
def is_available_task(
root: Path,
summary: dict[str, Any],
registry: TaskTypeRegistry,
agent: str,
claim_policy: str,
config: dict[str, Any] | None = None,
) -> bool:
config = config or load_config(root)
valid_statuses = available_statuses(config) if summary.get("source") == "issue" else {"todo"}
if summary["status"] not in valid_statuses:
return False
if not dependencies_satisfied(root, registry, summary["dependencies"], config):
return False
return assignee_matches(config, summary.get("assignee", ""), agent, claim_policy)
def find_similar_tasks(
root: Path,
registry: TaskTypeRegistry,
topic: str,
task_type: str = "",
threshold: float = 0.45,
limit: int | None = None,
config: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
config = config or load_config(root)
query = f"{task_type} {topic}".strip()
matches = []
threshold = 0.0 if not topic.strip() else threshold
for summary in iter_project_tasks(root, registry, config):
score = max(
similarity(query, summary["searchable"], registry),
similarity(topic, summary["title"], registry),
similarity(topic, summary["slug"], registry),
)
if score >= threshold:
summary["score"] = score
matches.append(summary)
matches.sort(key=lambda item: item["score"], reverse=True)
return matches[:limit] if limit else matches
def today() -> str:
return datetime.now().strftime("%Y-%m-%d")
#!/usr/bin/env python3
"""Compose email trigger drafts for external agents."""
from __future__ import annotations
import argparse
import re
from email.message import EmailMessage
from pathlib import Path
from collab_lib import (
find_task_by_ref,
get_agent_info,
load_config,
load_task_type_registry,
sanitize_slug_part,
)
SECTION_ALIASES = {
"目标": ["目标", "任务目标", "Objective"],
"验收标准": ["验收标准", "Acceptance Criteria", "检查清单"],
"来源材料": ["来源材料", "素材来源", "Sources", "Source Materials"],
"交接要求": ["交接要求", "交接记录", "Handoff", "Handoff Requirements"],
}
def find_task(root, registry, config, task_id="", slug="", issue=""):
ref = issue or task_id or slug
if not ref:
return {}
summary = find_task_by_ref(root, registry, ref, config)
if not summary:
return {}
if summary.get("source") == "task_folder":
readme = Path(summary["path"]) / "README.md"
summary["readme"] = readme
return summary
def agent_info(config, agent):
info = get_agent_info(config, agent)
return {
"id": agent,
"name": info.get("name") or info.get("git_name") or agent,
"email": info.get("email") or info.get("git_email") or f"{agent}@agents.local",
"from_alias": info.get("from_alias", ""),
"github_user": info.get("github_user", ""),
"token_env": info.get("token_env", ""),
"trigger_email": info.get("trigger_email", ""),
"reply_to": info.get("reply_to", ""),
}
def compose_subject(agent, task, topic):
marker = f"Issue #{task['id']}" if task and task.get("source") == "issue" else (task["id"] if task else "NEW")
title = task.get("title") if task else topic
return f"[Cross-Agent-Coordination][{marker}][{agent['id']}] {title}"
def extract_sections(readme):
if not readme or not readme.exists():
return ""
lines = readme.read_text(encoding="utf-8", errors="ignore").splitlines()
sections = {}
current = ""
buffer = []
for line in lines:
heading = re.match(r"^(#{2,4})\s+(.+?)\s*$", line)
if heading:
if current:
sections[current] = buffer
current = heading.group(2).strip()
buffer = []
elif current:
buffer.append(line)
if current:
sections[current] = buffer
output = []
for label, names in SECTION_ALIASES.items():
selected = []
for name in names:
if name in sections:
selected = sections[name]
break
cleaned = "\n".join(selected).strip()
if cleaned:
output.append(f"### {label}\n\n{cleaned}")
if not output:
return ""
return "## Task Context From README\n\n" + "\n\n".join(output)
def extract_issue_context(task):
if not task or task.get("source") != "issue":
return ""
task_source = task.get("issue_file") or "configured task source"
output = []
if task.get("objective"):
output.append(f"### 目标\n\n{task['objective']}")
if task.get("source_material"):
output.append(f"### 来源材料\n\n{task['source_material']}")
sections = task.get("sections", {})
preferred = [
"大纲要点",
"调研任务",
"需确定事项",
"审阅维度",
"验收标准",
"触发方式",
"交接要求",
]
for name in preferred:
value = sections.get(name, "").strip()
if value:
output.append(f"### {name}\n\n{value}")
if not output:
return ""
return f"## Task Context From {task_source}\n\n" + "\n\n".join(output)
def task_source_label(config, task):
if task and task.get("source") == "issue":
return task.get("issue_file") or "configured task source"
project = config.get("project", {})
if isinstance(project, dict):
return str(project.get("issue_file") or project.get("task_source_file") or "configured task source")
return "configured task source"
def compose_body(config, registry, agent, task, topic, task_type, instruction):
repo_url = config.get("github", {}).get("repo_url", "<repo_url>")
task_source = task_source_label(config, task)
if task and task.get("source") == "issue":
issue_ref = f"Issue #{task['id']}"
branch_slug = sanitize_slug_part(f"issue-{task['id']}-{task['title']}")
branch = f"agent/{agent['id']}/{branch_slug}"
topic = topic or task["title"]
deps = ", ".join(f"Issue #{dep}" for dep in task.get("dependencies", [])) or "-"
task_block = f"""## Bound Issue
- Issue: {issue_ref}
- Title: {task['title']}
- Type: {task['type']}
- Status: {task['status']}
- Assignee: {task.get('assignee') or '-'}
- Dependencies: {deps}
- Source: `{task_source}`
"""
context_block = extract_issue_context(task)
elif task:
slug = task["slug"]
task_id = task["id"]
branch = f"agent/{agent['id']}/{slug}"
task_block = f"""## Bound Task
- Task ID: {task_id}
- Slug: {slug}
- Title: {task['title']}
- Type: {task['type']}
- Status: {task['status']}
- Assignee: {task.get('assignee') or '-'}
- README: `{slug}/README.md`
"""
context_block = extract_sections(task.get("readme"))
else:
normalized_type = registry.normalize(task_type) if task_type else "<任务类型>"
branch = f"agent/{agent['id']}/<task-slug>"
task_block = f"""## New Task Request
- Topic: {topic}
- Type: {normalized_type}
- First run duplicate search. Create a new task only if no existing workstream matches.
"""
context_block = ""
instruction = instruction or "请完成该任务的下一步研究/整理,并提交到协作仓库。"
context_block = f"\n\n{context_block}" if context_block else ""
return f"""请作为 {agent['name']} 处理以下 Cross-Agent-Coordination 任务。
## Repository
- Repo: {repo_url}
{task_block}
{context_block}
## Assignment
{instruction}
## Required Workflow
1. Clone or pull the latest repository.
2. Run duplicate search before adding new work:
`python3 scripts/find_task.py . --topic "{topic}"`
3. If a related task exists, integrate new findings into that task instead of creating a duplicate folder.
4. Use branch:
`{branch}`
5. Commit as this agent:
`python3 scripts/gh_git.py commit --dest . --agent {agent['id']} --message "docs: update handoff"`
6. Push and create PR:
`python3 scripts/gh_git.py push --dest . --agent {agent['id']}`
`python3 scripts/gh_git.py pr --dest . --agent {agent['id']} --title "docs: {agent['name']} handoff update"`
## Attribution
- Agent ID: {agent['id']}
- Git Author: {agent['name']} <{agent['email']}>
- Expected GitHub Actor: {agent['github_user'] or '取决于实际执行 PR 的账号'}
## Handoff Requirements
- Update the configured task source (`{task_source}`) when this project uses one. If a task folder exists, update its README as handoff context only.
- Add sources reviewed, decisions made, blockers, and next step.
- Open a PR. Do not push directly to `main`.
"""
def write_eml(path, to_addr, subject, body, reply_to="", from_alias=""):
msg = EmailMessage()
msg["To"] = to_addr
if from_alias:
msg["From"] = from_alias
if reply_to:
msg["Reply-To"] = reply_to
msg["Subject"] = subject
msg.set_content(body)
Path(path).write_text(msg.as_string(), encoding="utf-8")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("root", help="cross-agent-coordination project root")
parser.add_argument("--agent", required=True, help="target agent id")
parser.add_argument("--topic", default="", help="task topic or assignment topic")
parser.add_argument("--type", default="", help="task type/category for new task requests")
parser.add_argument("--issue", default="", help="bind email to an Issue number in the configured task source")
parser.add_argument("--task-id", default="", help="bind email to an existing task id")
parser.add_argument("--slug", default="", help="bind email to an existing task slug")
parser.add_argument("--to", default="", help="override target email address")
parser.add_argument("--reply-to", default="", help="override Reply-To address")
parser.add_argument("--from-alias", default="", help="optional From header for .eml drafts")
parser.add_argument("--instruction", default="", help="extra assignment instructions")
parser.add_argument("--output", default="", help="write .eml draft to this path")
args = parser.parse_args()
root = Path(args.root).expanduser()
config = load_config(root)
registry = load_task_type_registry(root, config)
agent = agent_info(config, args.agent)
task = find_task(root, registry, config, args.task_id, args.slug, args.issue)
to_addr = args.to or agent["trigger_email"]
if not to_addr:
raise SystemExit(f"缺少触发邮箱:请在 agents.{args.agent}.trigger_email 中配置,或传入 --to")
reply_to = args.reply_to or agent["reply_to"]
from_alias = args.from_alias or agent["from_alias"]
topic = args.topic or (task.get("title") if task else "")
if not topic:
raise SystemExit("缺少任务主题:请传入 --topic,或使用 --issue/--task-id/--slug 绑定已有任务")
subject = compose_subject(agent, task, topic)
body = compose_body(config, registry, agent, task, topic, args.type, args.instruction)
if args.output:
write_eml(args.output, to_addr, subject, body, reply_to, from_alias)
print(f"EMAIL_DRAFT:{args.output}")
else:
print(f"To: {to_addr}")
if from_alias:
print(f"From: {from_alias}")
if reply_to:
print(f"Reply-To: {reply_to}")
print(f"Subject: {subject}")
print()
print(body)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Find existing cross-agent-coordination tasks."""
from __future__ import annotations
import argparse
from pathlib import Path
from collab_lib import (
find_similar_tasks,
get_current_agent,
is_available_task,
load_config,
load_task_type_registry,
project_config,
)
def render(matches):
lines = [
"# 相似任务检索",
"",
"| Score | Source | Slug | ID | Type | Status | Assignee | Title |",
"|---:|---|---|---:|---|---|---|---|",
]
if not matches:
lines.append("| - | - | - | - | - | - | - | 未找到相似任务 |")
for item in matches:
score = item.get("score")
score_text = f"{score:.2f}" if isinstance(score, float) else "-"
lines.append(
f"| {score_text} | `{item.get('source', '-')}` | `{item['slug']}` | `{item['id']}` | `{item['type']}` | "
f"`{item['status']}` | `{item.get('assignee', '') or '-'}` | {item['title']} |"
)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("root", help="cross-agent-coordination project root")
parser.add_argument("--topic", default="", help="topic to search before creating new work")
parser.add_argument("--type", default="", help="optional task type/category")
parser.add_argument("--threshold", type=float, default=0.45)
parser.add_argument("--available", action="store_true", help="only show executable todo tasks")
parser.add_argument("--agent", default="", help="current agent id for --available filtering")
args = parser.parse_args()
root = Path(args.root).expanduser()
config = load_config(root)
registry = load_task_type_registry(root, config)
task_type = registry.normalize(args.type) if args.type else ""
matches = find_similar_tasks(root, registry, args.topic, task_type, args.threshold, config=config)
if args.available:
agent = get_current_agent(config, args.agent)
claim_policy = str(project_config(config).get("claim_policy", "assigned_only"))
matches = [
item
for item in matches
if is_available_task(root, item, registry, agent, claim_policy, config)
]
print(render(matches))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Git 操作脚本"""
import argparse
import base64
import json
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from collab_lib import get_agent_identity, get_current_agent, get_token as resolve_token, load_config
def configure_git_identity(dest, identity):
run(['git', 'config', 'user.name', identity['git_name']], cwd=dest)
run(['git', 'config', 'user.email', identity['git_email']], cwd=dest)
def resolve_identity(dest, explicit_agent=''):
config = load_config(dest)
agent = get_current_agent(config, explicit_agent)
return get_agent_identity(config, agent)
def get_token(dest=None, agent=''):
return resolve_token(load_config(dest or '.'), agent)
def run(cmd, cwd=None):
subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=True)
def capture(cmd, cwd=None):
return subprocess.check_output(cmd, cwd=str(cwd) if cwd else None).decode('utf-8').strip()
def parse_repo(repo_url):
repo_url = repo_url.strip()
m = re.search(r'github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/.]+)', repo_url)
if not m:
raise SystemExit(f"无法解析 GitHub 仓库地址: {repo_url}")
return m.group('owner'), m.group('repo')
def clean_repo_url(repo_url):
owner, repo = parse_repo(repo_url)
return f"https://github.com/{owner}/{repo}.git"
def git_auth_args(token):
if not token:
return []
raw = f"x-access-token:{token}".encode("utf-8")
encoded = base64.b64encode(raw).decode("ascii")
return ['-c', f'http.extraHeader=Authorization: Basic {encoded}']
def git_with_token(token, git_args, cwd=None):
run(['git', *git_auth_args(token), *git_args], cwd=cwd)
def github_api(method, url, token, payload=None):
data = None
if payload is not None:
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
)
try:
with urllib.request.urlopen(req) as resp:
body = resp.read().decode('utf-8')
return resp.status, json.loads(body) if body else {}
except urllib.error.HTTPError as exc:
body = exc.read().decode('utf-8')
try:
detail = json.loads(body)
except json.JSONDecodeError:
detail = {'message': body}
return exc.code, detail
def cmd_clone(args):
token = get_token('.', args.agent)
if not token: raise SystemExit("需要 GITHUB_TOKEN 或本地配置 token")
dest = Path(args.dest).expanduser()
if dest.exists():
git_with_token(token, ['fetch', '--all'], cwd=dest)
git_with_token(token, ['pull'], cwd=dest)
else:
git_with_token(token, ['clone', clean_repo_url(args.repo), str(dest)])
run(['git', 'remote', 'set-url', 'origin', clean_repo_url(args.repo)], cwd=dest)
def cmd_branch(args):
dest = Path(args.dest).expanduser()
identity = resolve_identity(dest, args.agent)
configure_git_identity(dest, identity)
run(['git', 'checkout', '-B', args.name], cwd=dest)
print(f"AGENT:{identity['id']}")
print(f"GIT_AUTHOR:{identity['git_name']} <{identity['git_email']}>")
def cmd_commit(args):
dest = Path(args.dest).expanduser()
identity = resolve_identity(dest, args.agent)
configure_git_identity(dest, identity)
run(['git', 'add', '-A'], cwd=dest)
run(['git', 'commit', '-m', args.message], cwd=dest)
print(f"AGENT:{identity['id']}")
print(f"GIT_AUTHOR:{identity['git_name']} <{identity['git_email']}>")
def cmd_push(args):
dest = Path(args.dest or '.').expanduser()
identity = resolve_identity(dest, args.agent)
try:
origin = capture(['git', 'remote', 'get-url', 'origin'], cwd=dest)
except subprocess.CalledProcessError:
raise SystemExit("当前仓库没有 Git remote,无法 push")
token = get_token(dest, identity['id'])
if not token: raise SystemExit("需要 GITHUB_TOKEN 或本地配置 token")
run(['git', 'remote', 'set-url', 'origin', clean_repo_url(origin)], cwd=dest)
branch = capture(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=dest)
git_with_token(token, ['push', '-u', 'origin', branch], cwd=dest)
# 返回分支名和仓库信息
print(f"BRANCH:{branch}")
print(f"REPO:{clean_repo_url(origin)}")
print(f"AGENT:{identity['id']}")
print(f"GITHUB_ACTOR:{identity['github_user'] or '取决于实际执行 push/PR 的账号'}")
def cmd_merge_pr(args):
"""自动合并 PR"""
dest = Path(args.dest or '.').expanduser()
identity = resolve_identity(dest, args.agent)
# 解析仓库和 PR 号
try:
repo_url = args.repo or capture(['git', 'remote', 'get-url', 'origin'], cwd=dest)
except subprocess.CalledProcessError:
raise SystemExit("当前仓库没有 Git remote,无法合并 PR")
token = get_token(dest, identity['id'])
if not token: raise SystemExit("需要 GITHUB_TOKEN 或本地配置 token")
owner, repo = parse_repo(repo_url)
# 如果没指定 PR 号,从当前分支名提取
pr_number = args.pr
if not pr_number:
branch = capture(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=dest)
# 查找这个分支对应的 PR
encoded_head = urllib.parse.quote(f"{owner}:{branch}", safe='')
url = f"https://api.github.com/repos/{owner}/{repo}/pulls?head={encoded_head}"
status, body = github_api('GET', url, token)
if status == 200 and body:
pr_number = body[0]['number']
if not pr_number:
raise SystemExit("无法找到 PR 编号")
# 合并 PR
url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/merge"
data = {
"merge_method": args.strategy or "squash",
"commit_title": f"Merge PR #{pr_number}",
}
status, body = github_api('PUT', url, token, data)
if status == 200:
print(f"✅ 已合并 PR #{pr_number}")
# 删除远程分支
branch = capture(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=dest)
git_with_token(token, ['push', 'origin', '--delete', branch], cwd=dest)
print(f"✅ 已删除远程分支 {branch}")
else:
print(f"❌ 合并失败: {body}")
raise SystemExit(status)
def cmd_pr(args):
"""创建 PR,并在正文中记录 Agent 身份。"""
dest = Path(args.dest or '.').expanduser()
identity = resolve_identity(dest, args.agent)
try:
repo_url = args.repo or capture(['git', 'remote', 'get-url', 'origin'], cwd=dest)
except subprocess.CalledProcessError:
raise SystemExit("当前仓库没有 Git remote,无法创建 PR")
token = get_token(dest, identity['id'])
if not token:
raise SystemExit("需要 GITHUB_TOKEN 或本地配置 token")
owner, repo = parse_repo(repo_url)
branch = args.head or capture(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=dest)
title = args.title or f"chore: update from {identity['id']}"
user_body = args.body or ''
body = f"""## Agent Attribution
- Agent ID: {identity['id']}
- Git Author: {identity['git_name']} <{identity['git_email']}>
- Expected GitHub Actor: {identity['github_user'] or '取决于实际执行 PR 的账号'}
## Notes
{user_body}
""".strip()
url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
status, response = github_api('POST', url, token, {
'title': title,
'body': body,
'head': branch,
'base': args.base,
})
if status == 201:
print(f"PR_URL:{response['html_url']}")
print(f"AGENT:{identity['id']}")
print(f"GIT_AUTHOR:{identity['git_name']} <{identity['git_email']}>")
print(f"GITHUB_ACTOR:{identity['github_user'] or '取决于实际执行 PR 的账号'}")
else:
print(f"❌ 创建 PR 失败: {response}")
raise SystemExit(status)
def main():
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest='cmd', required=True)
clone = sub.add_parser('clone')
clone.add_argument('--repo', required=True)
clone.add_argument('--dest', required=True)
clone.add_argument('--agent', default='')
clone.set_defaults(func=cmd_clone)
branch = sub.add_parser('branch')
branch.add_argument('--dest', required=True)
branch.add_argument('--name', required=True)
branch.add_argument('--agent', default='')
branch.set_defaults(func=cmd_branch)
commit = sub.add_parser('commit')
commit.add_argument('--dest', required=True)
commit.add_argument('--message', required=True)
commit.add_argument('--agent', default='')
commit.set_defaults(func=cmd_commit)
push = sub.add_parser('push')
push.add_argument('--dest', nargs='?', default='.')
push.add_argument('--agent', default='')
push.set_defaults(func=cmd_push)
pr = sub.add_parser('pr')
pr.add_argument('--dest', nargs='?', default='.')
pr.add_argument('--agent', default='', help='Agent ID(可选)')
pr.add_argument('--title', default='', help='PR 标题(可选)')
pr.add_argument('--body', default='', help='PR 正文补充(可选)')
pr.add_argument('--base', default='main', help='目标分支')
pr.add_argument('--head', default='', help='来源分支,默认当前分支')
pr.add_argument('--repo', default='', help='仓库 URL(可选)')
pr.set_defaults(func=cmd_pr)
merge = sub.add_parser('merge')
merge.add_argument('--pr', help='PR 编号(可选)')
merge.add_argument('--strategy', choices=['merge','squash','rebase'], default='squash', help='合并策略')
merge.add_argument('--repo', help='仓库 URL(可选)')
merge.add_argument('--dest', nargs='?', help='仓库目录')
merge.add_argument('--agent', default='', help='Agent ID(可选)')
merge.set_defaults(func=cmd_merge_pr)
args = p.parse_args()
args.func(args)
if __name__ == '__main__':
main()
PyYAML>=6.0
#!/usr/bin/env python3
"""Create task folders for cross-agent-coordination projects."""
from __future__ import annotations
import argparse
import base64
import json
import re
import subprocess
import urllib.error
import urllib.request
from pathlib import Path
from collab_lib import (
get_agent_identity,
get_current_agent,
get_next_task_id,
get_token,
load_config,
load_task_type_registry,
merge_frontmatter,
parse_field,
project_config,
render_template,
resolve_project_path,
sanitize_slug_part,
find_similar_tasks,
skill_root,
today,
)
def run(cmd, cwd=None):
subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=True)
def capture(cmd, cwd=None):
return subprocess.check_output(cmd, cwd=str(cwd) if cwd else None).decode("utf-8").strip()
def configure_git_identity(root, identity):
run(["git", "config", "user.name", identity["git_name"]], cwd=root)
run(["git", "config", "user.email", identity["git_email"]], cwd=root)
def git_auth_args(token):
raw = f"x-access-token:{token}".encode("utf-8")
encoded = base64.b64encode(raw).decode("ascii")
return ["-c", f"http.extraHeader=Authorization: Basic {encoded}"]
def git_with_token(token, git_args, cwd=None):
run(["git", *git_auth_args(token), *git_args], cwd=cwd)
def parse_repo(repo_url):
match = re.search(r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/.]+)", repo_url)
if not match:
raise SystemExit(f"无法解析 GitHub 仓库地址: {repo_url}")
return match.group("owner"), match.group("repo")
def clean_repo_url(repo_url):
owner, repo = parse_repo(repo_url)
return f"https://github.com/{owner}/{repo}.git"
def github_api(method, url, token, payload=None):
data = json.dumps(payload).encode("utf-8") if payload is not None else None
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req) as resp:
body = resp.read().decode("utf-8")
return resp.status, json.loads(body) if body else {}
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
try:
detail = json.loads(body)
except json.JSONDecodeError:
detail = {"message": body}
return exc.code, detail
def render_matches(matches):
lines = ["发现可能重复的既有任务:"]
for item in matches:
lines.append(
f"- {item['slug']} (score={item['score']:.2f}, id={item['id']}, "
f"status={item['status']}, type={item['type']})"
)
lines.append("")
lines.append("请优先打开上述任务 README,把新材料整合进去;如确认不是重复主题,再添加 --force-new。")
return "\n".join(lines)
def template_candidates(root, config, task_type):
project = project_config(config)
template_dir = resolve_project_path(root, str(project.get("template_dir", "")), "templates/tasks")
candidates = [
template_dir / f"{task_type}.md",
template_dir / "default.md",
skill_root() / "templates" / "tasks" / f"{task_type}.md",
skill_root() / "templates" / "tasks" / "default.md",
]
return candidates
def load_template(root, config, task_type):
for path in template_candidates(root, config, task_type):
if path.exists():
return path.read_text(encoding="utf-8")
return """---
id: {{ id }}
slug: {{ slug }}
title: {{ title }}
type: {{ type }}
status: todo
assignee: {{ assignee }}
dependencies: []
artifact_paths: []
progress: 0
created: {{ created }}
updated: {{ updated }}
---
# {{ title }}
## 目标
## 验收标准
## 来源材料
## 交接记录
"""
def build_readme(root, config, task_type, fields):
content = render_template(load_template(root, config, task_type), fields)
return merge_frontmatter(content, fields)
def parse_extra_fields(raw_fields):
fields = {}
for raw in raw_fields:
key, value = parse_field(raw)
fields[key] = value
return fields
def get_origin(root):
try:
return capture(["git", "remote", "get-url", "origin"], cwd=root)
except subprocess.CalledProcessError:
return ""
def cmd_create(args):
root = Path(args.root).expanduser()
config = load_config(root)
registry = load_task_type_registry(root, config)
agent = get_current_agent(config, args.agent)
identity = get_agent_identity(config, agent)
task_type = registry.normalize(args.type)
matches = find_similar_tasks(root, registry, args.topic, task_type, args.match_threshold, limit=5, config=config)
if matches and not args.force_new:
if args.dry_run:
print(render_matches(matches))
return
raise SystemExit(render_matches(matches))
task_id = get_next_task_id(root)
topic_slug = sanitize_slug_part(args.topic)
slug = sanitize_slug_part(args.slug or f"{task_id}-{task_type}-{topic_slug}")
if not re.match(r"^\d{9}-", slug):
slug = f"{task_id}-{slug}"
dest = root / slug
if dest.exists() and not args.reuse:
raise SystemExit(f"任务目录已存在: {dest};如需复用请添加 --reuse")
extra_fields = parse_extra_fields(args.field)
fields = {
"id": task_id,
"slug": slug,
"title": args.topic,
"type": task_type,
"status": "todo",
"assignee": args.assignee or agent,
"dependencies": [],
"artifact_paths": [],
"progress": 0,
"created": today(),
"updated": today(),
}
fields.update(extra_fields)
if args.dry_run:
print(f"DRY_RUN_TASK_ID:{task_id}")
print(f"DRY_RUN_SLUG:{slug}")
print(f"DRY_RUN_TYPE:{task_type}")
print(f"DRY_RUN_ASSIGNEE:{fields['assignee']}")
return
dest.mkdir(parents=True, exist_ok=True)
readme = dest / "README.md"
if not readme.exists():
readme.write_text(build_readme(root, config, task_type, fields), encoding="utf-8")
print(f"✅ 创建任务: {slug} (ID: {task_id})")
if args.auto_commit:
branch = f"agent/{agent}/{slug}"
configure_git_identity(root, identity)
run(["git", "checkout", "-B", branch], cwd=root)
run(["git", "add", "-A"], cwd=root)
commit_msg = f"feat: 创建任务 {args.topic} ({slug})"
run(["git", "commit", "-m", commit_msg], cwd=root)
print(f"📝 提交: {commit_msg}")
token = get_token(config, agent)
origin = get_origin(root)
if not origin:
print("⚠️ 当前仓库没有 Git remote,已保留本地提交,跳过 push/PR")
return
if not token:
print("⚠️ 未找到 GITHUB_TOKEN 或 agent token,无法推送")
return
clean_origin = clean_repo_url(origin)
run(["git", "remote", "set-url", "origin", clean_origin], cwd=root)
git_with_token(token, ["push", "-u", "origin", branch], cwd=root)
print(f"✅ 已推送分支: {branch}")
owner, repo = parse_repo(clean_origin)
url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
data = {
"title": f"feat: {args.topic}",
"body": f"""## Agent Attribution
- Agent ID: {agent}
- Git Author: {identity['git_name']} <{identity['git_email']}>
- Expected GitHub Actor: {identity['github_user'] or '取决于实际执行 PR 的账号'}
## 任务信息
- ID: {task_id}
- 类型: {task_type}
- Assignee: {fields['assignee']}
- slug: {slug}
## 描述
创建任务「{args.topic}」""",
"head": branch,
"base": "main",
}
status, body = github_api("POST", url, token, data)
if status == 201:
pr_url = body["html_url"]
pr_number = body["number"]
print(f"✅ 已创建 PR: {pr_url}")
if args.auto_merge:
merge_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/merge"
merge_status, merge_body = github_api(
"PUT",
merge_url,
token,
{"merge_method": "squash", "commit_title": f"Merge: {args.topic}"},
)
if merge_status == 200:
print(f"✅ 已自动合并 PR #{pr_number}")
git_with_token(token, ["push", "origin", "--delete", branch], cwd=root)
print(f"✅ 已删除远程分支 {branch}")
else:
print(f"⚠️ 自动合并失败: {merge_status}")
print(f" {merge_body}")
else:
print(f"❌ 创建 PR 失败: {body}")
def main():
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd", required=True)
create = sub.add_parser("create")
create.add_argument("--root", required=True)
create.add_argument("--type", required=True, help="任务类型,可由 config/task-types.yaml 扩展")
create.add_argument("--topic", required=True)
create.add_argument("--slug", default="")
create.add_argument("--agent", default="")
create.add_argument("--assignee", default="", help="任务负责人,默认使用当前 agent")
create.add_argument("--field", action="append", default=[], help="追加 frontmatter 字段,格式 key=value")
create.add_argument("--reuse", action="store_true", help="复用已存在的同名任务目录")
create.add_argument("--force-new", action="store_true", help="即使命中相似主题也强制新建任务")
create.add_argument("--match-threshold", type=float, default=0.58, help="主题查重阈值,默认 0.58")
create.add_argument("--dry-run", action="store_true", help="只输出将要创建的任务 ID 和 slug")
create.add_argument("--auto-commit", action="store_true", help="自动提交并推送")
create.add_argument("--auto-merge", action="store_true", help="自动合并 PR")
create.set_defaults(func=cmd_create)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Regression tests for cross-agent-coordination scripts."""
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
TASK_SCAFFOLD = SCRIPT_DIR / "task_scaffold.py"
FIND_TASK = SCRIPT_DIR / "find_task.py"
EMAIL_TRIGGER = SCRIPT_DIR / "email_trigger.py"
GH_GIT = SCRIPT_DIR / "gh_git.py"
def run_cmd(args, cwd=None):
return subprocess.run(
[sys.executable, *map(str, args)],
cwd=str(cwd) if cwd else None,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
).stdout
BOOK_ISSUES = """# 待创建 Issues 清单
### ✅ Issue #1: ch04 Agent 应用介绍
- **Lead Author**: 杨卫薪
- **依赖**: 无
#### 验收标准
- [ ] 平台对比客观
### ✅ Issue #2: ch05 第三方工具配置
- **Lead Author**: 杨卫薪
- **依赖**: Issue #1 完成
#### 验收标准
- [ ] 配置步骤可复现
### ✅ Issue #3: ch06 单个 Skill 的编写
- **Lead Author**: 杨卫薪
### ✅ Issue #4: ch07 法律 Skill 的迭代优化
- **Lead Author**: 杨卫薪
### ✅ Issue #5: ch09 诉讼文书生成
- **Lead Author**: 杨卫薪
### ✅ Issue #6: ch12 律所 IP 运营
- **Lead Author**: 杨卫薪
### ⬜ Issue #7: 确定 STYLE-GUIDE 关键决策
- **类型**: 整合
- **负责**: 杨卫薪确认
- **依赖**: 无
#### 需确定事项
- [ ] 方法论统一命名
### ✅ Issue #8: 全书术语一致性检查
- **类型**: 整合
- **依赖**: 所有章节完成
### ✅ Issue #9: 触发 Manus 调研任务
- **类型**: 研究(委托 Manus)
- **依赖**: Issue #7 确定后
- **目标**: 为 ch03 法律 AI 基础设施章节提供调研支撑
#### 调研任务
1. **法律 AI 产品生态调查**
- 中国法律 AI 产品:法宝、华语原点、法天使、密率等
2. **MCP 生态与法律服务调查**
- 法律相关 MCP 服务清单
#### 验收标准
- [ ] 形成产品对比表
### ✅ Issue #10: 全书结构与衔接审阅
- **类型**: 审阅
- **依赖**: 各篇章初稿完成
#### 审阅维度
- [ ] 全书叙事线连贯
"""
class CrossAgentCollabTests(unittest.TestCase):
def test_config_uses_only_collab_yaml(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "config" / "monorepo.yaml").write_text(
"project:\n default_agent: codex\n",
encoding="utf-8",
)
output = run_cmd([TASK_SCAFFOLD, "create", "--root", root, "--type", "研究", "--topic", "旧配置忽略", "--dry-run"])
self.assertIn("DRY_RUN_ASSIGNEE:openclaw", output)
(root / "config" / "collab.yaml").write_text(
"project:\n default_agent: codex\n",
encoding="utf-8",
)
output = run_cmd([TASK_SCAFFOLD, "create", "--root", root, "--type", "研究", "--topic", "新配置生效", "--dry-run"])
self.assertIn("DRY_RUN_ASSIGNEE:codex", output)
def test_custom_type_template_and_fields(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "templates" / "tasks").mkdir(parents=True)
(root / "config" / "task-types.yaml").write_text(
"task_types:\n 整合:\n aliases: [integration]\n description: merge\n output_hint: output\n",
encoding="utf-8",
)
(root / "templates" / "tasks" / "default.md").write_text(
"---\nid: {{ id }}\nslug: {{ slug }}\ntitle: {{ title }}\ntype: {{ type }}\n---\n\n# {{ title }}\n\n## 验收标准\n\n完成。\n",
encoding="utf-8",
)
run_cmd([
TASK_SCAFFOLD,
"create",
"--root",
root,
"--type",
"integration",
"--topic",
"全书术语一致性检查",
"--field",
"chapter=ch01",
"--field",
"target_words=15000",
"--force-new",
])
readme = next(root.glob("*-整合-*/README.md")).read_text(encoding="utf-8")
self.assertIn("type: 整合", readme)
self.assertIn("chapter: ch01", readme)
self.assertIn("target_words: 15000", readme)
def test_available_filters_dependencies_and_claim_policy(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "config" / "collab.yaml").write_text(
"project:\n default_agent: codex\n claim_policy: assigned_only\n",
encoding="utf-8",
)
run_cmd([TASK_SCAFFOLD, "create", "--root", root, "--type", "研究", "--topic", "基础调研", "--assignee", "codex", "--force-new"])
first = next(root.glob("*-研究-基础调研/README.md"))
first_id = first.parent.name.split("-", 1)[0]
first.write_text(first.read_text(encoding="utf-8").replace("status: todo", "status: done"), encoding="utf-8")
run_cmd([
TASK_SCAFFOLD,
"create",
"--root",
root,
"--type",
"写作",
"--topic",
"章节写作",
"--assignee",
"codex",
"--field",
f"dependencies=[{first_id}]",
"--force-new",
])
run_cmd([
TASK_SCAFFOLD,
"create",
"--root",
root,
"--type",
"写作",
"--topic",
"阻塞章节",
"--assignee",
"codex",
"--field",
"dependencies=[999999999]",
"--force-new",
])
output = run_cmd([FIND_TASK, root, "--topic", "章节", "--available", "--agent", "codex"])
self.assertIn("章节写作", output)
self.assertNotIn("阻塞章节", output)
(root / "config" / "collab.yaml").write_text(
"project:\n default_agent: codex\n claim_policy: claim_pool\n",
encoding="utf-8",
)
run_cmd([TASK_SCAFFOLD, "create", "--root", root, "--type", "研究", "--topic", "池任务", "--field", "assignee=", "--force-new"])
output = run_cmd([FIND_TASK, root, "--topic", "池任务", "--available", "--agent", "codex"])
self.assertIn("池任务", output)
def test_email_trigger_includes_readme_context_without_removed_scripts(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "config" / "collab.yaml").write_text(
"github:\n repo_url: https://github.com/example/demo.git\nagents:\n manus:\n name: Manus Bot\n email: manus@agents.local\n token_env: MANUS_GITHUB_TOKEN\n trigger_email: manus@example.com\n",
encoding="utf-8",
)
run_cmd([TASK_SCAFFOLD, "create", "--root", root, "--type", "研究", "--topic", "法律AI产品生态调查", "--assignee", "manus", "--force-new"])
readme = next(root.glob("*-研究-法律AI产品生态调查/README.md"))
task_id = readme.parent.name.split("-", 1)[0]
text = readme.read_text(encoding="utf-8")
text = text.replace("## 目标\n", "## 目标\n\n调研法律 AI 产品。\n")
text = text.replace("## 验收标准\n", "## 验收标准\n\n- 形成对比表。\n")
readme.write_text(text, encoding="utf-8")
output = run_cmd([EMAIL_TRIGGER, root, "--agent", "manus", "--task-id", task_id, "--topic", "法律AI产品生态调查"])
self.assertIn("调研法律 AI 产品", output)
self.assertIn("形成对比表", output)
self.assertIn("python3 scripts/find_task.py", output)
self.assertNotIn("generate_dashboard", output)
def test_issues_md_available_filter_uses_issue_source(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "docs").mkdir()
(root / "config" / "collab.yaml").write_text(
"project:\n"
" default_agent: codex\n"
" issue_file: docs/TASKS.md\n"
" status_map:\n"
" \"⬜\": pending_confirmation\n"
" \"✅\": ready\n"
" \"🟢\": created\n"
" available_statuses: [ready, created, todo]\n"
" dependency_done_statuses: [ready, created, done, resolved, closed]\n"
" claim_policy: assigned_only\n"
"agents:\n"
" manus:\n"
" name: Manus Bot\n",
encoding="utf-8",
)
(root / "docs" / "TASKS.md").write_text(BOOK_ISSUES, encoding="utf-8")
output = run_cmd([FIND_TASK, root, "--topic", "Manus", "--available", "--agent", "manus"])
self.assertNotIn("Issue #9", output)
(root / "docs" / "TASKS.md").write_text(
BOOK_ISSUES.replace("### ⬜ Issue #7", "### ✅ Issue #7"),
encoding="utf-8",
)
output = run_cmd([FIND_TASK, root, "--available", "--agent", "manus"])
self.assertIn("Issue #9", output)
self.assertIn("触发 Manus 调研任务", output)
self.assertNotIn("Issue #7", output)
def test_email_trigger_issue_includes_issues_context(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "docs").mkdir()
(root / "config" / "collab.yaml").write_text(
"github:\n repo_url: https://github.com/example/book.git\n"
"project:\n"
" issue_file: docs/TASKS.md\n"
" status_map:\n"
" \"⬜\": pending_confirmation\n"
" \"✅\": ready\n"
" available_statuses: [ready, created, todo]\n"
"agents:\n"
" manus:\n"
" name: Manus Bot\n"
" email: manus@agents.local\n"
" trigger_email: manus@example.com\n",
encoding="utf-8",
)
(root / "docs" / "TASKS.md").write_text(BOOK_ISSUES, encoding="utf-8")
output = run_cmd([EMAIL_TRIGGER, root, "--agent", "manus", "--issue", "9"])
self.assertIn("Issue #9", output)
self.assertIn("Issue #7", output)
self.assertIn("法律 AI 产品生态调查", output)
self.assertIn("MCP 生态", output)
self.assertIn("docs/TASKS.md", output)
self.assertNotIn("token", output.lower())
self.assertNotIn("generate_dashboard", output)
def test_gh_git_sets_author_and_reports_missing_remote(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "config").mkdir()
(root / "config" / "collab.yaml").write_text(
"project:\n default_agent: codex\nagents:\n codex:\n name: Codex\n email: codex@agents.local\n",
encoding="utf-8",
)
subprocess.run(["git", "init"], cwd=root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(root / "README.md").write_text("# demo\n", encoding="utf-8")
output = run_cmd([GH_GIT, "commit", "--dest", root, "--agent", "codex", "--message", "docs: init"])
self.assertIn("GIT_AUTHOR:Codex <codex@agents.local>", output)
result = subprocess.run(
[sys.executable, str(GH_GIT), "pr", "--dest", str(root), "--agent", "codex"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("当前仓库没有 Git remote", result.stderr + result.stdout)
if __name__ == "__main__":
unittest.main()