From 7621e0a177f21256cbf318fe08965c8ebb7e8be3 Mon Sep 17 00:00:00 2001 From: FLYF <1827658783@qq.com> Date: Wed, 14 Oct 2026 06:37:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(collab):=20=E5=AE=8C=E5=96=84=E6=8C=89?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AE=89=E8=A3=85=E4=B8=8E=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按实际平台生成入口并复用本机安装源,减少冗余规则;通过统一会话命令检查文件占用、维护心跳和交接,让多 agent 协作可以验证。同步完善 README、平台说明与交互演示。 --- .github/workflows/selftest.yml | 6 + .gitignore | 9 + AGENTS.md | 49 ++- CHANGELOG.md | 20 + CLAUDE.md | 4 + README.md | 474 ++++++++++------------- docs/PLATFORMS.md | 93 +++++ docs/README.md | 16 + docs/START_HERE.md | 55 +++ install.py | 616 ++++++++++++++++++++---------- platforms/claude-command.md | 1 + platforms/cline-rule.md | 6 + platforms/continue-rule.md | 11 + platforms/copilot-instructions.md | 7 + platforms/cursor-rule.mdc | 11 + platforms/gemini-memory.md | 7 + platforms/kiro-steering.md | 10 + platforms/qwen-memory.md | 10 + platforms/roo-rule.md | 6 + platforms/trae-rule.md | 7 +- platforms/windsurf-rule.md | 10 + platforms/workbuddy-header.md | 7 +- scripts/sync_install_docs.py | 85 +++++ site/assets/css/demo.css | 96 +++++ site/assets/css/style.css | 43 +++ site/assets/js/demo.js | 153 ++++++++ site/assets/js/install-data.js | 135 +++++++ site/assets/js/install.js | 89 +++++ site/assets/js/ui.js | 23 +- site/demo.html | 72 ++++ site/index.html | 166 ++++---- templates/install-prompt.md | 14 + templates/session.md | 4 + tests/selftest.py | 165 +++++++- tests/test_sessions.py | 226 +++++++++++ tools/agents_session.py | 308 +++++++++++++++ tools/agents_status.py | 51 ++- 37 files changed, 2504 insertions(+), 561 deletions(-) create mode 100644 docs/PLATFORMS.md create mode 100644 docs/START_HERE.md create mode 100644 platforms/cline-rule.md create mode 100644 platforms/continue-rule.md create mode 100644 platforms/copilot-instructions.md create mode 100644 platforms/cursor-rule.mdc create mode 100644 platforms/gemini-memory.md create mode 100644 platforms/kiro-steering.md create mode 100644 platforms/qwen-memory.md create mode 100644 platforms/roo-rule.md create mode 100644 platforms/windsurf-rule.md create mode 100644 scripts/sync_install_docs.py create mode 100644 site/assets/css/demo.css create mode 100644 site/assets/js/demo.js create mode 100644 site/assets/js/install-data.js create mode 100644 site/assets/js/install.js create mode 100644 site/demo.html create mode 100644 templates/install-prompt.md create mode 100644 tests/test_sessions.py create mode 100644 tools/agents_session.py diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 7c1219c..a0a116e 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -31,5 +31,11 @@ jobs: - name: Board selfcheck run: python tools/agents_status.py --selfcheck + - name: Installation docs and platform catalog + run: python scripts/sync_install_docs.py --check + - name: Installer end-to-end tests run: python tests/selftest.py + + - name: Session coordination and platform integration + run: python -m unittest discover -s tests -p test_sessions.py diff --git a/.gitignore b/.gitignore index ee532b9..3c7b628 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,15 @@ env/ .workbuddy-ai/ .workbuddy/ .agent/ +.cursor/rules/multi-agent-collab.mdc +.github/copilot-instructions.md +.windsurf/rules/multi-agent-collab.md +GEMINI.md +.qwen/QWEN.local.md +.clinerules/multi-agent-collab.md +.roo/rules/00-multi-agent-collab.md +.continue/rules/multi-agent-collab.md +.kiro/steering/multi-agent-collab.md *.bak-* **/HANDOFF-*.md diff --git a/AGENTS.md b/AGENTS.md index 8c4152d..c5fe1bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,15 @@ > 全部**本机私有、不入 git**(安装器写 `.git/info/exclude`,清单以 `EXCLUDE_ENTRIES` 为准)。 > 同一工作区所有 agent 共用这一份;换机重跑安装器恢复;入口与本文件冲突时以本文件为准。 +**每次新对话开始工作前**,读取 [.agents/START_HERE.md](.agents/START_HERE.md), +按其顺序读取最新的看板、inbox 与近 1–2 天 worklog。不要沿用上一个对话的动态状态。 +各平台只加载自己的入口,随后遵循同一份总章。平台只在启动时发现入口,旧对话需主动重新读取。 + +> 套件开发者注意:以上私有约定针对**安装到目标项目的产物**。在 multi-agent-collab +> 源码仓库中,已跟踪的根规则、`platforms/` 与看板是产品模板,正常参与代码审查; +> 本机 `.agents/` 等运行记录仍不得入库。不要在套件源码目录自安装。 +> 源码仓库未安装本机入口时,启动说明读 `docs/START_HERE.md`,会话模板读 `templates/session.md`。 + 你的首要职责不是快,而是**不破坏仓库、不与其他 agent 互相覆盖工作**。 --- @@ -45,14 +54,17 @@ 2. `git switch develop && git pull --ff-only` 3. `git switch -c feature/<任务名>`(小写英文+连字符),建完 `git rev-parse --verify feature/<名>` 复核(沙箱可能"成功"却没建出来,见 B13/B14) -4. **注册会话**(纯本地):复制 `templates/session/_template.md` 建自己的 session 文件,保存即被看见 +4. **注册会话**(纯本地):优先用 `python tools/agents_session.py start --agent <平台> --task "任务" --files <文件或目录>`。 + 保存返回的会话 ID;工具检查同工作区、分支和跨 worktree 文件占用,有冲突就停止。 + 旧版没有该工具时,复制 `.agents/sessions/_template.md` 手动登记并提示升级。 5. **开发并 commit**:只 add 项目文件;每完成一个阶段更新心跳;每次 commit 后跑 `git rev-parse --short HEAD` 复核(B1/B2/B8) - **每个阶段间隙、跑长命令前重扫 inbox**:人类指令优先级最高(见第 4 节) 6. **测试门禁(命令见第 6 节)**:严格跑、不相关也保持绿;失败修代码不许改测试迁就; 能编译要真编译、能跑要真跑,静态检查/预览不算验证(A3/A5) 7. 推送 + `gh pr create --base develop`(推送失败按 C1/C5/C8/C9;记忆文件不随 git 走) -8. **收尾(本地)**:session 置 `done`、worklog 追加总结 +8. **收尾(本地)**:用 `agents_session.py finish <会话ID> --agent <平台> --summary "总结" --tests "实际测试结果"` + 将自己的 session 置 `done` 并追加 worklog;中途交接用 `handoff`,保留占用直到确认交接 9. 每步用中文汇报四件套:**当前分支 / commit message / 变更文件 / 测试结果** ### 3.1 工具接口卡 —— 先调命令,不要读实现代码 @@ -69,6 +81,9 @@ | 图形化/网页指挥/快照 | `… --web` / `… --html` | 参数看 `--help` | | 工具正不正常 / 版本 / 全部参数 | `… --selfcheck` / `… --version` / `… --help` | 先跑这个 | | 安装·体检·镜像·卸载 | `python install.py install\|doctor\|mirror\|uninstall <项目>` | | +| 平台列表 / 新增与同步 | `python <套件目录>/install.py platforms` / `… sync <项目> --platforms cursor` | 平台选择保存在 `.agents/install.json`;省略参数沿用 | +| 登记 / 心跳 / 收尾 | `python tools/agents_session.py start\|heartbeat\|finish\|handoff --help` | 只更新自己的会话;支持 `--json` | +| 文件占用检查 | `python tools/agents_session.py check --agent <平台> --files <范围> --json` | 返回 1 时先处理冲突或人类 inbox | | 报错怎么办 | 读 `.agents/TROUBLESHOOTING.md` | 按编号 A/B/C/D 查 | 退出码:0 = 无告警,1 = 有失活会话/未答问题/未注册 worktree。参数以 `--help` 实际输出为准,不用背。 @@ -86,6 +101,9 @@ - **心跳**:活跃期每 ≤15 分钟或每阶段更新(保存即生效);跑 >15 分钟长任务前写明"预计 HH:MM 回来"。 超 15 分钟算失活;要接管先在 inbox 发接管声明、等 5 分钟无反对,从其分支切新分支,**保留并注明其全部提交** - 多 agent 并发写同一记忆文件取**并集**,只多不少 +- 编码会话声明 `files`(JSON 数组,目录以 `/` 结尾);扩大范围前用 heartbeat 的 `--files` 重新检查。 + 会话工具通过主 worktree `.agents/coordination.lock` 的系统锁串行登记,进程退出自动释放锁。 + 失活或 blocked 会话的文件占用不会自动释放,不能借超时抢占。手工绕过登记仍可能覆盖文件。 - 任何环境/工具/git 报错解决后,往 TROUBLESHOOTING.md 追加一条(现象/判断/真因/修复) **人类指令(用户从看板网页下达,优先级最高)**:inbox 里 `from: human*` 的文件,与 agent 留言同协议。 @@ -154,21 +172,36 @@ -## 7. 各平台入口(本机文件;**新建对话**才加载,改完旧对话不刷新) +## 7. 各平台入口(按需安装;**新建对话**才加载,改完旧对话不刷新) -- **AGENTS.md 原生派(零配置,根 AGENTS.md 自动生效)**:Claude Code / TraeCode CLI、 - **Codex**、**OpenCode(sst)**、**Pi(pi.dev)** +- **Codex、OpenCode、Pi**:读取根 `AGENTS.md`,不额外生成平台配置目录 +- **Claude Code**:读取根 `CLAUDE.md`,通过 `@AGENTS.md` 和 `@.agents/START_HERE.md` 导入共享规则 - Trae:`.trae/rules/git-flow-agent.md`(alwaysApply,要求先读本文件) -- WorkBuddy:`.workbuddy-ai/` 是本文件镜像,改完跑 `install.py mirror` -- 斜杠命令:`.claude/commands/git-flow.md`(`/git-flow <任务>`) +- WorkBuddy:`.workbuddy-ai/git-flow-agent.md` 是本文件镜像,改完跑 `<套件目录>/install.py sync <项目>` +- Cursor:`.cursor/rules/multi-agent-collab.mdc`(alwaysApply) +- GitHub Copilot:`.github/copilot-instructions.md`(项目说明,需客户端启用自定义指令) +- Windsurf:`.windsurf/rules/multi-agent-collab.md`(always_on) +- Gemini CLI:`GEMINI.md` 导入共享文档;Qwen Code:`.qwen/QWEN.local.md` 引导读取共享文档 +- Cline:`.clinerules/multi-agent-collab.md`;Roo Code:`.roo/rules/00-multi-agent-collab.md`(所有模式) +- Continue:`.continue/rules/multi-agent-collab.md`(alwaysApply);Kiro:`.kiro/steering/multi-agent-collab.md`(always) +- Claude 斜杠命令:`.claude/commands/git-flow.md`(`/git-flow <任务>`),它是手动快捷入口,不负责自动加载 + +入口都要求读取 `.agents/START_HERE.md`。新增平台运行 `sync --platforms <名称>`, +不重新 clone,不复制整套规则;已有平台选择会保留。安装源目录记录在 `.agents/install.json`。 -验证:开新对话问"你开工前要先做什么?",应答出 fetch + `agents_status.py` 看板 + inbox。 +验证:开新对话确认 agent 实际读取入口,并能报告分支、看板和 inbox 的当前结果。 +客户端版本、信任设置、规则开关可能影响加载,安装器不能替代此验证。 ## 8. 本机私有清单与换机 ``` AGENTS.md CLAUDE.md .agents/ .agent/ .claude/ .trae/ .workbuddy-ai/ .workbuddy/ tools/agents_status.py +.cursor/rules/multi-agent-collab.mdc .github/copilot-instructions.md +.windsurf/rules/multi-agent-collab.md +tools/agents_session.py GEMINI.md .qwen/QWEN.local.md +.clinerules/multi-agent-collab.md .roo/rules/00-multi-agent-collab.md +.continue/rules/multi-agent-collab.md .kiro/steering/multi-agent-collab.md ``` 唯一事实源是安装器的 `EXCLUDE_ENTRIES`,别手改 exclude 托管块。换机/重 clone 重跑 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b45dcf..1ebe601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ `.agents/install.json`,`doctor` 据此提示升级。 版本号遵循语义化版本(major.minor.patch)。 +## 1.5.0 — 2026-10-14 + +- 首次安装按平台询问,脚本必须显式选择,不再默认全装;支持保存选择、旧版迁移及 linked worktree 继承。 +- `CLAUDE.md` 改为 Claude 专属文件;Codex / OpenCode / Pi 共用总章,不生成其它平台配置。 +- 新增 Cursor、GitHub Copilot、Windsurf 项目入口;`platforms [--json]` 列出实际文件。 +- 增加 Gemini CLI、Qwen Code、Cline、Roo Code、Continue、Kiro,按官方入口机制提供导入、规则或 steering;支持常见平台名称别名。 +- 新增会话命令 check / start / heartbeat / finish / handoff:主 worktree 系统锁串行登记,检查工作区、分支和文件占用, + 拦截未答人类指令,核对会话所有者,收尾只追加日志,失活不自动释放占用。看板展示文件范围、进度和占用冲突。 +- 新增 `sync` 命令:追加平台、刷新入口与 WorkBuddy 镜像,保留项目总章、启动文档与协作记录。 +- 新增 `.agents/START_HERE.md`,统一新对话的最小阅读顺序;明确自动发现、旧会话和客户端设置的边界。 +- 安装前检查已跟踪文件、越界符号链接、模板缺失与忽略编码;实际忽略未生效时不写入规则文件。 + Windows 的 stdout / stderr 统一 UTF-8。已有本机 Claude / Copilot 说明采用托管块保留其它内容。 +- 安装提示词默认复用用户主目录的套件 clone;README 改为按选择、安装、验证、追加平台的顺序说明。 + 展示页增加平台配置器与静态交互 demo,提示词和平台数据由脚本同步,CI 检查漂移。 +- 新增 9 个安装回归用例,覆盖最小安装、非交互选择、同步保留、旧版迁移、缺入口、跟踪保护、忽略冲突和预览。 +- 增加独立协作回归套件,实跑并发登记、跨 worktree 文件范围、失活占用、交接、所有者检查与新增入口兼容。 + +升级:在目标项目执行 `python "<套件目录>/install.py" install .`,会沿用或迁移已有选择。 +`--platforms none` 不再无条件生成 CLAUDE.md;`mirror` 作为追加 WorkBuddy 并同步入口的兼容命令保留。 + ## 1.4.1 — 2026-10-12 CI 三平台矩阵实跑暴露的缺陷修复(1.4.0 发布当天的热修): diff --git a/CLAUDE.md b/CLAUDE.md index f75c900..e2ffe51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,10 @@ 开始任何任务前必须先读: @AGENTS.md +@.agents/START_HERE.md + +每次新对话先执行启动入口的阅读顺序,重新检查动态的看板、inbox 与近期 worklog。 +这里只导入共享规则,不另存一份规则副本。 ## 项目速览 diff --git a/README.md b/README.md index c03ae81..e825384 100644 --- a/README.md +++ b/README.md @@ -6,344 +6,284 @@ [![python](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/) -[![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](#) -[![deps](https://img.shields.io/badge/dependencies-none-success)](#) +[![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](#安装下载一次按项目复用) +[![deps](https://img.shields.io/badge/dependencies-none-success)](#仓库结构与验证) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![changelog](https://img.shields.io/badge/changelog-1.4.1-orange)](CHANGELOG.md) +[![changelog](https://img.shields.io/badge/changelog-1.5.0-orange)](CHANGELOG.md) -> 装进任意 git 项目,让 Claude Code、Codex、OpenCode、Pi、Trae、WorkBuddy 等多个平台的 -> AI 助手在同一份规则下协同干活:不互相覆盖、不重复劳动;你通过一块网页看板实时查看 -> 每个 AI 的状态,直接下指令。 +让同一台电脑上、同一项目里的多个 AI 助手遵循一份协作规则:开工先确认谁在做什么, +改动前避开冲突,收工留下能接手的记录。支持 Codex、Claude Code、OpenCode、Pi、Trae、 +WorkBuddy、Cursor、GitHub Copilot、Windsurf、Gemini CLI、Qwen Code、Cline、Roo Code、Continue 和 Kiro, +**只安装当前项目需要的平台入口**。 -![网页看板:实时看到每个 AI 的状态,直接给 AI 下指令](docs/assets/board-screenshot.png) +[在线介绍与安装配置](https://aiflyf.github.io/multi-agent-collab/) · +[交互演示](https://aiflyf.github.io/multi-agent-collab/demo.html) · +[平台接入与验证](docs/PLATFORMS.md) · [更新记录](CHANGELOG.md) -## 这是什么 +![协作看板示例:会话状态、指令与回复](docs/assets/board-screenshot.png) -multi-agent-collab 给"一个项目里多个 AI 助手(外加你)"的协作立一套交规。安装后,项目里多出三样东西: +## 安装:下载一次,按项目复用 -- **一份规则总章**(`AGENTS.md` / `CLAUDE.md`):每个 AI 开工前必须读——先协调再干活、分支怎么切、测试怎么算过、人类指令优先级最高 -- **一块本机共享黑板**(`.agents/`):谁在哪个分支干什么、心跳还活不活、有问题问谁、前任走了怎么接手——全部是几个 Markdown 文件,不进 git,切分支不丢 -- **一个看板脚本**(`tools/agents_status.py`):零第三方依赖,AI 用它做一行摘要的开工协调,你用它开网页指挥台 +需要 **Python 3.8+、Git、一个已有的 Git 项目**。没有第三方 Python 依赖。 +套件源码默认放在用户主目录 `~/multi-agent-collab`(Windows 为 `$HOME\multi-agent-collab`), +各个项目复用同一个安装器。源码与目标项目是两个目录,不要把套件 clone 到业务项目里。 -没有服务器、没有数据库、没有账号。Windows / macOS / Linux 都能跑,Python 3.8+。 +下面的命令适用于 Bash 和 PowerShell: -它不启动 AI,也不给每个 AI 隔离工作区——那是 Claude Squad、Conductor、Vibe Kanban -这类编排工具负责的部分;multi-agent-collab 管的是另一半:让已经在干活的多个 AI -互相可见、守同一套规矩、听你指挥。两类工具可以叠加使用。 - -``` - Claude Code ─┐ - Codex ─┤ - OpenCode ─┤ <你的项目>/.agents/ - Pi ─┼── 读同一份规则 ──▶ ├── sessions/ 谁在哪个分支干什么(心跳) - Trae IDE ─┤ ├── worklog/ 今天做完了什么 - WorkBuddy ─┘ └── inbox/ 要问谁、谁来接手、你下的指令 - - 这些文件不进 git;任何分支、任何平台、任何 AI 看到的都是同一份 +```bash +# 只在第一次下载时运行;已经存在则核对来源后复用 +git clone https://github.com/AiFLYF/multi-agent-collab "$HOME/multi-agent-collab" - 你 ──▶ python tools/agents_status.py --web ──▶ 网页看板:实时看,直接指挥 +# 在你的目标项目目录运行:例如当前使用 Codex 与 Claude Code +python "$HOME/multi-agent-collab/install.py" install . --platforms codex,claude +python "$HOME/multi-agent-collab/install.py" doctor . ``` -## 解决什么问题 +- **自己在终端操作**:首次省略 `--platforms` 会询问,可输入编号或名称并用逗号多选。 +- **交给 AI / 脚本执行**:明确传入 `--platforms`;未选择时会停止,不会默认全装。 +- **原生平台**:只选 `codex`、`opencode` 或 `pi` 时共用 `AGENTS.md`,不生成其它平台目录,连 `CLAUDE.md` 也不安装。 +- **预览文件**:安装命令加 `--dry-run`;只要通用规则可选 `--platforms none`,确实需要全部平台才用 `all`。 +- **升级已有项目**:再次运行 `install .` 会沿用之前的选择,不再询问或偷偷增加平台。 -同一个项目里同时开着多个 AI——不同平台、不同窗口、甚至不同会话——常见的几种情况: +也可以从任意目录指定目标,例如: -- 两个 AI 改了同一个文件,后保存的把先保存的覆盖掉,白干一半 -- 两个 AI 各自去修同一个 bug,改动最后冲突 -- 一个 AI 干到一半窗口被关掉,没人知道它改到哪、下一步是什么 -- 每个平台的规则文件各写各的,换个 AI 工具就等于没有规则 -- 你想中途喊一句"先别碰那个文件",没有任何途径告诉它们 - -multi-agent-collab 的做法:一份规则管所有平台,一块黑板让所有会话互相可见,一个看板让你始终有指挥权。这些规则不是凭空设计的——每一条都来自真实项目里 15+ 个 AI 协作会话出过的事故。 - -## 30 秒上手 - -前提:电脑装了 Python 3.8+ 和 git,命令行能运行 `python --version` 和 `git --version`;项目需要是 git 仓库(还不是的话先 `git init`)。 +```powershell +python "$HOME/multi-agent-collab/install.py" install "D:\code\my-app" --platforms codex +``` -### 方式一:复制一段话给 AI +### 复制提示词给 AI -不想敲命令的人用这种:把下面整段复制给你项目里的任意一个 AI(套件还没下载时 AI 会自己 clone;如果用 fork,把第二行的地址换成你的 fork): +在**目标项目的对话**里粘贴下面这段。AI 能确认当前平台时直接使用;无法确认时只询问平台选择。 +[在线配置器](https://aiflyf.github.io/multi-agent-collab/#sec-6) 可以按勾选的平台生成同一份提示词。 +使用 fork 时,将仓库地址换成你的 fork。 + ```text -帮我把 multi-agent-collab 套件装到当前项目。 -套件仓库地址:https://github.com/AiFLYF/multi-agent-collab -1. 先确认 python 和 git 都能运行;不能运行就停下告诉我,不要自行安装任何软件。 -2. 电脑里还没有这个仓库就先 git clone 上面的地址;已经有了就跳过。 -3. 确认当前目录是我的项目根目录,执行 python <套件目录>/install.py install .; - 成功后再执行 python <套件目录>/install.py doctor .,把体检结果原样发给我。 -除安装器生成的文件外不要动项目里的其他文件,任何报错先停下来问我。 +帮我把 multi-agent-collab 安装到当前项目,并验证所选平台的新对话入口。 +套件仓库:https://github.com/AiFLYF/multi-agent-collab + +1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。 +2. 优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。 +3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。 + 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab "<用户主目录>/multi-agent-collab"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。 +4. 使用绝对路径运行 python "<套件目录>/install.py" install "" --platforms <已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 +5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。 +6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python "<套件目录>/install.py" sync "" 刷新已选入口。 +7. 执行 python "<套件目录>/install.py" doctor "",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。 +8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync "" --platforms <新增名称>,无需重复 clone;旧对话需要主动重新读取规则。 + +执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。 ``` + -装完后 AI 会把体检结果发给你,然后做下面「装完之后」的三件事。 - -### 方式二:自己跑一条命令 +### 装完验证三件事 -已经下载过本仓库的话,在仓库目录下执行: - -```bash -python install.py install /path/to/your-project -``` +1. 填写项目 `AGENTS.md` 第 6 节的真实测试、构建命令;选了 Claude 再填写 `CLAUDE.md` 速览。 + 定制区升级会保留。默认规则采用 Git Flow,项目需要有相应分支约定;安装器不会擅自创建分支。 +2. 跑 `doctor .`,再在目标项目执行 `python tools/agents_status.py --brief`。检查 Git 忽略和入口是否完整。 +3. **新开对话**,确认 agent 实际读取 `.agents/START_HERE.md`,并报告分支、看板与 inbox 当前结果。 + 旧对话不会自动重新加载。只复述“我会先看板”不算验证通过。 -Windows PowerShell 写法一样,例如: +## 安装后,项目里有什么 -```powershell -python install.py install D:\code\my-app +```text +你的项目/ 用户主目录/ +├── AGENTS.md 共享协作规则 └── multi-agent-collab/ +├── .agents/ └── install.py 所有项目复用 +│ ├── START_HERE.md 新对话入口 +│ ├── install.json 已选平台、版本、源码路径 +│ ├── sessions/ 谁在什么分支做什么 +│ ├── worklog/ 最近完成了什么,下一步是什么 +│ ├── inbox/ 问题、交接、你的指令 +│ └── … 格式说明、故障手册和模板 +├── tools/agents_status.py 本地看板 +├── tools/agents_session.py 登记、占用检查、心跳和收尾 +└── 所选平台入口 见下表;没有选的不会创建 ``` -想先看会改动哪些文件、不落盘:把 `install` 换成 `install --dry-run`。 - -### 装完之后(3 分钟) +总章规定协调、分支、测试与交接;共享记录让会话彼此可见;平台入口引导 agent 在每个新对话 +读取同一份文档。切分支不会丢掉本机记录,换电脑或重新 clone 则需要重新安装并自行迁移记录。 -1. 打开项目里的 `AGENTS.md` 第 6 节和 `CLAUDE.md`「项目速览」,把示例换成你项目真实的测试和构建命令(这两段是项目定制区,以后升级套件会自动保留) -2. 在项目目录跑一次 `python tools/agents_status.py --web`,确认看板正常 -3. 开一个新的 AI 对话(旧对话不会自动加载新规则),问它"你开工前要先做什么"——它应该回答:先看看板、查 inbox、再干活。答得出来,说明规则已经生效 +这是一套本机协作协议和工具。它可以和工作区编排工具配合使用;不会替你启动 AI、隔离文件系统、 +锁住源码或同步到其它电脑。多个 agent 仍需遵守检查和登记约定。 -不想跑脚本、想手工拷贝的,见页面底部的「手动安装」。 +## 协作不只靠提示词 ---- +新会话按启动清单协调并切到任务分支后,使用会话工具登记: -## 网页看板:你的指挥台 - -AI 看的是文件,你看的是网页。在项目目录运行: - -```powershell -python tools/agents_status.py --web +```bash +python tools/agents_session.py start --agent codex --task "完善登录" --files src/login.py tests/test_login.py --json +python tools/agents_session.py heartbeat <返回的会话ID> --agent codex --note "正在验证" +python tools/agents_session.py finish <会话ID> --agent codex --summary "完成登录修复" --tests "项目测试通过" ``` -浏览器会打开一块每 5 秒自动刷新的看板。你能看到: +- 登记时检查同工作区、同分支和跨 worktree 的文件占用;目录声明能覆盖其子文件。 +- 主 worktree 的系统锁让并发登记串行,避免两个 agent 同时通过检查;进程退出自动释放登记锁。 +- 未答的人类 inbox 会阻止新写任务;工具检查会话所有者与实际分支,防止误改其它会话。 +- 失活仍保留占用,按接管流程协调;`handoff` 生成交接记录并保留 blocked 状态。 +- `finish` 追加 worklog 并释放占用;看板显示文件范围、阶段进度与占用冲突。 + +扩大范围时先用 heartbeat 的 `--files` 更新完整列表。只读检查用 `check --files ... --json`。 +这些检查协调遵守协议的 agent,不能锁住源码或阻止其它程序绕过登记。 + +## 平台选择与同步 + + +| `--platforms` 名称 | 平台 | 本套件安装的入口 | +|---|---|---| +| `codex` | Codex | 共用 `AGENTS.md`,无额外文件 | +| `claude` | Claude Code | `CLAUDE.md`
`.claude/commands/git-flow.md` | +| `opencode` | OpenCode | 共用 `AGENTS.md`,无额外文件 | +| `pi` | Pi | 共用 `AGENTS.md`,无额外文件 | +| `trae` | Trae | `.trae/rules/git-flow-agent.md` | +| `workbuddy` | WorkBuddy | `.workbuddy-ai/git-flow-agent.md` | +| `cursor` | Cursor | `.cursor/rules/multi-agent-collab.mdc` | +| `copilot` | GitHub Copilot | `.github/copilot-instructions.md` | +| `windsurf` | Windsurf | `.windsurf/rules/multi-agent-collab.md` | +| `gemini` | Gemini CLI | `GEMINI.md` | +| `qwen` | Qwen Code | `.qwen/QWEN.local.md` | +| `cline` | Cline | `.clinerules/multi-agent-collab.md` | +| `roo` | Roo Code | `.roo/rules/00-multi-agent-collab.md` | +| `continue` | Continue | `.continue/rules/multi-agent-collab.md` | +| `kiro` | Kiro | `.kiro/steering/multi-agent-collab.md` | + + +Claude 通过 `CLAUDE.md` 导入共享规则;其斜杠命令需要手动调用。 +Cursor / Trae 使用始终应用的项目规则,Windsurf 使用 `always_on` 规则; +Copilot 需要客户端启用项目自定义说明。WorkBuddy 沿用兼容镜像,需在所用版本中验证入口加载。 +详细的规则发现方式与限制见 [平台说明](docs/PLATFORMS.md)。 -- 每个 AI 一张卡片:哪个平台、在哪个分支、做什么任务、几分钟前还有心跳(绿色正常,红色失联) -- 未答复的问题和工作交接 -- 所有 git worktree 的状态:有 AI 在没登记过的目录里动了手脚,会红色告警 -- 直接对 AI 下指令:在卡片上输入"先停一下,别碰登录模块",AI 在下个阶段间隙读到并执行,回复实时出现在网页上(可开桌面通知)。人类指令优先级最高,胜过 AI 手头的任何任务 +```bash +# 后来开始用 Cursor:追加入口,自动沿用原来的平台 +python "$HOME/multi-agent-collab/install.py" sync . --platforms cursor -不想开服务、只想把当前状态存成文件发给别人,用快照: +# 改过总章后刷新所有已选入口,含 WorkBuddy 镜像 +python "$HOME/multi-agent-collab/install.py" sync . -```powershell -python tools/agents_status.py --html # 生成单文件 .agents/board.html,无外链 -python tools/agents_status.py --html --open # 生成并直接打开 +# 看可选平台和将生成的入口 +python "$HOME/multi-agent-collab/install.py" platforms ``` -网页服务默认只允许本机访问。想在手机或同局域网其他电脑上看,见「命令速查」下方的三档安全设置。 +`sync` 保留整个项目总章、已有启动文档和协作记录,只补齐或刷新平台入口及忽略规则。 +`install` 用于升级核心模板,迁移项目定制区。两者的 `--platforms` 都是**追加**,不会删除已装入口; +`none` 不等于卸载。旧版安装会根据已有文件迁移平台选择,linked worktree 首装会继承主工作区的选择。 +无需重复 clone,也没有后台下载或常驻同步进程。 -AI 自己日常开工用的是一行摘要(输出短、省 token): +## Git 忽略与已有文件 -```text -$ python tools/agents_status.py --brief -OK 看板无告警:活跃3,无失活/未答/worktree 异常,可直接开工 -``` +安装器维护 **`.git/info/exclude`** 的标记区,不改目标项目的 `.gitignore`,不把本机约定传播给整个团队。 +新平台只忽略精确的套件入口,不会隐藏整个 `.github/`、`.cursor/` 或 `.windsurf/`。 -完整的终端看板长这样(三个不同平台的会话在同一个项目里干活): +完整私有清单以安装器 `EXCLUDE_ENTRIES` 为准: ```text -【会话】共 3 个,活跃 3,失活 0 - ● trae-261009-1015-a1b2 - 分支 feature/order-api 心跳 2 分钟前 - 任务 订单接口 - ● claude-261009-1030-c3d4 [wt] D:/code/app-wt - 分支 feature/coupon 心跳 5 分钟前 - 任务 优惠券 - ● codex-261009-1100-e5f6 - 分支 fix/login-timeout 心跳 1 分钟前 - 任务 登录超时 -【inbox】未答 1 / 共 3 - ? Q-2026-10-09-01.md to=any from=trae-… (2026-10-09 10:18) -【worktree】2 个 - [当前/已注册] feature/order-api @ a1b2c3d - [linked/linked] feature/coupon @ a1b2c3d +AGENTS.md CLAUDE.md .agents/ .agent/ .claude/ .trae/ +.workbuddy-ai/ .workbuddy/ tools/agents_status.py +.cursor/rules/multi-agent-collab.mdc .github/copilot-instructions.md +.windsurf/rules/multi-agent-collab.md +tools/agents_session.py GEMINI.md .qwen/QWEN.local.md +.clinerules/multi-agent-collab.md .roo/rules/00-multi-agent-collab.md +.continue/rules/multi-agent-collab.md .kiro/steering/multi-agent-collab.md ``` -会话文件就是一个 8 行的 Markdown 头,填法范例见 -[docs/examples/session-example.md](docs/examples/session-example.md); -两个会话协商"同文件不同函数、零重叠并行"的真实问答见 -[docs/examples/inbox-example.md](docs/examples/inbox-example.md)。 - ---- - -## 跨平台:各 AI 怎么接入 - -| 平台 | 怎么生效 | -|---|---| -| **Claude Code / TraeCode CLI** | 根目录 `AGENTS.md` 自动加载 | -| **Codex** | 原生自动加载 `AGENTS.md`(另有 `~/.codex/AGENTS.md` 全局位) | -| **OpenCode(sst/opencode)** | `AGENTS.md` 就是它的项目规则(官方约定) | -| **Pi(pi.dev)** | 自动从项目目录发现 `AGENTS.md` | -| **Trae IDE** | 安装器写入 `.trae/rules/git-flow-agent.md`(新对话自动生效) | -| **WorkBuddy** | 安装器生成 `.workbuddy-ai/git-flow-agent.md` 镜像(改总章后跑 `install.py mirror` 刷新) | - -前四个平台零配置,装完开个新对话即可。规则都在新对话开始时加载,改完规则后旧对话不刷新,开新对话就行。 - -规则直接写在 AGENTS.md——面向 coding agent 的开放格式([agents.md](https://agents.md)), -只写一遍,各平台各读各的入口;Trae 和 WorkBuddy 的入口文件由安装器生成,内容同样是这份规则。 -同一个项目里可以混用多个平台,黑板是同一块。 - ---- +已有文件的处理: -## 工作方式:四条交规 +- 已被 Git 跟踪的目标规则:停止并列出冲突,不覆盖、不自动 `git rm --cached`。忽略规则对已跟踪文件无效。 +- 已有本机 `CLAUDE.md` / Copilot 说明:保留其它说明,只维护套件标记区;Claude 的项目速览继续迁移。 +- 覆盖套件文件前:按原始字节备份到 `.agents/backups/`;内容相同则跳过。 +- 忽略文件不可读取、编码异常或被 `.gitignore` 反向规则覆盖:报告失败,不把未保护的规则文件写进去。 +- linked worktree:维护 Git 实际使用的忽略位置,协作文件仍需安装到各自工作区,会话也在各自工作区登记。 -1. **各写各的小文件,不共用大文件**:一个会话一个文件、一天一份日志、一个问题一张留言条,天然不撞车;万一同时写了,合并时取并集,只多不少。 -2. **用心跳判断死活,不用锁**:锁在 AI 崩溃时会永远卡死。心跳超过 15 分钟算失联,别人想接手要先在留言板声明、等 5 分钟没人反对,而且必须保留前任的全部提交。 -3. **看板上没人不等于真的没人**:忘了登记的 AI 是隐形的。所以除了看板,还要交叉看 git 记录、文件修改时间、有没有构建进程在跑。 -4. **协作文件只留本机,不进 git**:写在 `.git/info/exclude` 里,切分支不丢、不会误提交、不污染团队仓库。代价是没有云端备份,重要节点把 `.agents/` 拷到仓库外即可。 +## 网页看板 ---- +在**目标项目目录**运行: -## 命令速查 - -安装与维护(你跑): - -| 命令 | 作用 | -|---|---| -| `python install.py install <项目>` | 安装 / 升级(幂等,你改的定制内容自动保留) | -| `python install.py doctor <项目>` | 体检:文件、忽略规则、版本新旧、看板冒烟 | -| `python install.py mirror <项目>` | 改完 AGENTS.md 后刷新 WorkBuddy 镜像 | -| `python install.py uninstall <项目>` | 只移除忽略托管块,文件全部保留 | -| `python install.py uninstall <项目> --purge-files` | 再删没被你改过的套件文件;会话/日志/留言/备份永不删 | - -看板(你和 AI 都用): - -| 命令 | 作用 | -|---|---| -| `python tools/agents_status.py` | 完整终端看板 | -| `python tools/agents_status.py --brief` | 一行摘要:没事只回一句 OK(AI 开工首选) | -| `python tools/agents_status.py --web` | 图形化看板(可对 AI 下指令) | -| `python tools/agents_status.py --html [路径]` | 导出单文件 HTML 快照 | -| `python tools/agents_status.py --json` | 结构化输出(给脚本用),退出码 0/1 同口径 | -| `python tools/agents_status.py --version` / `--selfcheck` / `--help` | 版本 / 48 项自检 / 全部参数 | +```bash +python tools/agents_status.py --web # 本机网页指挥台 +python tools/agents_status.py --brief # agent 的一行开工检查 +python tools/agents_status.py --html # 单文件离线快照 +``` -安装器的几个保证:幂等(内容相同跳过,不同先字节备份到 `.agents/backups/`,连 GBK 旧编码也不丢内容);三个定制区升级保留;linked worktree 双写忽略规则。 +看板每 5 秒刷新,显示会话、心跳、未答问题和 worktree 告警,也可以向 inbox 写指令。 +agent 在任务阶段间隙读取和回复;空闲或已结束的会话需要你粘贴提醒再次唤起。 +[交互演示](https://aiflyf.github.io/multi-agent-collab/demo.html) 使用示例数据,便于先体验操作,不连接真实项目。
-局域网访问(手机或其他电脑也想看) - -```powershell -# ① 默认:只听 127.0.0.1,手机和别的电脑访问不到,网页可直接发指令 -python tools/agents_status.py --web +局域网访问与其它输出 -# ② 局域网只读大屏:能看不能动 +```bash +python tools/agents_status.py --json python tools/agents_status.py --web --bind 0.0.0.0 --no-directives - -# ③ 局域网可指挥:先设密码(浏览器弹登录框,用户名随便填) -$env:MULTI_AGENT_BOARD_PASSWORD = "你的密码" -python tools/agents_status.py --web --bind 0.0.0.0 ``` -- "对外可写又没有密码"的组合会被拒绝启动;连续输错密码有冷却限制 -- HTTP 是明文传输,只适合家里/公司可信 WiFi 或 VPN;公共 WiFi、云服务器公网 IP 不要开 0.0.0.0 -- 外网访问用 SSH 端口转发(`ssh -L 8731:127.0.0.1:8731 用户@电脑`) -- 一键只读开关:环境变量 `MULTI_AGENT_BOARD_READONLY=1` -- 网页发的指令就是 `.agents/inbox/` 里 `from: human` 的标准留言条;AI 空闲睡着时,每条指令旁有「复制提醒」按钮,把那句话粘进 AI 对话框即可唤醒 +默认只监听 `127.0.0.1`。局域网需要可写时,先设置 `MULTI_AGENT_BOARD_PASSWORD`; +不设置密码的对外可写服务会拒绝启动。HTTP 只适合可信网络,远程访问建议 SSH 端口转发。 +可用 `MULTI_AGENT_BOARD_READONLY=1` 强制只读。
---- - -## 出问题了怎么办 - -每个项目安装后的 `.agents/TROUBLESHOOTING.md` 收录 29 条来自真实事故的处理经验: - -- **A 组 · 协作流程**:隐形会话、并发覆盖、假绿测试、长任务被中断、假验证 -- **B 组 · Git 与引用**:引用"消失"、沙箱拦截写 ref、假 ahead/behind、推送兜底…… -- **C 组 · 网络/终端**:Windows schannel 502、代理拦截、PowerShell 兼容…… -- **D 组 · 你的项目专属(空区)**:自己框架/硬件的坑从 D1 开始记,升级自动保留 - -不知道 D 组怎么写?[docs/examples/project-specific-troubleshooting.md](docs/examples/project-specific-troubleshooting.md) -有 4 条范例(范例只留在本仓库,不会装进你的项目)。 - ---- - -## 适用边界 - -适合:同一台电脑、同一个 git 工作区、多个 AI 会话(含真人),不想搭任何服务器。 - -不适合或需要想清楚的情况: - -- 跨电脑协作(需要真正的远程同步,本工具刻意不做) -- AI 之间需要毫秒级实时通信(这里最快是"下一次工具调用时读到留言") -- AI 完全不遵守 `AGENTS.md`(那就退回最朴素的规矩:同一时间只让一个 AI 动同一文件) -- 想把 AGENTS.md 提交进仓库全团队共享:本套件的取舍是"规则本机私有、各自定制";需要共享时,可把共享部分写进仓库文档,让本机 AGENTS.md 引用它 - ---- +## 命令速查 -## 手动安装 +下表的 `install.py` 位于复用的套件源码目录,`<项目>` 是目标工作区。 -
-不想跑脚本?这是等价的手工拷贝命令 +| 命令 | 用途 | +|---|---| +| `python install.py platforms [--json]` | 支持的平台和文件清单 | +| `python install.py install <项目> --platforms codex` | 首次按需安装;以后省略参数沿用 | +| `python install.py install <项目> --dry-run` | 安装预览,不写盘 | +| `python install.py sync <项目> [--platforms cursor]` | 刷新已选入口,或追加平台 | +| `python install.py doctor <项目>` | 核心文件、已选入口、版本、忽略及看板冒烟检查 | +| `python install.py mirror <项目>` | 旧命令兼容:追加 WorkBuddy 并同步已选入口 | +| `python install.py uninstall <项目>` | 移除忽略托管块,文件保留,此后会出现在 Git 状态中 | +| `python install.py uninstall <项目> --purge-files` | 再清理未修改的套件文件;定制、会话、日志、留言、备份保留 | -```bash -SRC=/path/to/multi-agent-collab -DST=/path/to/your-project - -cp "$SRC/AGENTS.md" "$SRC/CLAUDE.md" "$DST/" -mkdir -p "$DST/tools" "$DST/.agents/sessions" "$DST/.agents/worklog" "$DST/.agents/inbox" -cp "$SRC/tools/agents_status.py" "$DST/tools/" -cp "$SRC/docs/README.md" "$DST/.agents/README.md" -cp "$SRC/docs/TROUBLESHOOTING.md" "$DST/.agents/" -cp "$SRC/templates/session.md" "$DST/.agents/sessions/_template.md" -cp "$SRC/templates/inbox.md" "$DST/.agents/inbox/_template.md" -cp "$SRC/templates/worklog.md" "$DST/.agents/worklog/_template.md" - -# 平台入口按需(Claude Code / Codex / OpenCode / Pi 直接读根 AGENTS.md,无需拷贝): -# cp "$SRC/platforms/trae-rule.md" "$DST/.trae/rules/git-flow-agent.md" -# cp "$SRC/platforms/claude-command.md" "$DST/.claude/commands/git-flow.md" - -# 关键:写进 .git/info/exclude(清单与 install.py 的 EXCLUDE_ENTRIES 保持一致) -cat >> "$DST/.git/info/exclude" <<'EOF' -/AGENTS.md -/CLAUDE.md -/.agents/ -/.agent/ -/.claude/ -/.trae/ -/.workbuddy-ai/ -/.workbuddy/ -/tools/agents_status.py -EOF -``` +任何命令先用 `--help` 查看当前参数。手工复制容易漏掉平台选择记录和 Git worktree 的忽略位置,建议使用安装器。 -WorkBuddy 镜像:`platforms/workbuddy-header.md` 内容在前,后面拼上目标项目的 -`AGENTS.md` 全文(或直接 `python install.py mirror <项目>`)。 +## 出问题时 -
+先看目标项目的 `.agents/TROUBLESHOOTING.md`,再运行 `doctor`。 +手册按协作、Git 引用、网络/终端分类;D 组用于追加自己的项目问题,升级保留。 ---- +- [会话填法](docs/examples/session-example.md) +- [同文件协作与交接示例](docs/examples/inbox-example.md) +- [项目故障记录示例](docs/examples/project-specific-troubleshooting.md) +- [平台没有加载规则时的检查顺序](docs/PLATFORMS.md#如何确认真的生效) -## 仓库结构与自测 +## 仓库结构与验证 ```text multi-agent-collab/ -├── install.py # install / doctor / mirror / uninstall -├── AGENTS.md CLAUDE.md # 规则模板(含项目定制区标记) -├── tools/agents_status.py # 看板(零依赖) -├── templates/ # session / inbox / worklog 三个模板 -├── platforms/ # trae / claude / workbuddy 平台入口 -├── docs/ -│ ├── README.md TROUBLESHOOTING.md -│ └── examples/ # 会话/留言填法 + 4 条 D 组写法范例 -├── tests/selftest.py # 34 个测试用例(15 单元 + 19 安装器 E2E) -└── .github/workflows/selftest.yml # 3 系统 × 4 Python 版本 CI +├── install.py # 安装 / 同步 / 体检 / 平台列表 / 卸载 +├── AGENTS.md CLAUDE.md # 发布到项目的规则模板 +├── tools/agents_status.py # 零依赖协作看板 +├── tools/agents_session.py # 占用检查、原子登记、心跳、收尾与交接 +├── templates/ # 会话 / 留言 / 日志 / 安装提示词 +├── platforms/ # 按需安装的平台入口模板 +├── docs/ # 启动流程、平台说明、共享格式与故障手册 +├── scripts/sync_install_docs.py # 从安装器与提示词模板同步 README 和页面数据 +├── site/ # 介绍页、安装配置器、交互演示(静态文件) +├── tests/selftest.py # 43 个测试用例(15 单元 + 28 安装器 E2E) +├── tests/test_sessions.py # 协作并发、占用、交接和新增平台回归 +└── .github/workflows/ # 3 系统 × 4 Python 版本 CI + Pages ``` ```bash -python tools/agents_status.py --selfcheck # 看板内置 48 项断言 -python tests/selftest.py # 临时真实 git 仓库端到端演练(含 linked worktree) +python tools/agents_status.py --selfcheck +python tests/selftest.py +python -m unittest discover -s tests -p test_sessions.py +python scripts/sync_install_docs.py --check ``` -自测覆盖:忽略规则逐条生效、幂等/备份、三个定制区迁移、GBK 旧文件、子目录探测、 -坏心跳与未来时钟语义、worktree 跨区可见且不误报、卸载安全(用户数据不丢)、 -忽略清单文档一致性等。Windows 上会自动处理 git 只读对象;`KEEP_TMP=1` 可保留现场。 - -贡献提示:仓库根目录的 `AGENTS.md` / `CLAUDE.md` / `platforms/` 是要发布的产品模板 -(正常入库),改它们等于改产品;测试一律通过 `tests/selftest.py` 在系统临时目录进行, -不要在 kit 目录里自安装(安装器会拒绝)。变更请同步更新 -[CHANGELOG.md](CHANGELOG.md) 与 `install.py` 的 `KIT_VERSION`。 - -## 起源 +测试在系统临时目录创建真实 Git 仓库,覆盖按平台安装、选择迁移、同步与幂等、忽略失败、 +已跟踪文件保护、定制/备份、linked worktree、卸载和看板语义。 +本地预览网站可在仓库执行 `python -m http.server 8080 --bind 127.0.0.1 --directory site`。 -规则全部来自真实产品 [ego-link-esp32](https://github.com/AiFLYF/ego-link-esp32) -(ESP32-S3 教学硬件 + Python 课堂服务器)在 15+ 个 AI 协作会话里的事故与复盘; -抽离为通用套件时,规则正文与手册已完全去技术栈化。 +贡献时,根 `AGENTS.md` / `CLAUDE.md` / `platforms/` / 看板脚本是**已跟踪的产品源码**, +目标项目的本机私有约定不表示要从套件仓库删除这些模板。 +运行记录 `.agents/` 仍留在本机,禁止在套件目录自安装。 +改平台目录或安装提示词后运行 `python scripts/sync_install_docs.py`,并同步版本和 CHANGELOG。 -## License +## 起源与许可 -[MIT](LICENSE) +协作规则来自 [ego-link-esp32](https://github.com/AiFLYF/ego-link-esp32) 多个 AI 会话中的事故与复盘, +抽离为适用于其它项目的本机套件。[MIT License](LICENSE)。 diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md new file mode 100644 index 0000000..aa5c302 --- /dev/null +++ b/docs/PLATFORMS.md @@ -0,0 +1,93 @@ +# 平台接入与新对话验证 + +平台名称和入口清单以 `python install.py platforms` 为准,机器读取用 `platforms --json`。 +本页解释加载方式、同步边界与扩展方法。 + +## 自动读取如何工作 + +1. 平台启动会话时,发现项目级规则文件。 +2. 平台入口导入或要求读取项目根 `AGENTS.md` 和 `.agents/START_HERE.md`。 +3. agent 按启动清单重新读取动态的看板、inbox 和近 1–2 天 worklog,并在编码前注册会话。 + +`AGENTS.md` 管规则,`START_HERE.md` 管阅读顺序,动态记录按需读取。 +不用在每个平台保存整套历史,也不用每次读取源码和全部故障手册。 +规则文件是指导机制,不是进程钩子;它不能唤醒已结束的 agent,也不能保证模型执行每条要求。 + +| 平台 | 本套件的接入方式 | 验证要点 | +|---|---|---| +| Codex | 根 `AGENTS.md` | 从正确工作区启动;同目录 `AGENTS.override.md` 可优先于 AGENTS;还受文档大小上限影响 | +| Claude Code | `CLAUDE.md` 的 `@AGENTS.md`、`@.agents/START_HERE.md` 导入 | 显式导入,兼容旧版本和已有 CLAUDE 文件影响发现的场景;`/git-flow` 是手动命令 | +| OpenCode / Pi | 根 `AGENTS.md` | 确认启动目录与当前客户端的规则发现设置 | +| Trae | `.trae/rules/git-flow-agent.md`,`alwaysApply: true` | 确认项目规则开启;Trae IDE 与其它同名 CLI 不应混为一种客户端 | +| WorkBuddy | `.workbuddy-ai/git-flow-agent.md` 兼容镜像 | 需确认当前客户端确实加载此项目入口;修改总章后执行 sync | +| Cursor | `.cursor/rules/multi-agent-collab.mdc`,`alwaysApply: true` | 使用明确的项目规则入口;本文件指向最新共享规则 | +| GitHub Copilot | `.github/copilot-instructions.md` | 需在支持此文件的客户端启用项目自定义说明;已有说明保留在托管块外 | +| Windsurf | `.windsurf/rules/multi-agent-collab.md`,`trigger: always_on` | 使用明确的始终启用入口;当前客户端也可能原生支持 AGENTS | +| Gemini CLI | `GEMINI.md` 使用 `@./` 导入 | 针对采用 GEMINI.md 的客户端;若自定义了 context.fileName,需确认仍包含此入口 | +| Qwen Code | `.qwen/QWEN.local.md` | 使用官方本机入口,在团队 QWEN.md 之后加载;不改写团队规则或全局配置 | +| Cline | `.clinerules/multi-agent-collab.md` | Rules 面板可控制启用;旧单文件 .clinerules 与目录冲突时安装会提前停止 | +| Roo Code | `.roo/rules/00-multi-agent-collab.md` | 通用规则覆盖所有模式;规划先只读,同任务切换模式沿用会话 ID | +| Continue | `.continue/rules/multi-agent-collab.md`,alwaysApply | Chat / Plan 先检查,可写 Agent 模式再登记文件范围 | +| Kiro | `.kiro/steering/multi-agent-collab.md`,inclusion: always | Spec 文档修改也登记;本机状态不自动进入云端独立工作区 | + +Claude Code 官方文档现已说明:v2.1.277 起,符合条件且没有其它项目 CLAUDE 文件时, +也能直接发现 AGENTS。本套件保留显式导入,便于兼容不同版本与项目现有配置。 +客户端的规则发现机制会变化。上述是本套件选择的入口,不代表列出的入口是平台唯一支持的方式。 +安装器不会改动用户的全局设置,也不会强制启用被用户关闭的规则加载开关。 + +## 安装、追加和刷新 + +```bash +# 首次仅启用当前项目需要的平台 +python "$HOME/multi-agent-collab/install.py" install . --platforms codex,claude + +# 新增 Cursor,保留先前的选择和项目总章 +python "$HOME/multi-agent-collab/install.py" sync . --platforms cursor + +# 刷新已选入口(包括 WorkBuddy 镜像) +python "$HOME/multi-agent-collab/install.py" sync . +``` + +`install` 升级核心模板并保留定制区;`sync` 刷新已选入口,保留整个项目总章及已有启动文档。 +两者都维护 Git 本地忽略、先备份再覆盖,可用 `--dry-run` 预览。 +`--platforms` 是追加式选择,`none` 只表示不新增平台,不负责卸载既有入口。 +旧版未保存选择时,安装器根据实际入口迁移;linked worktree 首装时继承主工作区选择。 + +## 如何确认真的生效 + +先执行 `doctor` 检查文件、镜像一致性和 Git 忽略,再新开对话使用: + +```text +请读取当前项目的协作启动入口,告诉我你实际读了哪些规则文件、 +当前分支是什么、看板是否有告警,以及发给当前会话或 any 的未答 inbox 有哪些。 +先只做只读检查。 +``` + +应看到实际工具读取和对应结果。只回答“我会先看看板”不能证明已加载。 +若未生效,检查工作区、规则开关、入口优先级、上下文上限,再重启会话。 +Codex 官方说明:默认指令大小上限为 32 KiB,按全局到项目目录顺序合并;本套件不修改此配置。 + +参考:[Codex AGENTS.md](https://developers.openai.com/codex/guides/agents-md/)、 +[Claude Code memory](https://code.claude.com/docs/en/memory)、 +[Cursor rules](https://cursor.com/docs/rules)、 +[Copilot 自定义说明](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot)、 +[Windsurf rules](https://docs.windsurf.com/windsurf/cascade/memories)。 + +新增平台参考:[Gemini context](https://geminicli.com/docs/cli/gemini-md/)、 +[Qwen memory](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/memory.md)、 +[Cline rules](https://docs.cline.bot/customization/cline-rules)、 +[Roo instructions](https://roocodeinc.github.io/Roo-Code/features/custom-instructions/)、 +[Continue rules](https://docs.continue.dev/customize/deep-dives/rules)、[Kiro steering](https://kiro.dev/docs/steering/)。 + +平台标签也接受常见名称,例如 `Gemini CLI`、`qwen-code`、`roo-code` 和 `GitHub Copilot`, +安装记录统一保存短名称。新协作命令随 `install` 升级;`sync` 不会擅自替换旧项目的核心工具。 + +## 为套件增加一个平台 + +1. 先核对该平台官方支持的项目规则入口、启用条件与文件格式。 +2. 在 `install.py` 的 `PLATFORM_LABELS` 登记名称;原生使用总章的加入 `NATIVE_PLATFORMS`, + 否则在 `platforms/` 添加只指向共享规则的入口,并登记 `PLATFORM_FILES`。 +3. 将新增的精确入口路径加入 `EXCLUDE_ENTRIES`;不要忽略整个 `.github/` 或编辑器目录。 +4. 更新总章与平台说明的私有路径记录,补真实安装、同步、忽略与卸载测试。 +5. 运行 `python scripts/sync_install_docs.py`,自动更新 README 平台表、提示词及页面数据。 + CI 的 `--check` 会检查是否漏同步。 diff --git a/docs/README.md b/docs/README.md index f609080..95402fd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,9 @@ ``` .agents/ ├── README.md # 本文件 +├── START_HERE.md # 每次新对话的最小阅读顺序 +├── install.json # 项目名、已选平台、套件源路径与版本 +├── coordination.lock # 登记过程的系统锁(主 worktree 共用,退出自动释放) ├── sessions/ # 每个活跃会话一个文件:我是谁、在哪个分支、心跳 │ ├── _template.md │ └── <会话ID>.md @@ -28,6 +31,16 @@ 为什么这样分片:多个 agent 同时写同一个文件必然冲突。**一会话一文件、一问题一文件、 一天一文件**,各写各的,万一冲突合并时取并集。 +## 新对话与新增平台 + +每次新对话先读 [START_HERE.md](START_HERE.md),不要把上个会话的看板与 inbox 状态当现状。 +原生读取 `AGENTS.md` 的平台共享根规则;其它平台安装自己的启动入口并指向相同文档。 +根规则修改后,新对话会重新读取;WorkBuddy 的完整镜像需要同步。 + +新增平台用 `python "<套件目录>/install.py" sync "<项目>" --platforms cursor`; +日常刷新入口省略 `--platforms`。选择会保存在 `install.json`,升级沿用,追加不会删除其它平台。 +套件默认复用用户主目录的 `multi-agent-collab`,实际位置以 `install.json` 的 `source_dir` 为准。 + ## 会话文件 `sessions/<会话ID>.md` - 文件名即会话 ID:`--<4位随机>`,如 `trae-261009-1115-a3f9` @@ -41,6 +54,9 @@ - heartbeat:每 ≤ 15 分钟或每完成一阶段更新,**保存文件即生效,不用 commit/push**; 超过 15 分钟没更新(或时间无法解析、比当前时刻快 10 分钟以上)视为失活 - 收工把 status 置 `done`;没做完要走,置 `blocked`、填 `handoff` 并在 inbox 留交接问题 +- 建议使用 `tools/agents_session.py` 的 start / heartbeat / finish / handoff 维护,避免漏字段。 + `files` 是 JSON 数组(如 `["src/app.py", "docs/"]`);看板显示占用并提醒交叉冲突。 + 同工作区不可重复登记;独立 worktree 可并行处理不重叠的文件。失活不会自动释放占用。 ## 工作日志 `worklog/YYYY-MM-DD.md` diff --git a/docs/START_HERE.md b/docs/START_HERE.md new file mode 100644 index 0000000..5869a27 --- /dev/null +++ b/docs/START_HERE.md @@ -0,0 +1,55 @@ +# 每次新对话,从这里开始 + +这是当前项目的开工入口。新建对话时执行一次;任务阶段间隙重扫 inbox、更新自己的心跳。 +平台入口会加载或指向根目录 `AGENTS.md`。如果它尚未进入上下文,先完整读取一次,随后读取本文件。 +旧对话不会因为安装或同步自动重新加载规则,需要新开对话,或明确要求重新读取。 + +## 最小阅读顺序 + +1. 确认当前工作区和分支,遵守根 `AGENTS.md`。不要默认其他会话已替你检查。 +2. `git fetch origin --prune`,然后 `python tools/agents_status.py --brief`。 + 返回 1 再展开完整看板;命令失败按 `.agents/TROUBLESHOOTING.md` 排查。 +3. 读 `.agents/inbox/` 中发给当前平台、当前会话或 `any` 的未答条目;读近 1–2 天 worklog。 + 这些文件会变化,每个新对话都要重新读。无文件就跳过,不能把“没有记录”当作“没有人在工作”。 +4. 查看开放 PR,结合进程、reflog 和文件时间确认没有同分支、同文件冲突。 +5. 编码任务开始写入前,按 `AGENTS.md` 建任务分支,然后运行: + + ```text + python tools/agents_session.py start --agent <当前平台> --task "任务" --files <相对文件或目录> --json + ``` + + 保存返回的会话 ID。若同工作区、分支或文件范围已被占用,停止并协调;不能重写别人的记录。 + 旧版没有此工具时,用 `.agents/sessions/_template.md` 手动登记并提示升级核心工具。 + +## 执行期间与收尾 + +```text +python tools/agents_session.py heartbeat <会话ID> --agent <当前平台> --note "当前进度" +python tools/agents_session.py finish <会话ID> --agent <当前平台> --summary "结果" --tests "实际测试结果" +python tools/agents_session.py handoff <会话ID> --agent <当前平台> --summary "下一步、阻塞点和未提交文件" +``` + +阶段间隙和长命令前仍需重扫 inbox;心跳不能证明已处理人类指令。 +扩大文件范围时先给 heartbeat 加 `--files` 声明完整新范围,再修改文件。 +finish 追加日志并释放占用;handoff 写交接留言、保留 blocked 占用,等待明确接管。 +文件范围只是协作约定,系统锁只保护登记过程,不锁住源码,也不能阻止绕过协议的程序。 + +共享格式不熟悉时再读 `.agents/README.md`;发生故障时再查手册相应条目。 +获取现状优先跑命令,不要每个新对话都读安装器、看板源码或全部历史日志。 + +## 新增平台与同步 + +各平台读取同一个 `AGENTS.md` 和本入口;更新这两份文档后,新对话读取的是最新内容。 +WorkBuddy 的完整镜像需要同步;新增平台也使用同一条命令: + +```text +python "<套件目录>/install.py" sync "<项目目录>" --platforms cursor +python "<套件目录>/install.py" sync "<项目目录>" +``` + +从 `.agents/install.json` 的 `source_dir` 找到套件目录;默认是用户主目录下的 +`multi-agent-collab`。`sync` 追加平台、刷新入口和忽略规则,保留项目总章与协作记录; +更新套件核心模板要运行 `install`。无需常驻服务,不会自动下载代码或修改平台的全局配置。 + +安装器只能验证入口路径、内容和忽略规则。平台版本、工作区信任设置及用户关闭规则加载等因素 +仍会影响自动读取。新对话验证时,确认 agent 能说明当前分支、看板结果及 inbox,而不只是复述口号。 diff --git a/install.py b/install.py index 57a29d2..4b19350 100644 --- a/install.py +++ b/install.py @@ -7,10 +7,12 @@ 用法: python install.py install [TARGET] # 安装/升级到 TARGET(默认当前目录) python install.py doctor [TARGET] # 体检:文件齐不齐、忽略生不生效、看板能不能跑 - python install.py mirror [TARGET] # 改完 AGENTS.md 后重生成 WorkBuddy 镜像 + python install.py sync [TARGET] # 同步已选平台;--platforms 可追加新平台 + python install.py platforms # 查看支持的平台与入口 + python install.py mirror [TARGET] # 兼容旧命令:同步 WorkBuddy 镜像 常用选项: - --platforms trae,claude,workbuddy # 选装平台入口(默认 all;none=不装;不会卸载旧入口) + --platforms codex,claude # 按需选装;首次询问,升级沿用,追加不删除旧入口 --project-name NAME # 替换 {{PROJECT_NAME}}(默认读 .agents/install.json) --dry-run # 只打印要做的事,不落盘 @@ -36,7 +38,7 @@ from pathlib import Path KIT_ROOT = Path(__file__).resolve().parent -KIT_VERSION = "1.4.1" # 升级 kit 时记得改;会写进目标项目的 .agents/install.json +KIT_VERSION = "1.5.0" # 升级 kit 时记得改;会写进目标项目的 .agents/install.json # 与 tools/agents_status.py 同名同义:git 可执行文件解析的环境变量覆盖 # (两份小拷贝是有意为之——看板会被单独装进目标项目,不能 import 安装器)。 @@ -87,9 +89,10 @@ def resolve_git() -> str | None: # (kit 内源路径, 安装目标相对路径, 定制区名或 None;"__troubles__" 走专用合并) CORE_FILES = [ ("AGENTS.md", "AGENTS.md", "project-env"), - ("CLAUDE.md", "CLAUDE.md", "project-overview"), ("tools/agents_status.py", "tools/agents_status.py", None), + ("tools/agents_session.py", "tools/agents_session.py", None), ("docs/README.md", ".agents/README.md", None), + ("docs/START_HERE.md", ".agents/START_HERE.md", None), ("docs/TROUBLESHOOTING.md", ".agents/TROUBLESHOOTING.md", "__troubles__"), ("templates/session.md", ".agents/sessions/_template.md", None), ("templates/inbox.md", ".agents/inbox/_template.md", None), @@ -99,12 +102,62 @@ def resolve_git() -> str | None: PLATFORM_FILES = { "trae": ("platforms/trae-rule.md", ".trae/rules/git-flow-agent.md"), "claude": ("platforms/claude-command.md", ".claude/commands/git-flow.md"), + "cursor": ("platforms/cursor-rule.mdc", ".cursor/rules/multi-agent-collab.mdc"), + "copilot": ("platforms/copilot-instructions.md", ".github/copilot-instructions.md"), + "windsurf": ("platforms/windsurf-rule.md", ".windsurf/rules/multi-agent-collab.md"), + "gemini": ("platforms/gemini-memory.md", "GEMINI.md"), + "qwen": ("platforms/qwen-memory.md", ".qwen/QWEN.local.md"), + "cline": ("platforms/cline-rule.md", ".clinerules/multi-agent-collab.md"), + "roo": ("platforms/roo-rule.md", ".roo/rules/00-multi-agent-collab.md"), + "continue": ("platforms/continue-rule.md", ".continue/rules/multi-agent-collab.md"), + "kiro": ("platforms/kiro-steering.md", ".kiro/steering/multi-agent-collab.md"), } WORKBUDDY_HEADER = "platforms/workbuddy-header.md" WORKBUDDY_TARGET = ".workbuddy-ai/git-flow-agent.md" -ALL_PLATFORMS = ["trae", "claude", "workbuddy"] +# 安装器、platforms 命令和展示页共用这一份目录;原生平台不生成空壳配置目录。 +PLATFORM_LABELS = { + "codex": "Codex", + "claude": "Claude Code", + "opencode": "OpenCode", + "pi": "Pi", + "trae": "Trae", + "workbuddy": "WorkBuddy", + "cursor": "Cursor", + "copilot": "GitHub Copilot", + "windsurf": "Windsurf", + "gemini": "Gemini CLI", + "qwen": "Qwen Code", + "cline": "Cline", + "roo": "Roo Code", + "continue": "Continue", + "kiro": "Kiro", +} +NATIVE_PLATFORMS = {"codex", "opencode", "pi"} +ALL_PLATFORMS = list(PLATFORM_LABELS) INSTALL_META = ".agents/install.json" +ENTRY_BEGIN = ")\n.*?()" + result, count = re.subn(pattern, lambda m: m[1] + "\n" + content.rstrip() + "\n" + m[2], + text, flags=re.S) + if count != 1: + raise ValueError(f"{name} 托管块应恰好出现一次,实际 {count}") + return result + + +def outputs() -> dict[Path, str]: + template = (ROOT / "templates/install-prompt.md").read_text(encoding="utf-8").strip() + if template.count("{{PLATFORM_SELECTION}}") != 1: + raise ValueError("安装提示词应包含一个 PLATFORM_SELECTION 占位符") + prompt = template.replace("{{PLATFORM_SELECTION}}", DEFAULT_SELECTION) + catalog = kit.platform_catalog() + table = ["| `--platforms` 名称 | 平台 | 本套件安装的入口 |", "|---|---|---|"] + for item in catalog: + entry = "
".join(f"`{path}`" for path in item["files"]) or "共用 `AGENTS.md`,无额外文件" + table.append(f"| `{item['id']}` | {item['label']} | {entry} |") + readme_path = ROOT / "README.md" + readme = readme_path.read_text(encoding="utf-8") + readme = replace_block(readme, "INSTALL_PROMPT", "```text\n" + prompt + "\n```") + readme = replace_block(readme, "PLATFORM_TABLE", "\n".join(table)) + index_path = ROOT / "site/index.html" + index = index_path.read_text(encoding="utf-8") + index, count = re.subn(r'().*?()', + lambda m: m[1] + html.escape(prompt, quote=False) + m[2], index, flags=re.S) + if count != 1: + raise ValueError("展示页必须包含一个 prompt-agent 代码块") + data = {"version": kit.KIT_VERSION, "platforms": catalog, + "coreFiles": [dst for _src, dst, _zone in kit.CORE_FILES] + [kit.INSTALL_META], + "promptTemplate": template, "defaultSelection": DEFAULT_SELECTION} + script = "// 由 scripts/sync_install_docs.py 生成,请修改安装器或提示词模板。\n" + script += "window.MAC_INSTALL = " + json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c") + ";\n" + return {readme_path: readme, index_path: index, ROOT / "site/assets/js/install-data.js": script} + + +def main() -> int: + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="仅检查生成内容是否最新") + args = parser.parse_args() + dirty = [] + for path, content in outputs().items(): + if path.exists() and path.read_text(encoding="utf-8") == content: + continue + dirty.append(path.relative_to(ROOT).as_posix()) + if not args.check: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + if args.check and dirty: + print("安装说明已过期,请运行 python scripts/sync_install_docs.py:" + ", ".join(dirty)) + return 1 + print("安装说明一致。" if not dirty else "已同步:" + ", ".join(dirty)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/assets/css/demo.css b/site/assets/css/demo.css new file mode 100644 index 0000000..f12eeda --- /dev/null +++ b/site/assets/css/demo.css @@ -0,0 +1,96 @@ +:root{color-scheme:dark;--bg:#0b0e13;--panel:#11161e;--line:#252e3a;--text:#edf1f7;--muted:#a0adbf;--cyan:#62dfda;--amber:#f0bb71;--violet:#b8a8fa} +*{box-sizing:border-box} +body{margin:0;background:radial-gradient(ellipse at 10% 0%,#12303766,transparent 40%),var(--bg);color:var(--text);font-family:"Sora","Microsoft YaHei",system-ui,sans-serif;font-size:14px;line-height:1.65} +a{color:inherit;text-decoration:none} +button,input,select,textarea{font:inherit} +button,a,select{touch-action:manipulation} +button{cursor:pointer} +:focus-visible{outline:2px solid var(--cyan);outline-offset:4px} +.mono,code{font-family:"IBM Plex Mono",Consolas,monospace} +.demo-shell{max-width:1320px;margin:auto;padding:0 44px} +.demo-nav{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:28px 0;border-bottom:1px solid var(--line)} +.demo-brand{display:flex;align-items:center;gap:12px;font-size:15px;font-weight:600} +.demo-nav nav{display:flex;align-items:center;gap:24px;font-size:12px;color:var(--muted)} +.button{display:inline-flex;align-items:center;justify-content:center;gap:14px;border:1px solid #354253;border-radius:7px;padding:10px 15px;color:var(--text);background:transparent;font-size:12px;font-weight:600;white-space:nowrap;transition:background .15s,border-color .15s} +.button:hover{background:#ffffff09;border-color:var(--muted)} +.button.primary{background:var(--cyan);border-color:var(--cyan);color:#102024} +.button.primary:hover{background:#98eee8} +.demo-intro{display:flex;align-items:center;justify-content:space-between;gap:30px;padding:48px 0 28px} +.demo-actions{display:flex;gap:10px;flex-wrap:wrap} +.demo-status[data-kind="warning"]{color:var(--amber)} +.claimed-files{font-size:10px;color:var(--cyan);overflow-wrap:anywhere;margin:0 0 14px} +.eyebrow{font-family:Consolas,monospace;letter-spacing:.13em;color:var(--cyan);font-size:10px;margin:0 0 16px} +h1{font-size:clamp(26px,3.3vw,40px);line-height:1.3;letter-spacing:-.055em;margin:0;font-weight:600} +.intro-copy{color:var(--muted);margin:16px 0 0;font-size:13px} +.demo-notice{display:flex;gap:10px;align-items:center;color:#c8b78f;background:#edbe6110;border:1px solid #6e5d373d;border-radius:7px;padding:12px 16px;margin:0 0 26px;font-size:11px} +.demo-notice strong{color:var(--amber);white-space:nowrap} +.notice-dot{width:5px;height:5px;border-radius:50%;background:var(--amber);flex-shrink:0} +.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px} +.metric{border:1px solid var(--line);border-radius:9px;background:var(--panel);padding:19px 22px} +.metric>span{font-size:11px;display:flex;align-items:center;gap:7px;color:var(--muted)} +.metric strong{display:block;font-size:33px;line-height:1.3;margin:8px 0 4px;font-weight:500} +.metric small{font-size:10px;color:var(--muted)} +.dot{width:6px;height:6px;border-radius:50%;display:inline-block;background:var(--muted)} +.dot.active{background:var(--cyan)} +.dot.blocked{background:var(--amber)} +.dot.done{background:var(--violet)} +.demo-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(310px,1fr);gap:26px;align-items:start} +.section-heading,.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px} +.section-heading{margin-bottom:18px;min-height:44px} +h2{font-size:15px;font-weight:600;letter-spacing:-.02em;margin:0} +.subtle{font-size:10px;color:var(--muted)} +.filter-label{display:flex;align-items:center;gap:9px;font-size:11px;color:var(--muted);white-space:nowrap} +select,textarea{border:1px solid #354253;background:#0c1118;color:var(--text);border-radius:6px;padding:10px 12px;font-size:12px;min-width:0;max-width:100%} +.filter-label select{padding:8px 10px;font-size:11px} +.session-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} +.session-card{padding:22px 20px;border:1px solid var(--line);border-top:2px solid var(--agent-color,var(--cyan));border-radius:9px;background:var(--panel);min-width:0} +.session-card.is-done{border-top-color:var(--violet)} +.session-top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:18px} +.platform-name{color:var(--agent-color,var(--cyan));font-size:12px;font-weight:600} +.state-label{font-size:10px;display:flex;gap:6px;align-items:center;color:var(--muted);white-space:nowrap} +.session-card h3{font-size:15px;line-height:1.6;font-weight:500;margin:0 0 9px} +.branch{font-size:10px;overflow-wrap:anywhere;color:var(--muted);margin:0 0 18px} +.session-note{color:var(--muted);font-size:11px;min-height:37px;margin:0 0 18px} +.session-bottom{border-top:1px solid var(--line);padding-top:14px;display:flex;align-items:center;justify-content:space-between;gap:10px} +.session-bottom span{font-size:10px;color:var(--muted)} +.text-button{border:0;background:none;padding:5px 0;font-size:11px;color:var(--cyan)} +.text-button:hover{text-decoration:underline;text-underline-offset:4px} +.text-button:disabled{color:var(--violet);cursor:default;text-decoration:none} +.side-stack{display:flex;flex-direction:column;gap:18px} +.panel{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:22px} +.panel-heading{margin-bottom:19px} +.small-badge{padding:3px 7px;border:1px solid #43615e;border-radius:4px;font-size:9px;color:var(--cyan);white-space:nowrap} +form>label{display:block;font-size:11px;color:var(--muted);margin:14px 0 7px} +form select,form textarea{display:block;width:100%} +textarea{resize:vertical;min-height:91px;line-height:1.7} +textarea::placeholder{color:#7d8ba0} +.form-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px} +.form-footer>span{font-size:9px;color:var(--muted)} +.messages{display:grid;gap:18px;max-height:340px;overflow:auto} +.message{border-bottom:1px solid var(--line);padding-bottom:16px} +.message:last-child{border-bottom:0;padding-bottom:0} +.message-meta{font-size:9px;color:var(--muted);display:flex;justify-content:space-between;gap:10px} +.message p{font-size:11px;margin:8px 0;color:#c9d3df;white-space:pre-wrap;overflow-wrap:anywhere} +.message-reply{border-left:2px solid var(--cyan);padding-left:12px;margin-top:12px} +.message-reply span{font-size:9px;color:var(--cyan)} +.message-reply p{font-size:10px;color:var(--muted);margin-bottom:0} +.workflow-note{padding:24px 4px;display:flex;gap:17px;align-items:flex-start;flex-wrap:wrap} +.step-number{color:var(--cyan);font-size:10px;letter-spacing:.04em;padding-top:3px} +.workflow-note p{font-size:11px;color:var(--muted);margin:0;flex:1;min-width:210px} +.workflow-note strong{color:#cdd8e4;font-weight:500} +.workflow-note a{font-size:10px;color:var(--cyan)} +.timeline-panel{margin-top:26px} +.timeline{list-style:none;padding:0;margin:0;display:grid;gap:12px;max-height:240px;overflow:auto} +.timeline li{display:flex;align-items:baseline;gap:18px;font-size:11px;color:#c9d3df} +.timeline time{font-family:Consolas,monospace;color:var(--muted);font-size:10px;flex-shrink:0} +.demo-status{font-size:11px;min-height:22px;color:var(--cyan);margin:14px 0} +.demo-footer{display:flex;justify-content:space-between;gap:22px;border-top:1px solid var(--line);padding:24px 0 34px;margin-top:32px;color:var(--muted);font-size:10px} +.demo-footer code{color:#c5d4e2;overflow-wrap:anywhere} +.demo-footer a{color:var(--cyan);white-space:nowrap} +.empty-state{padding:36px 20px;border:1px dashed var(--line);border-radius:8px;font-size:12px;color:var(--muted)} +.skip-demo{position:fixed;left:16px;top:-100px;background:var(--cyan);padding:10px 16px;color:#102024;z-index:10} +.skip-demo:focus{top:16px} +[hidden]{display:none!important} +@media(max-width:1024px){.demo-shell{padding:0 26px}.demo-grid{grid-template-columns:1fr}.side-stack{display:grid;grid-template-columns:1fr 1fr}.demo-intro{padding-top:38px}.workflow-note{padding-bottom:0}} +@media(max-width:620px){.demo-shell{padding:0 18px}.demo-nav{padding:20px 0;gap:14px}.demo-nav nav{gap:12px}.demo-nav nav>a:first-child{display:none}.demo-brand{gap:7px;font-size:11px}.demo-brand img{width:25px;height:25px}.demo-nav .button{font-size:10px;padding:9px 10px;gap:5px}.demo-intro{align-items:flex-start;flex-direction:column;gap:18px;padding-top:32px}h1{font-size:27px}.eyebrow{font-size:8px;letter-spacing:.08em}.intro-copy{font-size:12px}.demo-notice{flex-wrap:wrap;gap:8px;font-size:10px}.demo-notice>span:last-child{flex-basis:100%}.metrics{grid-template-columns:1fr 1fr;gap:10px;margin-bottom:28px}.metric{padding:16px}.metric strong{font-size:30px}.side-stack{display:flex}.session-grid{grid-template-columns:1fr}.section-heading{align-items:flex-start;gap:12px}.filter-label{font-size:10px;gap:7px}.panel{padding:18px}.timeline li{gap:12px;font-size:10px}.timeline-panel .panel-heading{align-items:flex-start;flex-direction:column;gap:4px}.demo-footer{flex-direction:column;gap:12px}.workflow-note p{min-width:190px}} +@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}} diff --git a/site/assets/css/style.css b/site/assets/css/style.css index bc2c7bc..b53d63f 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -664,6 +664,49 @@ body.from-file .file-note{display:block} .hero{min-height:auto;padding:var(--s12) 0 var(--s8)} } +/* 按平台安装配置器 */ +.install-config{margin-top:40px;padding:30px;border:1px solid var(--line);background:rgba(255,255,255,.025);border-radius:12px} +.install-config-head{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:22px} +.install-kicker{font-size:10px;letter-spacing:.14em;color:var(--am);margin:0 0 9px} +.install-config h3{font-size:23px;font-weight:600;letter-spacing:-.02em;margin:0} +.install-clear{border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--tx2);padding:10px 13px;font-size:11px;white-space:nowrap;cursor:pointer} +.install-clear:hover{border-color:var(--cy);color:var(--cy)} +.install-clear:disabled{opacity:.45;cursor:default} +.platform-picker{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;border:0;padding:0;margin:0;min-width:0} +.platform-search-label{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:var(--tx2);margin:0 0 8px} +.platform-search-label span{font-size:10px;color:var(--tx3)} +.platform-search{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:7px;background:rgba(0,0,0,.3);color:var(--tx);font:inherit;font-size:12px;margin-bottom:18px} +.platform-search::placeholder{color:var(--tx3)} +.platform-option[hidden],#platform-empty[hidden]{display:none} +.platform-option{display:flex;align-items:center;gap:12px;min-width:0;padding:16px;border:1px solid var(--line);border-radius:8px;background:rgba(0,0,0,.15);cursor:pointer;transition:border-color .2s,background .2s} +.platform-option:hover{border-color:rgba(70,224,255,.5)} +.platform-option:has(input:checked){border-color:var(--cy);background:rgba(70,224,255,.06)} +.platform-option:focus-within{outline:2px solid var(--cy);outline-offset:3px} +.platform-option input{width:17px;height:17px;accent-color:var(--cy);flex-shrink:0;cursor:pointer} +.platform-option span{min-width:0} +.platform-option strong{display:block;color:var(--tx);font-size:13px;font-weight:600} +.platform-option small{display:block;color:var(--tx3);font-size:11px;margin-top:5px} +.install-summary{font-size:13px;line-height:1.8;color:var(--tx2);margin:20px 0 0} +.install-files{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:12px;color:var(--tx2)} +.install-files summary{cursor:pointer;line-height:1.8} +.install-files summary span{color:var(--cy);margin-left:8px} +.install-files ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 28px;padding:0;margin:18px 0;list-style:none;font-size:11px;color:var(--tx3);overflow-wrap:anywhere} +.install-files li::before{content:'+ ';color:var(--cy)} +.install-files p{margin:12px 0 0;color:var(--tx3);line-height:1.8} +.prompt-block pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:480px;overflow-y:auto} +.bf-cap a{color:var(--cy);text-decoration:underline;text-underline-offset:3px} +@media(max-width:640px){ + .install-config{padding:20px 16px} + .install-config-head{gap:12px} + .install-config h3{font-size:19px} + .platform-picker{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} + .platform-option{padding:13px 10px;gap:8px} + .platform-option strong{font-size:11.5px} + .platform-option small{font-size:10px} + .install-files ul{grid-template-columns:1fr} + .prompt-block pre{max-height:440px;overflow-y:auto} +} + /* 降低动效偏好 */ @media (prefers-reduced-motion:reduce){ *,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important} diff --git a/site/assets/js/demo.js b/site/assets/js/demo.js new file mode 100644 index 0000000..3e3d232 --- /dev/null +++ b/site/assets/js/demo.js @@ -0,0 +1,153 @@ +(function () { + 'use strict'; + var initialSessions = [ + {id: 'codex-demo', platform: 'Codex', color: '#62dfda', task: '完善按平台安装流程', branch: 'feature/platform-install', status: 'active', note: '正在验证最小安装与平台选择的迁移。', age: '2 分钟前更新'}, + {id: 'claude-demo', platform: 'Claude Code', color: '#eaa282', task: '补充项目使用文档', branch: 'feature/onboarding-docs', status: 'active', note: '与安装任务协调,暂不修改安装器源码。', age: '1 分钟前更新'}, + {id: 'cursor-demo', platform: 'Cursor', color: '#b8a8fa', task: '优化移动端展示', branch: 'feature/mobile-demo', status: 'active', note: '仅改演示页面,已登记需要修改的文件。', age: '3 分钟前更新'}, + {id: 'trae-demo', platform: 'Trae', color: '#f0bb71', task: '等待接口约定确认', branch: 'feature/status-api', status: 'blocked', note: 'inbox 已留问题,确认后再调整接口字段。', age: '4 分钟前更新'} + ]; + var stateLabels = {active: '活跃', blocked: '等待确认', done: '已完成'}; + var sessions, messages, timeline; + var cards = document.getElementById('demo-sessions'); + var target = document.getElementById('directive-target'); + var body = document.getElementById('directive-body'); + var filter = document.getElementById('session-filter'); + var status = document.getElementById('demo-status'); + + function node(tag, className, text) { + var el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; + } + function reset() { + sessions = initialSessions.map(function (s) { return Object.assign({}, s); }); + ['install.py', 'README.md', 'site/assets/css/style.css', 'api/status.py'].forEach(function (file, i) { sessions[i].file = file; }); + status.dataset.kind = ''; + messages = [{target: '所有会话', text: '今天优先验证安装流程,完成后请补充测试结果。', reply: '已收到。各会话会在自己的工作范围内处理,完成后留下记录。'}]; + timeline = [{time: stamp(-4), text: 'Cursor 登记了移动端展示任务与涉及文件。'}, {time: stamp(-6), text: 'Claude Code 读取共享总章,确认文档改动范围。'}, {time: stamp(-8), text: 'Codex 完成开工检查,建立安装流程任务。'}]; + filter.value = 'all'; + body.value = ''; + target.replaceChildren(node('option', '', '所有会话')); + target.firstChild.value = 'any'; + sessions.forEach(function (s) { + var option = node('option', '', s.platform + ' · ' + s.task); + option.value = s.id; + target.appendChild(option); + }); + render(); + } + function stamp(minutes) { + return new Date(Date.now() + (minutes || 0) * 60000).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', hour12: false}); + } + function renderCards() { + var shown = sessions.filter(function (s) { return filter.value === 'all' || s.status === filter.value; }); + var focused = document.activeElement; + var focusId = focused && focused.dataset ? focused.dataset.session : null; + var fragment = document.createDocumentFragment(); + shown.forEach(function (s) { + var card = node('article', 'session-card' + (s.status === 'done' ? ' is-done' : '')); + card.style.setProperty('--agent-color', s.color); + var top = node('div', 'session-top'); + top.appendChild(node('span', 'platform-name', s.platform)); + var label = node('span', 'state-label'); + var dot = node('i', 'dot ' + s.status); + dot.setAttribute('aria-hidden', 'true'); + label.appendChild(dot); + label.appendChild(document.createTextNode(stateLabels[s.status])); + top.appendChild(label); + card.appendChild(top); + card.appendChild(node('h3', '', s.task)); + card.appendChild(node('p', 'branch mono', s.branch)); + card.appendChild(node('p', 'claimed-files mono', (s.status === 'done' ? '已释放:' : '文件占用:') + s.file)); + card.appendChild(node('p', 'session-note', s.note)); + var bottom = node('div', 'session-bottom'); + bottom.appendChild(node('span', '', s.age)); + var button = node('button', 'text-button', s.status === 'done' ? '已记录完成 ✓' : '标记完成 ↗'); + button.type = 'button'; + button.dataset.session = s.id; + button.setAttribute('aria-label', (s.status === 'done' ? '已完成:' : '标记完成:') + s.task); + button.disabled = s.status === 'done'; + button.addEventListener('click', function () { + s.status = 'done'; + s.age = '刚刚更新'; + s.note = '演示任务已收尾,完成记录已追加到共享时间线。'; + timeline.unshift({time: stamp(), text: s.platform + ' 完成了「' + s.task + '」,已记录收尾。'}); + render(); + status.dataset.kind = ''; + status.textContent = s.platform + ' 的任务已在演示中标记完成。'; + // 完成按钮会禁用或被筛选掉,把焦点移到稳定的状态筛选器。 + filter.focus(); + }); + bottom.appendChild(button); + card.appendChild(bottom); + fragment.appendChild(card); + }); + cards.replaceChildren(fragment); + document.getElementById('session-empty').hidden = shown.length > 0; + if (focusId) { + var replacement = cards.querySelector('[data-session="' + focusId + '"]'); + if (replacement && !replacement.disabled) replacement.focus(); + } + } + function render() { + document.getElementById('metric-total').textContent = sessions.length; + ['active', 'blocked', 'done'].forEach(function (key) { + document.getElementById('metric-' + key).textContent = sessions.filter(function (s) { return s.status === key; }).length; + }); + renderCards(); + var items = document.createDocumentFragment(); + messages.slice(0, 12).forEach(function (message) { + var item = node('article', 'message'); + var meta = node('div', 'message-meta'); + meta.appendChild(node('span', '', '你 → ' + message.target)); + meta.appendChild(node('span', '', '演示 · 已回复')); + item.appendChild(meta); + item.appendChild(node('p', '', message.text)); + var reply = node('div', 'message-reply'); + reply.appendChild(node('span', '', '模拟回复')); + reply.appendChild(node('p', '', message.reply)); + item.appendChild(reply); + items.appendChild(item); + }); + document.getElementById('demo-messages').replaceChildren(items); + document.getElementById('reply-count').textContent = messages.length + ' 条'; + var events = document.createDocumentFragment(); + timeline.slice(0, 12).forEach(function (entry) { + var item = node('li'); + item.appendChild(node('time', '', entry.time)); + item.appendChild(node('span', '', entry.text)); + events.appendChild(item); + }); + document.getElementById('demo-timeline').replaceChildren(events); + } + filter.addEventListener('change', renderCards); + document.getElementById('demo-reset').addEventListener('click', function () { + reset(); + status.textContent = '演示已恢复初始状态,可以再次体验。'; + }); + document.getElementById('demo-conflict').addEventListener('click', function () { + var owner = sessions.find(function (s) { return s.status !== 'done'; }); + status.dataset.kind = 'warning'; + if (!owner) { status.textContent = '所有演示会话均已收尾,当前没有文件占用。'; return; } + var text = '登记被阻止:' + owner.file + ' 已由 ' + owner.platform + ' 占用,新会话没有取得该文件。'; + timeline.unshift({time: stamp(), text: text}); + render(); + status.textContent = text + ' 请先协调或等原会话收尾。'; + }); + document.getElementById('demo-directive').addEventListener('submit', function (event) { + event.preventDefault(); + var text = body.value.trim(); + if (!text) { body.setCustomValidity('请输入一条演示指令。'); body.reportValidity(); return; } + var session = sessions.find(function (s) { return s.id === target.value; }); + var recipient = session ? session.platform : '所有会话'; + messages.unshift({target: recipient, text: text, reply: '已收到这条演示指令。真实使用时,agent 在下一次读取 inbox 后处理并回复。'}); + timeline.unshift({time: stamp(), text: '你向 ' + recipient + ' 发送了一条演示指令,模拟回复已生成。'}); + body.value = ''; + render(); + status.dataset.kind = ''; + status.textContent = '已模拟发送给 ' + recipient + ',请查看「指令与回复」。'; + }); + body.addEventListener('input', function () { body.setCustomValidity(''); }); + reset(); +})(); diff --git a/site/assets/js/install-data.js b/site/assets/js/install-data.js new file mode 100644 index 0000000..487cdf6 --- /dev/null +++ b/site/assets/js/install-data.js @@ -0,0 +1,135 @@ +// 由 scripts/sync_install_docs.py 生成,请修改安装器或提示词模板。 +window.MAC_INSTALL = { + "version": "1.5.0", + "platforms": [ + { + "id": "codex", + "label": "Codex", + "native": true, + "files": [] + }, + { + "id": "claude", + "label": "Claude Code", + "native": false, + "files": [ + "CLAUDE.md", + ".claude/commands/git-flow.md" + ] + }, + { + "id": "opencode", + "label": "OpenCode", + "native": true, + "files": [] + }, + { + "id": "pi", + "label": "Pi", + "native": true, + "files": [] + }, + { + "id": "trae", + "label": "Trae", + "native": false, + "files": [ + ".trae/rules/git-flow-agent.md" + ] + }, + { + "id": "workbuddy", + "label": "WorkBuddy", + "native": false, + "files": [ + ".workbuddy-ai/git-flow-agent.md" + ] + }, + { + "id": "cursor", + "label": "Cursor", + "native": false, + "files": [ + ".cursor/rules/multi-agent-collab.mdc" + ] + }, + { + "id": "copilot", + "label": "GitHub Copilot", + "native": false, + "files": [ + ".github/copilot-instructions.md" + ] + }, + { + "id": "windsurf", + "label": "Windsurf", + "native": false, + "files": [ + ".windsurf/rules/multi-agent-collab.md" + ] + }, + { + "id": "gemini", + "label": "Gemini CLI", + "native": false, + "files": [ + "GEMINI.md" + ] + }, + { + "id": "qwen", + "label": "Qwen Code", + "native": false, + "files": [ + ".qwen/QWEN.local.md" + ] + }, + { + "id": "cline", + "label": "Cline", + "native": false, + "files": [ + ".clinerules/multi-agent-collab.md" + ] + }, + { + "id": "roo", + "label": "Roo Code", + "native": false, + "files": [ + ".roo/rules/00-multi-agent-collab.md" + ] + }, + { + "id": "continue", + "label": "Continue", + "native": false, + "files": [ + ".continue/rules/multi-agent-collab.md" + ] + }, + { + "id": "kiro", + "label": "Kiro", + "native": false, + "files": [ + ".kiro/steering/multi-agent-collab.md" + ] + } + ], + "coreFiles": [ + "AGENTS.md", + "tools/agents_status.py", + "tools/agents_session.py", + ".agents/README.md", + ".agents/START_HERE.md", + ".agents/TROUBLESHOOTING.md", + ".agents/sessions/_template.md", + ".agents/inbox/_template.md", + ".agents/worklog/_template.md", + ".agents/install.json" + ], + "promptTemplate": "帮我把 multi-agent-collab 安装到当前项目,并验证所选平台的新对话入口。\n套件仓库:https://github.com/AiFLYF/multi-agent-collab\n\n1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。\n2. {{PLATFORM_SELECTION}}\n3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。\n 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab \"\u003c用户主目录>/multi-agent-collab\"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。\n4. 使用绝对路径运行 python \"\u003c套件目录>/install.py\" install \"\u003cTARGET>\" --platforms \u003c已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。\n5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。\n6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python \"\u003c套件目录>/install.py\" sync \"\u003cTARGET>\" 刷新已选入口。\n7. 执行 python \"\u003c套件目录>/install.py\" doctor \"\u003cTARGET>\",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。\n8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync \"\u003cTARGET>\" --platforms \u003c新增名称>,无需重复 clone;旧对话需要主动重新读取规则。\n\n执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。", + "defaultSelection": "优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。" +}; diff --git a/site/assets/js/install.js b/site/assets/js/install.js new file mode 100644 index 0000000..3b4c41a --- /dev/null +++ b/site/assets/js/install.js @@ -0,0 +1,89 @@ +/* 按安装器提供的平台目录生成选择器;只在浏览器中预览,不执行安装。 */ +(function () { + 'use strict'; + var data = window.MAC_INSTALL; + var picker = document.getElementById('platform-picker'); + if (!data || !picker) return; + var prompt = document.getElementById('prompt-agent'); + var command = document.getElementById('cmd-manual'); + var summary = document.getElementById('install-summary'); + var fileList = document.getElementById('install-file-list'); + var fileCount = document.getElementById('install-file-count'); + var clear = document.getElementById('platform-clear'); + var search = document.getElementById('platform-search'); + var empty = document.getElementById('platform-empty'); + + data.platforms.forEach(function (platform) { + var label = document.createElement('label'); + label.className = 'platform-option'; + label.dataset.search = (platform.label + ' ' + platform.id).toLowerCase(); + var input = document.createElement('input'); + input.type = 'checkbox'; + input.name = 'platform'; + input.value = platform.id; + var text = document.createElement('span'); + var name = document.createElement('strong'); + name.textContent = platform.label; + var detail = document.createElement('small'); + detail.textContent = platform.native ? '共用 AGENTS.md' : platform.files.length + ' 个平台入口'; + text.appendChild(name); + text.appendChild(detail); + label.appendChild(input); + label.appendChild(text); + picker.appendChild(label); + input.addEventListener('change', update); + }); + + function update() { + var selected = Array.prototype.map.call(picker.querySelectorAll('input:checked'), function (el) { return el.value; }); + var platforms = data.platforms.filter(function (p) { return selected.indexOf(p.id) !== -1; }); + var files = data.coreFiles.slice(); + platforms.forEach(function (p) { files = files.concat(p.files); }); + var selection = selected.length + ? '本次启用以下平台:' + selected.join(',') + '。使用 --platforms ' + selected.join(',') + ';已有选择保留,无需再询问平台。' + : data.defaultSelection; + prompt.textContent = data.promptTemplate.replace('{{PLATFORM_SELECTION}}', selection); + var flag = selected.length ? ' --platforms ' + selected.join(',') : ''; + command.textContent = [ + '# 仅第一次下载时运行;已经存在则核对后复用', + 'git clone https://github.com/AiFLYF/multi-agent-collab "$HOME/multi-agent-collab"', + '', + selected.length ? '# 在目标项目目录运行;需要预览可在安装命令末尾加 --dry-run' : '# 在目标项目目录运行,首次会询问平台;脚本执行请明确 --platforms', + 'python "$HOME/multi-agent-collab/install.py" install .' + flag, + 'python "$HOME/multi-agent-collab/install.py" doctor .', + '', + '# 追加平台或刷新入口,已有选择保留', + 'python "$HOME/multi-agent-collab/install.py" sync .' + flag + ].join('\n'); + summary.textContent = selected.length + ? '已选 ' + platforms.map(function (p) { return p.label; }).join('、') + ' · ' + data.coreFiles.length + ' 个通用文件 + ' + (files.length - data.coreFiles.length) + ' 个平台文件' + : '未选平台:提示词会先确认你正在使用的平台。终端安装会询问,不会默认全装。'; + fileCount.textContent = files.length + ' 个'; + var fragment = document.createDocumentFragment(); + files.forEach(function (file) { + var item = document.createElement('li'); + item.textContent = file; + fragment.appendChild(item); + }); + fileList.replaceChildren(fragment); + clear.disabled = selected.length === 0; + } + function filterPlatforms() { + var query = search.value.trim().toLowerCase(); + var count = 0; + Array.prototype.forEach.call(picker.querySelectorAll('.platform-option'), function (label) { + label.hidden = label.dataset.search.indexOf(query) === -1; + if (!label.hidden) count += 1; + }); + empty.hidden = count > 0; + } + search.addEventListener('input', filterPlatforms); + clear.addEventListener('click', function () { + Array.prototype.forEach.call(picker.querySelectorAll('input'), function (el) { el.checked = false; }); + update(); + search.value = ''; + filterPlatforms(); + picker.querySelector('input').focus(); + }); + update(); +})(); diff --git a/site/assets/js/ui.js b/site/assets/js/ui.js index a5f7d20..1c8587b 100644 --- a/site/assets/js/ui.js +++ b/site/assets/js/ui.js @@ -290,27 +290,36 @@ * 改用 .copied 类改写 ::after,同时给读屏报一句。 */ function copyText(text, btn) { + function feedback(message, failed) { + if (!btn) return; + if (!btn.dataset.copyLabel) btn.dataset.copyLabel = btn.textContent; + clearTimeout(btn.copyTimer); + btn.textContent = message; + btn.style.color = failed ? 'var(--am)' : 'var(--cy)'; + btn.copyTimer = setTimeout(function () { btn.textContent = btn.dataset.copyLabel; btn.style.color = ''; }, 1400); + } function ok() { - if (btn) { - var old = btn.textContent; - btn.textContent = '已复制 ✓'; - btn.style.color = 'var(--cy)'; - setTimeout(function () { btn.textContent = old; btn.style.color = ''; }, 1400); - } + feedback('已复制 ✓', false); announce('已复制到剪贴板'); } + function fail() { + feedback('请手动复制', true); + announce('未能写入剪贴板,请手动选择文本复制'); + } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(ok, function () { fallback(); }); } else { fallback(); } function fallback() { + var previousFocus = document.activeElement; var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); - try { document.execCommand('copy'); ok(); } catch (e) { /* 忽略 */ } + try { if (document.execCommand('copy')) ok(); else fail(); } catch (e) { fail(); } document.body.removeChild(ta); + if (previousFocus && previousFocus.focus) previousFocus.focus(); } } $$('.copy[data-copy]').forEach(function (btn) { diff --git a/site/demo.html b/site/demo.html new file mode 100644 index 0000000..62dc17b --- /dev/null +++ b/site/demo.html @@ -0,0 +1,72 @@ + + + + + + + + + 协作看板交互演示 · multi-agent-collab + + + + + + + 跳到演示内容 +
+
+ multi-agent-collab + +
+
+
+

THE SHARED WORKSPACE / INTERACTIVE DEMO

多个 AI,进度一眼可见。

查看谁在做什么,发一条演示指令,再把一个任务标记完成。

+
+
+

示例数据操作仅在本页模拟,不读取你的项目,不向真实 agent 发送指令。刷新页面即可复原。

+ + +
+
当前会话4一个共享工作区
+
活跃3正在推进任务
+
等待确认1需要协作回应
+
已完成0已留下收尾记录
+
+ +
+
+

工作中的会话

demo-shop / .agents/sessions
+
+ +
01 → 02 → 03

先协调,再开工,最后留记录。
所有平台读取同一份总章,每个会话只更新自己的状态。

了解协作约定 ↗
+
+ + +
+ +
+

共享工作记录

.agents/worklog / 演示时间线
+
    +
    +

    试着给一个会话发指令,或者把任务标记完成。

    +
    + +
    + + diff --git a/site/index.html b/site/index.html index a59f9b7..06607b8 100644 --- a/site/index.html +++ b/site/index.html @@ -9,7 +9,7 @@ - + @@ -18,7 +18,7 @@ - + @@ -47,7 +47,7 @@ MULTI-AGENT-COLLAB - V1.4.1 + V1.5.0
    @@ -63,7 +63,7 @@
    - SIX SIGNAL SOURCES · 六个信号源接入中 + SHARED CONTEXT · 示例会话接入中
    @@ -109,7 +109,7 @@
    - V1.4.1 + V1.5.0 --:--:--
    @@ -124,7 +124,7 @@ 03四条交规SOP 04网页看板TOWER 05协作拓扑MAP - 06三十秒上手GO + 06按需安装GO
    GITHUB ↗ @@ -146,9 +146,9 @@
    - + - + @@ -157,14 +157,14 @@

    - CROSS-PLATFORM MULTI-AGENT · 跨平台多 AI 协作 · v1.4.1 · MIT + CROSS-PLATFORM MULTI-AGENT · 跨平台多 AI 协作 · v1.5.0 · MIT

    -

    - 六个平台,一个项目。 +

    + 多个平台,一个项目。 一块黑板,一套规矩。

    - 让 Claude Code、Codex、OpenCode、Pi、Trae、WorkBuddy 在不同窗口、不同工具里 + 让你使用的 Codex、Claude Code、Cursor 等 AI 助手 在不同窗口、不同工具里 对着同一份 AGENTS.md 干活:不互相覆盖、不重复劳动, 谁掉线了一目了然。规则与共享黑板只留本机,看板脚本零依赖,Python 3.8+ 直接跑。

    @@ -173,7 +173,8 @@

    GITHUB 仓库 - 30 秒上手 + 选择平台安装 + 体验 demo ↗ README 文档

    @@ -198,7 +199,7 @@

    - SIX PLATFORMS ▸ ONE BOARD + MULTI-PLATFORM ▸ ONE BOARD 点击画面 = 下发一条人类指令 @@ -232,7 +233,7 @@

    PILLAR 01

    一份规则总章

    -

    AGENTS.md / CLAUDE.md 是每个 AI 开工前的必读:先协调再干活、分支怎么切、测试怎么算过、人类指令优先级最高。六个平台各读各的入口,规则只写一遍。

    +

    AGENTS.md / CLAUDE.md 是每个 AI 开工前的必读:先协调再干活、分支怎么切、测试怎么算过、人类指令优先级最高。各个平台按需安装入口,规则只写一遍。

    PILLAR 02 @@ -241,8 +242,8 @@

    一块共享黑板

    PILLAR 03 -

    一个看板脚本

    -

    tools/agents_status.py 零第三方依赖。AI 用它跑一行摘要做开工协调;你用它开网页指挥台——实时看每个 AI 状态,直接对它下指令。

    +

    一组协作命令

    +

    agents_session.py 登记会话、检查文件占用、更新心跳并收尾;agents_status.py 提供摘要和网页指挥台。两者都只需 Python 标准库。

    @@ -253,7 +254,7 @@

    一个看板脚本

    文件被覆盖 两个 AI 改了同一处,后保存的把先保存的覆盖掉,白干一半 - 各写各的小文件:一个会话一个文件、一天一份日志、一个问题一张留言条,天然不撞车 + 先登记任务和文件范围;同工作区或重叠范围会被拦截,独立 worktree 可处理不同文件
    重复劳动 @@ -320,7 +321,7 @@

    0+

    个 AI 协作会话的事故复盘

    0

    条 TROUBLESHOOTING 排障手册

    0

    项看板 selfcheck 内置断言

    -
    0

    项自测用例(含 19 项安装器 E2E)

    +
    0

    项回归用例(含并发登记与平台适配)

    0×0

    操作系统 × Python 版本的 CI 矩阵

    0

    第三方依赖 · Python 3.8+ 标准库

    @@ -332,7 +333,7 @@

    两个 AI 改了同一个文件,后保存的把先保存的覆盖掉,白干一半 → - 各写各的小文件:一个会话一个文件、一天一份日志、一个问题一张留言条;万一同写,合并取并集,只多不少 + 先声明文件范围:会话工具串行检查并登记占用;按会话保存状态,日志只追加,冲突先协调
    两个 AI 各自去修同一个 bug,改动最后冲突 @@ -347,7 +348,7 @@

    每个平台的规则文件各写各的,换个 AI 工具就等于没有规则 → - 一份 AGENTS.md:面向 coding agent 的开放格式,六平台各有入口,内容同一份 + 一份 AGENTS.md:面向 coding agent 的开放格式,各平台按需使用入口,内容同一份
    想中途喊一句「先别碰那个文件」,没有任何途径告诉它们 @@ -375,13 +376,13 @@

    RULE 01 · 文件纪律 -

    各写各的小文件,不共用大文件

    -

    一个会话一个 session 文件、一天一份 worklog、一个问题一张 inbox 留言条,天然不撞车;万一两个会话同时写了同一个文件,合并时取并集——只多不少。

    +

    先声明范围,再修改文件

    +

    开始任务用 agents_session.py start 登记 --files。文件与目录重叠时先协调,扩大范围先检查;状态按会话保存,worklog 只追加。

    RULE 02 · 判活方式 -

    用心跳判断死活,不用锁

    -

    锁在 AI 崩溃时会永远卡死。心跳超过 15 分钟算失联;别人想接手,先在 inbox 发接管声明、等 5 分钟无人反对,并保留前任的全部提交。

    +

    心跳判活,系统锁保护登记

    +

    系统锁仅串行保护状态更新,进程退出会自动释放。心跳超过 15 分钟算失活,文件占用仍保留;接管需先声明、等 5 分钟无反对,并保留前任提交。

    RULE 03 · 交叉验证 @@ -405,7 +406,7 @@

    协作文件只留本机,不进 git

    心跳长这样:绿色脉冲 = 会话还活着;灰点静止 = 已失联,别人可以在 inbox 声明接管。 - 跑长任务的 AI 会在 session 文件里写明「预计 HH:MM 回来」,超时才判失活。 + 长任务前写明预计返回时间,并在阶段间隙更新心跳;失活提醒不会自动让出文件。

    @@ -434,20 +435,20 @@

    multi-agent-collab 网页看板:上方的指令下发区与统计卡、左侧的四个 AI 会话卡片、右侧的指令与回复往来(演示数据) 点击放大 ⤢ -
    演示数据 · demo-shop 项目里四个平台同时在干活:会话卡片、指令下发、指令与回复都在这一屏
    +
    演示数据 · demo-shop 项目里四个平台协作。打开交互演示 →
    terminal · powershell / bash
    -
    # 你和 AI 都能用看板;--web 开图形化指挥台
    -$ python tools/agents_status.py --web
    +
    # 图形化指挥台
    +python tools/agents_status.py --web
     
    -# 不开服务,只把当前状态存成单文件快照(零外链,可发给别人)
    -$ python tools/agents_status.py --html
    +# 单文件快照(不需要服务)
    +python tools/agents_status.py --html
     
    -# AI 开工协调用的一行摘要(输出短、省 token)
    -$ python tools/agents_status.py --brief
    -OK 看板无告警:活跃3,无失活/未答/worktree 异常,可直接开工
    +# AI 开工协调用的一行摘要 +python tools/agents_status.py --brief +# 示例输出:OK 看板无告警,可直接开工
    @@ -457,19 +458,19 @@

    05MAP协作拓扑

    - 六种平台, + 不同平台, 同一块黑板。

    - Claude Code、Codex、OpenCode、Pi 零配置自动读根目录 AGENTS.md; - Trae 与 WorkBuddy 由安装器生成入口镜像。同一个项目可以混用多个平台—— - 黑板是同一块,规矩是同一套。拖拽下面这张图转一转。 + 按需安装的平台入口,最终指向同一份 AGENTS.md 和启动清单。 + 原生平台直接读取,Claude 通过导入,其它平台使用项目规则或镜像。 + 图中以六个会话为例:共享记录,协同工作。下方可搜索并选择受支持的平台。

    - +
    @@ -484,18 +485,39 @@

    -
    06GO三十秒上手
    +
    06GO按需安装

    - 三十秒, - 立好交规。 + 下载一次, + 按需接入。

    - 前提只有两个:电脑装了 Python 3.8+ 和 git,项目是 git 仓库。 - 安装器幂等,你定制过的内容(测试命令、构建命令)升级时自动保留。 + 需要 Python 3.8+、Git 和一个已有的 Git 项目。 + 套件放在用户主目录 ~/multi-agent-collab,多个项目共用同一个安装器。 + 选择当前项目使用的平台,查看文件,再复制对应提示词。

    +
    +
    +

    YOUR TOOLS / YOUR PROJECT

    这个项目,你用哪些 AI?

    + +
    + + + +
    + 选择当前项目使用的平台,可多选 +
    + +

    未选平台时,提示词会让 AI 先确认你正在使用的平台。

    +
    + 查看将安装的协作文件 +
      +

      另维护 Git 本地忽略。后续新增平台会保留已经安装的入口与协作记录。

      +
      +
      +
      @@ -504,47 +526,55 @@

      -
      -
      发给项目里任意一个 AI
      -
      帮我把 multi-agent-collab 套件装到当前项目。
      -套件仓库地址:https://github.com/AiFLYF/multi-agent-collab
      -1. 先确认 python 和 git 都能运行;不能运行就停下告诉我,不要自行安装任何软件。
      -2. 电脑里还没有这个仓库就先 git clone 上面的地址;已经有了就跳过。
      -3. 确认当前目录是我的项目根目录,执行 python <套件目录>/install.py install .;
      -   成功后再执行 python <套件目录>/install.py doctor .,把体检结果原样发给我。
      -除安装器生成的文件外不要动项目里的其他文件,任何报错先停下来问我。
      +
      +
      发给目标项目里的 AI · 跟随上方选择
      +
      帮我把 multi-agent-collab 安装到当前项目,并验证所选平台的新对话入口。
      +套件仓库:https://github.com/AiFLYF/multi-agent-collab
      +
      +1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。
      +2. 优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。
      +3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。
      +   目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab "<用户主目录>/multi-agent-collab"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。
      +4. 使用绝对路径运行 python "<套件目录>/install.py" install "<TARGET>" --platforms <已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。
      +5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。
      +6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python "<套件目录>/install.py" sync "<TARGET>" 刷新已选入口。
      +7. 执行 python "<套件目录>/install.py" doctor "<TARGET>",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。
      +8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync "<TARGET>" --platforms <新增名称>,无需重复 clone;旧对话需要主动重新读取规则。
      +
      +执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。
        -
      • AI 会自己 clone、安装、跑体检,把结果发给你——不想敲命令的人用这种
      • -
      • 用 fork 的话,把提示词第二行的地址换成你的 fork
      • +
      • AI 会核对主目录中的套件副本、按选择安装并体检,已有副本直接复用
      • +
      • 当前平台明确时直接使用;不明确时先询问。使用 fork 可替换仓库地址
      terminal
      -
      # 装进任意 git 项目(幂等;先看会改哪些文件不落盘,把 install 换成 install --dry-run)
      -$ python install.py install /path/to/your-project
      +
      # 仅第一次下载时运行;已经存在则核对后复用
      +git clone https://github.com/AiFLYF/multi-agent-collab "$HOME/multi-agent-collab"
       
      -# 体检:文件、忽略规则、版本新旧、看板冒烟
      -$ python install.py doctor /path/to/your-project
      +# 在目标项目目录运行,首次会询问平台
      +python "$HOME/multi-agent-collab/install.py" install .
      +python "$HOME/multi-agent-collab/install.py" doctor .
       
      -# 改完 AGENTS.md 总章后,刷新 WorkBuddy 入口镜像
      -$ python install.py mirror /path/to/your-project
      +# 刷新已选平台入口 +python "$HOME/multi-agent-collab/install.py" sync .
        -
      • Windows PowerShell 写法一样;卸载只移除忽略托管块,文件全部保留
      • -
      • 不想跑脚本想手工拷贝:README 里有等价的 cp 命令清单
      • +
      • 命令兼容 Bash 与 PowerShell;请在目标项目目录执行。clone 只在首次下载时运行
      • +
      • 后续新增平台用 sync --platforms 名称;升级核心规则用 install,都会沿用已保存的选择
      -

      装完之后(3 分钟)

      +

      安装后,确认真实生效

      1. ① 打开项目里的 AGENTS.md 第 6 节,把示例换成你项目真实的测试 / 构建命令(定制区,升级自动保留)
      2. -
      3. ② 在项目目录跑一次 python tools/agents_status.py --web,确认看板正常
      4. -
      5. ③ 新开一个 AI 对话,问它「你开工前要先做什么」——应答出:先看板、查 inbox、再干活,说明规则已生效
      6. +
      7. ② 执行安装器 doctor,再运行 python tools/agents_status.py --brief,检查入口与忽略规则
      8. +
      9. ③ 新开一个 AI 对话,确认它实际读取 .agents/START_HERE.md,并报告分支、看板和 inbox 的当前结果
      @@ -596,7 +626,7 @@

      ? 想清楚再用

      - © 2026 AiFLYF — MIT LICENSE · V1.4.1 + © 2026 AiFLYF — MIT LICENSE · V1.5.0 规则沉淀自 ego-link-esp32 · 15+ AI 协作会话复盘
      @@ -620,7 +650,9 @@

      ? 想清楚再用

      + + diff --git a/templates/install-prompt.md b/templates/install-prompt.md new file mode 100644 index 0000000..07528ef --- /dev/null +++ b/templates/install-prompt.md @@ -0,0 +1,14 @@ +帮我把 multi-agent-collab 安装到当前项目,并验证所选平台的新对话入口。 +套件仓库:https://github.com/AiFLYF/multi-agent-collab + +1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。 +2. {{PLATFORM_SELECTION}} +3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。 + 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab "<用户主目录>/multi-agent-collab"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。 +4. 使用绝对路径运行 python "<套件目录>/install.py" install "" --platforms <已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 +5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。 +6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python "<套件目录>/install.py" sync "" 刷新已选入口。 +7. 执行 python "<套件目录>/install.py" doctor "",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。 +8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync "" --platforms <新增名称>,无需重复 clone;旧对话需要主动重新读取规则。 + +执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。 diff --git a/templates/session.md b/templates/session.md index 8b70585..e519dba 100644 --- a/templates/session.md +++ b/templates/session.md @@ -3,6 +3,7 @@ - agent: - branch: feature/<任务名> - task: <一句话任务> +- files: [] - status: active - started: YYYY-MM-DD HH:MM - heartbeat: YYYY-MM-DD HH:MM @@ -10,6 +11,9 @@ ## 当前进度 +文件范围建议用 `agents_session.py start --files ...` 登记;手填时 files 使用 JSON 数组, +例如 `["src/login.py", "docs/"]`。目录以 `/` 结尾,失活不会自动释放占用。 + - <正在做什么,最近一次更新写在最上面> ## 下一步 diff --git a/tests/selftest.py b/tests/selftest.py index 07aada5..95d9f04 100644 --- a/tests/selftest.py +++ b/tests/selftest.py @@ -108,7 +108,7 @@ def _rmtree(): PY = sys.executable CORE_TARGETS = [dst for _src, dst, _zone in inst.CORE_FILES] -PLATFORM_TARGETS = [dst for _src, dst in inst.PLATFORM_FILES.values()] + [inst.WORKBUDDY_TARGET] +PLATFORM_TARGETS = [dst for _src, dst, _zone in inst.platform_files(inst.ALL_PLATFORMS)] def run(*args: str, cwd: Path, timeout: int = 90, @@ -508,9 +508,9 @@ def setUpClass(cls) -> None: run("git", "branch", "develop", cwd=cls.proj) # 基础安装放在 setUpClass:test_02..test_19 都依赖主工作区已装好(含 worktree # 场景要从主工作区继承项目名)。放在 test_01 里会造成"单跑后续用例必挂"的顺序耦合。 - r = install(cls.proj, "--project-name", "Demo 项目") + r = install(cls.proj, "--project-name", "Demo 项目", "--platforms", "all") if r.returncode != 0: - raise unittest.SkipTest(f"基础安装失败:{r.stdout + r.stderr}") + raise RuntimeError(f"基础安装失败:{r.stdout + r.stderr}") @classmethod def tearDownClass(cls) -> None: @@ -885,17 +885,168 @@ def test_18_done_session_does_not_suppress_worktree(self) -> None: self.assertTrue(any("feature/zombie" in w for w in warnings), f"done 会话不该抑制 worktree 告警:{warnings}") - def test_19_non_utf8_exclude_warns_and_untouched(self) -> None: - """已有的非 UTF-8 exclude:给出警告且保留原字节,绝不静默写成'已最新'。""" + def test_19_non_utf8_exclude_fails_before_install(self) -> None: + """忽略无法写入时必须拒绝安装;不能生成一批未被忽略的私有文件。""" t = self.root / "gbk-exclude" t.mkdir(exist_ok=True) self.assertEqual(run("git", "init", "-q", cwd=t).returncode, 0) exclude = t / ".git" / "info" / "exclude" exclude.write_bytes(b"# \xff\xfe garbage\r\n/somedir/\n") r = install(t, "--platforms", "none") - self.assertEqual(r.returncode, 0, r.stdout + r.stderr) - self.assertIn("非 UTF-8", r.stdout) + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("非 UTF-8", r.stdout + r.stderr) self.assertIn(b"\xff\xfe", exclude.read_bytes(), "原 exclude 字节不应被改写") + self.assertFalse((t / "AGENTS.md").exists()) + self.assertFalse((t / ".agents").exists()) + + def fresh_project(self, name: str) -> Path: + target = self.root / name + target.mkdir() + r = run("git", "init", "-q", cwd=target) + self.assertEqual(r.returncode, 0, r.stderr) + return target + + def test_20_native_selection_is_minimal_and_remembered(self) -> None: + target = self.fresh_project("native-only") + r = install(target, "--platforms", "codex,pi,codex,opencode") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + for rel in CORE_TARGETS: + self.assertTrue((target / rel).is_file(), rel) + for rel in PLATFORM_TARGETS + [".codex", ".opencode", ".pi"]: + self.assertFalse((target / rel).exists(), f"原生平台不应额外生成 {rel}") + meta_path = target / inst.INSTALL_META + before = meta_path.read_bytes() + self.assertEqual(json.loads(before)["platforms"], ["codex", "opencode", "pi"]) + again = install(target) + self.assertEqual(again.returncode, 0, again.stdout + again.stderr) + self.assertIn("新建/覆盖 0 个", again.stdout) + self.assertEqual(meta_path.read_bytes(), before, "幂等安装不重写安装时间") + self.assertEqual(run("git", "status", "--porcelain", cwd=target).stdout, "") + + def test_21_first_noninteractive_install_requires_choice(self) -> None: + target = self.fresh_project("needs-choice") + exclude = target / ".git/info/exclude" + before = exclude.read_bytes() + r = install(target) + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("--platforms", r.stderr) + self.assertFalse((target / "AGENTS.md").exists()) + self.assertEqual(exclude.read_bytes(), before) + for choice in ("", "all,codex", "all,unknown", "codxe"): + r = install(target, "--platforms", choice) + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertNotIn("Traceback", r.stderr) + + def test_22_sync_adds_platforms_and_preserves_shared_content(self) -> None: + target = self.fresh_project("sync-add") + r = install(target, "--platforms", "codex") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + agents = target / "AGENTS.md" + agents.write_text(agents.read_text(encoding="utf-8") + "\n项目额外约定:保留整段。\n", encoding="utf-8") + startup = target / ".agents/START_HERE.md" + startup.write_text(startup.read_text(encoding="utf-8") + "\n启动补充:检查本地工单。\n", encoding="utf-8") + originals = {p: p.read_bytes() for p in (agents, startup)} + copilot = target / ".github/copilot-instructions.md" + copilot.parent.mkdir() + copilot.write_text("# 我的说明\n请使用项目术语。\n", encoding="utf-8") + r = run(PY, str(KIT / "install.py"), "sync", str(target), + "--platforms", "cursor,copilot,workbuddy", cwd=KIT) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + for p, original in originals.items(): + self.assertEqual(p.read_bytes(), original, f"sync 不得覆盖 {p.name}") + self.assertIn("我的说明", copilot.read_text(encoding="utf-8")) + self.assertIn(".agents/START_HERE.md", copilot.read_text(encoding="utf-8")) + buddy = (target / inst.WORKBUDDY_TARGET).read_text(encoding="utf-8") + self.assertIn("项目额外约定:保留整段", buddy) + self.assertFalse((target / "CLAUDE.md").exists()) + meta = inst.read_meta(target) + self.assertEqual(set(meta["platforms"]), {"codex", "cursor", "copilot", "workbuddy"}) + r2 = run(PY, str(KIT / "install.py"), "sync", str(target), cwd=KIT) + self.assertEqual(r2.returncode, 0, r2.stdout + r2.stderr) + self.assertIn("新建/覆盖 0 个", r2.stdout) + self.assertEqual(copilot.read_text(encoding="utf-8").count(inst.ENTRY_BEGIN), 1) + for unrelated in (".github/workflows/build.yml", ".cursor/rules/team.mdc", ".windsurf/rules/team.md"): + chk = run("git", "check-ignore", "-q", unrelated, cwd=target) + self.assertEqual(chk.returncode, 1, f"不应隐藏其它配置:{unrelated}") + + def test_23_legacy_platform_selection_migrates_without_expanding(self) -> None: + target = self.fresh_project("legacy-platforms") + r = install(target, "--platforms", "trae,claude") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + meta = inst.read_meta(target) + del meta["platforms"] + meta["kit_version"] = "1.4.1" + inst.write_lf(target / inst.INSTALL_META, json.dumps(meta)) + r = install(target) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertEqual(set(inst.read_meta(target)["platforms"]), {"claude", "trae"}) + for name in ("cursor", "copilot", "windsurf", "workbuddy"): + for _src, dst, _zone in inst.platform_files([name]): + self.assertFalse((target / dst).exists(), f"升级不能自动铺入 {dst}") + + def test_24_doctor_fails_for_a_selected_missing_entry(self) -> None: + target = self.fresh_project("missing-entry") + r = install(target, "--platforms", "cursor") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + entry = target / ".cursor/rules/multi-agent-collab.mdc" + entry.unlink() + doctor = run(PY, str(KIT / "install.py"), "doctor", str(target), cwd=KIT, timeout=180) + self.assertEqual(doctor.returncode, 1, doctor.stdout + doctor.stderr) + self.assertIn("[FAIL] cursor: 已选平台入口缺失", doctor.stdout) + self.assertNotIn("[FAIL] claude", doctor.stdout) + r = run(PY, str(KIT / "install.py"), "sync", str(target), cwd=KIT) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertTrue(entry.is_file()) + + def test_25_tracked_rules_are_not_overwritten(self) -> None: + target = self.fresh_project("tracked-rules") + agents = target / "AGENTS.md" + agents.write_text("# 团队已跟踪规则\n不可覆盖\n", encoding="utf-8") + self.assertEqual(run("git", "add", "AGENTS.md", cwd=target).returncode, 0) + original = agents.read_bytes() + index = run("git", "ls-files", "--stage", cwd=target).stdout + exclude = (target / ".git/info/exclude").read_bytes() + r = install(target, "--platforms", "codex") + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("已被 Git 跟踪", r.stderr) + self.assertEqual(agents.read_bytes(), original) + self.assertEqual(run("git", "ls-files", "--stage", cwd=target).stdout, index) + self.assertEqual((target / ".git/info/exclude").read_bytes(), exclude) + self.assertFalse((target / ".agents").exists()) + + def test_26_ignore_override_prevents_false_success(self) -> None: + target = self.fresh_project("ignore-override") + (target / ".gitignore").write_text("!AGENTS.md\n", encoding="utf-8") + r = install(target, "--platforms", "codex") + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn("未被忽略", r.stderr) + self.assertFalse((target / "AGENTS.md").exists()) + self.assertFalse((target / ".agents").exists()) + self.assertEqual((target / ".gitignore").read_text(encoding="utf-8"), "!AGENTS.md\n") + + def test_27_sync_dry_run_is_read_only(self) -> None: + target = self.fresh_project("sync-preview") + r = install(target, "--platforms", "none") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + before = {p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()} + r = run(PY, str(KIT / "install.py"), "sync", str(target), + "--platforms", "claude,windsurf", "--dry-run", cwd=KIT) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + after = {p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()} + self.assertEqual(before, after, "sync --dry-run 不应改动任何文件或平台记录") + + def test_28_claude_imports_startup_and_retains_existing_notes(self) -> None: + target = self.fresh_project("claude-notes") + (target / "CLAUDE.md").write_text("# 本机说明\n先运行本项目 smoke。\n", encoding="utf-8") + r = install(target, "--platforms", "claude") + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + text = (target / "CLAUDE.md").read_text(encoding="utf-8") + self.assertIn("先运行本项目 smoke", text) + self.assertIn("@AGENTS.md", text) + self.assertIn("@.agents/START_HERE.md", text) + r = install(target) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertEqual((target / "CLAUDE.md").read_text(encoding="utf-8"), text) if __name__ == "__main__": diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 0000000..7961904 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,226 @@ +"""会话工具的真实 Git / worktree / 并发回归,以及新增平台的入口兼容。""" + +from __future__ import annotations + +import concurrent.futures +import json +import tempfile +import unittest +from pathlib import Path + +from selftest import KIT, PY, inst, rmtree_readonly, run + + +class TestSessionCoordination(unittest.TestCase): + def setUp(self) -> None: + self.base = Path(tempfile.mkdtemp(prefix="mac-session-tests-")).resolve() + self.root = self.base / "project" + self.root.mkdir() + for command in (("init", "-q"), ("config", "user.name", "Session Test"), + ("config", "user.email", "session@example.invalid")): + self.assertEqual(run("git", *command, cwd=self.root).returncode, 0) + (self.root / "README.md").write_text("# fixture\n", encoding="utf-8") + self.assertEqual(run("git", "add", "README.md", cwd=self.root).returncode, 0) + self.assertEqual(run("git", "commit", "-q", "-m", "test: fixture", cwd=self.root).returncode, 0) + self.assertEqual(run("git", "switch", "-c", "feature/one", cwd=self.root).returncode, 0) + self.install(self.root) + + def tearDown(self) -> None: + expected_parent = Path(tempfile.gettempdir()).resolve() + if self.base.parent != expected_parent or not self.base.name.startswith("mac-session-tests-"): + raise RuntimeError("拒绝清理测试目录之外的路径") + rmtree_readonly(self.base) + + def install(self, root: Path, platforms: str = "codex"): + result = run(PY, str(KIT / "install.py"), "install", str(root), + "--platforms", platforms, cwd=KIT) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def command(self, root: Path, *args: str): + result = run(PY, str(root / "tools/agents_session.py"), "--root", str(root), *args, + "--json", cwd=root) + return result, json.loads(result.stdout) + + def start(self, root: Path, agent: str = "codex", files: str = "src/app.py"): + return self.command(root, "start", "--agent", agent, "--task", "回归验证", "--files", files) + + def worktree(self) -> Path: + target = self.base / "linked" + self.assertEqual(run("git", "branch", "feature/two", cwd=self.root).returncode, 0) + r = run("git", "worktree", "add", str(target), "feature/two", cwd=self.root) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.install(target) + return target + + def test_lifecycle_preserves_notes_and_appends_log(self) -> None: + result, record = self.start(self.root) + self.assertEqual(result.returncode, 0, record) + path = Path(record["file"]) + with path.open("a", encoding="utf-8") as stream: + stream.write("\n## 我的补充\n必须保留这一段。\n") + heartbeat, data = self.command(self.root, "heartbeat", record["id"], "--agent", "codex", "--note", "完成用例") + self.assertEqual(heartbeat.returncode, 0, data) + self.assertIn("必须保留这一段", path.read_text(encoding="utf-8")) + log = self.root / ".agents/worklog" / (__import__("datetime").datetime.now().strftime("%Y-%m-%d") + ".md") + log.write_text("# 原有记录\n不能重写。\n", encoding="utf-8") + result, data = self.command(self.root, "finish", record["id"], "--agent", "codex", + "--summary", "完成并验证", "--tests", "回归通过") + self.assertEqual(result.returncode, 0, data) + self.assertEqual(data["status"], "done") + self.assertTrue(log.read_text(encoding="utf-8").startswith("# 原有记录\n不能重写。\n")) + self.assertIn("回归通过", log.read_text(encoding="utf-8")) + second, data = self.start(self.root) + self.assertEqual(second.returncode, 0, data) + self.assertEqual(run("git", "status", "--porcelain", cwd=self.root).stdout, "", + "使用协作命令后不应新增 Python 缓存等未忽略文件") + + def test_same_workspace_is_not_claimed_twice(self) -> None: + self.assertEqual(self.start(self.root)[0].returncode, 0) + result, data = self.start(self.root, "claude", "docs/guide.md") + self.assertEqual(result.returncode, 1, data) + self.assertIn("同一工作区", data["error"]) + + def test_directory_claim_blocks_nested_file_in_another_worktree(self) -> None: + linked = self.worktree() + self.assertEqual(self.start(self.root, files="src/")[0].returncode, 0) + result, data = self.start(linked, "claude", "src/app.py") + self.assertEqual(result.returncode, 1, data) + self.assertIn("文件范围重叠", data["error"]) + + def test_distinct_worktrees_and_files_can_work_together(self) -> None: + linked = self.worktree() + self.assertEqual(self.start(self.root, files="src/")[0].returncode, 0) + result, data = self.start(linked, "claude", "docs/") + self.assertEqual(result.returncode, 0, data) + + def test_stale_owner_does_not_lose_claim(self) -> None: + linked = self.worktree() + _, record = self.start(self.root) + path = Path(record["file"]) + text = path.read_text(encoding="utf-8") + import re + text = re.sub(r"(?m)^- heartbeat:.*$", "- heartbeat: 2000-01-01 00:00", text) + path.write_text(text, encoding="utf-8") + result, data = self.start(linked, "claude") + self.assertEqual(result.returncode, 1, data) + self.assertIn("失活仍保留占用", data["error"]) + + def test_owner_and_branch_are_checked_before_update(self) -> None: + _, record = self.start(self.root) + path = Path(record["file"]) + before = path.read_bytes() + result, data = self.command(self.root, "heartbeat", record["id"], "--agent", "claude") + self.assertEqual(result.returncode, 2, data) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(run("git", "switch", "-c", "feature/changed", cwd=self.root).returncode, 0) + result, data = self.command(self.root, "heartbeat", record["id"], "--agent", "codex") + self.assertEqual(result.returncode, 1, data) + self.assertEqual(path.read_bytes(), before) + + def test_handoff_is_agent_message_and_keeps_ownership(self) -> None: + _, record = self.start(self.root) + result, data = self.command(self.root, "handoff", record["id"], "--agent", "codex", "--summary", "需要确认接口约定") + self.assertEqual(result.returncode, 0, data) + self.assertEqual(data["status"], "blocked") + content = (self.root / data["inbox"]).read_text(encoding="utf-8") + self.assertIn("- from: " + record["id"], content) + self.assertNotIn("from: human", content) + self.assertEqual(self.start(self.root, "claude")[0].returncode, 1) + + def test_concurrent_conflicting_registration_has_one_winner(self) -> None: + linked = self.worktree() + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(self.start, self.root, "codex"), pool.submit(self.start, linked, "claude")] + results = [task.result() for task in tasks] + self.assertEqual(sorted(r.returncode for r, _data in results), [0, 1], [d for _r, d in results]) + count = sum(len([p for p in (root / ".agents/sessions").glob("*.md") if not p.name.startswith("_")]) + for root in (self.root, linked)) + self.assertEqual(count, 1) + + def test_human_inbox_must_be_handled_before_start(self) -> None: + question = self.root / ".agents/inbox/Q-2026-10-14-01.md" + question.write_text("# 人类指令\n- from: human\n- to: any\n- status: open\n\n暂停写操作。\n", encoding="utf-8") + result, data = self.start(self.root) + self.assertEqual(result.returncode, 1, data) + self.assertIn("人类指令", data["error"]) + question.write_text(question.read_text(encoding="utf-8").replace("status: open", "status: answered"), encoding="utf-8") + self.assertEqual(self.start(self.root)[0].returncode, 0) + + def test_unsafe_paths_and_shared_branch_are_rejected(self) -> None: + for path in ("../outside", ".git/objects", "C:/outside", "src/*.py"): + result, data = self.start(self.root, files=path) + self.assertEqual(result.returncode, 2, data) + self.assertEqual(run("git", "switch", "-c", "develop", cwd=self.root).returncode, 0) + result, data = self.start(self.root) + self.assertEqual(result.returncode, 2, data) + self.assertIn("任务分支", data["error"]) + + def test_board_reports_manual_claim_conflicts(self) -> None: + _, record = self.start(self.root) + path = Path(record["file"]) + second = path.with_name("claude-261014-1000-aabb.md") + second.write_text(path.read_text(encoding="utf-8").replace(record["id"], second.stem).replace("agent: codex", "agent: claude"), encoding="utf-8") + r = run(PY, str(self.root / "tools/agents_status.py"), "--json", cwd=self.root) + data = json.loads(r.stdout) + self.assertEqual(r.returncode, 1) + self.assertTrue(any("文件占用冲突" in warning for warning in data["warnings"])) + self.assertEqual(data["sessions"][0]["files"], ["src/app.py"]) + + def test_additional_platforms_and_legacy_directory_conflict(self) -> None: + self.assertEqual(inst.parse_platforms("Gemini CLI,qwen-code,roo-code,GitHub Copilot"), + ["copilot", "gemini", "qwen", "roo"]) + (self.root / "GEMINI.md").write_text("# 我的 Gemini 说明\n", encoding="utf-8") + (self.root / "QWEN.md").write_text("# 我的 Qwen 说明\n", encoding="utf-8") + self.install(self.root, "gemini,qwen,cline,roo,continue,kiro") + self.assertIn("我的 Gemini 说明", (self.root / "GEMINI.md").read_text(encoding="utf-8")) + self.assertEqual((self.root / "QWEN.md").read_text(encoding="utf-8"), "# 我的 Qwen 说明\n") + self.assertIn("@../AGENTS.md", (self.root / ".qwen/QWEN.local.md").read_text(encoding="utf-8")) + self.assertIn("@./AGENTS.md", (self.root / "GEMINI.md").read_text(encoding="utf-8")) + self.assertIn("alwaysApply: true", (self.root / ".continue/rules/multi-agent-collab.md").read_text(encoding="utf-8")) + self.assertIn("inclusion: always", (self.root / ".kiro/steering/multi-agent-collab.md").read_text(encoding="utf-8")) + target = self.base / "old-cline" + target.mkdir() + self.assertEqual(run("git", "init", "-q", cwd=target).returncode, 0) + (target / ".clinerules").write_text("旧版单文件规则", encoding="utf-8") + r = run(PY, str(KIT / "install.py"), "install", str(target), "--platforms", "cline", cwd=KIT) + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertFalse((target / "AGENTS.md").exists(), "父路径冲突必须在安装前发现") + + def test_expanding_claims_checks_other_owner_before_writing(self) -> None: + linked = self.worktree() + _, first = self.start(self.root, files="src/") + _, second = self.start(linked, "claude", "docs/") + path = Path(second["file"]) + before = path.read_bytes() + result, data = self.command(linked, "heartbeat", second["id"], "--agent", "claude", + "--files", "docs/", "src/new.py") + self.assertEqual(result.returncode, 1, data) + self.assertEqual(path.read_bytes(), before) + + def test_session_id_collision_is_avoided_across_worktrees(self) -> None: + from argparse import Namespace + from datetime import datetime + from types import SimpleNamespace + from unittest.mock import patch + import agents_session as sessions + linked = self.worktree() + _, first = self.start(self.root) + original = Path(first["file"]).read_bytes() + moment = datetime.strptime(sessions.board.parse_kv(Path(first["file"]))["started"], "%Y-%m-%d %H:%M") + suffix = first["id"].rsplit("-", 1)[-1] + alternate = "ffff" if suffix != "ffff" else "eeee" + class FixedClock: + @staticmethod + def now(): + return moment + with patch.object(sessions, "datetime", FixedClock), patch.object(sessions.uuid, "uuid4", side_effect=[ + SimpleNamespace(hex=suffix), SimpleNamespace(hex=alternate)]): + second = sessions.execute(Namespace(root=str(linked), cmd="start", agent="codex", + task="独立文档任务", files=["docs/"])) + self.assertNotEqual(second["id"], first["id"], "同平台同分钟的会话 ID 必须在全部工作区去重") + self.assertEqual(Path(first["file"]).read_bytes(), original) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/agents_session.py b/tools/agents_session.py new file mode 100644 index 0000000..78e5688 --- /dev/null +++ b/tools/agents_session.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""管理本机会话与文件占用:check / start / heartbeat / finish / handoff。""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import uuid +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path + +# 此脚本会安装进任意技术栈的项目,不能在 tools/ 下留下未被忽略的 Python 缓存。 +sys.dont_write_bytecode = True +import agents_status as board + + +class ConflictError(ValueError): + pass + + +def git(root: Path, *args: str) -> str: + executable = board.resolve_git() + if not executable: + raise ValueError("Git 不可用,无法核对工作区") + result = subprocess.run([executable, *args], cwd=str(root), capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=20) + if result.returncode: + raise ValueError(result.stderr.strip() or "Git 检查失败") + return result.stdout.strip() + + +def safe_path(root: Path, relative: str) -> Path: + path = root / relative + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + raise ValueError(f"路径指向工作区外部:{relative}") from None + return path + + +def one_line(value: str, label: str) -> str: + value = value.strip() + if not value or "\n" in value or "\r" in value or len(value) > 1000: + raise ValueError(f"{label} 必须是 1–1000 字符的单行文本") + return value + + +def normalize_files(root: Path, raw: list[str]) -> list[str]: + files = [] + for value in raw: + value = one_line(value, "文件范围").replace("\\", "/") + if value.startswith("/") or ":" in value or ".." in value.split("/") or any(c in value for c in "*?"): + raise ValueError(f"请使用项目相对文件或目录路径,不使用通配符:{value}") + path = safe_path(root, value) + relative = path.relative_to(root).as_posix().rstrip("/") + if relative in ("", ".") or relative.startswith(".git/") or relative == ".git": + raise ValueError("请声明具体项目文件或目录,不能占用整个工作区或 .git") + if path.is_dir() or value.endswith("/"): + relative += "/" + if relative not in files: + files.append(relative) + return files + + +def worktree_roots(root: Path) -> list[Path]: + raw = git(root, "worktree", "list", "--porcelain") + roots = [Path(line[9:]).resolve() for line in raw.splitlines() if line.startswith("worktree ")] + if not roots: + raise ValueError("无法确定共享仓库的工作区,停止写入") + return roots + + +@contextmanager +def coordination_lock(primary: Path): + """进程退出自动释放系统锁;不删除锁文件,避免替换 inode 形成两把锁。""" + lock_path = safe_path(primary, ".agents/coordination.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as stream: + if stream.seek(0, os.SEEK_END) == 0: + stream.write(b"0") + stream.flush() + deadline = time.monotonic() + 5 + while True: + try: + stream.seek(0) + if os.name == "nt": + import msvcrt + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError: + if time.monotonic() >= deadline: + raise ConflictError("另一个会话正在登记或更新状态,请稍后重试;没有抢占锁") from None + time.sleep(0.05) + try: + yield + finally: + stream.seek(0) + if os.name == "nt": + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +def sessions_in(roots: list[Path]) -> list[dict]: + return board.load_sessions([(root, False) for root in roots], datetime.now(), 15) + + +def pending_human_inbox(roots: list[Path], agent: str | None) -> list[str]: + return [q["file"] for q in board.load_inbox([(root, False) for root in roots]) + if q["open"] and q["from_human"] and q["to"] in ("any", agent)] + + +def check_conflicts(root: Path, branch: str, files: list[str], sessions: list[dict], + own_id: str | None = None) -> list[str]: + conflicts = [] + for session in sessions: + if session["closed"] or session["id"] == own_id: + continue + if session.get("claims_error"): + conflicts.append(f"{session['id']} 的 files 字段无效,先核对占用范围") + continue + same_workspace = Path(session["workspace"]).resolve() == root + overlaps = board.overlapping_files(files, session.get("files", [])) + if same_workspace or session["branch"] == branch or overlaps: + reason = "同一工作区已有未结束会话" if same_workspace else ( + "分支已被占用" if session["branch"] == branch else "文件范围重叠:" + ", ".join(overlaps)) + stale = ";心跳失活仍保留占用,按接管流程处理" if session["stale"] else "" + conflicts.append(f"{session['id']}:{reason}{stale}") + return conflicts + + +def put_fields(text: str, fields: dict[str, str]) -> str: + for key, value in fields.items(): + pattern = rf"(?m)^- {re.escape(key)}:.*$" + replacement = f"- {key}: {value}" + if re.search(pattern, text): + text = re.sub(pattern, lambda _match: replacement, text, count=1) + else: + head, separator, body = text.partition("\n\n") + text = head + "\n" + replacement + separator + body + return text + + +def append_worklog(root: Path, session_id: str, branch: str, summary: str, tests: str = "") -> None: + now = datetime.now() + path = safe_path(root, f".agents/worklog/{now:%Y-%m-%d}.md") + entry = f"\n## {now:%H:%M} · {session_id}\n\n- 分支:{branch}\n- {summary}\n" + if tests: + entry += f"- 测试:{tests}\n" + path.parent.mkdir(parents=True, exist_ok=True) + prefix = "" if path.exists() else f"# {now:%Y-%m-%d}\n" + # 真正只追加;不重写历史,避免覆盖其它仍用手工追加方式的会话。 + with path.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(prefix + entry) + + +def handoff_note(root: Path, session_id: str, summary: str) -> str: + now = datetime.now() + inbox = safe_path(root, ".agents/inbox") + inbox.mkdir(parents=True, exist_ok=True) + # 独立文件名避免与网页指令的递增序号竞争;from 使用 agent,绝不伪装成人类。 + path = inbox / f"Q-{now:%Y-%m-%d}-handoff-{uuid.uuid4().hex[:8]}.md" + content = (f"# 交接:{session_id}\n\n- from: {session_id}\n- to: any\n- status: open\n" + f"- created: {now:%Y-%m-%d %H:%M}\n\n## 问题\n\n{summary}\n\n" + "原会话保持 blocked,文件占用保留;确认交接后再由原会话收尾释放。\n\n## 回复\n") + with path.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(content) + return str(path.relative_to(root)) + + +def execute(args: argparse.Namespace) -> dict: + root = Path(args.root).expanduser().resolve() + root = Path(git(root, "rev-parse", "--show-toplevel")).resolve() + if not safe_path(root, ".agents/sessions").is_dir(): + raise ValueError("项目尚未安装会话目录,请先运行安装器") + roots = worktree_roots(root) + branch = git(root, "branch", "--show-current") + files = normalize_files(root, args.files or []) if hasattr(args, "files") else [] + if args.cmd == "check": + conflicts = check_conflicts(root, branch, files, sessions_in(roots), args.session) + pending = pending_human_inbox(roots, args.agent) + return {"ok": not conflicts and not pending, "branch": branch, "files": files, + "conflicts": conflicts, "human_inbox": pending} + agent = one_line(args.agent, "平台名").lower() + if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", agent): + raise ValueError("平台名使用小写英文、数字或连字符") + if not branch or branch in ("main", "master", "develop"): + raise ValueError("请先切到任务分支;会话工具不会创建分支或在共享主干登记写任务") + with coordination_lock(roots[0]): + # 等待锁期间可能有人切分支,写入前再次核对。 + if git(root, "branch", "--show-current") != branch: + raise ConflictError("等待期间工作区分支发生变化,停止写入") + now = datetime.now().strftime("%Y-%m-%d %H:%M") + if args.cmd == "start": + task = one_line(args.task, "任务") + if not files: + raise ValueError("开始写任务前请用 --files 声明具体文件或目录") + pending = pending_human_inbox(roots, agent) + if pending: + raise ConflictError("先读取并回复发给当前平台或 any 的人类指令:" + ", ".join(pending)) + current_sessions = sessions_in(roots) + conflicts = check_conflicts(root, branch, files, current_sessions) + if conflicts: + raise ConflictError("\n".join(conflicts)) + existing_ids = {session["id"] for session in current_sessions} + while True: + session_id = f"{agent}-{datetime.now():%y%m%d-%H%M}-{uuid.uuid4().hex[:4]}" + path = safe_path(root, f".agents/sessions/{session_id}.md") + if session_id not in existing_ids and not path.exists(): + break + text = (f"# Session: {session_id}\n\n- agent: {agent}\n- branch: {branch}\n- task: {task}\n" + f"- files: {json.dumps(files, ensure_ascii=False)}\n- status: active\n" + f"- started: {now}\n- heartbeat: {now}\n- pr: \n\n" + "## 当前进度\n\n- 已登记任务与文件范围。\n\n## 下一步\n\n- 按项目测试门禁完成任务。\n\n" + "## 阻塞点 / handoff\n\n- 无\n") + board._write_lf_atomic(path, text) + return {"ok": True, "id": session_id, "branch": branch, "files": files, "file": str(path)} + session_id = args.session + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,100}", session_id): + raise ValueError("无效的会话 ID") + path = safe_path(root, f".agents/sessions/{session_id}.md") + text = path.read_text(encoding="utf-8") + record = board.parse_kv(path) + if record.get("agent") != agent: + raise ValueError("平台与会话所有者不一致,不能更新其它 agent 的会话") + if record.get("branch") != branch: + raise ConflictError("当前分支与会话登记分支不一致,先排查工作区变化") + if board.status_word(record.get("status", "")) in board.CLOSED_WORDS: + raise ValueError("会话已经结束,请创建新会话,不要重新激活历史记录") + updates = {"heartbeat": now} + result = {"ok": True, "id": session_id, "branch": branch} + if args.cmd == "heartbeat": + if args.files is not None: + conflicts = check_conflicts(root, branch, files, sessions_in(roots), session_id) + if conflicts: + raise ConflictError("\n".join(conflicts)) + updates["files"] = json.dumps(files, ensure_ascii=False) + if args.note: + updates["progress"] = one_line(args.note, "进度") + else: + summary = one_line(args.summary, "总结") + tests = one_line(args.tests, "测试结果") if args.cmd == "finish" else "" + if args.cmd == "handoff": + result["inbox"] = handoff_note(root, session_id, summary) + updates["status"] = "blocked" + updates["handoff"] = summary + else: + updates["status"] = "done" + append_worklog(root, session_id, branch, summary, tests) + board._write_lf_atomic(path, put_fields(text, updates)) + result["status"] = updates.get("status", record.get("status")) + return result + + +def main() -> int: + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".", help="工作区路径,默认当前目录") + commands = parser.add_subparsers(dest="cmd", required=True) + for name in ("check", "start", "heartbeat", "finish", "handoff"): + sub = commands.add_parser(name) + sub.add_argument("--json", action="store_true", help="输出结构化结果") + if name != "check": + sub.add_argument("--agent", required=True, help="当前平台,必须与会话所有者一致") + if name in ("heartbeat", "finish", "handoff"): + sub.add_argument("session", help="自己的会话 ID") + if name == "check": + sub.add_argument("--session", default=None, help="只读检查时排除自己的会话") + sub.add_argument("--agent", default=None, help="过滤发给当前平台的人类指令") + if name in ("check", "start", "heartbeat"): + sub.add_argument("--files", nargs="+", help="准备修改的相对文件或目录(目录以 / 结尾)") + if name == "start": + sub.add_argument("--task", required=True) + if name == "heartbeat": + sub.add_argument("--note", default=None, help="简短进度,不重写已有正文") + if name in ("finish", "handoff"): + sub.add_argument("--summary", required=True) + if name == "finish": + sub.add_argument("--tests", required=True, help="实际测试结果;未测也要如实说明") + args = parser.parse_args() + rc = 0 + try: + result = execute(args) + if not result["ok"]: + rc = 1 + except (ConflictError, ValueError, OSError, subprocess.TimeoutExpired) as exc: + rc = 1 if isinstance(exc, ConflictError) else 2 + result = {"ok": False, "error": str(exc)} + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(("[OK] " if result["ok"] else "[FAIL] ") + json.dumps(result, ensure_ascii=False)) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/agents_status.py b/tools/agents_status.py index e4035c1..347a9b8 100644 --- a/tools/agents_status.py +++ b/tools/agents_status.py @@ -59,7 +59,7 @@ # 版本必须与 install.py 的 KIT_VERSION 保持一致(有测试锁定,防两边漂移)。 # 看板独立分发到目标项目,不能 import 安装器,所以各存一份常量。 -BOARD_VERSION = "1.4.1" +BOARD_VERSION = "1.5.0" # 人类可视化模式(--web / --html):纯本机,不影响 JSON/终端模式与 agent 用法 WEB_PORT_DEFAULT = 8731 @@ -294,6 +294,34 @@ def agent_roots() -> list[tuple[Path, bool]]: return found +def parse_claimed_files(raw: str) -> tuple[list[str], bool]: + if not raw: + return [], False + try: + value = json.loads(raw) + if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value): + return [], True + return value, False + except (ValueError, TypeError): + return [], True + + +def overlapping_files(left: list[str], right: list[str]) -> list[str]: + def normalize(path: str) -> str: + path = path.replace("\\", "/").rstrip("/") + return path.lower() if os.name == "nt" else path + matches = [] + for a in left: + an = normalize(a) + for b in right: + bn = normalize(b) + if an == bn or (a.endswith(("/", "\\")) and bn.startswith(an + "/")) or ( + b.endswith(("/", "\\")) and an.startswith(bn + "/")): + matches.append(a) + break + return sorted(set(matches)) + + def load_sessions(roots: list[tuple[Path, bool]], now: datetime, stale_min: int) -> list[dict]: out: dict[str, dict] = {} @@ -316,6 +344,7 @@ def load_sessions(roots: list[tuple[Path, bool]], now: datetime, ftime = None st = status_word(kv.get("status", "")) closed = st in CLOSED_WORDS + claimed, claims_error = parse_claimed_files(kv.get("files", "")) item = { "id": f.stem, "agent": kv.get("agent", "?"), @@ -331,6 +360,11 @@ def load_sessions(roots: list[tuple[Path, bool]], now: datetime, "pr": kv.get("pr", ""), "file": f.name, "worktree": tag, + "workspace": str(root.resolve()), + "files": claimed, + "claims_error": claims_error, + "progress": kv.get("progress", ""), + "handoff": kv.get("handoff", ""), } # 同一会话 ID 在多个 worktree 各有一份(拷过 .agents/)→ 留心跳最新的 old = out.get(f.stem) @@ -859,6 +893,14 @@ def collect(now: datetime, with_prs: bool, stale_min: int) -> dict: } warnings: list[str] = [] + open_sessions = [session for session in sessions if not session["closed"]] + for index, session in enumerate(open_sessions): + if session["claims_error"]: + warnings.append(f"文件占用记录无效:{session['id']} 的 files 应为 JSON 字符串数组") + for other in open_sessions[index + 1:]: + overlap = overlapping_files(session["files"], other["files"]) + if overlap: + warnings.append(f"文件占用冲突:{session['id']} 与 {other['id']}:{', '.join(overlap[:5])}") git_ok_flag = resolve_git() is not None # 在 git 仓库里却调不动 git:worktree 聚合/分支信息全部失效,必须明确喊出来, # 不能静默显示「worktree 0 个 / ✅ 无告警」(手册 A3:假绿比报错更危险) @@ -1190,6 +1232,9 @@ def gate_rc(payload: dict) -> int: if (s.worktree) head.appendChild(el("span", "tag wt", "wt: " + s.worktree)); node.appendChild(head); if (s.task) node.appendChild(el("div", "task", s.task)); + if (s.files && s.files.length) node.appendChild(el("div", "meta", "文件范围:" + s.files.join("、"))); + if (s.progress) node.appendChild(el("div", "meta", "进度:" + s.progress)); + if (s.handoff) node.appendChild(el("div", "meta", "交接:" + s.handoff)); const meta = el("div", "meta"); meta.appendChild(el("span", "", "状态 " + s.status + " · 心跳 ")); meta.appendChild(el("span", ageClass(s), ageText(s))); @@ -1992,6 +2037,10 @@ def print_human(p: dict) -> None: print(f" 状态 {s['status']} 心跳 {age}{blocked_tag}") if s["task"]: print(f" 任务 {s['task'][:70]}") + if s.get("files"): + print(f" 文件 {', '.join(s['files'][:8])}") + if s.get("progress"): + print(f" 进度 {s['progress'][:100]}") print(" (● 活跃 ! 失活 ✓ 已结束)") # ---- 未答问题 ---- From c7b026d181c6713bd1e1f20b4659751ee89690b7 Mon Sep 17 00:00:00 2001 From: FLYF <1827658783@qq.com> Date: Wed, 14 Oct 2026 08:26:32 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(workflow):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=BA=A4=E6=8E=A5=E4=B8=8E=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让只读咨询、继续任务和中断恢复遵循一致的规则,并通过真实隔离演练验证工具链。修复实际端口显示和交接状态断点,区分指令收到与完成,避免悬挂记录和过早标记成功。 --- .github/workflows/selftest.yml | 12 ++ AGENTS.md | 30 ++-- CHANGELOG.md | 10 ++ README.md | 23 +++- docs/README.md | 13 +- docs/START_HERE.md | 75 +++++----- docs/VALIDATION.md | 57 ++++++++ docs/WORKFLOW.md | 92 +++++++++++++ docs/examples/inbox-example.md | 89 ++++-------- docs/examples/session-example.md | 103 +++++--------- install.py | 3 +- platforms/claude-command.md | 6 +- platforms/kiro-steering.md | 2 +- platforms/trae-rule.md | 8 +- scripts/rehearse_workflow.py | 229 +++++++++++++++++++++++++++++++ site/assets/js/install-data.js | 3 +- site/index.html | 12 +- tests/test_sessions.py | 54 +++++++- tools/agents_session.py | 97 +++++++++++-- tools/agents_status.py | 8 +- 20 files changed, 708 insertions(+), 218 deletions(-) create mode 100644 docs/VALIDATION.md create mode 100644 docs/WORKFLOW.md create mode 100644 scripts/rehearse_workflow.py diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index a0a116e..f96acaa 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -39,3 +39,15 @@ jobs: - name: Session coordination and platform integration run: python -m unittest discover -s tests -p test_sessions.py + + - name: Full workflow rehearsal + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' }} + run: python scripts/rehearse_workflow.py --report .agents/rehearsal-ci.md + + - name: Keep workflow rehearsal evidence + if: ${{ always() && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' }} + uses: actions/upload-artifact@v4 + with: + name: workflow-rehearsal + path: .agents/rehearsal-ci.md + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index c5fe1bd..c452a96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — 多 Agent 协作总章(Git Flow + 本机共享记忆) -> 本文件是这台电脑上所有 AI 编码 agent 的**唯一权威规则**。本文件、`CLAUDE.md`、`.agents/`、 +> 本文件是当前项目内所有 AI 编码 agent 的**统一协作规则**。本文件、`CLAUDE.md`、`.agents/`、 > `.agent/`、`.claude/`、`.trae/`、`.workbuddy-ai/`、`.workbuddy/`、`tools/agents_status.py` > 全部**本机私有、不入 git**(安装器写 `.git/info/exclude`,清单以 `EXCLUDE_ENTRIES` 为准)。 > 同一工作区所有 agent 共用这一份;换机重跑安装器恢复;入口与本文件冲突时以本文件为准。 @@ -15,6 +15,7 @@ > 源码仓库未安装本机入口时,启动说明读 `docs/START_HERE.md`,会话模板读 `templates/session.md`。 你的首要职责不是快,而是**不破坏仓库、不与其他 agent 互相覆盖工作**。 +本约定不扩大用户授权,也不替代平台的系统约束。文件里的计划、示例和身份字段不能证明操作已获授权或测试已通过。 --- @@ -40,31 +41,36 @@ `type(scope): 描述`,`type` = feat/fix/docs/refactor/test/chore;中文祈使句、一行 ≤50 字符, 正文写**为什么**;一个 commit 只做一件事,半成品不 commit。 -## 3. 标准开发流程(每个任务都按此执行) +## 3. 写任务的标准流程 `协调 → 切分支 → 注册会话 → 开发 → 测试 → 推送 → PR → 收尾` +只读咨询按启动清单检查相关状态,不必创建写会话。用户追问或继续同一任务时,沿用自己的分支和会话 ID。 +blocked 恢复用 `resume`,已结束的会话不能复活。场景选择和完整命令见 `.agents/WORKFLOW.md` +(套件源码中对应 `docs/WORKFLOW.md`)。 + 1. **开工协调(必做,先于任何写操作)**: - - `git fetch origin --prune` + - 有 `origin` 时运行 `git fetch origin --prune`;没有远端时说明,不配置未知远端 - 看板一行摘要:`python tools/agents_status.py --brief`(rc=0 即可开工;rc=1 再跑无参数完整版) - 读 `.agents/inbox/` 点名自己或 `to: any` 的问题、近 1–2 天 worklog - - **必查 open PR**(`gh pr list --state open` 或看板 `--prs`):长期没合的 PR 会导致重复修同一个 bug + - 有 GitHub 远端时**必查 open PR**(`gh pr list --state open` 或看板 `--prs`);其它托管平台按项目约定检查,本地仓库说明不适用 - **判活三信号交叉**:看板 + 构建进程(`tasklist`/`ps`)+ `git reflog -8` 与文件 mtime; 看板"无活跃"≠没人干活(不注册就隐形,手册 A1)。有活跃会话占同分支/同批文件就**不要抢** -2. `git switch develop && git pull --ff-only` +2. 新写任务从已确认的 `develop` 基线开始:`git switch develop` 后 `git pull --ff-only`。 + 继续原任务不要切走。基线不存在时报告分支结构、请用户决定,不擅自建立远端主干;可继续独立的只读检查和本地验证。 3. `git switch -c feature/<任务名>`(小写英文+连字符),建完 `git rev-parse --verify feature/<名>` 复核(沙箱可能"成功"却没建出来,见 B13/B14) 4. **注册会话**(纯本地):优先用 `python tools/agents_session.py start --agent <平台> --task "任务" --files <文件或目录>`。 保存返回的会话 ID;工具检查同工作区、分支和跨 worktree 文件占用,有冲突就停止。 旧版没有该工具时,复制 `.agents/sessions/_template.md` 手动登记并提示升级。 -5. **开发并 commit**:只 add 项目文件;每完成一个阶段更新心跳;每次 commit 后跑 - `git rev-parse --short HEAD` 复核(B1/B2/B8) +5. **开发**:先登记真实文件范围,扩大范围先更新;每完成一个阶段更新心跳。 - **每个阶段间隙、跑长命令前重扫 inbox**:人类指令优先级最高(见第 4 节) 6. **测试门禁(命令见第 6 节)**:严格跑、不相关也保持绿;失败修代码不许改测试迁就; 能编译要真编译、能跑要真跑,静态检查/预览不算验证(A3/A5) -7. 推送 + `gh pr create --base develop`(推送失败按 C1/C5/C8/C9;记忆文件不随 git 走) +7. 测试通过后只 add 本任务项目文件并 commit,运行 `git rev-parse --short HEAD` 复核; + 再推送并 `gh pr create --base develop`(推送失败按 C1/C5/C8/C9;记忆文件不随 git 走) 8. **收尾(本地)**:用 `agents_session.py finish <会话ID> --agent <平台> --summary "总结" --tests "实际测试结果"` - 将自己的 session 置 `done` 并追加 worklog;中途交接用 `handoff`,保留占用直到确认交接 + 将自己的 session 置 `done` 并追加 worklog、回执自己的交接条;中途交接用 `handoff`,保留占用直到确认交接 9. 每步用中文汇报四件套:**当前分支 / commit message / 变更文件 / 测试结果** ### 3.1 工具接口卡 —— 先调命令,不要读实现代码 @@ -82,7 +88,7 @@ | 工具正不正常 / 版本 / 全部参数 | `… --selfcheck` / `… --version` / `… --help` | 先跑这个 | | 安装·体检·镜像·卸载 | `python install.py install\|doctor\|mirror\|uninstall <项目>` | | | 平台列表 / 新增与同步 | `python <套件目录>/install.py platforms` / `… sync <项目> --platforms cursor` | 平台选择保存在 `.agents/install.json`;省略参数沿用 | -| 登记 / 心跳 / 收尾 | `python tools/agents_session.py start\|heartbeat\|finish\|handoff --help` | 只更新自己的会话;支持 `--json` | +| 登记 / 心跳 / 恢复 / 收尾 | `python tools/agents_session.py start\|heartbeat\|resume\|finish\|handoff --help` | 只更新自己的会话;支持 `--json` | | 文件占用检查 | `python tools/agents_session.py check --agent <平台> --files <范围> --json` | 返回 1 时先处理冲突或人类 inbox | | 报错怎么办 | 读 `.agents/TROUBLESHOOTING.md` | 按编号 A/B/C/D 查 | @@ -104,6 +110,8 @@ - 编码会话声明 `files`(JSON 数组,目录以 `/` 结尾);扩大范围前用 heartbeat 的 `--files` 重新检查。 会话工具通过主 worktree `.agents/coordination.lock` 的系统锁串行登记,进程退出自动释放锁。 失活或 blocked 会话的文件占用不会自动释放,不能借超时抢占。手工绕过登记仍可能覆盖文件。 +- 当前占用粒度为文件/目录,不支持“同一文件不同函数”自动并行;先拆分任务或串行交接。 +- `resume` 恢复自己的 blocked 会话并回执其交接条;`finish` 收尾时也回执关联交接,普通问题不能代答。 - 任何环境/工具/git 报错解决后,往 TROUBLESHOOTING.md 追加一条(现象/判断/真因/修复) **人类指令(用户从看板网页下达,优先级最高)**:inbox 里 `from: human*` 的文件,与 agent 留言同协议。 @@ -111,6 +119,8 @@ - 点名本会话或 `to: any` 的 = 用户本人直接要求,高于手头任务:"暂停/取消/别碰某文件"立即执行; 其他先在 `## 回复` 回执"收到,预计何时做"再继续 - 执行完把结果写进同一文件 `## 回复` 并置 `status: answered`(看板实时展示并通知用户) +- 收到与完成要分开:待执行的人类任务先回执并保持 open;start / resume 可用 `--ack-inbox <文件名>` + 声明已阅读并纳入当前任务。这个参数不是授权开关,不能用来绕过暂停、取消或避让要求。 - 长任务中按第 3 节在阶段间隙重扫;空闲回合网页唤不醒 agent,用户粘贴"请读 Q-xxxx 并执行"时照做 **帮用户启动图形化看板(用户提出时主动做,用完帮他停)**: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebe601..0f24e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ `.agents/install.json`,`doctor` 据此提示升级。 版本号遵循语义化版本(major.minor.patch)。 +## 1.5.1 — 2026-10-14 + +- 将启动清单、操作手册和字段说明分开,区分只读咨询、新任务、继续原任务、blocked 恢复和收尾。 + 修正旧示例中 rebase、未恢复测试即推送及同文件并行的矛盾说明。 +- 新增 resume,恢复前重新检查分支、占用和点名会话的人类指令;resume / finish 自动回执自己的交接条。 +- 区分指令已收到与已完成:start / resume 的 --ack-inbox 记录明确回执,原指令仍保持 open,避免“必须完成才能开工”的循环。 +- 交接条关联到会话元数据,更新字段插入正确的头部位置,保留正文说明。 +- 完整隔离演练覆盖本地 clone、双项目安装、真实 HTTP 看板、指令与回复、业务测试、恢复收尾和本地远端推送。 +- 修正实际演练发现的端口 0 地址问题:网页入口使用系统分配的真实端口。 + ## 1.5.0 — 2026-10-14 - 首次安装按平台询问,脚本必须显式选择,不再默认全装;支持保存选择、旧版迁移及 linked worktree 继承。 diff --git a/README.md b/README.md index e825384..07a45bf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](#安装下载一次按项目复用) [![deps](https://img.shields.io/badge/dependencies-none-success)](#仓库结构与验证) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![changelog](https://img.shields.io/badge/changelog-1.5.0-orange)](CHANGELOG.md) +[![changelog](https://img.shields.io/badge/changelog-1.5.1-orange)](CHANGELOG.md) 让同一台电脑上、同一项目里的多个 AI 助手遵循一份协作规则:开工先确认谁在做什么, 改动前避开冲突,收工留下能接手的记录。支持 Codex、Claude Code、OpenCode、Pi、Trae、 @@ -20,6 +20,8 @@ WorkBuddy、Cursor、GitHub Copilot、Windsurf、Gemini CLI、Qwen Code、Cline [交互演示](https://aiflyf.github.io/multi-agent-collab/demo.html) · [平台接入与验证](docs/PLATFORMS.md) · [更新记录](CHANGELOG.md) +[完整流程验证与能力边界](docs/VALIDATION.md) 记录实际覆盖的环节、发现的问题和后续优先项。 + ![协作看板示例:会话状态、指令与回复](docs/assets/board-screenshot.png) ## 安装:下载一次,按项目复用 @@ -114,18 +116,34 @@ python "$HOME/multi-agent-collab/install.py" install "D:\code\my-app" --platform ```bash python tools/agents_session.py start --agent codex --task "完善登录" --files src/login.py tests/test_login.py --json python tools/agents_session.py heartbeat <返回的会话ID> --agent codex --note "正在验证" +python tools/agents_session.py resume <会话ID> --agent codex --note "阻塞解除,继续原任务" python tools/agents_session.py finish <会话ID> --agent codex --summary "完成登录修复" --tests "项目测试通过" ``` - 登记时检查同工作区、同分支和跨 worktree 的文件占用;目录声明能覆盖其子文件。 - 主 worktree 的系统锁让并发登记串行,避免两个 agent 同时通过检查;进程退出自动释放登记锁。 -- 未答的人类 inbox 会阻止新写任务;工具检查会话所有者与实际分支,防止误改其它会话。 +- 未确认的人类 inbox 会阻止新写任务;明确回执可用 `--ack-inbox` 纳入当前任务,实际完成前仍保留 open。 + 工具检查会话所有者字段与实际分支,防止误操作。 - 失活仍保留占用,按接管流程协调;`handoff` 生成交接记录并保留 blocked 状态。 - `finish` 追加 worklog 并释放占用;看板显示文件范围、阶段进度与占用冲突。 +- blocked 后用 `resume` 恢复自己的 ID;resume / finish 自动回执自己的交接条,避免长期残留未答告警。 扩大范围时先用 heartbeat 的 `--files` 更新完整列表。只读检查用 `check --files ... --json`。 这些检查协调遵守协议的 agent,不能锁住源码或阻止其它程序绕过登记。 +只读咨询不用登记写会话,继续同一任务不用新建 ID。同文件不同函数仍算冲突,需要拆分或串行交接。 +详细场景、失败处理与状态规则见 [协作操作手册](docs/WORKFLOW.md)。 + +### 自己验证一遍完整流程 + +```bash +python scripts/rehearse_workflow.py --report .agents/rehearsal-latest.md +``` + +脚本真实 clone 套件,在隔离项目中跑双项目复用、安装升级、worktree 协作、文件冲突、 +网页指令与回复、中断恢复、收尾、业务测试、向本地 bare 远端推送和卸载预览,输出逐步报告并清理现场。 +它不调用模型、不创建真实 GitHub PR;每个平台的新对话加载仍按平台说明单独验收。 + ## 平台选择与同步 @@ -261,6 +279,7 @@ multi-agent-collab/ ├── platforms/ # 按需安装的平台入口模板 ├── docs/ # 启动流程、平台说明、共享格式与故障手册 ├── scripts/sync_install_docs.py # 从安装器与提示词模板同步 README 和页面数据 +├── scripts/rehearse_workflow.py # 隔离项目中的完整使用流程演练和报告 ├── site/ # 介绍页、安装配置器、交互演示(静态文件) ├── tests/selftest.py # 43 个测试用例(15 单元 + 28 安装器 E2E) ├── tests/test_sessions.py # 协作并发、占用、交接和新增平台回归 diff --git a/docs/README.md b/docs/README.md index 95402fd..d43de7d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # `.agents/` — 多 Agent 共享记忆区(本机) -这里是这台电脑上所有 AI 编码会话之间的**公告板 + 交接班本**。 +这里是当前项目的 AI 编码会话之间的**公告板 + 交接班本**。 **纯本机目录,不进 git**(已在 `.git/info/exclude` 中忽略):同一工作区里切任何分支它都在, 所有 agent 直接读写同一份;换电脑/重新 clone 不会带走,需要自行拷贝。 行为规则见根目录 [AGENTS.md](../AGENTS.md),本文件只说明格式。 @@ -11,6 +11,7 @@ .agents/ ├── README.md # 本文件 ├── START_HERE.md # 每次新对话的最小阅读顺序 +├── WORKFLOW.md # 场景选择、状态转换、完整命令和验证边界 ├── install.json # 项目名、已选平台、套件源路径与版本 ├── coordination.lock # 登记过程的系统锁(主 worktree 共用,退出自动释放) ├── sessions/ # 每个活跃会话一个文件:我是谁、在哪个分支、心跳 @@ -28,8 +29,8 @@ 沙箱拦截 git 写引用、worktree 盲区、代理/终端环境异常等已知问题都有现成的判断和修复步骤; 解决了新问题必须往该手册追加一条。 -为什么这样分片:多个 agent 同时写同一个文件必然冲突。**一会话一文件、一问题一文件、 -一天一文件**,各写各的,万一冲突合并时取并集。 +按会话与问题分片可减少互相覆盖。**一会话一文件、一问题一文件、一天一文件**; +日志只追加,状态更新用系统锁串行保护。手工编辑仍需协调,不能认为小文件天然没有冲突。 ## 新对话与新增平台 @@ -54,9 +55,13 @@ - heartbeat:每 ≤ 15 分钟或每完成一阶段更新,**保存文件即生效,不用 commit/push**; 超过 15 分钟没更新(或时间无法解析、比当前时刻快 10 分钟以上)视为失活 - 收工把 status 置 `done`;没做完要走,置 `blocked`、填 `handoff` 并在 inbox 留交接问题 -- 建议使用 `tools/agents_session.py` 的 start / heartbeat / finish / handoff 维护,避免漏字段。 +- 建议使用 `tools/agents_session.py` 的 start / heartbeat / handoff / resume / finish 维护,避免漏字段。 `files` 是 JSON 数组(如 `["src/app.py", "docs/"]`);看板显示占用并提醒交叉冲突。 同工作区不可重复登记;独立 worktree 可并行处理不重叠的文件。失活不会自动释放占用。 +- `progress` 是当前简短进度;`handoff` 是阻塞说明;`handoff_inbox` 指向该会话生成的交接条。 + resume / finish 只自动回执自己的交接条,不会替你回答其它问题。 +- `acknowledged_inbox` 记录本次 start / resume 显式确认已纳入任务的人类指令;它不改变原指令的 open 状态。 +- 头部字段表示当前状态,正文保留上下文与历史;使用范式见 [WORKFLOW.md](WORKFLOW.md)。 ## 工作日志 `worklog/YYYY-MM-DD.md` diff --git a/docs/START_HERE.md b/docs/START_HERE.md index 5869a27..f09d9e3 100644 --- a/docs/START_HERE.md +++ b/docs/START_HERE.md @@ -1,55 +1,52 @@ # 每次新对话,从这里开始 -这是当前项目的开工入口。新建对话时执行一次;任务阶段间隙重扫 inbox、更新自己的心跳。 -平台入口会加载或指向根目录 `AGENTS.md`。如果它尚未进入上下文,先完整读取一次,随后读取本文件。 -旧对话不会因为安装或同步自动重新加载规则,需要新开对话,或明确要求重新读取。 +本文件是当前项目的最短启动清单。先读取根 `AGENTS.md`,再读取这里;已在上下文中的相同规则不用反复全文读取。 +平台发现入口不等于已经执行检查。新对话、上下文恢复或任务切换后,都要重新核对动态状态。 -## 最小阅读顺序 +## 先只读确认 -1. 确认当前工作区和分支,遵守根 `AGENTS.md`。不要默认其他会话已替你检查。 -2. `git fetch origin --prune`,然后 `python tools/agents_status.py --brief`。 - 返回 1 再展开完整看板;命令失败按 `.agents/TROUBLESHOOTING.md` 排查。 -3. 读 `.agents/inbox/` 中发给当前平台、当前会话或 `any` 的未答条目;读近 1–2 天 worklog。 - 这些文件会变化,每个新对话都要重新读。无文件就跳过,不能把“没有记录”当作“没有人在工作”。 -4. 查看开放 PR,结合进程、reflog 和文件时间确认没有同分支、同文件冲突。 -5. 编码任务开始写入前,按 `AGENTS.md` 建任务分支,然后运行: +1. 确认工作区与分支,运行 `git status --short --branch` 和 `python tools/agents_status.py --brief`。 + 看板返回 1 时展开完整版并查看原因,不能把“无活跃”当作“无人占用”。 +2. 读取发给当前平台、自己的会话或 `any` 的未答 inbox,以及近 1–2 天 worklog。 + 收到暂停、取消或避让要求先处理;缺文件就说明,不编造记录。 +3. 判断这是只读咨询、新写任务,还是当前任务继续。只读任务不用登记写会话。 + 继续同一任务时沿用自己的 ID;不要每次追问都创建新会话。 - ```text - python tools/agents_session.py start --agent <当前平台> --task "任务" --files <相对文件或目录> --json - ``` +## 即将写入时 - 保存返回的会话 ID。若同工作区、分支或文件范围已被占用,停止并协调;不能重写别人的记录。 - 旧版没有此工具时,用 `.agents/sessions/_template.md` 手动登记并提示升级核心工具。 - -## 执行期间与收尾 +按总章完成 fetch、PR 与进程/reflog 交叉检查,确认任务分支及文件范围,再登记: ```text -python tools/agents_session.py heartbeat <会话ID> --agent <当前平台> --note "当前进度" -python tools/agents_session.py finish <会话ID> --agent <当前平台> --summary "结果" --tests "实际测试结果" -python tools/agents_session.py handoff <会话ID> --agent <当前平台> --summary "下一步、阻塞点和未提交文件" +python tools/agents_session.py start --agent <平台> --task "任务" --files <相对文件或目录> --json ``` -阶段间隙和长命令前仍需重扫 inbox;心跳不能证明已处理人类指令。 -扩大文件范围时先给 heartbeat 加 `--files` 声明完整新范围,再修改文件。 -finish 追加日志并释放占用;handoff 写交接留言、保留 blocked 占用,等待明确接管。 -文件范围只是协作约定,系统锁只保护登记过程,不锁住源码,也不能阻止绕过协议的程序。 - -共享格式不熟悉时再读 `.agents/README.md`;发生故障时再查手册相应条目。 -获取现状优先跑命令,不要每个新对话都读安装器、看板源码或全部历史日志。 - -## 新增平台与同步 +保存实际返回的 ID。有占用或未答人类指令时先协调。 +人类指令本身就是待执行任务时,先读并回执,再用 `--ack-inbox <指令文件名>` 声明纳入计划; +保留 open,实际完成后才 answered。这个参数不能绕过暂停、取消或避让要求。 +分支基线缺失、没有远端或 GitHub 不适用时,说明具体情况;不擅自创建远端基线或绕过主干保护。 +旧版没有会话工具时可用 `.agents/sessions/_template.md` 手动登记,并提示升级。 -各平台读取同一个 `AGENTS.md` 和本入口;更新这两份文档后,新对话读取的是最新内容。 -WorkBuddy 的完整镜像需要同步;新增平台也使用同一条命令: +## 执行、恢复与收尾 ```text -python "<套件目录>/install.py" sync "<项目目录>" --platforms cursor -python "<套件目录>/install.py" sync "<项目目录>" +python tools/agents_session.py heartbeat --agent <平台> --note "当前进度" +python tools/agents_session.py handoff --agent <平台> --summary "完成项、下一步、阻塞和文件位置" +python tools/agents_session.py resume --agent <平台> --note "继续原任务的依据" +python tools/agents_session.py finish --agent <平台> --summary "实际结果" --tests "实际测试命令与结果" ``` -从 `.agents/install.json` 的 `source_dir` 找到套件目录;默认是用户主目录下的 -`multi-agent-collab`。`sync` 追加平台、刷新入口和忽略规则,保留项目总章与协作记录; -更新套件核心模板要运行 `install`。无需常驻服务,不会自动下载代码或修改平台的全局配置。 +- 每个阶段、长命令前重扫 inbox;每 15 分钟内或每阶段更新心跳。 +- 扩大修改范围前,用 heartbeat 的 `--files` 提交**完整新列表**,检查成功后再修改。 +- blocked 保留占用;恢复原任务用 resume。done 才释放占用,不能复活已结束的记录。 +- resume / finish 回执自己的交接条,普通问题仍需在原文件里回复。 +- 测试失败不能提交或推送;预览和静态检查不能替代实际运行。 + +操作细节、状态选择和例外见 [WORKFLOW.md](WORKFLOW.md);字段格式见 [README.md](README.md); +出错按 [TROUBLESHOOTING.md](TROUBLESHOOTING.md) 查询。只读相关段落,不扫描全部历史和工具源码。 + +## 同步入口 -安装器只能验证入口路径、内容和忽略规则。平台版本、工作区信任设置及用户关闭规则加载等因素 -仍会影响自动读取。新对话验证时,确认 agent 能说明当前分支、看板结果及 inbox,而不只是复述口号。 +新增平台或刷新 WorkBuddy 镜像用 `python "<套件目录>/install.py" sync "<项目>" --platforms <新增平台>`; +没有新增平台时省略参数。核心工具和规则升级使用 install,实际套件路径记录在 install.json 的 source_dir。 +已有对话不会因为文件变化就自动重新载入;需要新开对话或明确重读。 +系统锁只保护登记更新,不能锁住源码;规则是否被客户端加载仍需实际新会话验收。 diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..1c6581b --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,57 @@ +# 可行性验证与使用边界 + +## 本轮结论 + +在“同一台电脑、同一 Git 仓库、各 agent 遵守登记规则”的条件下,工具链可以形成完整闭环。 +2026-10-14 在 Windows / Python 3.12 的隔离目录中完成了 58 个实际检查点; +运行的是当前功能分支的工作副本,而非假设默认分支已经包含这些改动。 + +复现方式: + +```text +python scripts/rehearse_workflow.py --report .agents/rehearsal-latest.md +``` + +脚本真实启动进程、调用 Git、建立工作区、启动 HTTP 服务并读写协作文件。 +报告包含每步的命令、预期与实际退出码和输出;预期的冲突拒绝也需要命中正确原因才算通过。 +临时仓库和服务在结束时清理,实际项目不作为安装或业务修改目标。 + +## 实际覆盖的闭环 + +| 环节 | 验证结果 | +|---|---| +| 安装源复用 | 真实本地 clone 一次,两个项目使用同一个安装器副本 | +| 安装与升级 | 按平台生成文件,升级保留项目定制和已有协作记录 | +| 工作区与占用 | 同工作区重复登记被拒绝;跨 worktree 重叠文件被拒绝;独立文件可登记 | +| 网页指令 | 真实 HTTP 看板聚合会话,POST 指令落盘,回复能由 API 读到 | +| 指令状态 | 收到回执仍保持 open;显式纳入任务后恢复,执行完成才 answered | +| 中断与恢复 | handoff 保留占用,resume 沿用 ID,自己的交接条被回执 | +| 业务验证 | 在已登记范围内修改示例业务文件,运行真实单元测试后提交 | +| 推送 | 向隔离的本地 bare 远端推送 feature 分支,不触及用户远端主干 | +| 收尾 | finish 追加日志、回执关联交接,最终没有悬挂会话、问题或工作区告警 | +| 扩展与清理 | 追加平台、再体检、确认产物被忽略;卸载预览保留记录 | + +第一轮演练发现看板的 `--port 0` 输出了无效的 `:0` 地址,修复为系统实际分配的端口后通过。 +流程审查同时补齐了 blocked 恢复、交接留言收尾和“已收到/已完成”的区分。 +这些是使用流程中的缺口,不能只凭原有单元测试通过就认为没有问题。 + +## 没有声称验证的部分 + +- 没有启动 15 个真实 AI 客户端或调用模型;平台入口仍需在对应版本中新开会话验收。 +- 没有自动创建 GitHub PR、合并或部署;真实远端操作遵守仓库分支规则与用户授权。 +- 没有跨电脑同步、本机以外的身份认证或对源码强制加锁。 +- 占用粒度是文件/目录,不支持同一文件不同函数的自动并行。 +- finish 的测试文字来自调用者,不是测试真实性的密码学证明;仍需保留真实命令输出与 CI 记录。 + +开发分支尚未合并时,普通 `git clone` 下载的仍是默认分支。验证开发版本应先确认源码检出的提交, +不要把“功能分支验证通过”表述为“默认安装和线上页面已更新”。 + +## 下一步最值得改进的方向 + +| 优先项 | 价值 | 验收方式 | +|---|---|---| +| 常用客户端的真实新会话验收 | 验证发现入口、读取规则及执行检查的实际表现 | 从 Codex / Claude / 当前常用平台开始,记录版本、加载来源与真实工具动作 | +| 可配置的项目分支策略 | 适配只有 main、使用其它开发主干或不同 PR 流程的项目 | 项目明确选择策略,工具与文档同步,禁止静默猜测或修改远端结构 | +| 明确的人工接管流程支持 | 减少原会话永久离线后手工核对的成本 | 展示原 ID、分支、未提交文件和占用;只在用户明确批准后移交并保留审计记录 | + +当前版本采用保守的占用与接管规则。先保证不会悄悄覆盖他人工作,再考虑更细粒度的并行。 diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md new file mode 100644 index 0000000..333816c --- /dev/null +++ b/docs/WORKFLOW.md @@ -0,0 +1,92 @@ +# 协作操作手册 + +本手册用于当前项目。总章是 `AGENTS.md`,新对话先读 `START_HERE.md`;需要执行某一步时再查这里。 +规则不能代替实际检查,也不能证明模型已读懂上下文。每次操作应以命令结果和当前文件为准。 + +## 先判断任务状态 + +| 当前情况 | 应做的事 | 不应做的事 | +|---|---|---| +| 咨询、审查、解释代码 | 只读检查分支、看板和相关记录 | 为了回答问题就建分支、登记写任务 | +| 新的写任务 | 协调后切任务分支,`start` 登记范围 | 重复登记,或占用整个工作区 | +| 同一任务继续,自己的会话 active | 沿用 ID,`heartbeat` 更新进度 | 每个用户追问都新建会话 | +| 自己的会话 blocked,用户要求继续 | 先处理指令,再 `resume` | 删除旧会话或换 ID 绕过占用 | +| 其它会话占用文件 | 协商拆分、等待收尾,或按总章接管 | 因为心跳超时就认定可以抢占 | +| 本轮不能继续 | `handoff` 写清下一步与阻塞点 | 把未完成任务标成 done | +| 工作完成 | 真跑项目测试,再按项目流程提交/PR,最后 `finish` | 把预览、编译或口头“应该通过”当测试结果 | + +同一任务跨轮次继续时,只有明确持有自己的 ID、工作区和登记分支一致,才能恢复它。 +身份不明、会话属于别人或工作区被切换时,先只读调查并通知用户。 + +## 开工检查 + +1. 读当前任务涉及的规则。根总章和启动清单每个新对话读一次;字段格式不熟悉再读 `.agents/README.md`。 +2. 查看 `git status --short --branch`、看板、相关 inbox 和近 1–2 天日志。 +3. 准备写代码时,按总章 fetch、查 open PR、交叉检查构建进程与 reflog,确认没有重复任务。 +4. 有 GitHub 远端才查该仓库的 PR;本地仓库没有远端时说明这一点,不添加未知远端。 +5. Git Flow 的 `develop` 不存在时,先说明当前分支结构。不要擅自建立远端基线或把功能直接推入主干。 + 可以继续已经授权的只读审查、本地验证,或同一任务现有 feature 分支上的工作。 + +## 登记、扩大范围与心跳 + +以下 `` 都是 `start` 实际返回的会话 ID,不能直接照抄尖括号。 + +```text +python tools/agents_session.py start --agent codex --task "完善登录校验" --files src/login.py tests/test_login.py --json +python tools/agents_session.py heartbeat --agent codex --note "正在运行回归测试" +python tools/agents_session.py heartbeat --agent codex --files src/login.py tests/test_login.py docs/login.md +python tools/agents_session.py check --agent codex --session --files src/login.py --json +``` + +- 文件范围是相对于当前项目根目录的完整列表;目录以 `/` 结尾,不使用通配符或 `..`。 +- heartbeat 的 `--files` **替换完整范围**,不是追加;扩大范围先检查成功,再开始修改。 +- 当前粒度是文件/目录。即使改同一文件里的不同函数,也会冲突;本版不支持声明行号或函数范围。 + 应拆成不重叠文件,或约定先后顺序。独立 worktree 不会自动放行重复文件任务。 +- 同一物理工作区有未结束会话时,新的写任务会被阻止,避免互相切分支和覆盖文件。 +- 15 分钟内或每个阶段更新一次心跳。预计返回时间只是说明,不会延长失活阈值。 +- 检查返回 1 时看清原因:处理人类指令、解决占用或补齐协调;不能通过缩小虚假的范围绕过。 + +## 中断、恢复、交接与收尾 + +```text +python tools/agents_session.py handoff --agent codex --summary "已完成什么;待办和阻塞;未提交文件、测试结果与相关 PR" +python tools/agents_session.py resume --agent codex --note "阻塞已解除,继续原任务" +python tools/agents_session.py finish --agent codex --summary "实现与验收结果" --tests "实际运行的命令和结果" +``` + +`handoff` 将会话置为 blocked,保留文件占用,并写一条来自该会话的交接留言。 +`resume` 核对所有者字段、登记分支、文件占用及发给当前会话的人类指令,恢复 active 并回执自己的交接条。 +`finish` 追加日志、回执自己的交接条,置 done 后释放占用。已结束的会话不能重新激活。 +这些命令不替你处理普通业务问题或其它会话的留言;它们仍须在原文件中明确回复。 + +人类指令本身要求你执行新任务时,先阅读并在原文件回执计划,保留 open。 +start / resume 可用 `--ack-inbox Q-日期-序号.md` 显式确认已阅读并纳入本任务,避免为了开工而虚假标记完成。 +这个参数只适用于当前仍待处理的指令,不能忽略暂停、取消、避让或授权边界;执行完再写结果并置 answered。 + +如果要交给**另一个**执行者:先在交接条里明确下一步、分支和文件状态。原执行者确认移交后收尾, +新执行者再登记自己的任务;原执行者失联时,按总章的声明、等待、保留提交流程处理。 +不要冒用另一个 ID。`--agent` 的核对只是防误操作,不是身份认证或文件系统权限隔离。 + +`done` 是协作状态,不是测试引擎的认证。`--tests` 记录的是调用者提供的结果,必须如实填写; +测试未通过仍不能提交、推送或开 PR。只读任务没有改动时可说明“不适用”,不能填成“全绿”。 + +## 每次汇报只保留有用信息 + +说明当前分支、这一阶段的实际变更、运行过的测试和结果,以及下一步或阻塞点。 +没有提交就写“暂无新提交”。不要重复完整历史、倾倒源码,或把意向写成已经完成。 +工具失败时保留输出、分支、未提交位置;先查故障手册,再做可恢复的修复。 + +## 验证一次完整使用流程 + +在套件源码目录运行: + +```text +python scripts/rehearse_workflow.py --report .agents/rehearsal-latest.md +``` + +脚本在隔离临时目录中真实 clone 套件,用两个项目复用同一副本,执行安装/升级、工作区登记、 +冲突拦截、真实看板 HTTP 指令与回复、handoff/resume/finish、业务测试、向隔离 bare 远端推送和卸载预览。 +报告记录每步命令、预期退出码、实际输出和断点,结束时清理本次临时目录与服务。 + +它验证工具链能否闭环,不调用任何模型、不修改用户的实际项目,也不创建真实 GitHub PR。 +各平台是否自动加载规则,仍要按套件源码中 `docs/PLATFORMS.md` 的方法,在对应客户端新开对话验证。 diff --git a/docs/examples/inbox-example.md b/docs/examples/inbox-example.md index 9120e35..9f73e48 100644 --- a/docs/examples/inbox-example.md +++ b/docs/examples/inbox-example.md @@ -1,87 +1,58 @@ -# 问事板(inbox)填写范例 +# 问题、回执与交接示例 -> **这个文件是什么**:展示 `templates/inbox.md` 在真实协作里怎么用。 -> 三个虚构但写实的场景:开工前避让协商、交接提问、事后回复。 -> 安装器不会把本文件装进你的项目。 +本文件是教学示例,不会安装到项目。`open` 表示事情尚未完成,收到回执也可以继续保持 open; +只有问题得到回答或指令实际完成后才置 answered。回复保留在原文件中。 ---- - -## 例 1:避让协商——两个会话要碰同一个文件(最有价值的用法) - -文件名:`.agents/inbox/Q-2026-10-09-01.md` +## 两个任务涉及同一文件 ```markdown -# Q-2026-10-09-01: 限流任务会动 middleware.py,与你在改的审计日志冲突吗? +# Q-2026-10-14-01: 确认 middleware.py 的处理顺序 -- from: trae-261009-1015-a1b2 -- to: trae-261009-0930-c3d4 +- from: codex-261014-1000-a1b2 +- to: claude-261014-0930-c3d4 - status: open -- created: 2026-10-09 10:18 -- answered: +- created: 2026-10-14 10:05 ## 背景 -我在 feature/login-rate-limit 做登录限流,需要在 app/http/middleware.py 里挂一个 -限流中间件。看板显示你在 feature/audit-log 分支,open PR #44 里也改了 middleware.py。 +我准备做登录限流,你的 PR #44 也修改 app/http/middleware.py。 +会话工具已报告文件范围重叠,所以我尚未修改该文件。 ## 我已尝试 -- 看了 PR #44 的 diff:你改的是 AuditMiddleware(约 42-78 行)和第 120 行的注册顺序 -- 我计划新增 RateLimitMiddleware 类(文件末尾追加),并在第 121 行注册 -- git merge-tree 试算了一下两个分支:middleware.py 1 处冲突(都在注册那段),其余无交集 +已看你的 PR diff。即使函数不同,也属于同一文件,当前工具不能登记函数或行号范围。 ## 需要你做什么 -1. 确认一下你今天是否还会动 middleware.py 的注册段(115-125 行) -2. 若会动:建议我先开 PR、你 rebase;或反过来,你预计几点推完? -3. 若不会:我就按上面的行号开工,零重叠 -``` - -回复(写进同一文件): +确认处理顺序。我可以先做独立的限流模块和测试,等 PR #44 合并后再从更新的 develop 接入。 -```markdown ## 回复 -审计中间件代码已完成,PR #44 现在只在等评审,不会再改 middleware.py。 -你直接开工即可;万一注册段冲突,我这边 rebase 成本低,我来 rebase。 -—— trae-261009-0930-c3d4,10:22 - -(顶部 status 已改为 answered,answered: 2026-10-09 10:22) +PR #44 仍在评审,请先做不重叠文件。合并后我会更新此条,再由你接入路由。 ``` -## 例 2:交接——给"任何接手者"的提问 - -文件名:`.agents/inbox/Q-2026-10-09-03.md` - -```markdown -# Q-2026-10-09-03: test_webhook.py 在干净 develop 上也连续超时,是环境问题吗? - -- from: trae-261009-1015-a1b2 -- to: any -- status: open -- created: 2026-10-09 11:05 -- answered: +这条回复尚未完成协商,因此可以保持 open。确定分工和后续动作后,在同一文件补充结论并置 answered。 +不要约定强推或 rebase;已发布历史保持原样。新任务从正确基线开始,已有分支需要同步时按项目允许的合并流程处理。 -## 背景 +## 人类指令本身就是待执行任务 -feature/login-rate-limit 全量回归被 tests/test_webhook.py 卡住(3 次各超时 60s)。 +看板产生了一条要求“补充价格计算并运行测试”的人类指令。agent 应先阅读,写回“收到、计划和预计阶段”, +保留 open,然后在登记或恢复时明确声明已纳入任务: -## 我已尝试 +```text +python tools/agents_session.py start --agent codex --task "补充价格计算" --files cart.py test_cart.py --ack-inbox Q-2026-10-14-02.md +``` -- git stash 后在干净 origin/develop 上单跑该文件:同样超时 → 大概率与本改动无关 -- 本地 8080 端口没被占用(netstat 查过) -- CI 上同一提交是绿的(看了最近一次 develop 的流水线记录) +这个参数不代表指令已完成,也不扩大授权。暂停、取消或禁止修改某文件的要求仍须立即执行,不能用参数绕过。 +真正完成后,把实际测试命令和结果写入该指令的 `## 回复`,再置 answered 并记录时间。 +每个新执行者仍须自己阅读;不能认为别人确认过就无需检查。 -## 需要你做什么 +## 交接与恢复 -- 14:00 后开工的同学:先跑一次 pytest tests/test_webhook.py -q - - 若恢复正常:回一句"环境已恢复",我就推分支开 PR(分支只比 develop 多 2 个 commit) - - 若仍超时:帮忙看下是不是本机 webhook 回调需要的内网隧道没启动,把结论写这里 +```text +python tools/agents_session.py handoff --agent codex --summary "完成项、阻塞、下一步、未提交位置、测试和 PR 状态" +python tools/agents_session.py resume --agent codex --note "原会话继续处理" ``` -## 例 3:回复礼仪对照 - -- ✅ 回复写进**同一文件**的 `## 回复`,顶部 `status: open → answered` 并填 `answered` 时间 -- ✅ 一件事一个文件;新问题另开 `Q-YYYY-MM-DD-NN.md`(序号当日递增) -- ✅ 给得出动作就给动作:"你直接开工""我来 rebase""预计 14:00 回" -- ❌ 不要只回"好的"——后人翻 inbox 时看不出到底协商了什么 +工具生成的交接条来自会话 ID,不冒充 human。恢复或收尾时,只自动回执本会话的交接条; +其它问题和人类指令仍需独立处理。原会话失联的接管流程见总章,不能因为时间到了就冒用其 ID。 diff --git a/docs/examples/session-example.md b/docs/examples/session-example.md index 8b2aa4f..7fbe0f6 100644 --- a/docs/examples/session-example.md +++ b/docs/examples/session-example.md @@ -1,93 +1,56 @@ -# 会话文件填写范例(匿名化,来自真实协作) +# 会话全生命周期示例 -> **这个文件是什么**:`templates/session.md` 是空模板,第一次用可能不知道"填出来长什么样"。 -> 下面是一个**虚构但完全写实**的范例(一个 Web 后端项目的限流功能任务), -> 展示会话文件在任务全生命周期里怎么变化。安装器不会把本文件装进你的项目。 +这是教学示例,不会安装到项目。命令中的 `` 使用 start 实际返回的值;不要复制示例时间或编造心跳。 ---- +## 新任务:先协调,后登记 -## 例 1:任务刚开始(注册时) +在已有 Git Flow 基线的项目里,先读总章与启动清单,确认没有冲突和重复 PR,再建立 feature 分支。 -文件名:`.agents/sessions/trae-261009-1015-a1b2.md` +```text +python tools/agents_session.py start --agent codex --task "增加登录限流" --files app/core/rate_limit.py tests/test_rate_limit.py --json +``` + +返回的 ID 对应一个本机会话文件,关键字段形如: ```markdown -# Session: trae-261009-1015-a1b2 +# Session: codex-261014-1000-a1b2 -- agent: trae +- agent: codex - branch: feature/login-rate-limit -- task: 登录接口加速率限制(内存令牌桶 + 429 响应) +- task: 增加登录限流 +- files: ["app/core/rate_limit.py", "tests/test_rate_limit.py"] - status: active -- started: 2026-10-09 10:15 -- heartbeat: 2026-10-09 10:15 -- pr: - -## 当前进度 - -- 10:15 已 fetch,看板无活跃会话;open PR 只有 #42(支付模块,不相关) -- 已从 origin/develop 切出 feature/login-rate-limit -- 正在读 app/auth/login.py 与现有中间件结构 - -## 下一步 - -- 设计 RateLimiter 接口,先在 inbox 问一句是否有人在动 middleware.py - -## 阻塞点 / handoff - -- 无 +- heartbeat: 2026-10-14 10:00 ``` -## 例 2:任务进行中(只改心跳 + 进度,最新更新写最上面) +## 扩大范围:先检查再动手 -```markdown -- heartbeat: 2026-10-09 10:40 +后来需要修改登录路由,提交完整范围: -## 当前进度 - -- 10:40 RateLimiter 已实现(app/core/rate_limit.py),单测 12 条全绿 -- 10:25 已在 inbox 与 trae-261009-0930-c3d4 确认 middleware.py 改动零重叠 -- 正在接 login 路由,预计还要改 2 个文件 - -## 下一步 - -- 跑全量回归 pytest -q;绿了就提交、推分支、开 PR +```text +python tools/agents_session.py heartbeat --agent codex --files app/core/rate_limit.py tests/test_rate_limit.py app/auth/login.py --note "准备接入登录路由" ``` -## 例 3:要离开但任务没做完(handoff 必填,inbox 同步留交接) +若别人占用了路由文件,命令失败,原范围保留。此时先协商,不删除旧记录,也不把文件漏写在列表里。 -```markdown -- status: blocked -- heartbeat: 2026-10-09 11:05 - -## 阻塞点 / handoff - -- 代码与单测都完成了,但全量回归在 tests/test_webhook.py 连续 3 次超时, - 看起来与本改动无关(stash 后在干净 develop 上也超时),已在 inbox Q-2026-10-09-03 提问 -- 未提交:无(工作区干净);本地分支 feature/login-rate-limit 比 develop 多 2 个 commit, - 尚未 push -- 接手者:先看 inbox 那条问题;若 webhook 超时确认是环境问题,直接 push + 开 PR 即可, - PR 描述草稿在分支最后一个 commit 的正文里 -- 预计 14:00 回来 +## 受阻与恢复 + +```text +python tools/agents_session.py handoff --agent codex --summary "限流逻辑完成;回归因测试服务不可达受阻;未提交文件为 app/core/rate_limit.py 和 tests/test_rate_limit.py;服务恢复后先重跑全量测试" +python tools/agents_session.py resume --agent codex --note "测试服务恢复,继续原任务" ``` -## 例 4:任务收尾(置 done 后不再更新心跳) +handoff 保留 blocked 占用,并把交接路径写入会话。resume 保留同一个 ID、重新检查占用, +恢复 active 并回执该交接条。它不会自动解决普通业务问题,也不会把失败测试视为通过。 -```markdown -- status: done -- heartbeat: 2026-10-09 11:30 -- pr: https://github.com///pull/47 +## 测试与收尾 -## 当前进度 +先运行项目约定的测试;任何门禁失败都先修复或说明阻塞,不提交、不推送、不用 rebase 改写历史。 +测试通过后,按总章只暂存本任务文件、提交、复核 SHA、推送并建立 PR,再结束会话: -- 11:20 PR #47 已开(base=develop),全量回归 187 项全绿 -- 11:30 worklog 已追加总结;本会话结束 +```text +python tools/agents_session.py finish --agent codex --summary "登录限流已实现并完成评审准备,PR 地址见本次工作记录" --tests "pytest -q:实际通过数量填写在这里" ``` ---- - -## 要点回顾 - -- **文件名就是会话 ID**:`--<4位随机>`,不要改名 -- **心跳只需"保存文件"**,不用 git;≤15 分钟或每完成一个阶段更新一次 -- 最新进度永远写在 `## 当前进度` **最上面**,方便别人扫一眼就懂 -- 离开时没做完:status 置 `blocked`,handoff 写清"未完成部分 / 未推送改动在哪 / 接手第一步", - 并在 inbox 留一条点名接手方或 `to: any` 的问题 +`--tests` 是实际结果的记录,不会替你运行测试。finish 追加日志、回执自己的交接条并释放占用。 +终态记录保留;下一项新任务建立新 ID。 diff --git a/install.py b/install.py index 4b19350..c9f4123 100644 --- a/install.py +++ b/install.py @@ -38,7 +38,7 @@ from pathlib import Path KIT_ROOT = Path(__file__).resolve().parent -KIT_VERSION = "1.5.0" # 升级 kit 时记得改;会写进目标项目的 .agents/install.json +KIT_VERSION = "1.5.1" # 升级 kit 时记得改;会写进目标项目的 .agents/install.json # 与 tools/agents_status.py 同名同义:git 可执行文件解析的环境变量覆盖 # (两份小拷贝是有意为之——看板会被单独装进目标项目,不能 import 安装器)。 @@ -93,6 +93,7 @@ def resolve_git() -> str | None: ("tools/agents_session.py", "tools/agents_session.py", None), ("docs/README.md", ".agents/README.md", None), ("docs/START_HERE.md", ".agents/START_HERE.md", None), + ("docs/WORKFLOW.md", ".agents/WORKFLOW.md", None), ("docs/TROUBLESHOOTING.md", ".agents/TROUBLESHOOTING.md", "__troubles__"), ("templates/session.md", ".agents/sessions/_template.md", None), ("templates/inbox.md", ".agents/inbox/_template.md", None), diff --git a/platforms/claude-command.md b/platforms/claude-command.md index faf1482..c1e7adf 100644 --- a/platforms/claude-command.md +++ b/platforms/claude-command.md @@ -23,9 +23,9 @@ $ARGUMENTS 同一分支或同一批文件(rc=1 再跑无参数完整版);有冲突先在 `.agents/inbox/` 留问,不要抢活 4. 读 `.agents/inbox/` 中点名 claude / any 的未答问题与近两天 worklog -随后:从最新 `develop` 切 `feature/<任务名>` → 复制 -`.agents/sessions/_template.md` 注册本会话(ID `claude--<4位随机>`, -纯本地文件,不提交不推送)→ 开发(只 add 项目文件,禁止 `git add -A`, +随后:按总章确认分支基线;新任务切 `feature/<任务名>`,同任务继续沿用自己的分支和 ID。 +用 `python tools/agents_session.py start --agent claude --task "任务" --files <实际范围>` 登记, +blocked 恢复用 resume。会话文件纯本地、不提交不推送。开发(只 add 项目文件,禁止 `git add -A`, 禁止提交 AGENTS.md 第 8 节列出的本机协作文件)→ 跑 AGENTS.md 第 6 节约定的测试门禁 → 推送项目分支并 `gh pr create --base develop`(PR 描述写改动/原因/测试证据) → session 置 done、worklog 追加总结(本地保存即可)。 diff --git a/platforms/kiro-steering.md b/platforms/kiro-steering.md index 0b1cd47..df8e14c 100644 --- a/platforms/kiro-steering.md +++ b/platforms/kiro-steering.md @@ -6,5 +6,5 @@ inclusion: always 每次新会话读取根 `AGENTS.md` 与 `.agents/START_HERE.md`。 Spec 任务写设计、需求或任务清单时也先登记文件范围;实现阶段沿用自己的会话 ID 并更新范围。 -使用 `python tools/agents_session.py` 完成 check、start、heartbeat、finish 或 handoff。 +使用 `python tools/agents_session.py` 完成 check、start、heartbeat、handoff、resume 或 finish。 本规则适用于可访问本工作区的客户端;云端任务需要在其独立工作区另行安装,不能看到本机记录。 diff --git a/platforms/trae-rule.md b/platforms/trae-rule.md index c6d08ae..0ab0281 100644 --- a/platforms/trae-rule.md +++ b/platforms/trae-rule.md @@ -9,9 +9,9 @@ alwaysApply: true 1. 先用 Read 工具完整阅读项目根目录的 `AGENTS.md` 并严格遵守——它是唯一权威规程 (Git Flow 分支铁律、禁止操作、commit 规范、标准流程、多 agent 协作、事故应急)。 然后读取 `.agents/START_HERE.md`,按最小阅读顺序检查最新的协作记录。 -2. 开工第一个动作:`git fetch origin --prune`,然后跑 - `python tools/agents_status.py --brief`(一行摘要);rc=1(有失活/未答/告警) - 再跑无参数完整版并看 `.agents/inbox/`,有冲突不要抢活。 +2. 按启动清单先做只读检查。准备写入时再完成 fetch、PR 和占用协调; + `python tools/agents_status.py --brief` 返回 1 时展开详情和相关 inbox,有冲突不要抢活。 + 同任务继续沿用自己的 ID;blocked 用 resume,不能新建 ID 绕过占用。 3. `AGENTS.md`、`CLAUDE.md`、`.agents/`、`.agent/`、`.claude/`、`.trae/`、 `.workbuddy-ai/`、`.workbuddy/`、`tools/agents_status.py` 都是**本机私有文件,禁止 git add/commit/push**(完整清单以安装器 EXCLUDE_ENTRIES 为准)。 @@ -19,7 +19,7 @@ alwaysApply: true `.windsurf/rules/multi-agent-collab.md` 也是按需安装的私有入口,不忽略它们所在的整个配置目录。 新增入口与工具:`tools/agents_session.py`、`GEMINI.md`、`.qwen/QWEN.local.md`、`.clinerules/multi-agent-collab.md`、 `.roo/rules/00-multi-agent-collab.md`、`.continue/rules/multi-agent-collab.md`、`.kiro/steering/multi-agent-collab.md`。 -4. 每步操作后用中文向用户汇报:当前分支 / commit message / 变更文件 / 测试结果。 +4. 每个阶段用中文向用户汇报:当前分支 / commit message / 变更文件 / 实际测试结果。 5. 遇到 AGENTS.md 第 5 节的异常信号(.git 被其他进程改动、来历不明的分支、 远程历史突变)立即停手只读取证并通知用户。 6. 用户想看图形化看板或想在网页上指挥时,按 AGENTS.md 第 4 节帮他启动 diff --git a/scripts/rehearse_workflow.py b/scripts/rehearse_workflow.py new file mode 100644 index 0000000..2c29933 --- /dev/null +++ b/scripts/rehearse_workflow.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""在隔离目录真实演练安装、协作、网页指令、恢复、业务测试、升级与卸载。""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import time +import urllib.request +from datetime import datetime +from pathlib import Path + +KIT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(KIT)) +import install as kit # noqa: E402 + + +class Rehearsal: + def __init__(self): + self.root = Path(tempfile.mkdtemp(prefix="mac-rehearsal-")).resolve() + self.steps = [] + self.server = None + self.server_log = None + + def run(self, name, *args, cwd=None, expected=0): + started = time.monotonic() + result = subprocess.run(list(map(str, args)), cwd=str(cwd or self.root), capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=180) + self.steps.append({"name": name, "command": subprocess.list2cmdline(list(map(str, args))), + "expected": expected, "rc": result.returncode, + "seconds": round(time.monotonic() - started, 2), + "output": (result.stdout + result.stderr).strip()[-1800:]}) + print(f"{'PASS' if result.returncode == expected else 'FAIL'} {name}", flush=True) + if result.returncode != expected: + raise RuntimeError(f"{name}:预期退出码 {expected},实际 {result.returncode}") + return result.stdout + + def check(self, name, condition): + self.steps.append({"name": name, "command": "结果核对", "expected": 0, + "rc": 0 if condition else 1, "seconds": 0, "output": ""}) + print(f"{'PASS' if condition else 'FAIL'} {name}", flush=True) + if not condition: + raise AssertionError(name) + + def session(self, name, project, *args, expected=0): + return json.loads(self.run(name, sys.executable, project / "tools/agents_session.py", + "--root", project, *args, "--json", cwd=project, expected=expected)) + + def initialize(self, project, business=False): + project.mkdir(parents=True) + self.run("初始化演练项目 " + project.name, "git", "init", "-q", cwd=project) + self.run("设置演练作者", "git", "config", "user.name", "Workflow Rehearsal", cwd=project) + self.run("设置演练邮箱", "git", "config", "user.email", "rehearsal@example.invalid", cwd=project) + if business: + (project / ".gitignore").write_text("__pycache__/\n", encoding="utf-8") + (project / "cart.py").write_text("def total(items):\n return sum(price * quantity for price, quantity in items)\n", encoding="utf-8") + (project / "test_cart.py").write_text( + "import unittest\nfrom cart import total\n\nclass CartTest(unittest.TestCase):\n" + " def test_empty(self): self.assertEqual(total([]), 0)\n" + " def test_items(self): self.assertEqual(total([(120, 2), (50, 1)]), 290)\n", encoding="utf-8") + (project / "README.md").write_text("# 演练商店\n", encoding="utf-8") + self.run("执行演练项目原始测试", sys.executable, "-m", "unittest", "-q", cwd=project) + self.run("暂存演练基线", "git", "add", ".gitignore", "README.md", "cart.py", "test_cart.py", cwd=project) + self.run("提交演练基线", "git", "commit", "-q", "-m", "test: seed rehearsal", cwd=project) + self.run("建立演练 develop 基线", "git", "branch", "develop", cwd=project) + + def exercise(self): + cached = self.root / "home/multi-agent-collab" + cached.parent.mkdir() + self.run("真实 clone 一份可复用的套件", "git", "clone", "--local", "--no-hardlinks", "-q", KIT, cached) + first, second, worker = self.root / "shop", self.root / "other-project", self.root / "docs-worker" + self.initialize(first, business=True) + self.initialize(second) + installer = cached / "install.py" + self.run("从已提交版本首次安装 Codex", sys.executable, installer, "install", first, "--platforms", "codex") + self.check("原生安装不生成 Claude 入口", not (first / "CLAUDE.md").exists()) + self.run("同一副本安装第二个项目", sys.executable, installer, "install", second, "--platforms", "gemini,qwen") + a, b = [json.loads((p / kit.INSTALL_META).read_text(encoding="utf-8")) for p in (first, second)] + self.check("两个项目复用同一安装源", a["source_dir"] == b["source_dir"] == str(cached)) + agents = first / "AGENTS.md" + text = agents.read_text(encoding="utf-8") + text = kit.ZONE_RE.sub(lambda m: m[1] + "\n- 运行时:Python 3.8+\n- 测试:python -m unittest -q\n- 项目标记:REHEARSAL\n" + m[4] + if m[2] == "project-env" else m[0], text) + kit.write_lf(agents, text) + note = first / ".agents/worklog/2000-01-01.md" + note.write_text("演练前已有的协作记录\n", encoding="utf-8") + # clone 保留真实提交历史;仅覆盖本次要验证的发布源文件,不拷贝本机记忆或凭据。 + sources = {"install.py"} | {src for src, _dst, _zone in kit.CORE_FILES + kit.platform_files(kit.ALL_PLATFORMS)} + for relative in sources: + destination = cached / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(KIT / relative, destination) + self.run("用当前工作区版本升级核心", sys.executable, installer, "install", first) + self.check("升级保留项目定制和已有记录", "REHEARSAL" in agents.read_text(encoding="utf-8") and note.read_text(encoding="utf-8") == "演练前已有的协作记录\n") + self.run("安装体检", sys.executable, installer, "doctor", first) + self.run("新对话开工摘要", sys.executable, first / "tools/agents_status.py", "--brief", cwd=first) + self.run("创建业务任务分支", "git", "switch", "-c", "feature/cart-price", cwd=first) + owner = self.session("登记 Codex 文件范围", first, "start", "--agent", "codex", "--task", "整理价格计算", "--files", "cart.py") + self.session("阻止同工作区重复写任务", first, "start", "--agent", "claude", "--task", "重复任务", "--files", "README.md", expected=1) + self.run("先创建独立分支引用", "git", "branch", "feature/docs", "develop", cwd=first) + self.run("校验分支引用", "git", "rev-parse", "--verify", "feature/docs", cwd=first) + self.run("建立独立文档工作区", "git", "worktree", "add", "-q", worker, "feature/docs", cwd=first) + self.run("向 worktree 安装平台入口", sys.executable, installer, "install", worker, "--platforms", "claude") + conflict = self.session("阻止跨 worktree 的重复文件", worker, "start", "--agent", "claude", "--task", "冲突任务", "--files", "cart.py", expected=1) + self.check("冲突原因确为文件范围", "文件范围重叠" in conflict["error"]) + peer = self.session("允许独立文件任务", worker, "start", "--agent", "claude", "--task", "完善项目说明", "--files", "README.md") + log_path = self.root / "board-server.log" + self.server_log = log_path.open("w", encoding="utf-8") + self.server = subprocess.Popen([sys.executable, "-u", str(first / "tools/agents_status.py"), "--web", "--port", "0", "--no-open"], + cwd=first, stdout=self.server_log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + 20 + match = None + while time.monotonic() < deadline: + match = re.search(r"http://127\.0\.0\.1:(\d+)", log_path.read_text(encoding="utf-8")) + if match or self.server.poll() is not None: + break + time.sleep(0.1) + if not match: + raise RuntimeError("演练看板未能启动:" + log_path.read_text(encoding="utf-8")[-500:]) + base = match.group(0) + def read_board(): + with urllib.request.urlopen(base + "/data", timeout=10) as response: + return json.load(response) + self.check("真实网页 API 聚合两个会话", len(read_board()["sessions"]) == 2) + request = urllib.request.Request(base + "/directive", data=json.dumps({"to": "any", "body": "演练指令:完成后记录实际测试结果。"}).encode(), + headers={"Content-Type": "application/json", "X-Mac-Board-Action": "1", "Origin": base}) + with urllib.request.urlopen(request, timeout=10) as response: + directive = json.load(response) + self.check("网页指令真实落盘", (first / ".agents/inbox" / directive["file"]).is_file()) + rejected = self.session("未答人类指令阻止新开工", worker, "start", "--agent", "roo", "--task", "新任务", "--files", "notes/", expected=1) + self.check("阻止原因为人类 inbox", "人类指令" in rejected["error"]) + question = first / ".agents/inbox" / directive["file"] + content = question.read_text(encoding="utf-8") + kit.write_lf(question, content + "\n演练 agent 回执:已阅读并纳入任务,测试完成后补充结果。\n") + self.check("已确认的执行事项仍保持待处理", bool(read_board()["inbox_open"])) + self.session("更新原会话心跳", first, "heartbeat", owner["id"], "--agent", "codex", "--note", "已经处理指令") + handoff = self.session("记录中断与交接", first, "handoff", owner["id"], "--agent", "codex", "--summary", "等待确认后恢复价格计算任务") + self.session("未确认指令时阻止恢复", first, "resume", owner["id"], "--agent", "codex", expected=1) + resumed = self.session("确认指令后原会话恢复任务", first, "resume", owner["id"], "--agent", "codex", "--note", "已确认,继续原任务", "--ack-inbox", directive["file"]) + self.check("恢复沿用 ID 并关闭交接留言", resumed["id"] == owner["id"] and resumed["status"] == "active" and + "- status: answered" in (first / handoff["inbox"]).read_text(encoding="utf-8")) + (first / "cart.py").write_text("def total(items):\n subtotals = [price * quantity for price, quantity in items]\n return sum(subtotals)\n", encoding="utf-8") + self.run("真实业务代码测试", sys.executable, "-m", "unittest", "-q", cwd=first) + content = question.read_text(encoding="utf-8").replace("- status: open", "- status: answered") + kit.write_lf(question, content + "\n演练 agent 完成回复:python -m unittest -q,2 项业务测试通过,结果已记录。\n") + self.check("完成后网页 API 显示已答复", not read_board()["inbox_open"] and bool(read_board()["inbox_replied"])) + self.run("仅暂存本任务文件", "git", "add", "cart.py", cwd=first) + self.run("测试通过后提交业务改动", "git", "commit", "-q", "-m", "refactor(cart): clarify subtotals", cwd=first) + remote = self.root / "rehearsal-remote.git" + self.run("建立隔离的 bare 远端", "git", "init", "--bare", "-q", remote) + self.run("配置演练远端", "git", "remote", "add", "origin", remote, cwd=first) + self.run("向演练远端推送 feature 分支", "git", "push", "-q", "-u", "origin", "feature/cart-price", cwd=first) + self.session("完成文档会话", worker, "finish", peer["id"], "--agent", "claude", "--summary", "项目说明核对完成", "--tests", "未修改业务代码,已有测试在主工作区执行通过") + handoff = self.session("再次交接保留审计记录", first, "handoff", owner["id"], "--agent", "codex", "--summary", "任务已实现,等待收尾") + self.session("完成原会话并释放占用", first, "finish", owner["id"], "--agent", "codex", "--summary", "价格计算整理完成并推送本地演练远端", "--tests", "python -m unittest -q,2 tests passed") + self.check("收尾关闭关联交接", "- status: answered" in (first / handoff["inbox"]).read_text(encoding="utf-8")) + data = read_board() + self.check("看板收尾无悬挂会话或问题", all(s["closed"] for s in data["sessions"]) and not data["inbox_open"] and not data["warnings"]) + self.run("追加平台并同步", sys.executable, installer, "sync", first, "--platforms", "qwen,gemini,cline") + self.check("追加未覆盖原项目约定", "REHEARSAL" in agents.read_text(encoding="utf-8")) + self.run("完整流程后的体检", sys.executable, installer, "doctor", first) + status = self.run("核对 Git 工作区", "git", "status", "--porcelain", cwd=first) + self.check("全部协作产物均被忽略", not status.strip()) + original = note.read_bytes() + self.run("卸载预览", sys.executable, installer, "uninstall", first, "--purge-files", "--dry-run") + self.check("卸载预览不丢协作记录", note.read_bytes() == original) + + def cleanup(self): + if self.server is not None: + self.server.terminate() + self.server.wait(timeout=10) + if self.server_log is not None: + self.server_log.close() + if self.root.parent != Path(tempfile.gettempdir()).resolve() or not self.root.name.startswith("mac-rehearsal-"): + raise RuntimeError("拒绝清理演练目录之外的路径") + def writable(function, path, _error): + os.chmod(path, stat.S_IREAD | stat.S_IWRITE) + function(path) + shutil.rmtree(self.root, onerror=writable) + + def report(self, path, error): + text = ["# 协作流程实际演练报告", "", f"- 时间:{datetime.now().astimezone().isoformat(timespec='seconds')}", + f"- 结果:{'未通过' if error else '通过'}", f"- 检查数:{len(self.steps)}", f"- Python:{sys.version.split()[0]}", + "- 范围:真实本地 clone、Git/worktree、安装器、会话工具、HTTP 看板、业务测试和隔离 bare 远端。", + "- 边界:不创建真实 GitHub PR,不调用模型,不代替各平台的新会话加载验收。", ""] + if error: + text += ["## 断点", "", str(error), ""] + for i, step in enumerate(self.steps, 1): + text += [f"## {i}. {step['name']}", "", f"退出码 {step['rc']}(预期 {step['expected']}),耗时 {step['seconds']} 秒。", "", "```text", step["command"], "```", ""] + if step["output"]: + text += ["```text", step["output"], "```", ""] + output = "\n".join(text).replace(str(self.root), "<演练目录>").replace(str(KIT), "<套件工作区>") + path.parent.mkdir(parents=True, exist_ok=True) + kit.write_lf(path, output) + + +def main(): + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, default=KIT / ".agents/rehearsal-latest.md") + args = parser.parse_args() + rehearsal = Rehearsal() + error = None + try: + rehearsal.exercise() + except Exception as exc: + error = exc + print(f"FAIL {exc}", flush=True) + finally: + try: + rehearsal.cleanup() + except Exception as exc: + error = error or exc + rehearsal.report(args.report.resolve(), error) + print(f"报告:{args.report.resolve()}") + return 1 if error else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/assets/js/install-data.js b/site/assets/js/install-data.js index 487cdf6..32e2006 100644 --- a/site/assets/js/install-data.js +++ b/site/assets/js/install-data.js @@ -1,6 +1,6 @@ // 由 scripts/sync_install_docs.py 生成,请修改安装器或提示词模板。 window.MAC_INSTALL = { - "version": "1.5.0", + "version": "1.5.1", "platforms": [ { "id": "codex", @@ -124,6 +124,7 @@ window.MAC_INSTALL = { "tools/agents_session.py", ".agents/README.md", ".agents/START_HERE.md", + ".agents/WORKFLOW.md", ".agents/TROUBLESHOOTING.md", ".agents/sessions/_template.md", ".agents/inbox/_template.md", diff --git a/site/index.html b/site/index.html index 06607b8..125efe0 100644 --- a/site/index.html +++ b/site/index.html @@ -47,7 +47,7 @@ MULTI-AGENT-COLLAB - V1.5.0 + V1.5.1
      @@ -109,7 +109,7 @@
      - V1.5.0 + V1.5.1 --:--:--
      @@ -148,7 +148,7 @@
      - + @@ -157,7 +157,7 @@

      - CROSS-PLATFORM MULTI-AGENT · 跨平台多 AI 协作 · v1.5.0 · MIT + CROSS-PLATFORM MULTI-AGENT · 跨平台多 AI 协作 · v1.5.1 · MIT

      多个平台,一个项目。 @@ -321,7 +321,7 @@

      0+

      个 AI 协作会话的事故复盘

      0

      条 TROUBLESHOOTING 排障手册

      0

      项看板 selfcheck 内置断言

      -
      0

      项回归用例(含并发登记与平台适配)

      +
      0

      项回归用例(含并发登记与平台适配)

      0×0

      操作系统 × Python 版本的 CI 矩阵

      0

      第三方依赖 · Python 3.8+ 标准库

      @@ -626,7 +626,7 @@

      ? 想清楚再用

      - © 2026 AiFLYF — MIT LICENSE · V1.5.0 + © 2026 AiFLYF — MIT LICENSE · V1.5.1 规则沉淀自 ego-link-esp32 · 15+ AI 协作会话复盘
      diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 7961904..e2402c2 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -140,13 +140,65 @@ def test_concurrent_conflicting_registration_has_one_winner(self) -> None: def test_human_inbox_must_be_handled_before_start(self) -> None: question = self.root / ".agents/inbox/Q-2026-10-14-01.md" - question.write_text("# 人类指令\n- from: human\n- to: any\n- status: open\n\n暂停写操作。\n", encoding="utf-8") + question.write_text("# 人类指令\n- from: human\n- to: any\n- status: open\n\n请执行任务并记录实际测试结果。\n", encoding="utf-8") result, data = self.start(self.root) self.assertEqual(result.returncode, 1, data) self.assertIn("人类指令", data["error"]) + acknowledged, record = self.command(self.root, "start", "--agent", "codex", "--task", "按指令执行任务", + "--files", "src/app.py", "--ack-inbox", question.name) + self.assertEqual(acknowledged.returncode, 0, record) + self.assertIn("- status: open", question.read_text(encoding="utf-8"), "确认收到不能伪装成指令完成") + self.command(self.root, "finish", record["id"], "--agent", "codex", "--summary", "演练会话结束", "--tests", "仅验证登记流程,没有业务改动") question.write_text(question.read_text(encoding="utf-8").replace("status: open", "status: answered"), encoding="utf-8") self.assertEqual(self.start(self.root)[0].returncode, 0) + def test_resume_restores_same_session_and_answers_handoff(self) -> None: + _, record = self.start(self.root) + _, handoff = self.command(self.root, "handoff", record["id"], "--agent", "codex", "--summary", "等待外部结果") + path = Path(record["file"]) + with path.open("a", encoding="utf-8") as stream: + stream.write("\n## 保留的上下文\n不要覆盖。\n") + result, resumed = self.command(self.root, "resume", record["id"], "--agent", "codex", "--note", "外部结果就绪") + self.assertEqual(result.returncode, 0, resumed) + self.assertEqual(resumed["id"], record["id"]) + self.assertEqual(resumed["status"], "active") + content = path.read_text(encoding="utf-8") + self.assertTrue(content.startswith("# Session: " + record["id"] + "\n\n- agent:")) + self.assertIn("不要覆盖", content) + self.assertIn("- status: answered", (self.root / handoff["inbox"]).read_text(encoding="utf-8")) + + def test_resume_checks_human_instructions_addressed_to_session(self) -> None: + _, record = self.start(self.root) + _, handoff = self.command(self.root, "handoff", record["id"], "--agent", "codex", "--summary", "等待确认") + question = self.root / ".agents/inbox/Q-2026-10-14-targeted.md" + question.write_text(f"# 待执行任务\n- from: human\n- to: {record['id']}\n- status: open\n\n请补充测试。\n", encoding="utf-8") + result, data = self.command(self.root, "resume", record["id"], "--agent", "codex") + self.assertEqual(result.returncode, 1, data) + result, data = self.command(self.root, "check", "--agent", "codex", "--session", record["id"]) + self.assertIn(question.name, data["human_inbox"]) + result, data = self.command(self.root, "resume", record["id"], "--agent", "codex", "--ack-inbox", question.name) + self.assertEqual(result.returncode, 0, data) + self.assertIn("- status: open", question.read_text(encoding="utf-8")) + self.assertIn("- status: answered", (self.root / handoff["inbox"]).read_text(encoding="utf-8")) + + def test_finish_only_answers_own_linked_handoff(self) -> None: + _, record = self.start(self.root) + _, handoff = self.command(self.root, "handoff", record["id"], "--agent", "codex", "--summary", "准备结束") + path = Path(record["file"]) + original = path.read_text(encoding="utf-8") + human = self.root / ".agents/inbox/Q-2026-10-14-handoff-other.md" + human.write_text("# 人类问题\n- from: human\n- to: any\n- status: open\n", encoding="utf-8") + before = human.read_bytes() + path.write_text(original.replace(handoff["inbox"], ".agents/inbox/" + human.name), encoding="utf-8") + rejected, data = self.command(self.root, "finish", record["id"], "--agent", "codex", "--summary", "结束", "--tests", "测试场景") + self.assertEqual(rejected.returncode, 2, data) + self.assertEqual(human.read_bytes(), before) + path.write_text(original, encoding="utf-8") + result, data = self.command(self.root, "finish", record["id"], "--agent", "codex", "--summary", "正常收尾", "--tests", "测试场景") + self.assertEqual(result.returncode, 0, data) + self.assertIn("- status: answered", (self.root / handoff["inbox"]).read_text(encoding="utf-8")) + self.assertEqual(human.read_bytes(), before) + def test_unsafe_paths_and_shared_branch_are_rejected(self) -> None: for path in ("../outside", ".git/objects", "C:/outside", "src/*.py"): result, data = self.start(self.root, files=path) diff --git a/tools/agents_session.py b/tools/agents_session.py index 78e5688..fe0dfa5 100644 --- a/tools/agents_session.py +++ b/tools/agents_session.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""管理本机会话与文件占用:check / start / heartbeat / finish / handoff。""" +"""管理本机会话与文件占用:check / start / heartbeat / handoff / resume / finish。""" from __future__ import annotations @@ -114,9 +114,20 @@ def sessions_in(roots: list[Path]) -> list[dict]: return board.load_sessions([(root, False) for root in roots], datetime.now(), 15) -def pending_human_inbox(roots: list[Path], agent: str | None) -> list[str]: +def pending_human_inbox(roots: list[Path], agent: str | None, session_id: str | None = None) -> list[str]: return [q["file"] for q in board.load_inbox([(root, False) for root in roots]) - if q["open"] and q["from_human"] and q["to"] in ("any", agent)] + if q["open"] and q["from_human"] and q["to"] in ("any", agent, session_id)] + + +def acknowledge_for_call(pending: list[str], requested: list[str] | None) -> list[str]: + requested = requested or [] + unknown = set(requested) - set(pending) + if unknown: + raise ValueError("确认条目不是当前待处理的人类指令:" + ", ".join(sorted(unknown))) + unseen = set(pending) - set(requested) + if unseen: + raise ConflictError("先读取并回复人类指令;仍在执行的事项可显式 --ack-inbox 确认已纳入任务:" + ", ".join(sorted(unseen))) + return sorted(set(requested)) def check_conflicts(root: Path, branch: str, files: list[str], sessions: list[dict], @@ -145,8 +156,13 @@ def put_fields(text: str, fields: dict[str, str]) -> str: if re.search(pattern, text): text = re.sub(pattern, lambda _match: replacement, text, count=1) else: - head, separator, body = text.partition("\n\n") - text = head + "\n" + replacement + separator + body + metadata = list(re.finditer(r"(?m)^- [a-z_]+:.*$", text.split("\n## ", 1)[0])) + if metadata: + end = metadata[-1].end() + text = text[:end] + "\n" + replacement + text[end:] + else: + head, separator, body = text.partition("\n\n") + text = head + "\n\n" + replacement + "\n" + separator + body return text @@ -174,7 +190,34 @@ def handoff_note(root: Path, session_id: str, summary: str) -> str: "原会话保持 blocked,文件占用保留;确认交接后再由原会话收尾释放。\n\n## 回复\n") with path.open("x", encoding="utf-8", newline="\n") as stream: stream.write(content) - return str(path.relative_to(root)) + return path.relative_to(root).as_posix() + + +def close_handoffs(root: Path, record: dict, session_id: str, reply: str) -> list[str]: + """只回执本会话生成的交接条;普通问题、他人问题及人类指令不自动作答。""" + relative = record.get("handoff_inbox", "") + if relative: + candidate = safe_path(root, relative.replace("\\", "/")) + if candidate.parent != root / ".agents/inbox" or "-handoff-" not in candidate.name: + raise ValueError("关联交接路径无效,未修改会话") + candidates = [candidate] if candidate.is_file() else [] + else: + # 兼容 1.5.0:那时没有保存关联路径,仅匹配本会话生成的交接条。 + candidates = [p for p in safe_path(root, ".agents/inbox").glob("Q-*-handoff-*.md") + if board.parse_kv(p).get("from") == session_id] + pending = [] + for path in candidates: + safe_path(root, path.relative_to(root).as_posix()) + content = path.read_text(encoding="utf-8") + fields = board.parse_kv(path) + if fields.get("from") != session_id or not content.startswith(f"# 交接:{session_id}\n"): + raise ValueError("关联交接不属于本会话,拒绝自动回执") + if board.status_word(fields.get("status", "")) == "open": + content = put_fields(content, {"status": "answered", "answered": datetime.now().strftime("%Y-%m-%d %H:%M")}) + pending.append((path, content.rstrip() + "\n\n" + reply + "\n")) + for path, content in pending: + board._write_lf_atomic(path, content) + return [path.relative_to(root).as_posix() for path, _content in pending] def execute(args: argparse.Namespace) -> dict: @@ -187,7 +230,7 @@ def execute(args: argparse.Namespace) -> dict: files = normalize_files(root, args.files or []) if hasattr(args, "files") else [] if args.cmd == "check": conflicts = check_conflicts(root, branch, files, sessions_in(roots), args.session) - pending = pending_human_inbox(roots, args.agent) + pending = pending_human_inbox(roots, args.agent, args.session) return {"ok": not conflicts and not pending, "branch": branch, "files": files, "conflicts": conflicts, "human_inbox": pending} agent = one_line(args.agent, "平台名").lower() @@ -205,8 +248,7 @@ def execute(args: argparse.Namespace) -> dict: if not files: raise ValueError("开始写任务前请用 --files 声明具体文件或目录") pending = pending_human_inbox(roots, agent) - if pending: - raise ConflictError("先读取并回复发给当前平台或 any 的人类指令:" + ", ".join(pending)) + acknowledged = acknowledge_for_call(pending, getattr(args, "ack_inbox", None)) current_sessions = sessions_in(roots) conflicts = check_conflicts(root, branch, files, current_sessions) if conflicts: @@ -219,6 +261,7 @@ def execute(args: argparse.Namespace) -> dict: break text = (f"# Session: {session_id}\n\n- agent: {agent}\n- branch: {branch}\n- task: {task}\n" f"- files: {json.dumps(files, ensure_ascii=False)}\n- status: active\n" + f"- acknowledged_inbox: {json.dumps(acknowledged, ensure_ascii=False)}\n" f"- started: {now}\n- heartbeat: {now}\n- pr: \n\n" "## 当前进度\n\n- 已登记任务与文件范围。\n\n## 下一步\n\n- 按项目测试门禁完成任务。\n\n" "## 阻塞点 / handoff\n\n- 无\n") @@ -238,7 +281,26 @@ def execute(args: argparse.Namespace) -> dict: raise ValueError("会话已经结束,请创建新会话,不要重新激活历史记录") updates = {"heartbeat": now} result = {"ok": True, "id": session_id, "branch": branch} - if args.cmd == "heartbeat": + if args.cmd == "resume": + pending = pending_human_inbox(roots, agent, session_id) + acknowledged = acknowledge_for_call(pending, getattr(args, "ack_inbox", None)) + claimed, invalid = board.parse_claimed_files(record.get("files", "")) + if args.files is None: + if invalid: + raise ValueError("原文件范围无效,请用 --files 明确声明恢复范围") + files = normalize_files(root, claimed) + if not files: + raise ValueError("恢复写任务需要具体文件范围,请使用 --files") + conflicts = check_conflicts(root, branch, files, sessions_in(roots), session_id) + if conflicts: + raise ConflictError("\n".join(conflicts)) + note = one_line(args.note or "继续原任务", "恢复说明") + result["answered_handoffs"] = close_handoffs(root, record, session_id, "原会话恢复工作:" + note) + updates.update(status="active", files=json.dumps(files, ensure_ascii=False), progress=note, handoff="", handoff_inbox="") + updates["acknowledged_inbox"] = json.dumps(acknowledged, ensure_ascii=False) + if board.status_word(record.get("status", "")) == "blocked": + append_worklog(root, session_id, branch, "恢复原任务:" + note) + elif args.cmd == "heartbeat": if args.files is not None: conflicts = check_conflicts(root, branch, files, sessions_in(roots), session_id) if conflicts: @@ -250,11 +312,16 @@ def execute(args: argparse.Namespace) -> dict: summary = one_line(args.summary, "总结") tests = one_line(args.tests, "测试结果") if args.cmd == "finish" else "" if args.cmd == "handoff": + result["answered_handoffs"] = close_handoffs(root, record, session_id, "已由本会话的新交接条替代,详见最新会话记录。") result["inbox"] = handoff_note(root, session_id, summary) updates["status"] = "blocked" updates["handoff"] = summary + updates["handoff_inbox"] = result["inbox"] else: + result["answered_handoffs"] = close_handoffs(root, record, session_id, "原会话已收尾:" + summary + ";测试记录:" + tests) updates["status"] = "done" + updates["handoff"] = "" + updates["handoff_inbox"] = "" append_worklog(root, session_id, branch, summary, tests) board._write_lf_atomic(path, put_fields(text, updates)) result["status"] = updates.get("status", record.get("status")) @@ -268,21 +335,23 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", default=".", help="工作区路径,默认当前目录") commands = parser.add_subparsers(dest="cmd", required=True) - for name in ("check", "start", "heartbeat", "finish", "handoff"): + for name in ("check", "start", "heartbeat", "handoff", "resume", "finish"): sub = commands.add_parser(name) sub.add_argument("--json", action="store_true", help="输出结构化结果") if name != "check": sub.add_argument("--agent", required=True, help="当前平台,必须与会话所有者一致") - if name in ("heartbeat", "finish", "handoff"): + if name in ("heartbeat", "finish", "handoff", "resume"): sub.add_argument("session", help="自己的会话 ID") if name == "check": sub.add_argument("--session", default=None, help="只读检查时排除自己的会话") sub.add_argument("--agent", default=None, help="过滤发给当前平台的人类指令") - if name in ("check", "start", "heartbeat"): + if name in ("check", "start", "heartbeat", "resume"): sub.add_argument("--files", nargs="+", help="准备修改的相对文件或目录(目录以 / 结尾)") if name == "start": sub.add_argument("--task", required=True) - if name == "heartbeat": + if name in ("start", "resume"): + sub.add_argument("--ack-inbox", nargs="+", help="已实际读取并纳入本任务的人类指令文件名;不将其标记完成") + if name in ("heartbeat", "resume"): sub.add_argument("--note", default=None, help="简短进度,不重写已有正文") if name in ("finish", "handoff"): sub.add_argument("--summary", required=True) diff --git a/tools/agents_status.py b/tools/agents_status.py index 347a9b8..de69914 100644 --- a/tools/agents_status.py +++ b/tools/agents_status.py @@ -59,7 +59,7 @@ # 版本必须与 install.py 的 KIT_VERSION 保持一致(有测试锁定,防两边漂移)。 # 看板独立分发到目标项目,不能 import 安装器,所以各存一份常量。 -BOARD_VERSION = "1.5.0" +BOARD_VERSION = "1.5.1" # 人类可视化模式(--web / --html):纯本机,不影响 JSON/终端模式与 agent 用法 WEB_PORT_DEFAULT = 8731 @@ -1866,7 +1866,7 @@ def run_web(port: int, stale_min: int, with_prs: bool, open_browser: bool, httpd = make_server(ROOT, port=candidate, stale_min=stale_min, with_prs=with_prs, bind=bind, password=password, directives=directives) - actual = candidate + actual = httpd.server_address[1] break except OSError: continue @@ -1888,7 +1888,9 @@ def run_web(port: int, stale_min: int, with_prs: bool, open_browser: bool, if password else " 模式 只读分享(不能发指令)") print(f" 指挥 {'允许在网页上对 agent 下指令' if directives else '已关闭(只读)'}" f";每 {WEB_REFRESH_SEC} 秒刷新;Ctrl+C 停止") - if actual != port: + if port == 0: + print(f" (系统分配可用端口 {actual})") + elif actual != port: print(f" (默认端口 {port} 被占用,已自动改用 {actual})") if open_browser: try: From c0a1badba3997ba0253462821d8386819b8bcbe7 Mon Sep 17 00:00:00 2001 From: FLYF <1827658783@qq.com> Date: Wed, 14 Oct 2026 08:29:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(workflow):=20=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=81=9C=E6=AD=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组合工具调用需要逐项核对退出码,不能继续执行依赖已失败检查的提交或推送;同时清理演练脚本的行尾空格。 --- AGENTS.md | 1 + docs/WORKFLOW.md | 1 + scripts/rehearse_workflow.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c452a96..49aed69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ blocked 恢复用 `resume`,已结束的会话不能复活。场景选择和完 - **每个阶段间隙、跑长命令前重扫 inbox**:人类指令优先级最高(见第 4 节) 6. **测试门禁(命令见第 6 节)**:严格跑、不相关也保持绿;失败修代码不许改测试迁就; 能编译要真编译、能跑要真跑,静态检查/预览不算验证(A3/A5) + - 组合命令逐项检查退出码;前一步失败就停止依赖它的提交/推送,不用最后一条成功掩盖前面的失败 7. 测试通过后只 add 本任务项目文件并 commit,运行 `git rev-parse --short HEAD` 复核; 再推送并 `gh pr create --base develop`(推送失败按 C1/C5/C8/C9;记忆文件不随 git 走) 8. **收尾(本地)**:用 `agents_session.py finish <会话ID> --agent <平台> --summary "总结" --tests "实际测试结果"` diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 333816c..8164260 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -75,6 +75,7 @@ start / resume 可用 `--ack-inbox Q-日期-序号.md` 显式确认已阅读并 说明当前分支、这一阶段的实际变更、运行过的测试和结果,以及下一步或阻塞点。 没有提交就写“暂无新提交”。不要重复完整历史、倾倒源码,或把意向写成已经完成。 工具失败时保留输出、分支、未提交位置;先查故障手册,再做可恢复的修复。 +组合命令必须逐项核对退出码;前一步失败时停止依赖它的提交或推送,不能只看最后一条命令是否成功。 ## 验证一次完整使用流程 diff --git a/scripts/rehearse_workflow.py b/scripts/rehearse_workflow.py index 2c29933..f444a94 100644 --- a/scripts/rehearse_workflow.py +++ b/scripts/rehearse_workflow.py @@ -188,7 +188,7 @@ def writable(function, path, _error): def report(self, path, error): text = ["# 协作流程实际演练报告", "", f"- 时间:{datetime.now().astimezone().isoformat(timespec='seconds')}", f"- 结果:{'未通过' if error else '通过'}", f"- 检查数:{len(self.steps)}", f"- Python:{sys.version.split()[0]}", - "- 范围:真实本地 clone、Git/worktree、安装器、会话工具、HTTP 看板、业务测试和隔离 bare 远端。", + "- 范围:真实本地 clone、Git/worktree、安装器、会话工具、HTTP 看板、业务测试和隔离 bare 远端。", "- 边界:不创建真实 GitHub PR,不调用模型,不代替各平台的新会话加载验收。", ""] if error: text += ["## 断点", "", str(error), ""] From 7fb94371b418b1d6d8f4e383dc52ea2f9c802f8a Mon Sep 17 00:00:00 2001 From: FLYF <1827658783@qq.com> Date: Wed, 14 Oct 2026 10:43:38 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(collab):=20=E9=80=82=E9=85=8D=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=AD=96=E7=95=A5=E4=B8=8E=E5=A4=B1=E8=81=94=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让不同分支流程的项目复用同一协作工具,并为失联占用提供保留现场和审计的恢复路径;同步平台入口、安装提示词与完整验证。 --- .github/workflows/selftest.yml | 2 +- AGENTS.md | 24 +- CHANGELOG.md | 12 + CLAUDE.md | 2 +- README.md | 39 ++- docs/PLATFORMS.md | 12 + docs/README.md | 4 + docs/START_HERE.md | 5 +- docs/TAKEOVER.md | 58 +++++ docs/TROUBLESHOOTING.md | 2 +- docs/VALIDATION.md | 33 ++- docs/WORKFLOW.md | 22 +- install.py | 114 ++++++++- platforms/claude-command.md | 10 +- platforms/trae-rule.md | 4 +- platforms/workbuddy-header.md | 4 +- scripts/rehearse_workflow.py | 32 ++- scripts/sync_install_docs.py | 15 +- site/assets/js/install-data.js | 11 +- site/assets/js/install.js | 11 +- site/index.html | 32 ++- templates/install-prompt.md | 5 +- tests/test_policy_takeover.py | 432 +++++++++++++++++++++++++++++++++ tools/agents_policy.py | 101 ++++++++ tools/agents_session.py | 303 ++++++++++++++++++++++- tools/agents_status.py | 2 +- 26 files changed, 1218 insertions(+), 73 deletions(-) create mode 100644 docs/TAKEOVER.md create mode 100644 tests/test_policy_takeover.py create mode 100644 tools/agents_policy.py diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index f96acaa..4d1c722 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -38,7 +38,7 @@ jobs: run: python tests/selftest.py - name: Session coordination and platform integration - run: python -m unittest discover -s tests -p test_sessions.py + run: python -m unittest discover -s tests -p "test_*.py" - name: Full workflow rehearsal if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' }} diff --git a/AGENTS.md b/AGENTS.md index 49aed69..6e84d3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.md — 多 Agent 协作总章(Git Flow + 本机共享记忆) +# AGENTS.md — 多 Agent 协作总章(项目分支策略 + 本机共享记忆) > 本文件是当前项目内所有 AI 编码 agent 的**统一协作规则**。本文件、`CLAUDE.md`、`.agents/`、 > `.agent/`、`.claude/`、`.trae/`、`.workbuddy-ai/`、`.workbuddy/`、`tools/agents_status.py` @@ -21,6 +21,11 @@ ## 1. 分支铁律(项目代码走这套;协作体系文件不参与 git) +新对话先读 `.agents/POLICY.md`,写任务用 `agents_session.py check` 核对实际策略。 +安装时可明确选择 Git Flow、GitHub Flow 或 custom;未配置的旧项目默认使用下表的 Git Flow。 +已配置时,任务前缀、开发基线和 PR 目标以项目策略为准;下表仅适用于 Git Flow。 +策略不能放开主干直推、强推、历史改写或测试门禁,也不代表授权修改远端分支结构。 + | 分支 | 用途 | 允许的合并来源 | |---|---|---| | `main` | 生产分支 | 仅 `release/*`、`hotfix/*` | @@ -30,7 +35,7 @@ **禁止操作(无例外)**: -- 禁直接 commit/push 到 `main`/`develop`(走 PR);禁 `push --force` 到共享分支、历史改写 +- 禁直接 commit/push 到受保护分支(始终含 `main`/`master`/`develop` 和所选基线,走 PR);禁 `push --force` 到共享分支、历史改写 - 禁删除/移动/重建 `.git`;禁删别的 agent 的分支、stash、session 记录 - 禁测试未过就提交/推送/开 PR;禁 `git add -A`/`git add .`,只 add 本任务相关文件 - 禁提交第 8 节的本机私有文件(忽略失效先排查,不得 `git add -f`) @@ -56,9 +61,9 @@ blocked 恢复用 `resume`,已结束的会话不能复活。场景选择和完 - 有 GitHub 远端时**必查 open PR**(`gh pr list --state open` 或看板 `--prs`);其它托管平台按项目约定检查,本地仓库说明不适用 - **判活三信号交叉**:看板 + 构建进程(`tasklist`/`ps`)+ `git reflog -8` 与文件 mtime; 看板"无活跃"≠没人干活(不注册就隐形,手册 A1)。有活跃会话占同分支/同批文件就**不要抢** -2. 新写任务从已确认的 `develop` 基线开始:`git switch develop` 后 `git pull --ff-only`。 +2. 新写任务从项目策略的已确认基线开始:`git switch <基线>` 后 `git pull --ff-only`(Git Flow 默认 `develop`)。 继续原任务不要切走。基线不存在时报告分支结构、请用户决定,不擅自建立远端主干;可继续独立的只读检查和本地验证。 -3. `git switch -c feature/<任务名>`(小写英文+连字符),建完 `git rev-parse --verify feature/<名>` +3. 按项目允许前缀新建任务分支,如 `git switch -c feature/<任务名>`(小写英文+连字符),建完 `git rev-parse --verify <分支>` 复核(沙箱可能"成功"却没建出来,见 B13/B14) 4. **注册会话**(纯本地):优先用 `python tools/agents_session.py start --agent <平台> --task "任务" --files <文件或目录>`。 保存返回的会话 ID;工具检查同工作区、分支和跨 worktree 文件占用,有冲突就停止。 @@ -69,7 +74,7 @@ blocked 恢复用 `resume`,已结束的会话不能复活。场景选择和完 能编译要真编译、能跑要真跑,静态检查/预览不算验证(A3/A5) - 组合命令逐项检查退出码;前一步失败就停止依赖它的提交/推送,不用最后一条成功掩盖前面的失败 7. 测试通过后只 add 本任务项目文件并 commit,运行 `git rev-parse --short HEAD` 复核; - 再推送并 `gh pr create --base develop`(推送失败按 C1/C5/C8/C9;记忆文件不随 git 走) + 再推送并 `gh pr create --base <基线>`(Git Flow 为 develop;推送失败按 C1/C5/C8/C9;记忆文件不随 git 走) 8. **收尾(本地)**:用 `agents_session.py finish <会话ID> --agent <平台> --summary "总结" --tests "实际测试结果"` 将自己的 session 置 `done` 并追加 worklog、回执自己的交接条;中途交接用 `handoff`,保留占用直到确认交接 9. 每步用中文汇报四件套:**当前分支 / commit message / 变更文件 / 测试结果** @@ -91,6 +96,9 @@ blocked 恢复用 `resume`,已结束的会话不能复活。场景选择和完 | 平台列表 / 新增与同步 | `python <套件目录>/install.py platforms` / `… sync <项目> --platforms cursor` | 平台选择保存在 `.agents/install.json`;省略参数沿用 | | 登记 / 心跳 / 恢复 / 收尾 | `python tools/agents_session.py start\|heartbeat\|resume\|finish\|handoff --help` | 只更新自己的会话;支持 `--json` | | 文件占用检查 | `python tools/agents_session.py check --agent <平台> --files <范围> --json` | 返回 1 时先处理冲突或人类 inbox | +| 项目分支策略 | 读 `.agents/POLICY.md`;`check --json` 报告实际策略 | 主 worktree 的 policy.json 为共同来源 | +| 原会话失联 | `python tools/agents_session.py takeover-inspect --json` | 先只读;执行流程见 `.agents/TAKEOVER.md` | +| 客户端检查 | `python <套件目录>/install.py clients <项目> --versions` | 只查 CLI 版本,新对话加载仍需实际验证 | | 报错怎么办 | 读 `.agents/TROUBLESHOOTING.md` | 按编号 A/B/C/D 查 | 退出码:0 = 无告警,1 = 有失活会话/未答问题/未注册 worktree。参数以 `--help` 实际输出为准,不用背。 @@ -106,13 +114,15 @@ blocked 恢复用 `resume`,已结束的会话不能复活。场景选择和完 会话 ID:`--<4位随机>`。 - **心跳**:活跃期每 ≤15 分钟或每阶段更新(保存即生效);跑 >15 分钟长任务前写明"预计 HH:MM 回来"。 - 超 15 分钟算失活;要接管先在 inbox 发接管声明、等 5 分钟无反对,从其分支切新分支,**保留并注明其全部提交** + 超 15 分钟算失活,不自动释放。原会话失联时先 inspect 核对,再 plan 发布声明;等 5 分钟无反对、 + 用户确认具体计划且快照一致后才能 apply 收回占用,详见 `.agents/TAKEOVER.md`。随后从原提交切新分支,**保留并注明其全部提交和未提交文件** - 多 agent 并发写同一记忆文件取**并集**,只多不少 - 编码会话声明 `files`(JSON 数组,目录以 `/` 结尾);扩大范围前用 heartbeat 的 `--files` 重新检查。 会话工具通过主 worktree `.agents/coordination.lock` 的系统锁串行登记,进程退出自动释放锁。 失活或 blocked 会话的文件占用不会自动释放,不能借超时抢占。手工绕过登记仍可能覆盖文件。 - 当前占用粒度为文件/目录,不支持“同一文件不同函数”自动并行;先拆分任务或串行交接。 - `resume` 恢复自己的 blocked 会话并回执其交接条;`finish` 收尾时也回执关联交接,普通问题不能代答。 +- 人工接管将原会话置为 closed,表示收回占用,不表示原任务或测试完成;不删除原 session,也不自动切换分支。 - 任何环境/工具/git 报错解决后,往 TROUBLESHOOTING.md 追加一条(现象/判断/真因/修复) **人类指令(用户从看板网页下达,优先级最高)**:inbox 里 `from: human*` 的文件,与 agent 留言同协议。 @@ -210,7 +220,7 @@ AGENTS.md CLAUDE.md .agents/ .agent/ .claude/ .trae/ .workbuddy-ai/ .workbuddy/ tools/agents_status.py .cursor/rules/multi-agent-collab.mdc .github/copilot-instructions.md .windsurf/rules/multi-agent-collab.md -tools/agents_session.py GEMINI.md .qwen/QWEN.local.md +tools/agents_session.py tools/agents_policy.py GEMINI.md .qwen/QWEN.local.md .clinerules/multi-agent-collab.md .roo/rules/00-multi-agent-collab.md .continue/rules/multi-agent-collab.md .kiro/steering/multi-agent-collab.md ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f24e3f..de255d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ `.agents/install.json`,`doctor` 据此提示升级。 版本号遵循语义化版本(major.minor.patch)。 +## 1.6.0 — 2026-10-14 + +- 新增项目分支策略:Git Flow、GitHub Flow 与自定义基线/前缀。安装和同步生成 policy.json / POLICY.md, + 升级沿用;各 worktree 共用主策略,不能通过旧副本绕过主干保护。 +- 会话工具按策略校验新任务、恢复与范围扩展;已持有会话仍可记录进度和收尾。损坏配置明确失败,不猜测默认值。 +- 新增失联接管的 inspect / plan / apply / cancel。计划保存会话、Git 和改动指纹;等待 5 分钟并记录实际用户确认后, + 快照未变才收回占用。原记录为 closed,保留原提交、分支、暂存及未提交文件;中断留下审计状态供人工核对。 +- 新增 clients 检查:区分 PATH 可发现、版本查询与新对话验证。只运行固定 --version,不调用模型,不保存原始输出或修改全局设置。 +- 启动清单、操作手册、Claude/Trae/WorkBuddy 入口、README 与安装提示词统一采用项目策略;补充接管手册及卸载保留说明。 + 展示页可选 Git Flow / GitHub Flow,生成一致的命令和提示词。 +- 增加策略、接管和客户端回归,并纳入三系统四 Python 版本 CI;完整隔离演练覆盖新流程。 + ## 1.5.1 — 2026-10-14 - 将启动清单、操作手册和字段说明分开,区分只读咨询、新任务、继续原任务、blocked 恢复和收尾。 diff --git a/CLAUDE.md b/CLAUDE.md index e2ffe51..7cd68cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md(本机文件,不入库;见 AGENTS.md 第 8 节) -本工作区的完整工作规则在 [AGENTS.md](AGENTS.md)(Git Flow 分支铁律 + 多 agent 本机共享记忆协议), +本工作区的完整工作规则在 [AGENTS.md](AGENTS.md)(项目分支策略 + 多 agent 本机共享记忆协议), 开始任何任务前必须先读: @AGENTS.md diff --git a/README.md b/README.md index 07a45bf..19a041e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](#安装下载一次按项目复用) [![deps](https://img.shields.io/badge/dependencies-none-success)](#仓库结构与验证) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![changelog](https://img.shields.io/badge/changelog-1.5.1-orange)](CHANGELOG.md) +[![changelog](https://img.shields.io/badge/changelog-1.6.0-orange)](CHANGELOG.md) 让同一台电脑上、同一项目里的多个 AI 助手遵循一份协作规则:开工先确认谁在做什么, 改动前避开冲突,收工留下能接手的记录。支持 Codex、Claude Code、OpenCode、Pi、Trae、 @@ -46,6 +46,7 @@ python "$HOME/multi-agent-collab/install.py" doctor . - **原生平台**:只选 `codex`、`opencode` 或 `pi` 时共用 `AGENTS.md`,不生成其它平台目录,连 `CLAUDE.md` 也不安装。 - **预览文件**:安装命令加 `--dry-run`;只要通用规则可选 `--platforms none`,确实需要全部平台才用 `all`。 - **升级已有项目**:再次运行 `install .` 会沿用之前的选择,不再询问或偷偷增加平台。 +- **适配项目分支流程**:默认 Git Flow;只有 main 的项目可明确使用 `--workflow github-flow`,也可配置基线和任务前缀。安装器不创建分支。 也可以从任意目录指定目标,例如: @@ -55,7 +56,7 @@ python "$HOME/multi-agent-collab/install.py" install "D:\code\my-app" --platform ### 复制提示词给 AI -在**目标项目的对话**里粘贴下面这段。AI 能确认当前平台时直接使用;无法确认时只询问平台选择。 +在**目标项目的对话**里粘贴下面这段。AI 先沿用已确认的平台和项目分支约定,缺少信息时才询问对应项。 [在线配置器](https://aiflyf.github.io/multi-agent-collab/#sec-6) 可以按勾选的平台生成同一份提示词。 使用 fork 时,将仓库地址换成你的 fork。 @@ -66,12 +67,13 @@ python "$HOME/multi-agent-collab/install.py" install "D:\code\my-app" --platform 1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。 2. 优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。 + 读取项目现有分支约定与 .agents/policy.json;已有策略沿用,首次安装按已确认流程选择 git-flow、github-flow 或 custom。只有 main 的项目可使用 github-flow;无法确认时只询问分支流程,不擅自建立 develop 或改变远端结构。 3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab "<用户主目录>/multi-agent-collab"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。 -4. 使用绝对路径运行 python "<套件目录>/install.py" install "" --platforms <已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 +4. 使用绝对路径运行 python "<套件目录>/install.py" install "" --platforms <已选名称> --dry-run;首次安装附加已确认的 --workflow 参数,自定义流程还要 --base-branch 和 --task-prefixes。核对文件清单与分支策略后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。 6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python "<套件目录>/install.py" sync "" 刷新已选入口。 -7. 执行 python "<套件目录>/install.py" doctor "",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。 +7. 执行 python "<套件目录>/install.py" doctor "",并核对 git status --short;可用 clients "" --versions 检查 CLI 可用性,不启动模型。汇报套件复用路径、已选平台、项目分支策略、生成文件和体检结果;解释 WARN,版本查询不代表新对话已加载规则。 8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync "" --platforms <新增名称>,无需重复 clone;旧对话需要主动重新读取规则。 执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。 @@ -81,7 +83,7 @@ python "$HOME/multi-agent-collab/install.py" install "D:\code\my-app" --platform ### 装完验证三件事 1. 填写项目 `AGENTS.md` 第 6 节的真实测试、构建命令;选了 Claude 再填写 `CLAUDE.md` 速览。 - 定制区升级会保留。默认规则采用 Git Flow,项目需要有相应分支约定;安装器不会擅自创建分支。 + 定制区升级会保留。核对 `.agents/POLICY.md` 与项目分支约定一致;安装器不会擅自创建分支。 2. 跑 `doctor .`,再在目标项目执行 `python tools/agents_status.py --brief`。检查 Git 忽略和入口是否完整。 3. **新开对话**,确认 agent 实际读取 `.agents/START_HERE.md`,并报告分支、看板与 inbox 当前结果。 旧对话不会自动重新加载。只复述“我会先看板”不算验证通过。 @@ -94,12 +96,14 @@ python "$HOME/multi-agent-collab/install.py" install "D:\code\my-app" --platform ├── .agents/ └── install.py 所有项目复用 │ ├── START_HERE.md 新对话入口 │ ├── install.json 已选平台、版本、源码路径 +│ ├── POLICY.md 当前分支策略(配置在 policy.json) │ ├── sessions/ 谁在什么分支做什么 │ ├── worklog/ 最近完成了什么,下一步是什么 │ ├── inbox/ 问题、交接、你的指令 │ └── … 格式说明、故障手册和模板 ├── tools/agents_status.py 本地看板 ├── tools/agents_session.py 登记、占用检查、心跳和收尾 +├── tools/agents_policy.py 安装器与会话工具共用的策略校验 └── 所选平台入口 见下表;没有选的不会创建 ``` @@ -199,7 +203,7 @@ AGENTS.md CLAUDE.md .agents/ .agent/ .claude/ .trae/ .workbuddy-ai/ .workbuddy/ tools/agents_status.py .cursor/rules/multi-agent-collab.mdc .github/copilot-instructions.md .windsurf/rules/multi-agent-collab.md -tools/agents_session.py GEMINI.md .qwen/QWEN.local.md +tools/agents_session.py tools/agents_policy.py GEMINI.md .qwen/QWEN.local.md .clinerules/multi-agent-collab.md .roo/rules/00-multi-agent-collab.md .continue/rules/multi-agent-collab.md .kiro/steering/multi-agent-collab.md ``` @@ -251,12 +255,28 @@ python tools/agents_status.py --web --bind 0.0.0.0 --no-directives | `python install.py install <项目> --dry-run` | 安装预览,不写盘 | | `python install.py sync <项目> [--platforms cursor]` | 刷新已选入口,或追加平台 | | `python install.py doctor <项目>` | 核心文件、已选入口、版本、忽略及看板冒烟检查 | +| `python install.py clients <项目> [--versions] [--json]` | CLI 发现与版本检查;新对话加载仍需实际验证 | +| `python install.py install <项目> --workflow github-flow` | 明确使用 main 为 PR 基线;不建立或切换 Git 分支 | | `python install.py mirror <项目>` | 旧命令兼容:追加 WorkBuddy 并同步已选入口 | | `python install.py uninstall <项目>` | 移除忽略托管块,文件保留,此后会出现在 Git 状态中 | -| `python install.py uninstall <项目> --purge-files` | 再清理未修改的套件文件;定制、会话、日志、留言、备份保留 | +| `python install.py uninstall <项目> --purge-files` | 再清理未修改的套件文件;定制、会话、日志、留言、策略、接管记录及备份保留 | 任何命令先用 `--help` 查看当前参数。手工复制容易漏掉平台选择记录和 Git worktree 的忽略位置,建议使用安装器。 +### 项目策略与失联接管 + +`git-flow` 默认使用 develop;`github-flow` 默认使用 main;`custom` 明确基线和任务前缀。 +升级和同步会沿用保存的策略,同一仓库各 worktree 以主工作区策略为准。 +main、master、develop 和所选基线始终受保护。详见 [协作手册](docs/WORKFLOW.md#按项目选择分支策略)。 + +```text +python <套件目录>/install.py install <项目> --workflow custom --base-branch trunk --task-prefixes task/,fix/ +python tools/agents_session.py takeover-inspect <原会话ID> --json +``` + +原会话失联时,先只读检查,再生成接管计划。声明至少等待 5 分钟、用户确认具体计划且快照一致后, +才收回协作占用;保留原会话、提交与未提交文件。完整命令见 [失联接管](docs/TAKEOVER.md)。 + ## 出问题时 先看目标项目的 `.agents/TROUBLESHOOTING.md`,再运行 `doctor`。 @@ -275,6 +295,7 @@ multi-agent-collab/ ├── AGENTS.md CLAUDE.md # 发布到项目的规则模板 ├── tools/agents_status.py # 零依赖协作看板 ├── tools/agents_session.py # 占用检查、原子登记、心跳、收尾与交接 +├── tools/agents_policy.py # 项目分支策略与校验 ├── templates/ # 会话 / 留言 / 日志 / 安装提示词 ├── platforms/ # 按需安装的平台入口模板 ├── docs/ # 启动流程、平台说明、共享格式与故障手册 @@ -283,13 +304,15 @@ multi-agent-collab/ ├── site/ # 介绍页、安装配置器、交互演示(静态文件) ├── tests/selftest.py # 43 个测试用例(15 单元 + 28 安装器 E2E) ├── tests/test_sessions.py # 协作并发、占用、交接和新增平台回归 +├── tests/test_policy_takeover.py # 策略继承、接管保护和客户端探测回归 └── .github/workflows/ # 3 系统 × 4 Python 版本 CI + Pages ``` ```bash python tools/agents_status.py --selfcheck python tests/selftest.py -python -m unittest discover -s tests -p test_sessions.py +python -m unittest discover -s tests -p "test_*.py" +python scripts/rehearse_workflow.py --report .agents/rehearsal-latest.md python scripts/sync_install_docs.py --check ``` diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index aa5c302..9a506cd 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -55,6 +55,18 @@ python "$HOME/multi-agent-collab/install.py" sync . ## 如何确认真的生效 +可先查询项目已选客户端的 CLI: + +```text +python <套件目录>/install.py clients <项目> +python <套件目录>/install.py clients <项目> --versions --json +``` + +不加 --versions 只查 PATH;加上后运行固定的版本命令,不启动模型或修改全局配置。 +没有安装记录时用 --platforms 指定检查范围。IDE/应用内平台显示手动验证,不会误报“未安装”。 +输出只保留版本与状态,不保存原始 stdout/stderr;session_verified 始终为 false,需要下面的实际新对话检查。 +命令返回 0 表示检查已完成,不表示每个平台可用;检查各条目的 probe_status。 + 先执行 `doctor` 检查文件、镜像一致性和 Git 忽略,再新开对话使用: ```text diff --git a/docs/README.md b/docs/README.md index d43de7d..414bf46 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ ├── README.md # 本文件 ├── START_HERE.md # 每次新对话的最小阅读顺序 ├── WORKFLOW.md # 场景选择、状态转换、完整命令和验证边界 +├── POLICY.md / policy.json # 项目分支策略:阅读入口 / 配置事实源 +├── TAKEOVER.md / takeovers/ # 失联接管手册 / 不自动清理的计划和审计 ├── install.json # 项目名、已选平台、套件源路径与版本 ├── coordination.lock # 登记过程的系统锁(主 worktree 共用,退出自动释放) ├── sessions/ # 每个活跃会话一个文件:我是谁、在哪个分支、心跳 @@ -61,6 +63,8 @@ - `progress` 是当前简短进度;`handoff` 是阻塞说明;`handoff_inbox` 指向该会话生成的交接条。 resume / finish 只自动回执自己的交接条,不会替你回答其它问题。 - `acknowledged_inbox` 记录本次 start / resume 显式确认已纳入任务的人类指令;它不改变原指令的 open 状态。 +- 人工接管后状态为 `closed`;`closed_by`、`closed_at`、`takeover_plan` 保存出处,原正文保留。 + 这仅表示占用已收回,不代表原任务完成,具体步骤见 [TAKEOVER.md](TAKEOVER.md)。 - 头部字段表示当前状态,正文保留上下文与历史;使用范式见 [WORKFLOW.md](WORKFLOW.md)。 ## 工作日志 `worklog/YYYY-MM-DD.md` diff --git a/docs/START_HERE.md b/docs/START_HERE.md index f09d9e3..5f34da0 100644 --- a/docs/START_HERE.md +++ b/docs/START_HERE.md @@ -7,6 +7,7 @@ 1. 确认工作区与分支,运行 `git status --short --branch` 和 `python tools/agents_status.py --brief`。 看板返回 1 时展开完整版并查看原因,不能把“无活跃”当作“无人占用”。 + 读取 `.agents/POLICY.md` 的项目分支策略;旧安装缺失时按总章的 Git Flow,并在升级后生成策略说明。 2. 读取发给当前平台、自己的会话或 `any` 的未答 inbox,以及近 1–2 天 worklog。 收到暂停、取消或避让要求先处理;缺文件就说明,不编造记录。 3. 判断这是只读咨询、新写任务,还是当前任务继续。只读任务不用登记写会话。 @@ -37,7 +38,7 @@ python tools/agents_session.py finish --agent <平台> --summary "实际结 - 每个阶段、长命令前重扫 inbox;每 15 分钟内或每阶段更新心跳。 - 扩大修改范围前,用 heartbeat 的 `--files` 提交**完整新列表**,检查成功后再修改。 -- blocked 保留占用;恢复原任务用 resume。done 才释放占用,不能复活已结束的记录。 +- blocked 保留占用;恢复原任务用 resume。正常收尾用 done;失联接管按 [TAKEOVER.md](TAKEOVER.md) 收回为 closed,不能复活已结束的记录。 - resume / finish 回执自己的交接条,普通问题仍需在原文件里回复。 - 测试失败不能提交或推送;预览和静态检查不能替代实际运行。 @@ -47,6 +48,6 @@ python tools/agents_session.py finish --agent <平台> --summary "实际结 ## 同步入口 新增平台或刷新 WorkBuddy 镜像用 `python "<套件目录>/install.py" sync "<项目>" --platforms <新增平台>`; -没有新增平台时省略参数。核心工具和规则升级使用 install,实际套件路径记录在 install.json 的 source_dir。 +没有新增平台时省略参数。sync 同时刷新项目策略说明;核心工具和规则升级使用 install,实际套件路径记录在 install.json 的 source_dir。 已有对话不会因为文件变化就自动重新载入;需要新开对话或明确重读。 系统锁只保护登记更新,不能锁住源码;规则是否被客户端加载仍需实际新会话验收。 diff --git a/docs/TAKEOVER.md b/docs/TAKEOVER.md new file mode 100644 index 0000000..77b7343 --- /dev/null +++ b/docs/TAKEOVER.md @@ -0,0 +1,58 @@ +# 原会话失联时如何接管 + +同一任务继续用自己的 ID;原执行者仍能回应时,优先让其 handoff / finish。 +只有原会话失联、占用确实妨碍已授权任务时,才使用这里的流程。心跳过期本身不会释放占用。 + +## 先检查,再生成可核对的计划 + +```text +python tools/agents_session.py takeover-inspect <原会话ID> --json +python tools/agents_session.py takeover-plan <原会话ID> --agent <当前平台> --reason "接管理由" --json +``` + +inspect 只读。plan 在主 worktree 的 `.agents/takeovers/` 保存快照,并在 inbox 发布 agent 接管声明。 +计划包含原 ID、所属工作区、分支与提交、文件范围、Git 状态以及改动文件的指纹,不复制文件内容。 +它只申请收回协作占用,没有执行接管。 + +生成计划前必须交叉核对看板、进程、reflog 和文件修改时间。工具会拒绝以下情况: + +- 原会话已结束、分支改变、心跳缺失或无效,或不同工作区有相同会话 ID。 +- 心跳、会话记录或改动文件在 15 分钟内更新;原工作区存在 Git index.lock。 +- 有待处理的人类指令,或已有待处理接管计划。 +- 改动超过 100 个文件或 8 MiB,涉及链接、目录或子模块等需要人工核对的情况。 + +系统锁只约束本套件的登记工具,不能证明别的编辑器或进程已停。没有足够证据时继续只读调查。 +无法使用自动流程时,保留现场与记录,让用户决定具体处理方式;不要直接删 session 或改成 done。 + +## 等待并确认具体计划 + +把 plan 返回的计划 ID、原会话、分支、提交和未提交改动交给用户核对。 +取得用户对**该计划**的明确确认后,再使用以下命令;已有的明确确认无需重复索取。 + +```text +python tools/agents_session.py takeover-apply <计划ID> --agent <当前平台> --confirm-session <原会话ID> --approval-note "实际用户确认的记录" --json +``` + +从声明发布起至少等待 5 分钟,期间继续检查 inbox。任何回复、心跳、工作文件、暂存内容、分支/提交或策略变化, +都会使 apply 停止。不能为了通过校验修改快照、时间戳或删除回复;原计划取消后重新协调。 +`--approval-note` 和 `--agent` 都只是记录与防误操作字段,不是身份认证,也不能代替用户实际授权。 +`--ack-inbox` 仅表示真实阅读并纳入任务,不能绕过暂停、取消、避让要求。 + +成功后原会话保留全文并置为 `closed`,记录执行者、原因与计划 ID,同时回执关联交接并追加日志。 +这不表示原任务完成或测试通过。Git 分支、提交、stash、暂存和工作文件保持原状。 + +后续先确认原工作区不再有写入进程,保存和核对未提交文件,再从计划里的原提交建立自己的任务分支并登记。 +新 worktree 不会自动带走未提交内容,禁止未经核对就清理旧工作区。后续汇报必须注明继承的提交和文件。 + +## 取消或处理中断 + +```text +python tools/agents_session.py takeover-cancel <计划ID> --agent <发起平台> --reason "取消原因" --json +``` + +取消会保留计划、声明及所有回复,只改自己的计划状态,不释放原会话占用。 +已应用、已取消或正在应用的计划不能重复执行。 +多份本地记录无法同时原子写入;工具在变更会话前先记 `applying` 审计状态。 +若写入中断,应逐项核对计划、原 session、交接条和 worklog 后人工恢复,不能重跑命令赌结果。 + +接管记录属于本机数据,即使 `uninstall --purge-files` 也会保留;卸载会移除忽略规则,避免将残留记录误提交。 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 01338c8..88aaf9f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -90,7 +90,7 @@ - 同一分支/同一批文件已有活跃会话:不抢活,inbox 留言或等失活 - 长时间占用主工作区时,另一个会话应开 worktree 而不是并发 switch (但要遵守 [A1](#a1) 的注册义务) -- 接管失活会话分支前:inbox 发接管声明、等 5 分钟无反对,保留并注明其全部提交 +- 接管失活会话分支前:按 [TAKEOVER.md](TAKEOVER.md) 生成计划和声明、等 5 分钟无反对,用户确认后才收回占用;保留并注明其全部提交与未提交文件 - 两个会话确实要碰同一个文件时:先在 inbox 交换"我改哪几个函数/哪几行", 确认零重叠再动手;后动手的一方在对方基础上适配,而不是各改各的 diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 1c6581b..ddd3947 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -3,8 +3,9 @@ ## 本轮结论 在“同一台电脑、同一 Git 仓库、各 agent 遵守登记规则”的条件下,工具链可以形成完整闭环。 -2026-10-14 在 Windows / Python 3.12 的隔离目录中完成了 58 个实际检查点; +2026-10-14 在 Windows / Python 3.12 的隔离目录中完成了 77 个实际检查点; 运行的是当前功能分支的工作副本,而非假设默认分支已经包含这些改动。 +同时通过 89 项回归(43 项基础/安装 + 17 项会话 + 29 项策略/接管/客户端)和 48 项看板自检。 复现方式: @@ -30,6 +31,30 @@ python scripts/rehearse_workflow.py --report .agents/rehearsal-latest.md | 推送 | 向隔离的本地 bare 远端推送 feature 分支,不触及用户远端主干 | | 收尾 | finish 追加日志、回执关联交接,最终没有悬挂会话、问题或工作区告警 | | 扩展与清理 | 追加平台、再体检、确认产物被忽略;卸载预览保留记录 | +| 项目策略 | 第二项目仅使用 main,GitHub Flow 正确允许 codex/ 任务分支;回归覆盖 custom 与跨 worktree 继承 | +| 失联接管 | 真实 CLI 执行 inspect / plan / 等待门禁拒绝 / cancel,取消不修改原会话 | + +接管成功路径在真实临时 Git 仓库中验证,仅以测试补丁控制时钟:299.99 秒拒绝、300 秒允许; +校验原分支、引用、暂存和未提交文件保持不变。产品没有跳过等待的开关。 +额外回归覆盖声明回复、心跳、内容、暂存、提交、分支及策略变化时的拒绝;损坏计划与写入中断保留可追溯状态。 + +## 客户端和页面验证 + +使用 `clients --versions` 在本机实际查询了以下 CLI,未启动模型或改变客户端全局配置: + +| 客户端 | 本次查询的版本 | +|---|---| +| Codex | 0.162.1 | +| Claude Code | 2.1.263 | +| OpenCode | 1.18.35 | +| Gemini CLI | 0.63.0 | +| Qwen Code | 0.11.1 | + +版本可发现与新对话加载是两项检查。以上不证明模型已读取入口;各客户端仍需按 [PLATFORMS.md](PLATFORMS.md) 新建会话验收。 +IDE/应用内平台显示“手动验证”,PATH 未找到某个 CLI 不表示该应用未安装。 + +本地浏览器验证了平台多选、文件清单、Git Flow/GitHub Flow/沿用切换、清空恢复、提示词与命令复制、键盘标签切换。 +桌面与 390px 视口无横向溢出,控制台无错误。复制核对以页面“已复制”反馈完成后读取的内容为准。 第一轮演练发现看板的 `--port 0` 输出了无效的 `:0` 地址,修复为系统实际分配的端口后通过。 流程审查同时补齐了 blocked 恢复、交接留言收尾和“已收到/已完成”的区分。 @@ -51,7 +76,7 @@ python scripts/rehearse_workflow.py --report .agents/rehearsal-latest.md | 优先项 | 价值 | 验收方式 | |---|---|---| | 常用客户端的真实新会话验收 | 验证发现入口、读取规则及执行检查的实际表现 | 从 Codex / Claude / 当前常用平台开始,记录版本、加载来源与真实工具动作 | -| 可配置的项目分支策略 | 适配只有 main、使用其它开发主干或不同 PR 流程的项目 | 项目明确选择策略,工具与文档同步,禁止静默猜测或修改远端结构 | -| 明确的人工接管流程支持 | 减少原会话永久离线后手工核对的成本 | 展示原 ID、分支、未提交文件和占用;只在用户明确批准后移交并保留审计记录 | +| 客户端版本兼容记录 | 明确哪些入口在具体版本和设置下完成了验收 | 记录规则来源、操作系统、客户端版本和实际新对话行为 | +| 安装选择与策略的可发现性 | 降低首次配置和升级时的理解成本 | 结合真实使用反馈完善选择说明与体检建议,保持旧配置可迁移 | -当前版本采用保守的占用与接管规则。先保证不会悄悄覆盖他人工作,再考虑更细粒度的并行。 +项目分支策略和人工接管流程已实现并完成上述工具层验证。跨机器协作、同文件行级并行与真实客户端验收仍不在这些结果的覆盖范围内。 diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 8164260..0193945 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -24,9 +24,27 @@ 2. 查看 `git status --short --branch`、看板、相关 inbox 和近 1–2 天日志。 3. 准备写代码时,按总章 fetch、查 open PR、交叉检查构建进程与 reflog,确认没有重复任务。 4. 有 GitHub 远端才查该仓库的 PR;本地仓库没有远端时说明这一点,不添加未知远端。 -5. Git Flow 的 `develop` 不存在时,先说明当前分支结构。不要擅自建立远端基线或把功能直接推入主干。 +5. 读取 `POLICY.md`;配置的基线不存在时,先说明当前分支结构。不要擅自建立远端基线或把功能直接推入主干。 可以继续已经授权的只读审查、本地验证,或同一任务现有 feature 分支上的工作。 +## 按项目选择分支策略 + +首次安装默认 Git Flow;已有安装沿用已保存策略。只有确认项目流程后才配置,不能为绕过占用或主干保护临时改策略。 + +```text +python <套件目录>/install.py install <项目> --workflow github-flow +python <套件目录>/install.py install <项目> --workflow custom --base-branch trunk --task-prefixes task/,fix/ --protect-branches staging +``` + +- `git-flow`:默认基线 develop,允许 feature/、release/、hotfix/。 +- `github-flow`:默认基线 main,允许 feature/、fix/、docs/、chore/、codex/。 +- `custom`:明确基线和任务前缀。无论哪种策略,main、master、develop 和所选基线都受保护。 +- `.agents/policy.json` 保存配置,`POLICY.md` 是安装器生成的阅读入口。用安装命令修改,先 `--dry-run` 预览。 +- 在同一仓库的主 worktree 修改策略,再对其它 worktree 执行 sync。会话工具始终优先读主策略,不会用旧副本放行。 +- 已有会话仍可更新进度、handoff 或 finish;恢复写入和扩大文件范围要符合新策略。 + +工具只校验登记,不会替 Git 设置服务器分支保护。PR 目标必须符合实际项目规则;策略不创建分支、不合并、不推送。 + ## 登记、扩大范围与心跳 以下 `` 都是 `start` 实际返回的会话 ID,不能直接照抄尖括号。 @@ -64,7 +82,7 @@ start / resume 可用 `--ack-inbox Q-日期-序号.md` 显式确认已阅读并 这个参数只适用于当前仍待处理的指令,不能忽略暂停、取消、避让或授权边界;执行完再写结果并置 answered。 如果要交给**另一个**执行者:先在交接条里明确下一步、分支和文件状态。原执行者确认移交后收尾, -新执行者再登记自己的任务;原执行者失联时,按总章的声明、等待、保留提交流程处理。 +新执行者再登记自己的任务;原执行者失联时,按 [TAKEOVER.md](TAKEOVER.md) 检查、声明、等待与确认。 不要冒用另一个 ID。`--agent` 的核对只是防误操作,不是身份认证或文件系统权限隔离。 `done` 是协作状态,不是测试引擎的认证。`--tests` 记录的是调用者提供的结果,必须如实填写; diff --git a/install.py b/install.py index c9f4123..7e41996 100644 --- a/install.py +++ b/install.py @@ -9,6 +9,7 @@ python install.py doctor [TARGET] # 体检:文件齐不齐、忽略生不生效、看板能不能跑 python install.py sync [TARGET] # 同步已选平台;--platforms 可追加新平台 python install.py platforms # 查看支持的平台与入口 + python install.py clients [TARGET] # 只检查所选平台的 CLI 可发现性;--versions 可查询版本 python install.py mirror [TARGET] # 兼容旧命令:同步 WorkBuddy 镜像 常用选项: @@ -38,7 +39,10 @@ from pathlib import Path KIT_ROOT = Path(__file__).resolve().parent -KIT_VERSION = "1.5.1" # 升级 kit 时记得改;会写进目标项目的 .agents/install.json +KIT_VERSION = "1.6.0" # 升级 kit 时记得改;会写进目标项目的 .agents/install.json +sys.dont_write_bytecode = True +sys.path.insert(0, str(KIT_ROOT / "tools")) +from agents_policy import POLICY_FILE, POLICY_DOC, build_policy, effective_policy, render_policy # noqa: E402 # 与 tools/agents_status.py 同名同义:git 可执行文件解析的环境变量覆盖 # (两份小拷贝是有意为之——看板会被单独装进目标项目,不能 import 安装器)。 @@ -91,9 +95,11 @@ def resolve_git() -> str | None: ("AGENTS.md", "AGENTS.md", "project-env"), ("tools/agents_status.py", "tools/agents_status.py", None), ("tools/agents_session.py", "tools/agents_session.py", None), + ("tools/agents_policy.py", "tools/agents_policy.py", None), ("docs/README.md", ".agents/README.md", None), ("docs/START_HERE.md", ".agents/START_HERE.md", None), ("docs/WORKFLOW.md", ".agents/WORKFLOW.md", None), + ("docs/TAKEOVER.md", ".agents/TAKEOVER.md", None), ("docs/TROUBLESHOOTING.md", ".agents/TROUBLESHOOTING.md", "__troubles__"), ("templates/session.md", ".agents/sessions/_template.md", None), ("templates/inbox.md", ".agents/inbox/_template.md", None), @@ -201,6 +207,7 @@ def detect_native_agents_platforms(target: Path) -> list[str]: "/.workbuddy/", "/tools/agents_status.py", "/tools/agents_session.py", + "/tools/agents_policy.py", "/.cursor/rules/multi-agent-collab.mdc", "/.github/copilot-instructions.md", "/.windsurf/rules/multi-agent-collab.md", @@ -747,6 +754,26 @@ def apply_install_plan(target: Path, plan: list[tuple[str, str]], dry_run: bool) return stats +def policy_plan(target: Path, args=None): + primary = primary_worktree(target) + current = effective_policy(target, primary) + changed = args is not None and any(getattr(args, key, None) is not None for key in + ("workflow", "base_branch", "task_prefixes", "protect_branches")) + if changed: + prefixes = args.task_prefixes.split(",") if args.task_prefixes is not None else None + protected = args.protect_branches.split(",") if args.protect_branches is not None else None + policy = build_policy(args.workflow or current["workflow"], args.base_branch or + (None if args.workflow else current["base_branch"]), + prefixes if prefixes is not None else (None if args.workflow else current["task_prefixes"]), + protected if protected is not None else current["protected_branches"]) + if primary is not None and primary != target and policy != current: + raise ValueError(f"请在主工作区 {primary} 修改仓库策略,再同步各 worktree") + else: + policy = current + return [(POLICY_FILE, json.dumps(policy, ensure_ascii=False, indent=2) + "\n"), + (POLICY_DOC, render_policy(policy))] + + def cmd_install(args: argparse.Namespace) -> int: try: target = resolve_target(args.target) @@ -765,6 +792,7 @@ def cmd_install(args: argparse.Namespace) -> int: if args.dry_run: print(" (--dry-run:只演示,不写任何文件)") plan = install_plan(target, project_name, platforms) + plan.extend(policy_plan(target, args)) stats = apply_install_plan(target, plan, args.dry_run) save_meta(target, project_name, platforms, args.dry_run) for name in platforms: @@ -779,6 +807,7 @@ def cmd_install(args: argparse.Namespace) -> int: "旧版备份统一在 .agents/backups/。") print(f'体检:python "{KIT_ROOT / "install.py"}" doctor "{target}"') print(f'新增平台:python "{KIT_ROOT / "install.py"}" sync "{target}" --platforms cursor') + print(f"项目策略:{target / POLICY_DOC}(配置不修改实际 Git 分支)") return 0 @@ -811,6 +840,7 @@ def fail(msg: str) -> None: ok(f"目标目录存在(Python {sys.version_info.major}.{sys.version_info.minor})") meta = read_meta(target) + policy = effective_policy(target, primary_worktree(target)) if meta: inst_ver = meta.get("kit_version", "未知(旧版本安装)") if inst_ver == KIT_VERSION: @@ -821,6 +851,15 @@ def fail(msg: str) -> None: elif (target / "AGENTS.md").exists(): warn("没有 .agents/install.json(1.0 版之前手工安装?)—— 重跑一次安装器可登记版本") + print("\n【项目分支策略】") + ok(f"{policy['workflow']};PR 基线 {policy['base_branch']};任务前缀 {', '.join(policy['task_prefixes'])}") + if read_text(target / POLICY_DOC) != render_policy(policy): + warn("项目策略说明未生成或已过期,请执行 install / sync 同步") + base_ready, _ = git_ok(target, "rev-parse", "--verify", "refs/heads/" + policy["base_branch"]) + remote_ready, _ = git_ok(target, "rev-parse", "--verify", "refs/remotes/origin/" + policy["base_branch"]) + if not base_ready and not remote_ready: + warn(f"未找到基线 {policy['base_branch']};请按项目实际流程确认,不会自动建立远端分支") + print("\n【核心文件】") main_wt = primary_worktree(target) linked = main_wt is not None and main_wt != target @@ -878,6 +917,7 @@ def fail(msg: str) -> None: (ok if has_block else fail)("托管忽略块存在(私有/公共 exclude 至少一处)") # 验证本次实际管理的路径。未选平台已有的团队配置不属于本套件。 paths = [dst for _src, dst, _zone in CORE_FILES + platform_files(platforms)] + paths.extend([POLICY_FILE, POLICY_DOC]) if meta: paths.append(INSTALL_META) missing = set(unignored_paths(target, paths)) @@ -942,6 +982,7 @@ def cmd_sync(args: argparse.Namespace) -> int: print(f"multi-agent-collab 平台同步 {target}") print(f" 已选平台:{', '.join(platforms) or 'none(仅通用规则)'}") plan = install_plan(target, project_name, platforms, sync_only=True) + plan.extend(policy_plan(target)) # 给 1.4 安装补上新入口;已有的启动文档和总章不在 sync 中覆盖。 if not (target / ".agents/START_HERE.md").exists(): startup = read_text(KIT_ROOT / "docs/START_HERE.md") @@ -973,6 +1014,62 @@ def cmd_platforms(args: argparse.Namespace) -> int: return 0 +CLIENT_COMMANDS = {"codex": "codex", "claude": "claude", "opencode": "opencode", "pi": "pi", + "gemini": "gemini", "qwen": "qwen"} + + +def client_readiness(platforms: list[str], versions: bool = False) -> list[dict]: + """只运行固定命令的 --version,不启动模型、不读取配置或记录原始输出。""" + rows = [] + for name in platforms: + command = CLIENT_COMMANDS.get(name) + row = {"id": name, "label": PLATFORM_LABELS[name], "command": command, + "discovered": None, "probe_status": "manual", "version": None, + "session_verified": False} + if command: + executable = shutil.which(command) + row.update(discovered=executable is not None, + probe_status="not-requested" if executable else "not-found") + if executable and versions: + try: + result = subprocess.run([executable, "--version"], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=12, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) + match = re.search(r"(? int: + platforms = parse_platforms(args.platforms) if args.platforms is not None else saved_platforms(Path(args.target).resolve()) + if platforms is None: + raise ValueError("没有已保存的平台选择;请用 --platforms codex,claude 指定要检查的平台") + rows = client_readiness(platforms, args.versions) + notice = "此检查不启动模型。PATH 未发现命令不代表客户端未安装;新对话是否加载规则仍需在客户端验证。" + if args.json: + print(json.dumps({"clients": rows, "notice": notice}, ensure_ascii=False, indent=2)) + else: + labels = {"manual": "IDE/应用内手动验证", "not-found": "PATH 未发现 CLI", + "not-requested": "CLI 已发现(加 --versions 查询版本)", "ok": "版本查询成功", + "failed": "版本命令失败", "timeout": "版本命令超时", "unrecognized": "输出中未识别版本号"} + for row in rows: + version = " " + row["version"] if row["version"] else "" + print(f" {row['label']}: {labels[row['probe_status']]}{version};新对话加载:待验证") + print(notice) + return 0 + + # --------------------------------------------------------------------------- # # uninstall # --------------------------------------------------------------------------- # @@ -1015,7 +1112,7 @@ def cmd_uninstall(args: argparse.Namespace) -> int: if not args.purge_files: print("\n已完成:只移除了忽略托管块。所有协作文件仍在磁盘上(此刻起会出现在 git status)。") - print("会话/worklog/inbox/备份/手册 D 组条目都是你的数据,本命令永不删除。") + print("会话/worklog/inbox/备份/项目策略/接管审计/手册 D 组条目都是你的数据,本命令永不删除。") print("如要一并清理**未被你改过的**套件文件,加 --purge-files 再跑一次" "(改过的文件会自动保留并列出)。") return 0 @@ -1067,6 +1164,7 @@ def cmd_uninstall(args: argparse.Namespace) -> int: pass print(f"\n完成:删除 {len(deleted)} 个未修改的套件文件,保留 {len(kept)} 个含定制的文件。") + print("项目策略 policy.json / POLICY.md、接管审计和协作记录仍保留;忽略已移除,请勿误提交。") backups = target / ".agents" / "backups" if backups.exists(): print(f"历史备份仍在 {backups}(由你决定是否手工删除)。") @@ -1093,6 +1191,11 @@ def main() -> int: p_inst.add_argument("--project-name", default=None, help="替换 {{PROJECT_NAME}}(默认读 .agents/install.json,再默认用目录名)") p_inst.add_argument("--dry-run", action="store_true", help="只打印,不落盘") + p_inst.add_argument("--workflow", choices=["git-flow", "github-flow", "custom"], default=None, + help="明确选择分支策略;初装默认 git-flow,升级沿用") + p_inst.add_argument("--base-branch", default=None, help="PR 基线,不自动创建分支") + p_inst.add_argument("--task-prefixes", default=None, help="允许的任务分支前缀,逗号分隔,均以 / 结尾") + p_inst.add_argument("--protect-branches", default=None, help="额外保护分支,逗号分隔;核心主干始终保护") p_inst.set_defaults(func=cmd_install) p_doc = sub.add_parser("doctor", help="体检安装结果") @@ -1109,6 +1212,13 @@ def main() -> int: p_list.add_argument("--json", action="store_true", help="输出结构化平台目录") p_list.set_defaults(func=cmd_platforms) + p_clients = sub.add_parser("clients", help="检查所选客户端 CLI;不会调用模型或证明规则已加载") + p_clients.add_argument("target", nargs="?", default=".") + p_clients.add_argument("--platforms", default=None, help="待检查的平台;默认使用项目保存的选择") + p_clients.add_argument("--versions", action="store_true", help="运行固定的 --version 命令,每个最多等待 12 秒") + p_clients.add_argument("--json", action="store_true") + p_clients.set_defaults(func=cmd_clients) + p_mir = sub.add_parser("mirror", help="按目标当前 AGENTS.md 重生成 WorkBuddy 镜像") p_mir.add_argument("target", nargs="?", default=".") p_mir.add_argument("--dry-run", action="store_true") diff --git a/platforms/claude-command.md b/platforms/claude-command.md index c1e7adf..d735be1 100644 --- a/platforms/claude-command.md +++ b/platforms/claude-command.md @@ -1,5 +1,5 @@ --- -description: 按 Git Flow + 多 agent 本机共享记忆协议执行一个完整开发任务(开分支→注册→开发→测试→PR) +description: 按项目分支策略和多 agent 本机共享记忆协议执行开发任务(开分支→注册→开发→测试→PR) argument-hint: <任务描述> --- @@ -17,17 +17,17 @@ $ARGUMENTS 开工检查清单(任何写操作之前): -1. `git fetch origin --prune` -2. `gh pr list --state open` —— 看有没有已经开着、改了同一批文件的 PR +1. 有 origin 时 `git fetch origin --prune`,没有远端时说明 +2. 有 GitHub 远端时 `gh pr list --state open` —— 看有没有已经开着、改了同一批文件的 PR 3. `python tools/agents_status.py --brief` —— 一行摘要确认本机没有活跃会话占用 同一分支或同一批文件(rc=1 再跑无参数完整版);有冲突先在 `.agents/inbox/` 留问,不要抢活 4. 读 `.agents/inbox/` 中点名 claude / any 的未答问题与近两天 worklog -随后:按总章确认分支基线;新任务切 `feature/<任务名>`,同任务继续沿用自己的分支和 ID。 +随后:按总章和 `.agents/POLICY.md` 确认基线与允许的任务前缀;同任务继续沿用自己的分支和 ID。 用 `python tools/agents_session.py start --agent claude --task "任务" --files <实际范围>` 登记, blocked 恢复用 resume。会话文件纯本地、不提交不推送。开发(只 add 项目文件,禁止 `git add -A`, 禁止提交 AGENTS.md 第 8 节列出的本机协作文件)→ 跑 AGENTS.md 第 6 节约定的测试门禁 → -推送项目分支并 `gh pr create --base develop`(PR 描述写改动/原因/测试证据) +推送项目分支并 `gh pr create --base <项目基线>`(PR 描述写改动/原因/测试证据) → session 置 done、worklog 追加总结(本地保存即可)。 遇到 AGENTS.md 第 5 节的异常信号(.git 被其他进程修改、出现来历不明的分支、 diff --git a/platforms/trae-rule.md b/platforms/trae-rule.md index 0ab0281..bbd3b7a 100644 --- a/platforms/trae-rule.md +++ b/platforms/trae-rule.md @@ -7,7 +7,7 @@ alwaysApply: true 在这个仓库({{PROJECT_NAME}})**每次新对话开始工作前**: 1. 先用 Read 工具完整阅读项目根目录的 `AGENTS.md` 并严格遵守——它是唯一权威规程 - (Git Flow 分支铁律、禁止操作、commit 规范、标准流程、多 agent 协作、事故应急)。 + (项目分支策略、禁止操作、commit 规范、标准流程、多 agent 协作、事故应急)。 然后读取 `.agents/START_HERE.md`,按最小阅读顺序检查最新的协作记录。 2. 按启动清单先做只读检查。准备写入时再完成 fetch、PR 和占用协调; `python tools/agents_status.py --brief` 返回 1 时展开详情和相关 inbox,有冲突不要抢活。 @@ -17,7 +17,7 @@ alwaysApply: true 都是**本机私有文件,禁止 git add/commit/push**(完整清单以安装器 EXCLUDE_ENTRIES 为准)。 另外,`.cursor/rules/multi-agent-collab.mdc`、`.github/copilot-instructions.md`、 `.windsurf/rules/multi-agent-collab.md` 也是按需安装的私有入口,不忽略它们所在的整个配置目录。 - 新增入口与工具:`tools/agents_session.py`、`GEMINI.md`、`.qwen/QWEN.local.md`、`.clinerules/multi-agent-collab.md`、 + 新增入口与工具:`tools/agents_session.py`、`tools/agents_policy.py`、`GEMINI.md`、`.qwen/QWEN.local.md`、`.clinerules/multi-agent-collab.md`、 `.roo/rules/00-multi-agent-collab.md`、`.continue/rules/multi-agent-collab.md`、`.kiro/steering/multi-agent-collab.md`。 4. 每个阶段用中文向用户汇报:当前分支 / commit message / 变更文件 / 实际测试结果。 5. 遇到 AGENTS.md 第 5 节的异常信号(.git 被其他进程改动、来历不明的分支、 diff --git a/platforms/workbuddy-header.md b/platforms/workbuddy-header.md index 13b29c1..8c030fa 100644 --- a/platforms/workbuddy-header.md +++ b/platforms/workbuddy-header.md @@ -1,4 +1,4 @@ -# WorkBuddy 入口:Git Flow 多 agent 开发规则(本机文件,不入库) +# WorkBuddy 入口:多 agent 开发规则(本机文件,不入库) > 本文件是仓库根 AGENTS.md 的本地镜像,由 multi-agent-collab 的 `install.py` 生成。 > - 不一致时以 AGENTS.md 为准;改完 AGENTS.md 后运行 @@ -9,7 +9,7 @@ > (完整清单以安装器 EXCLUDE_ENTRIES 为准) > - 按需新增入口:.cursor/rules/multi-agent-collab.mdc、.github/copilot-instructions.md、 > .windsurf/rules/multi-agent-collab.md;只忽略这些文件,不隐藏整个配置目录 -> - tools/agents_session.py、GEMINI.md、.qwen/QWEN.local.md、.clinerules/multi-agent-collab.md、 +> - tools/agents_session.py、tools/agents_policy.py、GEMINI.md、.qwen/QWEN.local.md、.clinerules/multi-agent-collab.md、 > .roo/rules/00-multi-agent-collab.md、.continue/rules/multi-agent-collab.md、.kiro/steering/multi-agent-collab.md 同样本机私有 > - 报错先查 .agents/TROUBLESHOOTING.md diff --git a/scripts/rehearse_workflow.py b/scripts/rehearse_workflow.py index f444a94..d92655d 100644 --- a/scripts/rehearse_workflow.py +++ b/scripts/rehearse_workflow.py @@ -172,6 +172,35 @@ def read_board(): self.run("卸载预览", sys.executable, installer, "uninstall", first, "--purge-files", "--dry-run") self.check("卸载预览不丢协作记录", note.read_bytes() == original) + # 第二项目用单主干流程,证明不依赖 develop;仅操作本次创建的隔离仓库。 + self.run("为第二项目选择 GitHub Flow", sys.executable, installer, "install", second, "--workflow", "github-flow") + self.run("建立第二项目 main 基线", "git", "switch", "-c", "main", cwd=second) + (second / "README.md").write_text("# 单主干演练项目\n", encoding="utf-8") + self.run("暂存第二项目演练基线", "git", "add", "README.md", cwd=second) + self.run("提交第二项目演练基线", "git", "commit", "-q", "-m", "test: seed single-main project", cwd=second) + self.run("创建策略允许的任务分支", "git", "switch", "-c", "codex/policy-rehearsal", cwd=second) + policy = self.session("读取实际生效的项目策略", second, "check", "--files", "README.md") + self.check("单主干策略允许 codex 前缀", policy["policy"]["base_branch"] == "main" and policy["task_branch_allowed"]) + cli = json.loads(self.run("检查已选客户端就绪信息", sys.executable, installer, "clients", second, "--json")) + self.check("客户端探测不冒充新对话验证", len(cli["clients"]) == 2 and all(not row["session_verified"] for row in cli["clients"])) + stale = self.session("登记接管演练会话", second, "start", "--agent", "qwen", "--task", "接管流程演练", "--files", "README.md") + session_path = Path(stale["file"]) + old_text = re.sub(r"(?m)^- heartbeat:.*$", "- heartbeat: 2000-01-01 00:00", session_path.read_text(encoding="utf-8")) + kit.write_lf(session_path, old_text) + old_time = time.time() - 1800 + os.utime(session_path, (old_time, old_time)) + inspected = self.session("只读核对失联快照", second, "takeover-inspect", stale["id"]) + self.check("演练快照可供人工核对", not inspected["blockers"]) + planned = self.session("发布演练接管计划", second, "takeover-plan", stale["id"], "--agent", "gemini", "--reason", "隔离夹具中的失联会话") + rejected = self.session("真实命令拒绝跳过等待", second, "takeover-apply", planned["plan"], "--agent", "gemini", + "--confirm-session", stale["id"], "--approval-note", "仅验证等待门禁的隔离测试", expected=1) + self.check("阻止原因确为未满五分钟", "5 分钟" in rejected["error"]) + self.session("取消接管并保留记录", second, "takeover-cancel", planned["plan"], "--agent", "gemini", "--reason", "演练结束,原会话继续") + self.check("取消未修改原会话", session_path.read_text(encoding="utf-8") == old_text) + self.session("原会话完成演练收尾", second, "finish", stale["id"], "--agent", "qwen", "--summary", "接管检查和取消已验证", + "--tests", "真实 CLI 检查、等待拒绝、取消保留均通过;未执行强制接管") + self.run("第二项目协作记录闭环", sys.executable, second / "tools/agents_status.py", "--brief", cwd=second) + def cleanup(self): if self.server is not None: self.server.terminate() @@ -188,7 +217,8 @@ def writable(function, path, _error): def report(self, path, error): text = ["# 协作流程实际演练报告", "", f"- 时间:{datetime.now().astimezone().isoformat(timespec='seconds')}", f"- 结果:{'未通过' if error else '通过'}", f"- 检查数:{len(self.steps)}", f"- Python:{sys.version.split()[0]}", - "- 范围:真实本地 clone、Git/worktree、安装器、会话工具、HTTP 看板、业务测试和隔离 bare 远端。", + "- 范围:真实本地 clone、Git/worktree、安装器、项目策略、会话/接管工具、HTTP 看板、业务测试和隔离 bare 远端。", + "- 接管:真实 CLI 验证 inspect/plan/等待拒绝/cancel;成功 apply 的时间边界由受控时钟回归验证。", "- 边界:不创建真实 GitHub PR,不调用模型,不代替各平台的新会话加载验收。", ""] if error: text += ["## 断点", "", str(error), ""] diff --git a/scripts/sync_install_docs.py b/scripts/sync_install_docs.py index e20f1d7..f33c299 100644 --- a/scripts/sync_install_docs.py +++ b/scripts/sync_install_docs.py @@ -19,6 +19,10 @@ + "、".join(kit.ALL_PLATFORMS) + "。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。" ) +DEFAULT_WORKFLOW = ( + "读取项目现有分支约定与 .agents/policy.json;已有策略沿用,首次安装按已确认流程选择 git-flow、github-flow 或 custom。" + "只有 main 的项目可使用 github-flow;无法确认时只询问分支流程,不擅自建立 develop 或改变远端结构。" +) def replace_block(text: str, name: str, content: str) -> str: @@ -32,9 +36,9 @@ def replace_block(text: str, name: str, content: str) -> str: def outputs() -> dict[Path, str]: template = (ROOT / "templates/install-prompt.md").read_text(encoding="utf-8").strip() - if template.count("{{PLATFORM_SELECTION}}") != 1: - raise ValueError("安装提示词应包含一个 PLATFORM_SELECTION 占位符") - prompt = template.replace("{{PLATFORM_SELECTION}}", DEFAULT_SELECTION) + if any(template.count("{{" + name + "}}") != 1 for name in ("PLATFORM_SELECTION", "WORKFLOW_SELECTION")): + raise ValueError("安装提示词应各包含一个 PLATFORM_SELECTION 和 WORKFLOW_SELECTION 占位符") + prompt = template.replace("{{PLATFORM_SELECTION}}", DEFAULT_SELECTION).replace("{{WORKFLOW_SELECTION}}", DEFAULT_WORKFLOW) catalog = kit.platform_catalog() table = ["| `--platforms` 名称 | 平台 | 本套件安装的入口 |", "|---|---|---|"] for item in catalog: @@ -51,8 +55,9 @@ def outputs() -> dict[Path, str]: if count != 1: raise ValueError("展示页必须包含一个 prompt-agent 代码块") data = {"version": kit.KIT_VERSION, "platforms": catalog, - "coreFiles": [dst for _src, dst, _zone in kit.CORE_FILES] + [kit.INSTALL_META], - "promptTemplate": template, "defaultSelection": DEFAULT_SELECTION} + "coreFiles": [dst for _src, dst, _zone in kit.CORE_FILES] + + [kit.POLICY_FILE, kit.POLICY_DOC, kit.INSTALL_META], + "promptTemplate": template, "defaultSelection": DEFAULT_SELECTION, "defaultWorkflow": DEFAULT_WORKFLOW} script = "// 由 scripts/sync_install_docs.py 生成,请修改安装器或提示词模板。\n" script += "window.MAC_INSTALL = " + json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c") + ";\n" return {readme_path: readme, index_path: index, ROOT / "site/assets/js/install-data.js": script} diff --git a/site/assets/js/install-data.js b/site/assets/js/install-data.js index 32e2006..8a3f0c7 100644 --- a/site/assets/js/install-data.js +++ b/site/assets/js/install-data.js @@ -1,6 +1,6 @@ // 由 scripts/sync_install_docs.py 生成,请修改安装器或提示词模板。 window.MAC_INSTALL = { - "version": "1.5.1", + "version": "1.6.0", "platforms": [ { "id": "codex", @@ -122,15 +122,20 @@ window.MAC_INSTALL = { "AGENTS.md", "tools/agents_status.py", "tools/agents_session.py", + "tools/agents_policy.py", ".agents/README.md", ".agents/START_HERE.md", ".agents/WORKFLOW.md", + ".agents/TAKEOVER.md", ".agents/TROUBLESHOOTING.md", ".agents/sessions/_template.md", ".agents/inbox/_template.md", ".agents/worklog/_template.md", + ".agents/policy.json", + ".agents/POLICY.md", ".agents/install.json" ], - "promptTemplate": "帮我把 multi-agent-collab 安装到当前项目,并验证所选平台的新对话入口。\n套件仓库:https://github.com/AiFLYF/multi-agent-collab\n\n1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。\n2. {{PLATFORM_SELECTION}}\n3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。\n 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab \"\u003c用户主目录>/multi-agent-collab\"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。\n4. 使用绝对路径运行 python \"\u003c套件目录>/install.py\" install \"\u003cTARGET>\" --platforms \u003c已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。\n5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。\n6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python \"\u003c套件目录>/install.py\" sync \"\u003cTARGET>\" 刷新已选入口。\n7. 执行 python \"\u003c套件目录>/install.py\" doctor \"\u003cTARGET>\",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。\n8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync \"\u003cTARGET>\" --platforms \u003c新增名称>,无需重复 clone;旧对话需要主动重新读取规则。\n\n执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。", - "defaultSelection": "优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。" + "promptTemplate": "帮我把 multi-agent-collab 安装到当前项目,并验证所选平台的新对话入口。\n套件仓库:https://github.com/AiFLYF/multi-agent-collab\n\n1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。\n2. {{PLATFORM_SELECTION}}\n {{WORKFLOW_SELECTION}}\n3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。\n 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab \"\u003c用户主目录>/multi-agent-collab\"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。\n4. 使用绝对路径运行 python \"\u003c套件目录>/install.py\" install \"\u003cTARGET>\" --platforms \u003c已选名称> --dry-run;首次安装附加已确认的 --workflow 参数,自定义流程还要 --base-branch 和 --task-prefixes。核对文件清单与分支策略后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。\n5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。\n6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python \"\u003c套件目录>/install.py\" sync \"\u003cTARGET>\" 刷新已选入口。\n7. 执行 python \"\u003c套件目录>/install.py\" doctor \"\u003cTARGET>\",并核对 git status --short;可用 clients \"\u003cTARGET>\" --versions 检查 CLI 可用性,不启动模型。汇报套件复用路径、已选平台、项目分支策略、生成文件和体检结果;解释 WARN,版本查询不代表新对话已加载规则。\n8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync \"\u003cTARGET>\" --platforms \u003c新增名称>,无需重复 clone;旧对话需要主动重新读取规则。\n\n执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。", + "defaultSelection": "优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。", + "defaultWorkflow": "读取项目现有分支约定与 .agents/policy.json;已有策略沿用,首次安装按已确认流程选择 git-flow、github-flow 或 custom。只有 main 的项目可使用 github-flow;无法确认时只询问分支流程,不擅自建立 develop 或改变远端结构。" }; diff --git a/site/assets/js/install.js b/site/assets/js/install.js index 3b4c41a..42e610b 100644 --- a/site/assets/js/install.js +++ b/site/assets/js/install.js @@ -12,6 +12,7 @@ var clear = document.getElementById('platform-clear'); var search = document.getElementById('platform-search'); var empty = document.getElementById('platform-empty'); + var workflow = document.getElementById('install-workflow'); data.platforms.forEach(function (platform) { var label = document.createElement('label'); @@ -42,14 +43,19 @@ var selection = selected.length ? '本次启用以下平台:' + selected.join(',') + '。使用 --platforms ' + selected.join(',') + ';已有选择保留,无需再询问平台。' : data.defaultSelection; - prompt.textContent = data.promptTemplate.replace('{{PLATFORM_SELECTION}}', selection); + var flow = workflow.value; + var workflowSelection = flow + ? '本次采用 ' + (flow === 'git-flow' ? 'Git Flow(develop 基线)' : 'GitHub Flow(main 基线)') + ',安装时使用 --workflow ' + flow + '。先核对项目现有约定;若冲突再说明,不自动建立或修改远端分支。' + : data.defaultWorkflow; + prompt.textContent = data.promptTemplate.replace('{{PLATFORM_SELECTION}}', selection).replace('{{WORKFLOW_SELECTION}}', workflowSelection); var flag = selected.length ? ' --platforms ' + selected.join(',') : ''; + var workflowFlag = flow ? ' --workflow ' + flow : ''; command.textContent = [ '# 仅第一次下载时运行;已经存在则核对后复用', 'git clone https://github.com/AiFLYF/multi-agent-collab "$HOME/multi-agent-collab"', '', selected.length ? '# 在目标项目目录运行;需要预览可在安装命令末尾加 --dry-run' : '# 在目标项目目录运行,首次会询问平台;脚本执行请明确 --platforms', - 'python "$HOME/multi-agent-collab/install.py" install .' + flag, + 'python "$HOME/multi-agent-collab/install.py" install .' + flag + workflowFlag, 'python "$HOME/multi-agent-collab/install.py" doctor .', '', '# 追加平台或刷新入口,已有选择保留', @@ -78,6 +84,7 @@ empty.hidden = count > 0; } search.addEventListener('input', filterPlatforms); + workflow.addEventListener('change', update); clear.addEventListener('click', function () { Array.prototype.forEach.call(picker.querySelectorAll('input'), function (el) { el.checked = false; }); update(); diff --git a/site/index.html b/site/index.html index 125efe0..bed5428 100644 --- a/site/index.html +++ b/site/index.html @@ -47,7 +47,7 @@ MULTI-AGENT-COLLAB - V1.5.1 + V1.6.0
      @@ -109,7 +109,7 @@
      - V1.5.1 + V1.6.0 --:--:--
      @@ -148,7 +148,7 @@
      - + @@ -157,7 +157,7 @@

      - CROSS-PLATFORM MULTI-AGENT · 跨平台多 AI 协作 · v1.5.1 · MIT + CROSS-PLATFORM MULTI-AGENT · 跨平台多 AI 协作 · v1.6.0 · MIT

      多个平台,一个项目。 @@ -264,7 +264,7 @@

      一组协作命令

      干到一半失踪 窗口被关掉,没人知道它改到哪、下一步是什么 - 会话注册 + 15 分钟心跳;想接管先声明、等 5 分钟,且保留前任全部提交 + 会话注册 + 15 分钟心跳;失联接管先核对计划、等待与确认,保留原提交和工作文件
      想喊停 @@ -321,7 +321,7 @@

      0+

      个 AI 协作会话的事故复盘

      0

      条 TROUBLESHOOTING 排障手册

      0

      项看板 selfcheck 内置断言

      -
      0

      项回归用例(含并发登记与平台适配)

      +
      0

      项回归用例(含策略、接管与平台适配)

      0×0

      操作系统 × Python 版本的 CI 矩阵

      0

      第三方依赖 · Python 3.8+ 标准库

      @@ -343,7 +343,7 @@

      一个 AI 干到一半窗口被关掉,没人知道它改到哪、下一步是什么 → - 会话注册 + 心跳:超 15 分钟算失联;接管要先在 inbox 声明、等 5 分钟无反对,并保留前任全部提交 + 会话注册 + 心跳:超 15 分钟仍保留占用;接管需检查、声明、等待 5 分钟与用户确认,并保留原文件
      每个平台的规则文件各写各的,换个 AI 工具就等于没有规则 @@ -382,7 +382,7 @@

      先声明范围,再修改文件

      RULE 02 · 判活方式

      心跳判活,系统锁保护登记

      -

      系统锁仅串行保护状态更新,进程退出会自动释放。心跳超过 15 分钟算失活,文件占用仍保留;接管需先声明、等 5 分钟无反对,并保留前任提交。

      +

      系统锁仅串行保护状态更新,进程退出会自动释放。心跳超过 15 分钟算失活,文件占用仍保留;接管计划需等待与确认,快照变化就停止,保留原提交和工作文件。

      RULE 03 · 交叉验证 @@ -501,7 +501,7 @@

      YOUR TOOLS / YOUR PROJECT

      这个项目,你用哪些 AI?

      - +
      @@ -509,6 +509,13 @@

      选择当前项目使用的平台,可多选
      + + +

      流程不明确时,提示词会先确认。自定义基线和前缀可通过安装器设置。

      未选平台时,提示词会让 AI 先确认你正在使用的平台。

      @@ -533,12 +540,13 @@

      1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。 2. 优先根据当前对话确认正在使用的平台并告诉我;无法确认时先问我使用哪些平台(可多选):codex、claude、opencode、pi、trae、workbuddy、cursor、copilot、windsurf、gemini、qwen、cline、roo、continue、kiro。使用对应的 --platforms 名称;不要根据电脑里装过哪些软件就全部安装。 + 读取项目现有分支约定与 .agents/policy.json;已有策略沿用,首次安装按已确认流程选择 git-flow、github-flow 或 custom。只有 main 的项目可使用 github-flow;无法确认时只询问分支流程,不擅自建立 develop 或改变远端结构。 3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab "<用户主目录>/multi-agent-collab"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。 -4. 使用绝对路径运行 python "<套件目录>/install.py" install "<TARGET>" --platforms <已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 +4. 使用绝对路径运行 python "<套件目录>/install.py" install "<TARGET>" --platforms <已选名称> --dry-run;首次安装附加已确认的 --workflow 参数,自定义流程还要 --base-branch 和 --task-prefixes。核对文件清单与分支策略后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。 6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python "<套件目录>/install.py" sync "<TARGET>" 刷新已选入口。 -7. 执行 python "<套件目录>/install.py" doctor "<TARGET>",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。 +7. 执行 python "<套件目录>/install.py" doctor "<TARGET>",并核对 git status --short;可用 clients "<TARGET>" --versions 检查 CLI 可用性,不启动模型。汇报套件复用路径、已选平台、项目分支策略、生成文件和体检结果;解释 WARN,版本查询不代表新对话已加载规则。 8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync "<TARGET>" --platforms <新增名称>,无需重复 clone;旧对话需要主动重新读取规则。 执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。 @@ -626,7 +634,7 @@

      ? 想清楚再用

      - © 2026 AiFLYF — MIT LICENSE · V1.5.1 + © 2026 AiFLYF — MIT LICENSE · V1.6.0 规则沉淀自 ego-link-esp32 · 15+ AI 协作会话复盘
      diff --git a/templates/install-prompt.md b/templates/install-prompt.md index 07528ef..1aa94b3 100644 --- a/templates/install-prompt.md +++ b/templates/install-prompt.md @@ -3,12 +3,13 @@ 1. 检查 Python 3.8+ 和 Git,确认当前项目的 Git 根目录并记下绝对路径 TARGET。缺依赖时说明,不自行安装软件或 git init。 2. {{PLATFORM_SELECTION}} + {{WORKFLOW_SELECTION}} 3. 套件默认放在用户主目录的 multi-agent-collab:Windows 为 $HOME\multi-agent-collab,macOS/Linux 为 ~/multi-agent-collab。这里是可供多个项目复用的源码目录,不要 clone 到 TARGET 内。 目录不存在时执行 git clone https://github.com/AiFLYF/multi-agent-collab "<用户主目录>/multi-agent-collab"。已存在时核对 origin 和 install.py 后复用;来源不符或有本地修改时保留现场并说明,不覆盖、不删除、不重置。只有确认工作区干净且配置了上游后,才用 git pull --ff-only 更新。 -4. 使用绝对路径运行 python "<套件目录>/install.py" install "" --platforms <已选名称> --dry-run。核对文件清单后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 +4. 使用绝对路径运行 python "<套件目录>/install.py" install "" --platforms <已选名称> --dry-run;首次安装附加已确认的 --workflow 参数,自定义流程还要 --base-branch 和 --task-prefixes。核对文件清单与分支策略后执行同一条命令去掉 --dry-run;不要默认 all。只选原生平台时不生成其它平台配置。 5. 安装器负责把本机协作路径写入 .git/info/exclude。若已有规则被 Git 跟踪或忽略无法生效,报告具体文件和原因,不自动取消跟踪、不改写项目 .gitignore、不绕过检查。 6. 根据项目的真实配置填写生成的 AGENTS.md 第 6 节;选了 Claude 再填写 CLAUDE.md 速览。保留已有定制、会话、日志和备份。若改过总章,再运行 python "<套件目录>/install.py" sync "" 刷新已选入口。 -7. 执行 python "<套件目录>/install.py" doctor "",并核对 git status --short。汇报套件复用路径、已选平台、生成文件和体检结果;解释 WARN,不能把未验证项说成成功。 +7. 执行 python "<套件目录>/install.py" doctor "",并核对 git status --short;可用 clients "" --versions 检查 CLI 可用性,不启动模型。汇报套件复用路径、已选平台、项目分支策略、生成文件和体检结果;解释 WARN,版本查询不代表新对话已加载规则。 8. 告诉我新开对话验证:让 agent 读取 .agents/START_HERE.md,报告当前分支、看板和 inbox 的实际结果。之后增加平台用 sync "" --platforms <新增名称>,无需重复 clone;旧对话需要主动重新读取规则。 执行安装范围内可恢复的修复即可;遇到需要改变现有项目规则或 Git 跟踪状态的冲突,再说明具体情况让我决定。 diff --git a/tests/test_policy_takeover.py b/tests/test_policy_takeover.py new file mode 100644 index 0000000..26eeab0 --- /dev/null +++ b/tests/test_policy_takeover.py @@ -0,0 +1,432 @@ +"""真实临时 Git 仓库验证项目策略与接管;等待边界仅在测试中替换时钟。""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import time +import unittest +from pathlib import Path +from unittest import mock + +import test_sessions as fixtures +from selftest import KIT, PY, inst, run +import agents_session as sessions +from agents_policy import build_policy, check_task_branch, read_policy, validate_policy + + +class GitFixture(unittest.TestCase): + # 复用真实 Git 环境,不继承已有测试,避免重复运行同一套用例。 + setUp = fixtures.TestSessionCoordination.setUp + tearDown = fixtures.TestSessionCoordination.tearDown + install = fixtures.TestSessionCoordination.install + command = fixtures.TestSessionCoordination.command + start = fixtures.TestSessionCoordination.start + worktree = fixtures.TestSessionCoordination.worktree + + def configure(self, *args, root=None, expected=0): + result = run(PY, str(KIT / "install.py"), "install", str(root or self.root), *args, cwd=KIT) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + return result + + +class TestPolicyValues(unittest.TestCase): + def test_presets_and_custom_always_protect_main_and_base(self): + for policy in (build_policy(), build_policy("github-flow"), + build_policy("custom", "trunk", ["task/"], ["task/integration"])): + for branch in {"main", "master", "develop", policy["base_branch"]} | set(policy["protected_branches"]): + with self.subTest(policy=policy["workflow"], branch=branch), self.assertRaises(ValueError): + check_task_branch(branch, policy) + check_task_branch(policy["task_prefixes"][0] + "sample", policy) + self.assertEqual(build_policy("github-flow")["base_branch"], "main") + self.assertIn("codex/", build_policy("github-flow")["task_prefixes"]) + + def test_custom_requires_explicit_valid_branch_and_prefixes(self): + for kwargs in ({}, {"base_branch": "trunk"}, {"base_branch": "../main", "task_prefixes": ["task/"]}, + {"base_branch": "trunk", "task_prefixes": ["task"]}, + {"base_branch": "trunk", "task_prefixes": [None]}): + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + build_policy("custom", **kwargs) + with self.assertRaises(ValueError): + build_policy("git-flow", "main") + + def test_invalid_saved_fields_never_fall_back_to_defaults(self): + original = build_policy() + invalid = [None, [], {}, dict(original, schema_version=True), dict(original, base_branch=""), + dict(original, task_prefixes=None), dict(original, protected_branches={}), + dict(original, workflow=["git-flow"]), dict(original, extra=True)] + for value in invalid: + with self.subTest(value=value), self.assertRaises(ValueError): + validate_policy(value) + + +class TestProjectPolicy(GitFixture): + def test_install_upgrade_and_sync_preserve_explicit_policy(self): + self.configure("--workflow", "custom", "--base-branch", "trunk", "--task-prefixes", "feature/,task/", + "--protect-branches", "task/integration") + before = read_policy(self.root) + self.configure() + result = run(PY, str(KIT / "install.py"), "sync", str(self.root), cwd=KIT) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(read_policy(self.root), before) + self.assertIn("`trunk`", (self.root / ".agents/POLICY.md").read_text(encoding="utf-8")) + for path in (".agents/policy.json", ".agents/POLICY.md", "tools/agents_policy.py"): + self.assertEqual(run("git", "check-ignore", "-q", path, cwd=self.root).returncode, 0) + + def test_policy_controls_registration_and_check_but_allows_existing_task_cleanup(self): + _, record = self.start(self.root) + self.configure("--workflow", "custom", "--base-branch", "trunk", "--task-prefixes", "task/") + result, data = self.command(self.root, "check", "--session", record["id"], "--files", "src/app.py") + self.assertEqual(result.returncode, 1, data) + self.assertFalse(data["task_branch_allowed"]) + self.assertEqual(data["policy"]["base_branch"], "trunk") + result, data = self.command(self.root, "heartbeat", record["id"], "--agent", "codex", "--note", "准备交接") + self.assertEqual(result.returncode, 0, data) + result, _ = self.command(self.root, "heartbeat", record["id"], "--agent", "codex", "--files", "src/new.py") + self.assertEqual(result.returncode, 2) + result, data = self.command(self.root, "finish", record["id"], "--agent", "codex", "--summary", "记录收尾", + "--tests", "测试夹具,没有业务任务") + self.assertEqual(result.returncode, 0, data) + + def test_main_worktree_policy_is_authoritative_and_linked_override_is_rejected(self): + self.configure("--workflow", "github-flow") + linked = self.worktree() + self.assertEqual(read_policy(linked), read_policy(self.root)) + before = (linked / ".agents/policy.json").read_bytes() + self.configure("--workflow", "custom", "--base-branch", "trunk", "--task-prefixes", "task/", + root=linked, expected=2) + self.assertEqual((linked / ".agents/policy.json").read_bytes(), before) + self.configure("--workflow", "custom", "--base-branch", "trunk", "--task-prefixes", "task/") + result, data = self.start(linked, "claude") + self.assertEqual(result.returncode, 2, data) + self.assertIn("前缀", data["error"], "linked 旧副本不能绕过主策略") + result = run(PY, str(KIT / "install.py"), "sync", str(linked), cwd=KIT) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(read_policy(linked), read_policy(self.root)) + + def test_malformed_policy_fails_before_install_or_session_writes(self): + path = self.root / ".agents/policy.json" + path.write_text("[]", encoding="utf-8") + agents_before = (self.root / "AGENTS.md").read_bytes() + self.configure(expected=2) + self.assertEqual((self.root / "AGENTS.md").read_bytes(), agents_before) + result, data = self.start(self.root) + self.assertEqual(result.returncode, 2, data) + self.assertIn("策略", data["error"]) + + def test_missing_policy_keeps_legacy_git_flow_and_uninstall_preserves_policy_data(self): + path = self.root / ".agents/policy.json" + path.unlink() + result, data = self.command(self.root, "check", "--files", "src/app.py") + self.assertEqual(result.returncode, 0, data) + self.assertEqual(data["policy"]["workflow"], "git-flow") + self.configure() + original = path.read_bytes() + result = run(PY, str(KIT / "install.py"), "uninstall", str(self.root), "--purge-files", cwd=KIT) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(path.read_bytes(), original) + self.assertTrue((self.root / ".agents/POLICY.md").is_file()) + + +class TestClientReadiness(unittest.TestCase): + def test_discovery_is_separate_from_versions_and_ide_manual_verification(self): + with mock.patch.object(inst.shutil, "which", return_value=None), mock.patch.object(inst.subprocess, "run") as probe: + rows = inst.client_readiness(["codex", "cursor"], True) + probe.assert_not_called() + self.assertEqual([r["probe_status"] for r in rows], ["not-found", "manual"]) + self.assertIsNone(rows[1]["discovered"]) + with mock.patch.object(inst.shutil, "which", return_value="codex"), mock.patch.object(inst.subprocess, "run") as probe: + self.assertEqual(inst.client_readiness(["codex"])[0]["probe_status"], "not-requested") + probe.assert_not_called() + + def test_version_probes_keep_only_version_and_never_certify_session_loading(self): + result = subprocess.CompletedProcess(["codex", "--version"], 0, "private-output\ncodex-cli 0.162.1\n", "private-error") + with mock.patch.object(inst.shutil, "which", return_value="known-codex"), \ + mock.patch.object(inst.subprocess, "run", return_value=result) as probe: + row = inst.client_readiness(["codex"], True)[0] + self.assertEqual(probe.call_args.args[0], ["known-codex", "--version"]) + self.assertEqual(row["version"], "0.162.1") + self.assertFalse(row["session_verified"]) + self.assertNotIn("private", json.dumps(row)) + + def test_failed_and_timed_out_probes_report_status_without_raw_output(self): + for response, expected in ((subprocess.CompletedProcess([], 1, "secret 1.2.3", "secret"), "failed"), + (subprocess.CompletedProcess([], 0, "unusual output", "secret"), "unrecognized"), + (subprocess.TimeoutExpired("codex", 12, output="secret"), "timeout")): + kwargs = {"side_effect": response} if isinstance(response, Exception) else {"return_value": response} + with self.subTest(expected=expected), mock.patch.object(inst.shutil, "which", return_value="codex"), \ + mock.patch.object(inst.subprocess, "run", **kwargs): + row = inst.client_readiness(["codex"], True)[0] + self.assertEqual(row["probe_status"], expected) + self.assertIsNone(row["version"]) + self.assertNotIn("secret", json.dumps(row)) + + +class TestTakeover(GitFixture): + def make_stale(self, path): + text = path.read_text(encoding="utf-8") + text = re.sub(r"(?m)^- heartbeat:.*$", "- heartbeat: 2000-01-01 00:00", text) + path.write_text(text, encoding="utf-8") + old = time.time() - 1800 + os.utime(path, (old, old)) + + def stale_session(self, dirty=False): + result, record = self.start(self.root) + self.assertEqual(result.returncode, 0, record) + path = Path(record["file"]) + with path.open("a", encoding="utf-8") as stream: + stream.write("\n## 保留的交接上下文\n不能丢失原任务记录。\n") + self.make_stale(path) + if dirty: + old = time.time() - 1800 + for name in ("README.md", "notes with space.txt"): + target = self.root / name + target.write_text("未提交内容,必须原样保留。\n", encoding="utf-8") + os.utime(target, (old, old)) + return record, path + + def plan(self, record): + result, data = self.command(self.root, "takeover-plan", record["id"], "--agent", "claude", "--reason", "测试夹具原会话失联") + self.assertEqual(result.returncode, 0, data) + path = self.root / ".agents/takeovers" / (data["plan"] + ".json") + return data, path, json.loads(path.read_text(encoding="utf-8")) + + def apply(self, plan, offset=300, **overrides): + values = dict(cmd="takeover-apply", root=str(self.root), plan=plan["id"], agent="claude", + confirm_session=plan["snapshot"]["session_id"], approval_note="测试夹具中的明确用户确认", ack_inbox=None) + values.update(overrides) + with mock.patch.object(sessions.time, "time", return_value=plan["created_at"] + offset): + return sessions.execute(argparse.Namespace(**values)) + + def test_fresh_owner_cannot_be_reclaimed(self): + _, record = self.start(self.root) + before = Path(record["file"]).read_bytes() + result, data = self.command(self.root, "takeover-inspect", record["id"]) + self.assertEqual(result.returncode, 0, data) + self.assertTrue(data["blockers"]) + result, data = self.command(self.root, "takeover-plan", record["id"], "--agent", "claude", "--reason", "测试") + self.assertEqual(result.returncode, 1, data) + self.assertEqual(Path(record["file"]).read_bytes(), before) + + def test_stale_plan_waits_then_closes_only_coordination_preserving_git_and_dirty_files(self): + record, path = self.stale_session(dirty=True) + git_before = run("git", "status", "--porcelain", cwd=self.root).stdout + refs_before = run("git", "show-ref", cwd=self.root).stdout + file_before = {name: (self.root / name).read_bytes() for name in ("README.md", "notes with space.txt")} + data, plan_path, plan = self.plan(record) + self.assertEqual(self.start(self.root, "claude")[0].returncode, 1) + with self.assertRaises(sessions.ConflictError): + self.apply(plan, 299.99) + result = self.apply(plan, 300) + self.assertEqual(result["status"], "applied") + self.assertEqual(json.loads(plan_path.read_text(encoding="utf-8"))["status"], "applied") + self.assertIn("- status: closed", path.read_text(encoding="utf-8")) + self.assertIn("不能丢失原任务记录", path.read_text(encoding="utf-8")) + self.assertNotIn("- status: done", path.read_text(encoding="utf-8")) + self.assertEqual(run("git", "status", "--porcelain", cwd=self.root).stdout, git_before) + self.assertEqual(run("git", "show-ref", cwd=self.root).stdout, refs_before) + self.assertEqual(run("git", "branch", "--show-current", cwd=self.root).stdout.strip(), "feature/one") + for name, content in file_before.items(): + self.assertEqual((self.root / name).read_bytes(), content) + self.assertIn("- status: answered", (self.root / data["declaration"]).read_text(encoding="utf-8")) + with self.assertRaises(ValueError): + self.apply(plan) + self.assertEqual(self.start(self.root, "claude")[0].returncode, 0) + + def test_wrong_requester_and_repeated_target_are_rejected_without_mutation(self): + record, path = self.stale_session() + _, plan_path, plan = self.plan(record) + original, plan_before = path.read_bytes(), plan_path.read_bytes() + for override in ({"agent": "codex"}, {"confirm_session": "wrong-session"}, {"approval_note": ""}): + with self.subTest(override=override), self.assertRaises(ValueError): + self.apply(plan, **override) + self.assertEqual(path.read_bytes(), original) + self.assertEqual(plan_path.read_bytes(), plan_before) + + def test_recent_working_file_prevents_stale_classification(self): + record, _ = self.stale_session() + (self.root / "README.md").write_text("另一个进程仍在工作", encoding="utf-8") + result, data = self.command(self.root, "takeover-plan", record["id"], "--agent", "claude", "--reason", "测试") + self.assertEqual(result.returncode, 1, data) + self.assertIn("改动文件", data["error"]) + + def test_reply_blocks_apply_and_cancel_preserves_the_reply(self): + record, path = self.stale_session() + data, _, plan = self.plan(record) + declaration = self.root / data["declaration"] + with declaration.open("a", encoding="utf-8") as stream: + stream.write("\n原执行者回复:还在工作,不同意接管。\n") + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + result, cancelled = self.command(self.root, "takeover-cancel", plan["id"], "--agent", "claude", "--reason", "原执行者已回复") + self.assertEqual(result.returncode, 0, cancelled) + self.assertIn("不同意接管", declaration.read_text(encoding="utf-8")) + self.assertEqual(path.read_bytes(), before) + with self.assertRaises(ValueError): + self.apply(plan) + + def test_owner_heartbeat_invalidates_plan(self): + record, path = self.stale_session() + _, _, plan = self.plan(record) + self.command(self.root, "heartbeat", record["id"], "--agent", "codex", "--note", "原会话回来了") + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + + def test_working_content_change_is_detected_even_if_git_status_and_mtime_match(self): + record, path = self.stale_session(dirty=True) + _, _, plan = self.plan(record) + target = self.root / "notes with space.txt" + old_stat = target.stat() + target.write_text("同样未跟踪,但内容改变", encoding="utf-8") + os.utime(target, ns=(old_stat.st_atime_ns, old_stat.st_mtime_ns)) + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + + def test_index_changes_are_detected_when_working_content_stays_the_same(self): + record, path = self.stale_session(dirty=True) + target = self.root / "README.md" + run("git", "add", "README.md", cwd=self.root) + target.write_text("工作内容", encoding="utf-8") + old = time.time() - 1800 + os.utime(target, (old, old)) + _, _, plan = self.plan(record) + before_stat = target.stat() + target.write_text("不同的暂存内容", encoding="utf-8") + self.assertEqual(run("git", "add", "README.md", cwd=self.root).returncode, 0) + target.write_text("工作内容", encoding="utf-8") + os.utime(target, ns=(before_stat.st_atime_ns, before_stat.st_mtime_ns)) + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + + def test_policy_change_invalidates_plan(self): + record, path = self.stale_session() + _, _, plan = self.plan(record) + self.configure("--workflow", "github-flow") + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + + def test_branch_change_invalidates_plan(self): + record, path = self.stale_session() + _, _, plan = self.plan(record) + self.assertEqual(run("git", "switch", "-c", "feature/moved", cwd=self.root).returncode, 0) + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + + def test_new_commit_invalidates_plan_without_rewriting_history(self): + record, path = self.stale_session() + _, _, plan = self.plan(record) + self.assertEqual(run("git", "commit", "--allow-empty", "-m", "test: new owner activity", cwd=self.root).returncode, 0) + head = run("git", "rev-parse", "HEAD", cwd=self.root).stdout + before = path.read_bytes() + with self.assertRaises(sessions.ConflictError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(run("git", "rev-parse", "HEAD", cwd=self.root).stdout, head) + + def test_plan_schema_and_declaration_path_fail_cleanly_before_writes(self): + record, path = self.stale_session() + data, plan_path, plan = self.plan(record) + before = path.read_bytes() + readme = (self.root / "README.md").read_bytes() + declaration = (self.root / data["declaration"]).read_bytes() + for invalid in ([], {}, dict(plan, snapshot=[]), dict(plan, created_at=float("nan")), + dict(plan, created_at=10 ** 1000), dict(plan, schema_version=True), dict(plan, declaration="README.md")): + with self.subTest(invalid=type(invalid).__name__): + plan_path.write_text(json.dumps(invalid), encoding="utf-8") + result, rejected = self.command(self.root, "takeover-cancel", plan["id"], "--agent", "claude", "--reason", "测试") + self.assertEqual(result.returncode, 2, rejected) + self.assertNotIn("Traceback", result.stderr) + self.assertEqual(path.read_bytes(), before) + self.assertEqual((self.root / "README.md").read_bytes(), readme) + self.assertEqual((self.root / data["declaration"]).read_bytes(), declaration) + + def test_cancel_cannot_modify_another_declaration(self): + record, path = self.stale_session() + data, _, plan = self.plan(record) + declaration = self.root / data["declaration"] + declaration.write_text("# 其它问题\n- from: human\n- to: any\n- status: open\n", encoding="utf-8") + before = declaration.read_bytes(), path.read_bytes() + result, rejected = self.command(self.root, "takeover-cancel", plan["id"], "--agent", "claude", "--reason", "测试") + self.assertEqual(result.returncode, 2, rejected) + self.assertEqual((declaration.read_bytes(), path.read_bytes()), before) + + def test_duplicate_sessions_and_plans_are_not_automatically_taken_over(self): + record, path = self.stale_session() + _, _, plan = self.plan(record) + result, rejected = self.command(self.root, "takeover-plan", record["id"], "--agent", "claude", "--reason", "重复计划") + self.assertEqual(result.returncode, 1, rejected) + self.assertIn(plan["id"], rejected["error"]) + linked = self.worktree() + (linked / ".agents/sessions" / path.name).write_bytes(path.read_bytes()) + result, rejected = self.command(self.root, "takeover-inspect", record["id"]) + self.assertEqual(result.returncode, 2, rejected) + self.assertIn("相同 ID", rejected["error"]) + + def test_targeted_human_instruction_blocks_plan_without_fake_completion(self): + record, _ = self.stale_session() + inbox = self.root / ".agents/inbox/Q-targeted.md" + inbox.write_text(f"# 暂停\n- from: human\n- to: {record['id']}\n- status: open\n\n先等我确认。\n", encoding="utf-8") + result, rejected = self.command(self.root, "takeover-plan", record["id"], "--agent", "claude", "--reason", "测试") + self.assertEqual(result.returncode, 1, rejected) + self.assertIn("人类指令", rejected["error"]) + self.assertIn("- status: open", inbox.read_text(encoding="utf-8")) + + def test_invalid_handoff_leaves_plan_pending_and_session_intact(self): + record, path = self.stale_session() + path.write_text(sessions.put_fields(path.read_text(encoding="utf-8"), {"handoff_inbox": "README.md"}), encoding="utf-8") + self.make_stale(path) + _, plan_path, plan = self.plan(record) + before = path.read_bytes(), plan_path.read_bytes() + with self.assertRaises(ValueError): + self.apply(plan) + self.assertEqual((path.read_bytes(), plan_path.read_bytes()), before) + + def test_interrupted_apply_keeps_an_audit_marker_and_refuses_blind_retry(self): + record, path = self.stale_session() + _, plan_path, plan = self.plan(record) + before = path.read_bytes() + real_write = sessions.board._write_lf_atomic + def fail_session_write(target, content): + if target == path: + raise OSError("模拟写会话失败") + return real_write(target, content) + with mock.patch.object(sessions.board, "_write_lf_atomic", side_effect=fail_session_write): + with self.assertRaises(OSError): + self.apply(plan) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(json.loads(plan_path.read_text(encoding="utf-8"))["status"], "applying") + with self.assertRaisesRegex(ValueError, "执行中断"): + self.apply(plan) + + def test_large_or_locked_git_workspace_requires_manual_review(self): + record, _ = self.stale_session() + target = self.root / "large.bin" + with target.open("wb") as stream: + stream.truncate(sessions.TAKEOVER_LIMIT + 1) + result, rejected = self.command(self.root, "takeover-inspect", record["id"]) + self.assertEqual(result.returncode, 2, rejected) + self.assertIn("8 MiB", rejected["error"]) + target.unlink() + lock = self.root / ".git/index.lock" + lock.write_text("测试锁", encoding="utf-8") + result, rejected = self.command(self.root, "takeover-inspect", record["id"]) + self.assertEqual(result.returncode, 1, rejected) + self.assertTrue(lock.exists(), "不能自动删除其它 Git 进程的锁") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/agents_policy.py b/tools/agents_policy.py new file mode 100644 index 0000000..97e3a2c --- /dev/null +++ b/tools/agents_policy.py @@ -0,0 +1,101 @@ +"""项目分支策略:安装器与会话工具共用,纯标准库。""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +POLICY_FILE = ".agents/policy.json" +POLICY_DOC = ".agents/POLICY.md" +ALWAYS_PROTECTED = {"main", "master", "develop"} +PRESETS = { + "git-flow": ("develop", ["feature/", "release/", "hotfix/"]), + "github-flow": ("main", ["feature/", "fix/", "docs/", "chore/", "codex/"]), +} + + +def valid_ref(value: str, prefix: bool = False) -> str: + if not isinstance(value, str): + raise ValueError("分支名称和前缀必须是字符串") + value = value.strip() + check = value[:-1] if prefix and value.endswith("/") else value + if (not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", check) + or ".." in check or "//" in check or check.endswith((".", "/", ".lock")) + or any(part.startswith(".") or part.endswith(".lock") for part in check.split("/")) + or (prefix and not value.endswith("/"))): + raise ValueError(f"无效的分支{'前缀' if prefix else '名称'}:{value};使用英文、数字、点、下划线和连字符,前缀以 / 结尾") + return value + + +def build_policy(workflow="git-flow", base_branch=None, task_prefixes=None, protected_branches=None): + if workflow not in (*PRESETS, "custom"): + raise ValueError("workflow 必须为 git-flow、github-flow 或 custom") + if workflow == "custom" and (not base_branch or not task_prefixes): + raise ValueError("custom 策略必须明确 --base-branch 和 --task-prefixes") + default_base, defaults = PRESETS.get(workflow, (base_branch, task_prefixes)) + base = valid_ref(base_branch or default_base) + prefixes = task_prefixes if task_prefixes is not None else defaults + protected = protected_branches if protected_branches is not None else [] + if not isinstance(prefixes, list) or not prefixes or not isinstance(protected, list): + raise ValueError("task_prefixes 必须为非空数组,protected_branches 必须为数组") + if workflow == "git-flow" and base in ("main", "master"): + raise ValueError("Git Flow 的开发基线不能是生产主干,请选择 github-flow 或 custom") + return {"schema_version": 1, "workflow": workflow, "base_branch": base, + "task_prefixes": list(dict.fromkeys(valid_ref(p, prefix=True) for p in prefixes)), + "protected_branches": sorted(ALWAYS_PROTECTED | {base} | {valid_ref(p) for p in protected})} + + +def validate_policy(value): + required = {"schema_version", "workflow", "base_branch", "task_prefixes", "protected_branches"} + if (not isinstance(value, dict) or set(value) != required + or type(value["schema_version"]) is not int or value["schema_version"] != 1 + or not isinstance(value["workflow"], str) or not isinstance(value["base_branch"], str) + or not value["base_branch"].strip() or not isinstance(value["task_prefixes"], list) + or not isinstance(value["protected_branches"], list)): + raise ValueError("策略字段或版本无效;不会静默退回默认策略") + return build_policy(value["workflow"], value["base_branch"], value["task_prefixes"], value["protected_branches"]) + + +def read_policy(root: Path): + path = root / POLICY_FILE + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + raise ValueError("策略文件指向工作区外部,停止读取") from None + if not path.exists(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + return validate_policy(value) + except (OSError, ValueError) as exc: + raise ValueError(f"无法读取 {path},请修复策略后重试:{exc}") from None + + +def effective_policy(root: Path, primary: Path | None = None): + # 主工作区是同一仓库的策略源;linked worktree 中的副本用于展示和离线检查。 + if primary is not None: + policy = read_policy(primary) + if policy is not None: + return policy + return read_policy(root) or build_policy() + + +def check_task_branch(branch: str, policy): + if not branch or branch in policy["protected_branches"]: + raise ValueError("请先切到任务分支;不能在受保护主干登记写任务") + if not branch.startswith(tuple(policy["task_prefixes"])): + raise ValueError("任务分支不符合项目策略,允许的前缀:" + ", ".join(policy["task_prefixes"])) + + +def render_policy(policy): + prefixes = "、".join(f"`{p}*`" for p in policy["task_prefixes"]) + protected = "、".join(f"`{p}`" for p in policy["protected_branches"]) + extra = ("Git Flow 中,生产主干 main/master 只接受 release/hotfix;开发基线接受上列任务分支的 PR。" + if policy["workflow"] == "git-flow" else "任务分支通过 PR 合入上述基线;不直接提交或推送主干。") + return ("# 当前项目分支策略\n\n此文件由安装器生成,来源为同一仓库主工作区的 policy.json。\n\n" + f"- 工作流:`{policy['workflow']}`\n- PR 基线:`{policy['base_branch']}`\n" + f"- 写任务允许前缀:{prefixes}\n- 受保护分支:{protected}\n\n{extra}\n\n" + "策略不会自动建立分支、切换工作区、合并或推送。实际用户指令与项目约定仍优先。\n" + "变更策略应在主工作区明确配置,再对各 worktree 执行 sync;会话工具读取主策略,不能用旧副本绕过保护。\n" + "main、master、develop 及所选基线始终受保护;额外保护分支可以添加,不能静默解除。\n") diff --git a/tools/agents_session.py b/tools/agents_session.py index fe0dfa5..bf46cc9 100644 --- a/tools/agents_session.py +++ b/tools/agents_session.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import re @@ -18,21 +19,22 @@ # 此脚本会安装进任意技术栈的项目,不能在 tools/ 下留下未被忽略的 Python 缓存。 sys.dont_write_bytecode = True import agents_status as board +from agents_policy import effective_policy, check_task_branch, validate_policy class ConflictError(ValueError): pass -def git(root: Path, *args: str) -> str: +def git(root: Path, *args: str, raw: bool = False) -> str: executable = board.resolve_git() if not executable: raise ValueError("Git 不可用,无法核对工作区") - result = subprocess.run([executable, *args], cwd=str(root), capture_output=True, - text=True, encoding="utf-8", errors="replace", timeout=20) + result = subprocess.run([executable, *args], cwd=str(root), capture_output=True, timeout=20) if result.returncode: - raise ValueError(result.stderr.strip() or "Git 检查失败") - return result.stdout.strip() + raise ValueError(result.stderr.decode("utf-8", errors="replace").strip() or "Git 检查失败") + output = result.stdout.decode("utf-8", errors="strict" if raw else "replace") + return output if raw else output.strip() def safe_path(root: Path, relative: str) -> Path: @@ -193,8 +195,8 @@ def handoff_note(root: Path, session_id: str, summary: str) -> str: return path.relative_to(root).as_posix() -def close_handoffs(root: Path, record: dict, session_id: str, reply: str) -> list[str]: - """只回执本会话生成的交接条;普通问题、他人问题及人类指令不自动作答。""" +def handoff_updates(root: Path, record: dict, session_id: str, reply: str) -> list[tuple[Path, str]]: + """只准备本会话的交接回执,完成校验后由调用方写入。""" relative = record.get("handoff_inbox", "") if relative: candidate = safe_path(root, relative.replace("\\", "/")) @@ -215,11 +217,261 @@ def close_handoffs(root: Path, record: dict, session_id: str, reply: str) -> lis if board.status_word(fields.get("status", "")) == "open": content = put_fields(content, {"status": "answered", "answered": datetime.now().strftime("%Y-%m-%d %H:%M")}) pending.append((path, content.rstrip() + "\n\n" + reply + "\n")) + return pending + + +def close_handoffs(root: Path, record: dict, session_id: str, reply: str) -> list[str]: + """普通问题、他人问题及人类指令不自动作答。""" + pending = handoff_updates(root, record, session_id, reply) for path, content in pending: board._write_lf_atomic(path, content) return [path.relative_to(root).as_posix() for path, _content in pending] +def digest(value) -> str: + data = value if isinstance(value, bytes) else json.dumps(value, sort_keys=True, ensure_ascii=False).encode("utf-8") + return hashlib.sha256(data).hexdigest() + + +PLAN_ID = r"T-\d{8}-\d{4}-[a-f0-9]{8}" +SESSION_ID = r"[A-Za-z0-9][A-Za-z0-9_-]{0,100}" +SHA256 = r"[a-f0-9]{64}" +TAKEOVER_LIMIT = 8 * 1024 * 1024 + + +def takeover_path(root: Path, relative: str) -> Path: + path = safe_path(root, relative) + if path.is_symlink() or path.resolve() != path: + raise ValueError(f"接管涉及链接路径 {relative},先人工核对") + return path + + +def takeover_snapshot(roots: list[Path], session_id: str) -> dict: + if not re.fullmatch(SESSION_ID, session_id): + raise ValueError("无效的会话 ID") + locations = [takeover_path(root, f".agents/sessions/{session_id}.md") for root in roots] + locations = [path for path in locations if path.is_file()] + if len(locations) != 1: + raise ValueError("会话不存在或多个工作区存在相同 ID,先人工核对") + path = locations[0] + root = path.parents[2] + record = board.parse_kv(path) + if any(not record.get(key, "").strip() for key in ("agent", "branch", "status")): + raise ValueError("原会话缺少所有者、分支或状态,先人工核对") + index_lock = Path(git(root, "rev-parse", "--git-path", "index.lock")) + if not index_lock.is_absolute(): + index_lock = root / index_lock + if index_lock.exists(): + raise ConflictError("原工作区有 Git index.lock,先确认 Git 进程已经停止") + claimed, invalid = board.parse_claimed_files(record.get("files", "")) + if invalid: + raise ValueError("原会话文件范围无效,不能生成可执行接管计划") + changed = git(root, "diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "HEAD", "--", raw=True) + untracked = git(root, "ls-files", "--others", "--exclude-standard", "-z", raw=True) + names = sorted(set(filter(None, (changed + untracked).split("\0")))) + if len(names) > 100: + raise ValueError("改动文件超过 100 个,先人工核对,不自动收回") + fingerprints, total, last_file_activity = {}, 0, 0.0 + for name in names: + item = takeover_path(root, name) + if not item.exists(): + fingerprints[name] = "deleted" + elif not item.is_file(): + raise ValueError(f"改动含目录或子模块 {name},先人工核对") + else: + before = item.stat() + if total + before.st_size > TAKEOVER_LIMIT: + raise ValueError("改动内容超过 8 MiB,先人工核对") + with item.open("rb") as stream: + content = stream.read(TAKEOVER_LIMIT - total + 1) + after = item.stat() + total += len(content) + if total > TAKEOVER_LIMIT or before.st_mtime_ns != after.st_mtime_ns or before.st_size != after.st_size: + raise ValueError("检查时文件继续增长,停止生成计划") + fingerprints[name] = {"sha256": digest(content), "mtime_ns": str(after.st_mtime_ns)} + last_file_activity = max(last_file_activity, after.st_mtime) + stat = path.stat() + return {"session_id": session_id, "workspace": str(root), "owner": record.get("agent"), + "recorded_branch": record.get("branch"), "branch": git(root, "branch", "--show-current"), + "head": git(root, "rev-parse", "HEAD"), "status": record.get("status"), + "heartbeat": record.get("heartbeat", ""), "files": claimed, + "record_sha256": digest(path.read_bytes()), "mtime_ns": str(stat.st_mtime_ns), + "mtime": stat.st_mtime, "last_file_activity": last_file_activity, + "git_status": git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", raw=True), + "index_sha256": digest(git(root, "ls-files", "--stage", "-z", raw=True)), + "working_files": fingerprints} + + +def takeover_blockers(snapshot: dict) -> list[str]: + blockers = [] + if board.status_word(snapshot["status"]) in board.CLOSED_WORDS: + blockers.append("原会话已经结束,无需收回") + if snapshot["branch"] != snapshot["recorded_branch"]: + blockers.append("原工作区分支已改变,先调查") + heartbeat = board.valid_heartbeat(snapshot["heartbeat"], datetime.now()) + if heartbeat is None: + blockers.append("原会话心跳无效,不能自动认定失联") + last_activity = max(snapshot["mtime"], snapshot["last_file_activity"], + heartbeat.timestamp() if heartbeat else snapshot["mtime"]) + if time.time() - last_activity <= 15 * 60: + blockers.append("心跳、会话或改动文件在 15 分钟内更新,不能认定原会话失联") + return blockers + + +def declaration_name(plan_id: str) -> str: + return ".agents/inbox/Q-" + plan_id[2:] + "-takeover.md" + + +def valid_timestamp(value) -> bool: + return type(value) in (int, float) and 0 <= value <= 253402300799 + + +def read_takeover_plan(primary: Path, plan_id: str) -> tuple[Path, dict]: + if not re.fullmatch(PLAN_ID, plan_id): + raise ValueError("无效的接管计划 ID") + path = takeover_path(primary, f".agents/takeovers/{plan_id}.json") + with path.open("rb") as stream: + data = stream.read(1024 * 1024 + 1) + if len(data) > 1024 * 1024: + raise ValueError("接管计划过大,先人工核对") + plan = json.loads(data.decode("utf-8")) + required = {"schema_version", "id", "status", "requester", "reason", "created_at", "snapshot", + "policy", "declaration", "declaration_sha256"} + if (not isinstance(plan, dict) or not required.issubset(plan) + or type(plan["schema_version"]) is not int or plan["schema_version"] != 1 + or plan["id"] != plan_id or plan["status"] not in ("pending", "applying", "applied", "cancelled") + or not isinstance(plan["requester"], str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", plan["requester"]) + or not isinstance(plan["reason"], str) + or not valid_timestamp(plan["created_at"]) + or plan["created_at"] <= 0 or plan["declaration"] != declaration_name(plan_id) + or not isinstance(plan["declaration_sha256"], str) or not re.fullmatch(SHA256, plan["declaration_sha256"])): + raise ValueError("接管计划结构无效,未修改原会话") + one_line(plan["reason"], "接管理由") + validate_policy(plan["policy"]) + snapshot = plan["snapshot"] + strings = ("session_id", "workspace", "owner", "recorded_branch", "branch", "head", "status", + "heartbeat", "record_sha256", "mtime_ns", "git_status", "index_sha256") + if (not isinstance(snapshot, dict) or any(not isinstance(snapshot.get(k), str) for k in strings) + or not re.fullmatch(SESSION_ID, snapshot["session_id"]) + or not isinstance(snapshot.get("files"), list) or not all(isinstance(p, str) for p in snapshot["files"]) + or not isinstance(snapshot.get("working_files"), dict) + or any(not valid_timestamp(snapshot.get(k)) for k in ("mtime", "last_file_activity"))): + raise ValueError("接管快照结构无效,未修改原会话") + return path, plan + + +def read_declaration(primary: Path, plan: dict) -> tuple[Path, str]: + path = takeover_path(primary, plan["declaration"]) + notice = path.read_text(encoding="utf-8") + fields = board.parse_kv(path) + if (not notice.startswith(f"# 接管声明:{plan['snapshot']['session_id']}\n") + or fields.get("from") != plan["requester"] + "-takeover" + or fields.get("takeover_plan") != plan["id"] or fields.get("to") != "any"): + raise ValueError("声明不属于此接管计划,拒绝修改") + return path, notice + + +def execute_takeover(args, root: Path, roots: list[Path], policy) -> dict: + if args.cmd == "takeover-inspect": + snapshot = takeover_snapshot(roots, args.session) + return {"ok": True, "snapshot": snapshot, "blockers": takeover_blockers(snapshot), + "notice": "这里只读核对,不代表获准接管。"} + actor = one_line(args.agent, "执行者") + if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", actor): + raise ValueError("执行者使用小写平台名") + primary = roots[0] + with coordination_lock(primary): + if effective_policy(root, primary) != policy: + raise ConflictError("等待期间项目策略发生变化,请重新检查") + if args.cmd == "takeover-plan": + snapshot = takeover_snapshot(roots, args.session) + pending = set(pending_human_inbox(roots, actor, args.session)) + pending.update(pending_human_inbox(roots, snapshot["owner"], args.session)) + acknowledge_for_call(sorted(pending), getattr(args, "ack_inbox", None)) + blockers = takeover_blockers(snapshot) + if blockers: + raise ConflictError(";".join(blockers)) + reason = one_line(args.reason, "接管理由") + directory = takeover_path(primary, ".agents/takeovers") + for old_file in directory.glob("T-*.json"): + _path, old = read_takeover_plan(primary, old_file.stem) + if old["status"] in ("pending", "applying") and old["snapshot"]["session_id"] == args.session: + raise ConflictError(f"已有待处理计划 {old_file.stem},先检查或取消,不重复发布声明") + plan_id = "T-" + datetime.now().strftime("%Y%m%d-%H%M") + "-" + uuid.uuid4().hex[:8] + relative = declaration_name(plan_id) + declaration = takeover_path(primary, relative) + if declaration.exists() or (directory / (plan_id + ".json")).exists(): + raise ConflictError("计划编号碰撞,请重新生成;未覆盖已有文件") + if takeover_snapshot(roots, args.session) != snapshot: + raise ConflictError("检查期间原会话或文件正在变化,停止生成计划") + body = (f"# 接管声明:{args.session}\n\n- from: {actor}-takeover\n- to: any\n- status: open\n" + f"- takeover_plan: {plan_id}\n" + f"- created: {datetime.now():%Y-%m-%d %H:%M}\n\n## 说明\n\n{reason}\n\n" + f"原会话:{args.session};分支:{snapshot['branch']};提交:{snapshot['head']}。\n" + "仅申请收回协作占用。至少等待 5 分钟、无人回复、用户明确确认且快照仍一致后才能执行。\n\n## 回复\n") + board._write_lf_atomic(declaration, body) + plan = {"schema_version": 1, "id": plan_id, "status": "pending", "requester": actor, + "reason": reason, "created_at": time.time(), "snapshot": snapshot, "policy": policy, + "declaration": relative, "declaration_sha256": digest(declaration.read_bytes())} + board._write_lf_atomic(directory / (plan_id + ".json"), json.dumps(plan, ensure_ascii=False, indent=2) + "\n") + return {"ok": True, "plan": plan_id, "snapshot": snapshot, "declaration": relative, + "earliest_apply": datetime.fromtimestamp(plan["created_at"] + 300).isoformat(timespec="seconds"), + "notice": "计划未执行。先核对进程、改动和声明回复,并取得用户对本计划的明确确认。"} + plan_path, plan = read_takeover_plan(primary, args.plan) + if plan["requester"] != actor: + raise ValueError("计划结构或执行者不匹配") + if plan["status"] != "pending": + raise ValueError("计划已执行、执行中或已取消,不可重复使用;执行中断时先人工核对审计记录") + declaration, notice = read_declaration(primary, plan) + if args.cmd == "takeover-cancel": + reason = one_line(args.reason, "取消原因") + plan.update(status="cancelled", cancelled_reason=reason) + board._write_lf_atomic(declaration, put_fields(notice, {"status": "cancelled"}) + "\n计划已取消:" + reason + "\n") + else: + approval = one_line(args.approval_note, "实际用户确认的记录") + target_id = plan["snapshot"]["session_id"] + if args.confirm_session != target_id: + raise ValueError("再次输入的原会话 ID 与计划不一致") + if time.time() < plan["created_at"] + 300: + raise ConflictError("声明后尚未满 5 分钟,不能执行") + pending = set(pending_human_inbox(roots, actor, target_id)) + pending.update(pending_human_inbox(roots, plan["snapshot"]["owner"], target_id)) + acknowledge_for_call(sorted(pending), getattr(args, "ack_inbox", None)) + if digest(declaration.read_bytes()) != plan["declaration_sha256"]: + raise ConflictError("接管声明出现回复或被修改,先人工协调,不忽略异议") + snapshot = takeover_snapshot(roots, target_id) + if snapshot != plan["snapshot"] or effective_policy(root, primary) != plan["policy"]: + raise ConflictError("原会话、Git 状态或项目策略已变化,旧计划失效") + blockers = takeover_blockers(snapshot) + if blockers: + raise ConflictError(";".join(blockers)) + source = Path(snapshot["workspace"]) + path = takeover_path(source, f".agents/sessions/{target_id}.md") + record = board.parse_kv(path) + handoffs = handoff_updates(source, record, target_id, "按人工确认收回协作占用,计划 " + args.plan) + for candidate, _text in handoffs: + takeover_path(source, candidate.relative_to(source).as_posix()) + takeover_path(source, f".agents/worklog/{datetime.now():%Y-%m-%d}.md") + updated = put_fields(path.read_text(encoding="utf-8"), {"status": "closed", "closed_by": actor, + "closed_at": datetime.now().isoformat(timespec="seconds"), + "closed_reason": "按人工确认收回占用,未声称原任务或测试完成", "takeover_plan": args.plan}) + if takeover_snapshot(roots, target_id) != snapshot or digest(declaration.read_bytes()) != plan["declaration_sha256"]: + raise ConflictError("最终检查发现会话、Git 或声明变化,未收回占用") + # 多文件更新无法整体原子提交;先落审计状态,中断后必须人工核对,不能盲目重复执行。 + plan.update(status="applying", approval_note=approval, applying_at=time.time()) + board._write_lf_atomic(plan_path, json.dumps(plan, ensure_ascii=False, indent=2) + "\n") + for candidate, content in handoffs: + board._write_lf_atomic(candidate, content) + board._write_lf_atomic(path, updated) + notice = put_fields(notice, {"status": "answered"}) + board._write_lf_atomic(declaration, notice + "\n人工确认后已收回占用;原提交和工作文件均保留。\n") + plan.update(status="applied", applied_at=time.time(), approval_note=approval) + append_worklog(source, actor, snapshot["branch"], f"人工确认收回 {target_id} 的占用,计划 {args.plan};保留提交 {snapshot['head']} 与全部工作文件。") + board._write_lf_atomic(plan_path, json.dumps(plan, ensure_ascii=False, indent=2) + "\n") + return {"ok": True, "plan": args.plan, "status": plan["status"], "base_commit": plan["snapshot"]["head"], + "notice": "没有切换、删除或重建 Git 分支。后续从原提交建立自己的任务分支,并保留未提交文件后重新登记。"} + + def execute(args: argparse.Namespace) -> dict: root = Path(args.root).expanduser().resolve() root = Path(git(root, "rev-parse", "--show-toplevel")).resolve() @@ -227,21 +479,36 @@ def execute(args: argparse.Namespace) -> dict: raise ValueError("项目尚未安装会话目录,请先运行安装器") roots = worktree_roots(root) branch = git(root, "branch", "--show-current") + policy = effective_policy(root, roots[0]) + if args.cmd.startswith("takeover-"): + return execute_takeover(args, root, roots, policy) files = normalize_files(root, args.files or []) if hasattr(args, "files") else [] if args.cmd == "check": conflicts = check_conflicts(root, branch, files, sessions_in(roots), args.session) pending = pending_human_inbox(roots, args.agent, args.session) + try: + check_task_branch(branch, policy) + branch_ok = True + except ValueError as exc: + branch_ok = False + if files: + conflicts.append(str(exc)) return {"ok": not conflicts and not pending, "branch": branch, "files": files, - "conflicts": conflicts, "human_inbox": pending} + "conflicts": conflicts, "human_inbox": pending, "policy": policy, + "task_branch_allowed": branch_ok} agent = one_line(args.agent, "平台名").lower() if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", agent): raise ValueError("平台名使用小写英文、数字或连字符") - if not branch or branch in ("main", "master", "develop"): - raise ValueError("请先切到任务分支;会话工具不会创建分支或在共享主干登记写任务") + if args.cmd in ("start", "resume") or (args.cmd == "heartbeat" and args.files is not None): + check_task_branch(branch, policy) + elif not branch: + raise ValueError("当前没有有效分支,先排查工作区状态") with coordination_lock(roots[0]): # 等待锁期间可能有人切分支,写入前再次核对。 if git(root, "branch", "--show-current") != branch: raise ConflictError("等待期间工作区分支发生变化,停止写入") + if effective_policy(root, roots[0]) != policy: + raise ConflictError("等待期间项目策略发生变化,请重新检查") now = datetime.now().strftime("%Y-%m-%d %H:%M") if args.cmd == "start": task = one_line(args.task, "任务") @@ -357,6 +624,22 @@ def main() -> int: sub.add_argument("--summary", required=True) if name == "finish": sub.add_argument("--tests", required=True, help="实际测试结果;未测也要如实说明") + for name in ("takeover-inspect", "takeover-plan", "takeover-apply", "takeover-cancel"): + sub = commands.add_parser(name, help="人工接管:先检查与声明,再确认执行;不操作 Git 分支") + sub.add_argument("--json", action="store_true") + if name in ("takeover-inspect", "takeover-plan"): + sub.add_argument("session", help="原会话 ID") + else: + sub.add_argument("plan", help="本机接管计划 ID") + if name != "takeover-inspect": + sub.add_argument("--agent", required=True) + if name in ("takeover-plan", "takeover-cancel"): + sub.add_argument("--reason", required=True) + if name in ("takeover-plan", "takeover-apply"): + sub.add_argument("--ack-inbox", nargs="+") + if name == "takeover-apply": + sub.add_argument("--confirm-session", required=True, help="再次输入原会话 ID") + sub.add_argument("--approval-note", required=True, help="记录实际取得的用户确认;此参数本身不是授权") args = parser.parse_args() rc = 0 try: diff --git a/tools/agents_status.py b/tools/agents_status.py index de69914..588d5e0 100644 --- a/tools/agents_status.py +++ b/tools/agents_status.py @@ -59,7 +59,7 @@ # 版本必须与 install.py 的 KIT_VERSION 保持一致(有测试锁定,防两边漂移)。 # 看板独立分发到目标项目,不能 import 安装器,所以各存一份常量。 -BOARD_VERSION = "1.5.1" +BOARD_VERSION = "1.6.0" # 人类可视化模式(--web / --html):纯本机,不影响 JSON/终端模式与 agent 用法 WEB_PORT_DEFAULT = 8731