Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 983183d5b3 | |||
| b78126830b | |||
| 3411e54b13 | |||
| b9963b66de | |||
| 86cd1fa36d | |||
| 8b82539f6f | |||
| b3765ecad6 | |||
| ae05845ee7 | |||
| af823e867f | |||
| 61ef55c514 | |||
| d11f90fd66 | |||
| f33992a0aa | |||
| 6bd03c2f6d | |||
| d7a74578dc | |||
| 58de2650c2 | |||
| c4c68572c3 | |||
| f1ed320991 | |||
| 9a3285fba6 | |||
| af0efc7870 |
@@ -1,12 +1,12 @@
|
|||||||
# Agent Skills 仓库
|
# Agent Skills 仓库
|
||||||
|
|
||||||
自研 Agent Skills 的单一事实来源(SSOT)。Skill 内容在本仓库维护;安装与管理由 [skiff](https://git.yumee.top/laily/skiff) CLI 负责。
|
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。Skill 内容、规范包与可运行的 skiff CLI 在本仓库一并维护。
|
||||||
|
|
||||||
|
|
||||||
| 仓库 | 地址 | 职责 |
|
| 仓库 | 地址 | 职责 |
|
||||||
| --------------- | ------------------------------------------------------------------------ | --------------- |
|
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
|
||||||
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill 内容与规范 |
|
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill、规范包、skiff CLI 的当前 SSOT |
|
||||||
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | 安装、symlink、健康检查 |
|
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | skiff CLI 的独立来源 / 上游同步参考 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -15,13 +15,12 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆并关联
|
# 1. 克隆并关联
|
||||||
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
|
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
||||||
git clone https://git.yumee.top/laily/skiff.git ~/code/gitea/skiff
|
cd ~/.skills && ./install.sh
|
||||||
cd ~/code/gitea/skiff && ./install.sh
|
|
||||||
skiff setup ~/code/gitea/skills
|
|
||||||
|
|
||||||
# 2. 全局安装 skill
|
# install.sh 会自动把 skiff 项目 skill 安装到所有 Agent
|
||||||
skiff install declarative-openspec-loop
|
# 2. 安装其他 skill
|
||||||
|
skiff add declarative-openspec-loop -g
|
||||||
|
|
||||||
# 3. 查看状态
|
# 3. 查看状态
|
||||||
skiff list
|
skiff list
|
||||||
@@ -35,18 +34,22 @@ skiff status
|
|||||||
```
|
```
|
||||||
skills/
|
skills/
|
||||||
├── _template/ # 新建 skill 的脚手架
|
├── _template/ # 新建 skill 的脚手架
|
||||||
├── declarative-openspec-loop/ # 自研 skill
|
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
|
||||||
│ ├── SKILL.md
|
│ ├── SKILL.md
|
||||||
│ └── reference.md
|
│ └── reference.md
|
||||||
├── discussion-notes/ # 讨论沉淀笔记
|
├── discussion-notes/ # 讨论沉淀笔记
|
||||||
│ ├── SKILL.md
|
│ ├── SKILL.md
|
||||||
│ └── reference.md
|
│ └── reference.md
|
||||||
|
kits/
|
||||||
|
└── agent-collaboration-kit/ # 可复制到项目的协作规范包,不是 skill
|
||||||
|
skiff/ # CLI 源码(Python 3)
|
||||||
|
bin/skiff # CLI 入口
|
||||||
registry.yaml # 外部 Git skill 来源目录
|
registry.yaml # 外部 Git skill 来源目录
|
||||||
AGENTS.md # 本文档
|
AGENTS.md # 本文档
|
||||||
```
|
```
|
||||||
|
|
||||||
**本仓库包含**:`skills/`、`registry.yaml`、`AGENTS.md`
|
**本仓库包含**:`skills/`、`kits/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md`
|
||||||
**本仓库不包含**:CLI 代码、各项目的 skill 启用清单
|
**本仓库不包含**:各项目的 skill 启用清单
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,12 +60,28 @@ AGENTS.md # 本文档
|
|||||||
|
|
||||||
| Skill | 说明 |
|
| Skill | 说明 |
|
||||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
||||||
|
|
||||||
|
|
||||||
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
|
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
|
||||||
|
|
||||||
|
### 规范包(Kits,不是 Skill)
|
||||||
|
|
||||||
|
`kits/` 放可复制到项目的流程规范、模板、检查清单和示例资料。它们不是 Agent Skill:
|
||||||
|
|
||||||
|
- 不放 `SKILL.md` frontmatter
|
||||||
|
- 不由 `skiff install` / `skiff add` 安装
|
||||||
|
- 不会被 Agent 自动触发加载
|
||||||
|
- 使用方式是复制到目标项目文档目录,或在目标项目 `AGENTS.md` 中引用
|
||||||
|
|
||||||
|
| Kit | 说明 | 推荐落地位置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包 | `docs/agent-collaboration-kit/` |
|
||||||
|
|
||||||
|
新建规范包:创建 `kits/<name>/README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。
|
||||||
|
|
||||||
### 外部(External Git)
|
### 外部(External Git)
|
||||||
|
|
||||||
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
|
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
|
||||||
@@ -78,6 +97,28 @@ skiff fetch superpowers
|
|||||||
skiff install-external superpowers
|
skiff install-external superpowers
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 自定义仓库(Custom Sources)
|
||||||
|
|
||||||
|
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff source add company \
|
||||||
|
git@git.company.com:platform/agent-skills.git \
|
||||||
|
--skills-path skills
|
||||||
|
skiff list --source company
|
||||||
|
skiff add company/internal-review -g
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以接入已有本地 checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff source add company --local ~/code/company-skills --skills-path skills
|
||||||
|
```
|
||||||
|
|
||||||
|
配置保存在 `~/.config/skiff/config.yaml`,Git source 默认 clone 到
|
||||||
|
`~/.local/share/skiff/sources/<source>/`。项目 `.skills.yaml` 只记录逻辑
|
||||||
|
source 名称,每台机器独立配置实际仓库地址。
|
||||||
|
|
||||||
### 社区(External NPM / GitHub)
|
### 社区(External NPM / GitHub)
|
||||||
|
|
||||||
推荐使用 Vercel CLI 安装第三方 skill:
|
推荐使用 Vercel CLI 安装第三方 skill:
|
||||||
@@ -94,7 +135,8 @@ npx skills find typescript
|
|||||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
||||||
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
|
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
|
||||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||||
4. **仓库分离** — skill 内容与 CLI 工具分仓库,互不影响更新
|
4. **类型分离** — `skills/` 只放可安装 skill;非 skill 的规范包、模板包放 `kits/`
|
||||||
|
5. **一体维护** — skill、规范包与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,6 +145,8 @@ npx skills find typescript
|
|||||||
```
|
```
|
||||||
skills 仓库(本仓库) skiff CLI
|
skills 仓库(本仓库) skiff CLI
|
||||||
skills/<name>/ ←── skiff install / enable
|
skills/<name>/ ←── skiff install / enable
|
||||||
|
kits/<name>/ ←── 手动复制 / 项目文档引用
|
||||||
|
skiff/ ←── python3 -m skiff
|
||||||
registry.yaml ←── skiff add / fetch
|
registry.yaml ←── skiff add / fetch
|
||||||
↑
|
↑
|
||||||
~/.skills(symlink)
|
~/.skills(symlink)
|
||||||
@@ -121,8 +165,16 @@ registry.yaml ←── skiff add / fetch
|
|||||||
| ---------------- | ---------------------------------- | ------------------------------- |
|
| ---------------- | ---------------------------------- | ------------------------------- |
|
||||||
| **Owned** | `skills/<name>/` | 本仓库 commit |
|
| **Owned** | `skills/<name>/` | 本仓库 commit |
|
||||||
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
|
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
|
||||||
|
| **Custom Source** | `~/.local/share/skiff/sources/` 或本地路径 | `skiff source add/fetch` |
|
||||||
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
|
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
|
||||||
|
|
||||||
|
### 非 Skill 资料分类
|
||||||
|
|
||||||
|
| 类型 | 位置 | 维护方式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Kit** | `kits/<name>/` | 本仓库 commit;复制到目标项目 `docs/` 或由项目 `AGENTS.md` 引用 |
|
||||||
|
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
|
||||||
|
|
||||||
|
|
||||||
### 多 Agent 路径
|
### 多 Agent 路径
|
||||||
|
|
||||||
@@ -147,6 +199,8 @@ skills:
|
|||||||
- name: superpowers
|
- name: superpowers
|
||||||
source: registry
|
source: registry
|
||||||
ref: main
|
ref: main
|
||||||
|
- name: internal-review
|
||||||
|
source: company
|
||||||
|
|
||||||
targets: # 可选,默认 all
|
targets: # 可选,默认 all
|
||||||
- cursor
|
- cursor
|
||||||
@@ -163,7 +217,7 @@ targets: # 可选,默认 all
|
|||||||
| `skiff sync` | `npm ci` |
|
| `skiff sync` | `npm ci` |
|
||||||
|
|
||||||
|
|
||||||
项目级命令(skiff 待实现):
|
项目级命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/code/my-app
|
cd ~/code/my-app
|
||||||
@@ -183,7 +237,7 @@ skiff sync
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| -------------------------------------- | ------------------- |
|
| -------------------------------------- | ------------------- |
|
||||||
| `skiff setup <path>` | 关联 `~/.skills` 到本仓库 |
|
| `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent |
|
||||||
| `skiff list` | 列出所有 skill |
|
| `skiff list` | 列出所有 skill |
|
||||||
| `skiff status` | 安装状态总览 |
|
| `skiff status` | 安装状态总览 |
|
||||||
| `skiff install <name>` | 全局安装(symlink) |
|
| `skiff install <name>` | 全局安装(symlink) |
|
||||||
@@ -191,15 +245,15 @@ skiff sync
|
|||||||
| `skiff add / fetch / install-external` | 外部 Git skill |
|
| `skiff add / fetch / install-external` | 外部 Git skill |
|
||||||
|
|
||||||
|
|
||||||
### 待实现
|
### 草稿与健康检查
|
||||||
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ------------------------ | --------------------------- |
|
| --- | --- |
|
||||||
| `skiff enable / disable` | 项目级启用/关闭 |
|
| `skiff create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
|
||||||
| `skiff sync` | 按 `.skills.yaml` 重建 symlink |
|
| `skiff check <name>` | 校验草稿或正式 skill |
|
||||||
| `skiff create` | 从 `_template/` 脚手架创建 skill |
|
| `skiff finalize <name>` | 校验并将草稿转为正式 skill |
|
||||||
| `skiff doctor` | symlink 健康检查 |
|
| `skiff doctor [--fix]` | symlink 健康检查与修复 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -234,11 +288,12 @@ description: >-
|
|||||||
|
|
||||||
### 新建流程
|
### 新建流程
|
||||||
|
|
||||||
1. `cp -r skills/_template skills/my-skill`
|
1. `skiff create my-skill --idea "..." --from-project .`
|
||||||
2. 编辑 `skills/my-skill/SKILL.md`
|
2. Agent 编辑 `~/.skills/.drafts/my-skill/SKILL.md`
|
||||||
3. `skiff install my-skill --target cursor` 验证
|
3. `skiff check my-skill`
|
||||||
4. 在本仓库 commit
|
4. 用户确认后执行 `skiff finalize my-skill`
|
||||||
5. 各项目 `skiff enable my-skill`(待实现)
|
5. `skiff add my-skill -a cursor -g -y` 验证
|
||||||
|
6. 在本仓库 commit;需要的项目再用 `skiff add my-skill` 启用
|
||||||
|
|
||||||
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
|
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
|
||||||
|
|
||||||
@@ -265,7 +320,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
| -------------- | ----------------------------------------------------- |
|
| -------------- | ----------------------------------------------------- |
|
||||||
| Cursor / Codex | symlink,正常 |
|
| Cursor / Codex | symlink,正常 |
|
||||||
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
||||||
| symlink 被替换 | `skiff doctor`(待实现)→ 重建 symlink |
|
| symlink 被替换 | `skiff doctor --fix` → 重建 symlink |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -288,8 +343,8 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
|
|
||||||
| 我要… | 命令 | 在哪 |
|
| 我要… | 命令 | 在哪 |
|
||||||
| ---------- | --------------------------------- | ---- |
|
| ---------- | --------------------------------- | ---- |
|
||||||
| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 |
|
| 首次安装 | `git clone <repo> ~/.skills && ~/.skills/install.sh` | 任意 |
|
||||||
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 |
|
| 新建 skill | `skiff create` → Agent 完善 → `check/finalize` | 任意项目 |
|
||||||
| 全局启用 | `skiff install <name>` | 任意 |
|
| 全局启用 | `skiff install <name>` | 任意 |
|
||||||
| 项目启用 | `skiff enable <name>` | 项目目录 |
|
| 项目启用 | `skiff enable <name>` | 项目目录 |
|
||||||
| 看状态 | `skiff status` | 任意 |
|
| 看状态 | `skiff status` | 任意 |
|
||||||
@@ -306,4 +361,3 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
||||||
- [skills.sh](https://skills.sh)
|
- [skills.sh](https://skills.sh)
|
||||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
|
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
||||||
cd ~/code/gitea/skills
|
cd ~/.skills
|
||||||
./install.sh
|
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
|
||||||
skiff setup ~/code/gitea/skills
|
|
||||||
|
|
||||||
skiff install declarative-openspec-loop
|
skiff install declarative-openspec-loop
|
||||||
|
skiff select # 交互式选择并批量安装
|
||||||
skiff list
|
skiff list
|
||||||
skiff status
|
skiff status
|
||||||
```
|
```
|
||||||
@@ -18,7 +18,8 @@ skiff status
|
|||||||
## 仓库结构
|
## 仓库结构
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/ # 自研 skill(SSOT)
|
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
|
||||||
|
kits/ # 可复制到项目的规范包 / 模板包,不是 Agent Skill
|
||||||
skiff/ # CLI 源码(Python 3)
|
skiff/ # CLI 源码(Python 3)
|
||||||
bin/skiff # CLI 入口
|
bin/skiff # CLI 入口
|
||||||
registry.yaml # 外部 Git skill 目录
|
registry.yaml # 外部 Git skill 目录
|
||||||
@@ -28,6 +29,7 @@ AGENTS.md # 详细规范与架构说明
|
|||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
|
||||||
|
| [kits/](kits/) | 项目规范包、协作模板、可复制资料,不由 skiff 安装 |
|
||||||
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
|
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
|
||||||
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
|
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
|
||||||
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
||||||
@@ -36,17 +38,30 @@ AGENTS.md # 详细规范与架构说明
|
|||||||
|
|
||||||
| Skill | 说明 |
|
| Skill | 说明 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
|
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
||||||
|
|
||||||
|
## 规范包(不是 Skill)
|
||||||
|
|
||||||
|
`kits/` 用来维护可复制到项目里的规范说明、模板和检查清单。它们没有 `SKILL.md`,不会被 `skiff install`、`skiff add` 或 Agent 自动加载。
|
||||||
|
|
||||||
|
| Kit | 说明 |
|
||||||
|
|-----|------|
|
||||||
|
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包,复制到项目 `docs/agent-collaboration-kit/` 后按项目情况填写 |
|
||||||
|
|
||||||
新建 skill:
|
新建 skill:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff create my-skill # 从 _template/ 脚手架创建
|
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||||
# 编辑 skills/my-skill/SKILL.md
|
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
|
||||||
skiff install my-skill # 全局安装验证
|
skiff check my-skill
|
||||||
|
skiff finalize my-skill
|
||||||
|
skiff add my-skill -g # 全局安装验证
|
||||||
```
|
```
|
||||||
|
|
||||||
|
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `skiff` skill 收集实际结果与期望结果,修改 `~/.skills/skills/<name>/` 的 SSOT,并执行 `skiff check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
|
||||||
|
|
||||||
## 安装方式
|
## 安装方式
|
||||||
|
|
||||||
### 全局(用户级)
|
### 全局(用户级)
|
||||||
@@ -100,6 +115,7 @@ npx skills find typescript
|
|||||||
```
|
```
|
||||||
本仓库
|
本仓库
|
||||||
├── skills/<name>/ ←── skiff install / enable
|
├── skills/<name>/ ←── skiff install / enable
|
||||||
|
├── kits/<name>/ ←── 手动复制 / 项目文档引用
|
||||||
├── registry.yaml ←── skiff add / fetch
|
├── registry.yaml ←── skiff add / fetch
|
||||||
└── skiff/ ←── python3 -m skiff
|
└── skiff/ ←── python3 -m skiff
|
||||||
↑
|
↑
|
||||||
@@ -117,7 +133,8 @@ npx skills find typescript
|
|||||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
|
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
|
||||||
2. **项目自治** — 各项目自行维护 `.skills.yaml`
|
2. **项目自治** — 各项目自行维护 `.skills.yaml`
|
||||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||||
4. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
4. **规范包分离** — 非 skill 的流程规范、模板包放在 `kits/<name>/`,不混入 `skills/`
|
||||||
|
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
|
# 跟随软链,确保从 ~/.local/bin/skiff 调用时仍能找到仓库根目录
|
||||||
|
SCRIPT="${BASH_SOURCE[0]}"
|
||||||
|
while [ -L "$SCRIPT" ]; do
|
||||||
|
link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
SCRIPT="$(readlink "$SCRIPT")"
|
||||||
|
[[ "$SCRIPT" != /* ]] && SCRIPT="$link_dir/$SCRIPT"
|
||||||
|
done
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
|
||||||
|
|
||||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
exec python3 -m skiff "$@"
|
exec python3 -m skiff "$@"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ mkdir -p "$BIN_DIR"
|
|||||||
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff"
|
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff"
|
||||||
echo "已安装 skiff -> $BIN_DIR/skiff"
|
echo "已安装 skiff -> $BIN_DIR/skiff"
|
||||||
|
|
||||||
|
PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}" python3 -m skiff bootstrap
|
||||||
|
|
||||||
path_already_configured() {
|
path_already_configured() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
[[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file"
|
[[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file"
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# ack — Agent Collaboration Kit
|
||||||
|
|
||||||
|
`ack`(Agent Collaboration Kit)是一套可复用到其它项目的多 Agent 协作规范,默认三个独立角色:**Coordinator (PM) 拆解需求并调度闭环,Developer 实现与白盒验证,Test 独立黑盒复测**。关键属性是验证者 ≠ 实现者。角色默认按成本分层:Coordinator 用强模型(且不亲自跑测试),Test/Developer 用中低模型(见 `core/model-routing.md`)。
|
||||||
|
|
||||||
|
当前版本见 `VERSION`。这不是 Agent Skill(无 `SKILL.md`),不由 skiff 安装,而是复制/引用到目标项目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 多个 Agent 分工协作,而非单个 Agent 从需求写到代码。
|
||||||
|
- 需要区分规格、测试、实现、复测的责任边界(Coordinator / Test / Developer 三角色)。
|
||||||
|
- 需要把失败项派发给 Developer,`worker_done` 后由独立的 Test 复测、Coordinator 终检。
|
||||||
|
- 需要连续修复多个问题,并把三轮仍未修好的问题留档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
ack/
|
||||||
|
VERSION # kit 版本,接入时记入项目
|
||||||
|
README.md
|
||||||
|
init-new-project.md # 给 Agent 的新项目初始化手册
|
||||||
|
adoption-checklist.md
|
||||||
|
core/ # 稳定核心:跨项目通用,随 kit 升级,尽量别改
|
||||||
|
kickoff.md # 如何开始一个需求(启动手册 + 开场指令模板)
|
||||||
|
roles-and-permissions.md # 角色/权限/状态机/完成定义(SSOT)
|
||||||
|
model-routing.md # 三角色默认模型档位 + 升级规则(SSOT)
|
||||||
|
closed-loop.md # 编排无关闭环 + 手动模式 + worktree 对齐
|
||||||
|
optimization-method.md # 验收信号 + 三轮失败策略(SSOT)
|
||||||
|
prompt-templates.md # 派发 prompt 模板
|
||||||
|
orca-adapter.md # Orca 具体命令(一种编排实现,可选)
|
||||||
|
templates/ # 项目覆盖层:复制一次并填空
|
||||||
|
project.template.md # 项目覆盖层模板(文件名可配置,默认不占用 AGENTS.md)
|
||||||
|
tasks.template.yaml
|
||||||
|
tasks.schema.json # 任务板权威结构(跨语言)
|
||||||
|
examples/ # 填好的最小可跑示例
|
||||||
|
project.example.md
|
||||||
|
tasks.example.yaml
|
||||||
|
scripts/
|
||||||
|
validate_tasks.py # 校验 tasks.yaml(参考实现)
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心原则**:`core/` 是稳定核心,每个概念只定义一次;`templates/` 是项目覆盖层。二者分离,让 kit 升级和项目定制互不干扰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目目录布局(推荐)
|
||||||
|
|
||||||
|
每个项目在 `docs/ack/` 下只放**一个软链接**加两个项目文件,稳定核心全部走软链接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<project>/docs/ack/
|
||||||
|
kit -> <此框架目录> # 唯一软链接:core/templates/examples/scripts 全在里面
|
||||||
|
project.md # 项目覆盖层(差异),可改名,见下
|
||||||
|
tasks.yaml # 项目任务板
|
||||||
|
```
|
||||||
|
|
||||||
|
引用稳定核心时统一走 `kit/` 前缀,例如 `docs/ack/kit/core/roles-and-permissions.md`。
|
||||||
|
这样「一个项目一个软链接」,kit 升级自动生效,项目定制(`project.md` + `tasks.yaml`)互不干扰。
|
||||||
|
|
||||||
|
## 分发方式(二选一)
|
||||||
|
|
||||||
|
### 方式 A:单软链接引用(推荐,可升级)
|
||||||
|
|
||||||
|
把本框架目录整体 symlink 成项目的 `docs/ack/kit`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p <project>/docs/ack
|
||||||
|
ln -s <此框架绝对路径> <project>/docs/ack/kit
|
||||||
|
```
|
||||||
|
|
||||||
|
也可用 git submodule / sparse checkout 达到同样效果。项目只维护 `project.md` 与 `tasks.yaml`。
|
||||||
|
|
||||||
|
### 方式 B:整份复制(简单,手动升级)
|
||||||
|
|
||||||
|
把本框架整目录复制成 `docs/ack/kit/`(或直接铺平到 `docs/ack/`,此时引用去掉 `kit/` 前缀)。**务必**在 `project.md` 与 `tasks.yaml` 的 `kitVersion` 记录来源版本,日后照 `VERSION` diff 升级。
|
||||||
|
|
||||||
|
### 覆盖层文件名(重要)
|
||||||
|
|
||||||
|
项目差异(路径、命令、handle、模型档位)写在**项目覆盖层文件**里,**默认 `docs/ack/project.md`,不占用 `AGENTS.md`**,以免与团队已有的 `AGENTS.md` 约定冲突。文件名可配置:想让 Agent 自动加载,可在 `AGENTS.md` 加一行指向它,或直接命名为 `AGENTS.md`。无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。覆盖层只填差异,不复制 `core/` 内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速接入
|
||||||
|
|
||||||
|
让 Agent 在新项目执行初始化时,优先给它 `init-new-project.md`。
|
||||||
|
|
||||||
|
1. 建 `docs/ack/`,按方式 A 建单软链接 `kit`。
|
||||||
|
2. 复制 `kit/templates/project.template.md` → `docs/ack/project.md`,填项目差异与模型档位,记 `kitVersion`。
|
||||||
|
3. 复制 `kit/templates/tasks.template.yaml` → `docs/ack/tasks.yaml`,填 `project`(含 `overlayFile`)与首个任务。
|
||||||
|
4. 跑 `python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml` 确认结构。
|
||||||
|
5. 按 `kit/core/closed-loop.md` 跑闭环(Orca 见 `kit/core/orca-adapter.md`,无 Orca 用手动模式)。
|
||||||
|
6. 逐项对照 `kit/adoption-checklist.md`。
|
||||||
|
|
||||||
|
参考 `examples/` 里填好的 `project.example.md` 与 `tasks.example.yaml`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用使用方法
|
||||||
|
|
||||||
|
### 开一个新需求(最常用)
|
||||||
|
|
||||||
|
你(强模型会话)就是 Coordinator(PM)。完整手册见 `core/kickoff.md`,开场指令:
|
||||||
|
|
||||||
|
```text
|
||||||
|
我要做一个新需求:<一句话需求>。你作为 ack 的 Coordinator(PM),按 docs/ack/kit/core 规范:
|
||||||
|
先读 project.md 与 core/*;写产品文档到 docs/ 并把需求拆成带可观测验收信号的任务写进 docs/ack/tasks.yaml;
|
||||||
|
先给我确认产品文档+任务拆分,再按 project.md 优先复用空闲 DEV/TEST worker,没有时才校验并创建,核对实际模型后循环派发/复测/终检;
|
||||||
|
每个任务最多三轮,三轮不过记 leftover。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 复用或启动 worker
|
||||||
|
|
||||||
|
先按 `core/orca-adapter.md` §「解析并复用 worker」运行 `terminal list` 和活跃任务查询。只有没有同 worktree、同角色、配置兼容的空闲 worker 时,才执行下面的创建命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium'
|
||||||
|
TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
|
||||||
|
python3 docs/ack/validate_worker_command.py --role developer --command "$DEV_CMD"
|
||||||
|
python3 docs/ack/validate_worker_command.py --role test --command "$TEST_CMD"
|
||||||
|
orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
|
||||||
|
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
|
||||||
|
|
||||||
|
# Cursor worker 使用同一项目校验器
|
||||||
|
CURSOR_CMD='cursor-agent --yolo --model auto'
|
||||||
|
python3 docs/ack/validate_worker_command.py --role developer --command "$CURSOR_CMD"
|
||||||
|
```
|
||||||
|
|
||||||
|
派发成功后把实际 handle 写入任务的 `dispatch.worker`,下次优先复用。需要隔离/并行时先建 worktree:`orca worktree create --name <feature> --base-branch <base> --json`(选择依据见 `core/closed-loop.md`)。
|
||||||
|
|
||||||
|
### 派发与等待(Orca)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration task-create --spec "<任务与验收>" --json
|
||||||
|
orca orchestration dispatch --task <task_id> --to <dev_handle> --json
|
||||||
|
orca orchestration check --terminal <coordinator_handle> --wait \
|
||||||
|
--types worker_done,retest_result,escalation,decision_gate --timeout-ms 900000 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
不能 `--inject` 时手动投递 `core/prompt-templates.md` 的模板;命令细节见 `core/orca-adapter.md`。
|
||||||
|
|
||||||
|
### 校验任务板结构
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 只处理已有未通过项
|
||||||
|
|
||||||
|
用「默认口令」(见下)让 Coordinator 直接扫 `tasks.yaml` 里的 `open` / `failed_retest` 继续闭环。
|
||||||
|
|
||||||
|
### 速查:想做什么 → 看哪个文件
|
||||||
|
|
||||||
|
| 想做的事 | 文件 |
|
||||||
|
|----------|------|
|
||||||
|
| 开一个新需求怎么起步 | `core/kickoff.md` |
|
||||||
|
| 角色/权限/状态机/完成定义 | `core/roles-and-permissions.md` |
|
||||||
|
| 三角色各自该怎么做(规划/写测试/复测能力清单) | `core/roles-and-permissions.md` §三角色能力清单 |
|
||||||
|
| 用哪个模型、怎么固定、何时升级 | `core/model-routing.md` |
|
||||||
|
| 闭环步骤、手动模式、worktree 决策与对齐 | `core/closed-loop.md` |
|
||||||
|
| 验收信号写法、三轮失败策略 | `core/optimization-method.md` |
|
||||||
|
| 派发/复测/回报的 prompt 文案 | `core/prompt-templates.md` |
|
||||||
|
| Orca 具体命令 | `core/orca-adapter.md` |
|
||||||
|
| 让 Agent 在新项目初始化 ack | `init-new-project.md` |
|
||||||
|
| 新项目接入步骤 | `adoption-checklist.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 默认口令
|
||||||
|
|
||||||
|
```text
|
||||||
|
用三角色协作闭环处理 docs/ack/tasks.yaml 里的未通过项:Coordinator 派发给 Developer 修复,再交给 Test 独立复测并终检;每个问题最多修三轮,三轮仍不过就记录为遗留,然后继续下一个。
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.8.1
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 接入清单
|
||||||
|
|
||||||
|
把本工具包引用/复制到新项目后,按此清单接入。术语与规则详见 `core/`。
|
||||||
|
|
||||||
|
## 1. 分发与版本
|
||||||
|
|
||||||
|
- [ ] 选定分发方式(README §「分发方式」A 单软链接 / B 复制)。
|
||||||
|
- [ ] 在 `docs/ack/` 下建单软链接 `kit`(指向本框架)。
|
||||||
|
- [ ] 覆盖层文件与 `tasks.yaml` 记录 `kitVersion`(对齐 `kit/VERSION`)。
|
||||||
|
|
||||||
|
## 2. 项目级规范(覆盖层)
|
||||||
|
|
||||||
|
- [ ] 由 `kit/templates/project.template.md` 生成覆盖层文件(默认 `docs/ack/project.md`,**不占用 `AGENTS.md`**)。
|
||||||
|
- [ ] 在 `tasks.yaml` 的 `project.overlayFile` 记录覆盖层实际路径。
|
||||||
|
- [ ] 覆盖层引用 `docs/ack/kit/core/`,不复制其内容。
|
||||||
|
- [ ] 填清项目简介、技术栈、运行/构建/单测/集成测试命令。
|
||||||
|
- [ ] 若要 Agent 自动加载覆盖层:在 `AGENTS.md` 加一行指向它,或直接命名为 `AGENTS.md`(可选)。
|
||||||
|
|
||||||
|
## 2b. 模型档位
|
||||||
|
|
||||||
|
- [ ] Coordinator 默认强模型且不亲自跑测试。
|
||||||
|
- [ ] Test、Developer 默认中低模型。
|
||||||
|
- [ ] 记录升级规则:三轮失败升级 Coordinator 复盘;复杂实现升级 Developer 档位(见 `kit/core/model-routing.md`)。
|
||||||
|
|
||||||
|
## 3. 路径权限
|
||||||
|
|
||||||
|
- [ ] 填实际的 Coordinator (PM) 可写路径(规格)。
|
||||||
|
- [ ] 填实际的 Test 可写路径(集成测试、复测记录)。
|
||||||
|
- [ ] 填实际的 Developer 可写路径(源码、单元测试、配置模板)。
|
||||||
|
- [ ] 本地私有配置标记只读或不提交。
|
||||||
|
- [ ] 确认 `tasks.yaml` 只有 Coordinator 写(见 `kit/core/roles-and-permissions.md`)。
|
||||||
|
|
||||||
|
## 4. 任务板
|
||||||
|
|
||||||
|
- [ ] 由 `kit/templates/tasks.template.yaml` 生成 `docs/ack/tasks.yaml`。
|
||||||
|
- [ ] 替换 `<project_name>`、`<repo_path>`、`<base_url>`、`<dev_worktree>`、`overlayFile`。
|
||||||
|
- [ ] 至少加一个真实任务,验收写成可观测信号(见 `kit/core/optimization-method.md` §1)。
|
||||||
|
- [ ] 跑 `python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml` 通过。
|
||||||
|
|
||||||
|
## 5. 编排
|
||||||
|
|
||||||
|
- [ ] 决定用 Orca(`kit/core/orca-adapter.md`)还是手动模式(`kit/core/closed-loop.md`)。
|
||||||
|
- [ ] Orca:`orca status`、`terminal list` 可用,Coordinator / Developer / Test 三个终端都在,回报能发回 Coordinator。
|
||||||
|
- [ ] 派发前会先查空闲 worker:按 `ACK-<ROLE>-<CLI>-<TIER>-<N>` 复用同 worktree、同角色、同配置终端;仅在不存在或都 busy 时新建,并把实际 handle 写入 `dispatch.worker`。
|
||||||
|
- [ ] 决定子任务放当前 worktree 起子 agent,还是新建隔离 worktree(决策速查见 `kit/core/closed-loop.md` §「子任务放哪」)。
|
||||||
|
- [ ] 多 worktree 时确认 `tasks.yaml`(SSOT)只保留一个权威副本,由 Coordinator 单写。
|
||||||
|
|
||||||
|
## 6. 验证命令
|
||||||
|
|
||||||
|
- [ ] Test 有黑盒复测命令,Developer 有白盒验证命令。
|
||||||
|
- [ ] 构建产物与服务启动命令写清。
|
||||||
|
- [ ] 浏览器测试 `BASE_URL` 写清,复测前能确认服务来自正确 worktree。
|
||||||
|
|
||||||
|
## 7. 三轮失败策略
|
||||||
|
|
||||||
|
- [ ] 每个任务记录 `dispatch.rounds`。
|
||||||
|
- [ ] 三轮失败标记 `leftover` 并填 `resolution.leftoverReason`。
|
||||||
|
- [ ] `leftover` 不阻塞其它任务;最终报告列出所有 `leftover` 与最后失败证据。
|
||||||
|
|
||||||
|
## 8. 第一次试运行
|
||||||
|
|
||||||
|
选一个低风险 bug 跑完整闭环,结束后检查是否出现:
|
||||||
|
|
||||||
|
- Developer 修改了越权路径,或 Test 改了源码。
|
||||||
|
- worker_done 或复测报告没有验证证据。
|
||||||
|
- Test 复测到了旧服务。
|
||||||
|
- Coordinator 没做终检就直接 verified。
|
||||||
|
- 任务板字段不够记录失败原因。
|
||||||
|
- 三轮失败策略没被执行。
|
||||||
|
|
||||||
|
把发现的问题回写到项目覆盖层文件;若属通用问题,回流到 kit 的 `kit/core/` 并升 `VERSION`。
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# 闭环流程(稳定核心,编排无关)
|
||||||
|
|
||||||
|
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
|
||||||
|
|
||||||
|
原则:调度消息只是运行时载体,**所有结论都必须回写到 `tasks.yaml`**(事实源),不要把消息当最终记录。角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、Developer 只实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编排抽象
|
||||||
|
|
||||||
|
无论用什么工具,闭环都由这几个能力组成。注意 `dispatch` 有两个目标(Developer 与 Test):
|
||||||
|
|
||||||
|
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|
||||||
|
|----------|------|-----------|----------|
|
||||||
|
| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml` | 同左 | 同左 |
|
||||||
|
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
|
||||||
|
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
|
||||||
|
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
|
||||||
|
| `gate(task)` | Coordinator 读 Test 证据并对齐原始意图(不重测) | 同左 | 同左 |
|
||||||
|
| `writeback(task, result)` | Coordinator 把结果写回 `tasks.yaml` | 同左 | 同左 |
|
||||||
|
|
||||||
|
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 标准闭环
|
||||||
|
|
||||||
|
```text
|
||||||
|
Coordinator 发现或读取 open 任务
|
||||||
|
-> prepare:写/补全 tasks.yaml 验收信号
|
||||||
|
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||||
|
-> 解析 worker:先复用同 worktree、同角色的空闲 worker;没有可复用项时才校验命令并新建(见 orca-adapter.md)
|
||||||
|
-> dispatch 给 Developer(--to <worker handle>)
|
||||||
|
-> wait:Developer 的 worker_done / escalation
|
||||||
|
-> writeback fixed_by_dev
|
||||||
|
-> dispatch 给 Test(retesting)
|
||||||
|
-> wait:Test 的 retest_result
|
||||||
|
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
||||||
|
-> 通过 gate:writeback verified
|
||||||
|
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
|
||||||
|
-> Test 失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮)
|
||||||
|
-> 累计三轮失败:writeback leftover,继续下一个任务
|
||||||
|
```
|
||||||
|
|
||||||
|
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
||||||
|
「决定 worktree」「解析 worker」两步的决策见下节与 `model-routing.md` / `orca-adapter.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子任务放哪:新 worktree 还是当前 worktree
|
||||||
|
|
||||||
|
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
||||||
|
|
||||||
|
**方式 1:当前 worktree 复用或起子 agent(终端)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 先按 orca-adapter.md 的 worker 复用流程查找空闲终端;没有时才创建
|
||||||
|
orca terminal create --worktree active --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
|
||||||
|
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
|
||||||
|
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
|
||||||
|
|
||||||
|
**方式 2:新建隔离 worktree,再在其中起子 agent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca worktree create --name <feature> --base-branch <base> --json
|
||||||
|
# 然后先查该 worktree 的同角色空闲终端;没有时才创建
|
||||||
|
orca terminal create --worktree path:<new> --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
- 适合:并行多个互不依赖的子任务、大/高风险/实验性改动、要保持基线分支干净(如 prod 不动、feature 走独立分支)、要独立 build 或跑独立服务实例、best-of-N 尝试。
|
||||||
|
- 优点:隔离 + 并行 + 易回滚(删 worktree 即可)。
|
||||||
|
- 成本:各自 build/依赖、服务要用不同端口、必须处理下节的 worktree/服务对齐。
|
||||||
|
|
||||||
|
**决策速查:**
|
||||||
|
|
||||||
|
| 情况 | 选择 |
|
||||||
|
|------|------|
|
||||||
|
| 一次一个任务、串行修复 | 当前 worktree 子 agent |
|
||||||
|
| 多个任务并行、互不依赖 | 每个任务一个新 worktree |
|
||||||
|
| 高风险 / 实验 / 可能整体丢弃 | 新 worktree |
|
||||||
|
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
||||||
|
| 小改动、追求快 | 当前 worktree |
|
||||||
|
|
||||||
|
**任务板(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 只认一个权威副本(通常在基线/协调所在 worktree),由 Coordinator 单写。不要每个 worktree 各留一份会分叉的任务板。模型固定方式见 `model-routing.md` 与 `orca-adapter.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动模式(无 Orca)
|
||||||
|
|
||||||
|
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
|
||||||
|
|
||||||
|
1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。
|
||||||
|
2. 用 `prompt-templates.md` §1 的初始派发模板生成 prompt,手动发给 Developer(另一个会话/终端/人)。
|
||||||
|
3. Developer 完成后按 worker_done 模板回报。
|
||||||
|
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
|
||||||
|
5. Test 独立复测后按复测报告模板回报证据。
|
||||||
|
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
|
||||||
|
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
||||||
|
|
||||||
|
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator 写 `tasks.yaml`、三轮失败留档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 复测(编排无关)
|
||||||
|
|
||||||
|
即使 Developer 的 worker_done 写了"全部通过",Test 仍必须独立复测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
<test_commands>
|
||||||
|
curl -s <base_url>/health-or-summary
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器复测建议记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BASE_URL:
|
||||||
|
page:
|
||||||
|
steps:
|
||||||
|
expected:
|
||||||
|
actual:
|
||||||
|
snapshot evidence:
|
||||||
|
```
|
||||||
|
|
||||||
|
Test 只回传证据,不写 `tasks.yaml`;由 Coordinator 落盘。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordinator 终检(gate)
|
||||||
|
|
||||||
|
Test 报通过后,Coordinator 不重测,而是做一次读证据的终检:
|
||||||
|
|
||||||
|
- Test 证据是否覆盖了任务的**每一条**验收信号(见 `optimization-method.md` §1)。
|
||||||
|
- 结果是否符合任务的**原始意图**,而不只是通过了字面文案。
|
||||||
|
- 运行环境是否对齐(见下方「服务与 worktree 对齐」)。
|
||||||
|
|
||||||
|
终检不通过(例如意图漂移、验收信号写漏)时,回写 `failed_retest` 并把意图差异带给下一轮 Developer,必要时先补全验收信号。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务与 worktree 对齐(防假通过/假失败)
|
||||||
|
|
||||||
|
Test 复测前记录运行环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwd
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
serverPid:
|
||||||
|
serverCommand:
|
||||||
|
BASE_URL:
|
||||||
|
frontendDir:
|
||||||
|
worktreePath:
|
||||||
|
```
|
||||||
|
|
||||||
|
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结果回写
|
||||||
|
|
||||||
|
Coordinator 回写 `tasks.yaml` 时按状态填写(字段结构见 `templates/tasks.schema.json`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 通过(Test 复测 + Coordinator 终检)
|
||||||
|
status: verified
|
||||||
|
resolution:
|
||||||
|
verifiedAt: "<timestamp>"
|
||||||
|
verifiedBy: "test"
|
||||||
|
evidence:
|
||||||
|
verification: "<commands passed>"
|
||||||
|
browser: "<snapshot or API evidence>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 失败但未满三轮
|
||||||
|
status: failed_retest
|
||||||
|
dispatch:
|
||||||
|
rounds:
|
||||||
|
- round: 1
|
||||||
|
result: failed
|
||||||
|
evidence: "<latest evidence from Test or gate>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 累计三轮失败
|
||||||
|
status: leftover
|
||||||
|
resolution:
|
||||||
|
leftoverReason: "failed after 3 supervised developer rounds"
|
||||||
|
evidence:
|
||||||
|
final: "<latest failing evidence>"
|
||||||
|
```
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 如何开始一个需求(Kickoff)
|
||||||
|
|
||||||
|
从零开一个需求的启动手册。角色/权限见 `roles-and-permissions.md`,闭环见 `closed-loop.md`,模型见 `model-routing.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前提:谁是 Coordinator
|
||||||
|
|
||||||
|
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;具体 CLI、模型、reasoning effort 和执行模式必须先读项目覆盖层 `docs/ack/project.md`,禁止照抄未核对的默认命令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 0 步:给 Coordinator 的开场指令(复制改需求)
|
||||||
|
|
||||||
|
```text
|
||||||
|
我要做一个新需求:<一句话需求>。
|
||||||
|
你作为 ack 的 Coordinator(PM),按 docs/ack/kit/core 的规范执行:
|
||||||
|
|
||||||
|
1. 先读 docs/ack/project.md、docs/ack/kit/core/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
||||||
|
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||||
|
3. 把任务写进 docs/ack/tasks.yaml(只有你写),跑 validate 校验结构。
|
||||||
|
4. 先把「产品文档 + 任务拆分 + 验收信号」给我确认,不要急着派发。
|
||||||
|
5. 我确认后,按 ack 闭环循环:先按 docs/ack/project.md 校验 Developer/Test worker 启动命令,
|
||||||
|
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||||
|
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 1 步:Coordinator 产出(确认前)
|
||||||
|
|
||||||
|
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
|
||||||
|
2. 任务板 → `docs/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(见 `optimization-method.md` §1)。
|
||||||
|
3. 校验结构:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **停下来给人确认**——这是强模型该花时间的地方,不要跳过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 2 步:决定 worktree
|
||||||
|
|
||||||
|
见 `closed-loop.md` §「子任务放哪」:
|
||||||
|
|
||||||
|
- 需求大 / 要并行 / 要保基线分支干净 → 新建隔离 worktree。
|
||||||
|
- 小改动 / 串行修复 → 当前 worktree 起子 agent。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 3 步:复用或启动 worker
|
||||||
|
|
||||||
|
先读 `docs/ack/project.md` 的项目覆盖规则,再按 `orca-adapter.md` §「解析并复用 worker」执行:先列出现有终端和活跃任务,优先复用同 worktree、同角色、配置兼容且空闲的 worker。只有没有可复用项时才创建新终端;如果项目提供 `validate_worker_command.py`,校验未通过时不得创建。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Codex 示例(模型与执行模式以项目覆盖层为准)
|
||||||
|
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium'
|
||||||
|
TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
|
||||||
|
python3 docs/ack/validate_worker_command.py --role developer --command "$DEV_CMD"
|
||||||
|
python3 docs/ack/validate_worker_command.py --role test --command "$TEST_CMD"
|
||||||
|
orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
|
||||||
|
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
|
||||||
|
|
||||||
|
# Cursor 示例
|
||||||
|
CURSOR_CMD='cursor-agent --yolo --model auto'
|
||||||
|
python3 docs/ack/validate_worker_command.py --role developer --command "$CURSOR_CMD"
|
||||||
|
orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
上面的 `terminal create` 都是“没有空闲兼容 worker”时的兜底,不是每次 kickoff 的固定动作。新 worktree 时先 `orca worktree create --name <feature> --base-branch <base>`,再解析该 worktree 的 worker。选型与升级见 `model-routing.md`。新建终端后、dispatch 前必须读取启动信息,确认实际 CLI、模型和 reasoning effort;派发后把实际 handle 写入对应任务的 `dispatch.worker`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 4 步:跑闭环(每个任务)
|
||||||
|
|
||||||
|
```text
|
||||||
|
task-create → dispatch 给 DEV → 等 worker_done
|
||||||
|
→ 回写 fixed_by_dev → dispatch 给 TEST 复测 → 等 retest_result
|
||||||
|
→ Coordinator 读证据终检 → 过则 verified,不过则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||||
|
→ 三轮失败:leftover,升级复盘,继续下一个
|
||||||
|
```
|
||||||
|
|
||||||
|
具体命令见 `orca-adapter.md`(Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 5 步:收尾
|
||||||
|
|
||||||
|
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
|
||||||
|
产品文档 + 验收信号写在前(你,强模型)→ 按项目覆盖层校验并启动 DEV/TEST → 核对实际模型 → dispatch / 复测 / 终检循环 → 结论只落 `tasks.yaml`。
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 模型路由(稳定核心)
|
||||||
|
|
||||||
|
本文件是**三角色默认模型档位**和**升级规则**的单一事实源(SSOT)。目标:在不牺牲质量的前提下降低 token 和模型成本——把昂贵的强模型留给需要判断的工作,把机械执行交给较弱模型。
|
||||||
|
|
||||||
|
角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer 实现)。本文件只补一层正交的「用哪个档位的模型」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 默认档位
|
||||||
|
|
||||||
|
| 角色 | 默认模型档位 | 理由 |
|
||||||
|
|------|--------------|------|
|
||||||
|
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
|
||||||
|
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
||||||
|
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
||||||
|
|
||||||
|
关键点:Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token 花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,同时保持「验证者 ≠ 实现者」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么时候用强模型
|
||||||
|
|
||||||
|
- 新需求理解、产品取舍、范围决策。
|
||||||
|
- 架构与数据模型决策。
|
||||||
|
- 把验收写成可观测信号(见 `optimization-method.md` §1)。
|
||||||
|
- 需求含糊、规格与实现/测试冲突时的裁决。
|
||||||
|
- Coordinator 终检:读证据、对齐原始意图。
|
||||||
|
- 重复失败后的根因复盘与重新拆分。
|
||||||
|
|
||||||
|
## 什么时候用中低模型
|
||||||
|
|
||||||
|
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
|
||||||
|
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级规则
|
||||||
|
|
||||||
|
**升级到 Coordinator(强模型)复盘**,当:
|
||||||
|
|
||||||
|
- 同一验收路径 Developer 连续失败三轮(见 `optimization-method.md` §4)。
|
||||||
|
- Test 两次仍无法给出清晰失败证据。
|
||||||
|
- 任务需要改动产品范围或验收标准。
|
||||||
|
- 修复涉及持久化数据、破坏性文件操作、安全或回滚。
|
||||||
|
- 规格、测试、实现三者出现冲突。
|
||||||
|
|
||||||
|
**升级 Developer 模型档位**,当:
|
||||||
|
|
||||||
|
- 任务横跨多个子系统。
|
||||||
|
- 改动涉及数据模型或迁移。
|
||||||
|
- 需要设计新的抽象。
|
||||||
|
- 低档位反复产出表面修复。
|
||||||
|
|
||||||
|
升级动作本身由 Coordinator 判断并记录(可写进 `tasks.yaml` 的 `dispatch` 备注或 `resolution`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 如何给 worker 指定模型(编排相关)
|
||||||
|
|
||||||
|
**模型不由编排层设置。** Orca 的 `orchestration task-create` / `dispatch` 没有 `--model` 参数——`dispatch` 只是把任务投递给一个已存在的终端 handle。**模型在创建 worker 终端、启动 agent CLI 时用 CLI 自带的 flag 固定**,之后该终端的所有 dispatch 都用这个模型。
|
||||||
|
|
||||||
|
**默认不跨 Agent CLI 创建 worker。** Coordinator 应按自己所在的运行环境选择同类 worker:Cursor 会话创建 `cursor-agent` worker,Codex 会话创建 `codex` worker。不要依赖 Coordinator 凭模型回答来猜测运行环境或精确模型名;以实际 CLI / 终端环境为准。只有项目 overlay 或用户明确指定跨 Agent 时,才混用不同 CLI,并记录原因。
|
||||||
|
|
||||||
|
因此「档位 → 具体模型」的映射是 **agent 相关** 的,落地方式见 `orca-adapter.md` §「给 worker 终端固定模型」。常见 CLI:
|
||||||
|
|
||||||
|
| Agent CLI | 指定模型的方式 |
|
||||||
|
|-----------|----------------|
|
||||||
|
| Cursor (`cursor-agent`) | `cursor-agent --model <model>`;`--model auto` 让 Cursor 自动选(推荐给 Test/Developer worker) |
|
||||||
|
| Codex (`codex`) | `codex -m <model> -c model_reasoning_effort=<effort>` |
|
||||||
|
| 其它(opencode 等) | 用各自 CLI 的模型参数或配置 |
|
||||||
|
|
||||||
|
执行模式也必须在创建 worker 时固定,并服从项目覆盖层或用户的明确要求。Cursor 的 YOLO 参数是 `--yolo`;Codex 的等价参数是 `--dangerously-bypass-approvals-and-sandbox`。不要把 Cursor 的 `--yolo` 原样传给 Codex,也不要把裸启动 `codex` 当作“自动选择了正确角色模型”。如果项目提供 worker 命令校验脚本,校验通过是 `terminal create` 的前置条件。
|
||||||
|
|
||||||
|
**本 kit 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。
|
||||||
|
|
||||||
|
### Codex 默认映射
|
||||||
|
|
||||||
|
| 角色 | 模型 | reasoning effort |
|
||||||
|
|------|------|------------------|
|
||||||
|
| Coordinator (PM) | `gpt-5.6-sol` | `high` |
|
||||||
|
| Developer | `gpt-5.6-terra` | `medium` |
|
||||||
|
| Test | `gpt-5.6-luna` | `low` |
|
||||||
|
| Developer 升级 | `gpt-5.6-sol` | `high`;极复杂任务可用 `xhigh` |
|
||||||
|
|
||||||
|
Codex worker 应明确指定模型和 reasoning effort,不把“未指定模型”当作 Cursor `auto` 的等价物。Codex 未指定模型时使用产品推荐模型,但推荐值可能随版本更新,也不保证符合 Test / Developer 的成本档位。具体模型若失效或被弃用,应保持上面的角色档位不变,只更新本映射;项目也可在 overlay 中覆盖映射。
|
||||||
|
|
||||||
|
### Worker 固定启动命令
|
||||||
|
|
||||||
|
以下是 core 的默认完整命令。Developer/Test worker 禁止裸启动;必须同时固定 Agent CLI、角色模型和执行模式。
|
||||||
|
|
||||||
|
| Agent CLI | 角色 | 完整启动命令 |
|
||||||
|
|-----------|------|--------------|
|
||||||
|
| Codex | Developer | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium` |
|
||||||
|
| Codex | Test | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low` |
|
||||||
|
| Codex | Developer 升级 | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high` |
|
||||||
|
| Cursor | Developer / Test | `cursor-agent --yolo --model auto` |
|
||||||
|
|
||||||
|
硬规则:
|
||||||
|
|
||||||
|
1. Codex worker 必须带 `--dangerously-bypass-approvals-and-sandbox`;Cursor worker 必须带 `--yolo`。
|
||||||
|
2. 禁止用裸 `codex`、裸 `cursor-agent` 或只指定 CLI 而不指定角色模型。
|
||||||
|
3. 创建终端前先运行项目提供的 worker 命令校验器;校验失败时不得执行 `orca terminal create`。
|
||||||
|
4. 创建终端后、dispatch 前读取启动信息,确认实际 CLI、模型和 reasoning effort 与命令一致。
|
||||||
|
5. Developer 升级到强模型必须由 Coordinator 记录原因;Test 不得复用 Developer 的强模型终端。
|
||||||
|
6. YOLO 会跳过确认和沙箱,只能在用户已明确授权、外层运行环境受控时启用;项目覆盖层可以收紧执行模式,不能静默放宽。
|
||||||
|
|
||||||
|
项目可以覆盖模型名称或收紧权限,但必须在 `project.md` 中写出完整替代命令,不能只写“auto”“默认模型”或依赖当前 CLI 的隐式默认值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 成本原则
|
||||||
|
|
||||||
|
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
||||||
|
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、改动文件清单。
|
||||||
|
|
||||||
|
这样把昂贵推理挡在重复执行之外。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
|
||||||
|
Coordinator 是脑,Test 是眼,Developer 是手。脑用最强的模型且不做机械测试,眼和手用便宜模型,只有常规闭环卡住时才升级。
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 优化方法(稳定核心)
|
||||||
|
|
||||||
|
减少"开发声称完成但复测不过"的循环成本。本文件是**验收信号写法**和**三轮失败策略**的 SSOT,其它文件引用这里。
|
||||||
|
|
||||||
|
三角色分工(Coordinator 编排 / Test 验证 / Developer 实现)见 `roles-and-permissions.md`;复测步骤、Coordinator 终检与环境对齐见 `closed-loop.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 先把验收写成可观测信号(SSOT)
|
||||||
|
|
||||||
|
不要只写"页面可用""体验更好"。每个任务至少写三类信号:
|
||||||
|
|
||||||
|
| 类型 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| 可见文本 | 页面出现 `确认应用标签`、`取消`、`将修改` |
|
||||||
|
| API 结果 | `POST /api/...` 返回 `code=0` 且字段存在 |
|
||||||
|
| 交互结果 | 点击按钮后出现确认区;取消后不触发写入 |
|
||||||
|
|
||||||
|
好的验收:
|
||||||
|
|
||||||
|
```text
|
||||||
|
点击"预览变更"后,页面必须出现 API 返回的 diff 行:
|
||||||
|
- title
|
||||||
|
- 修改前值
|
||||||
|
- 修改后值
|
||||||
|
- coverChanged 提示
|
||||||
|
```
|
||||||
|
|
||||||
|
不好的验收:
|
||||||
|
|
||||||
|
```text
|
||||||
|
预览功能正常。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 回报必须带证据,不带结论
|
||||||
|
|
||||||
|
Developer 的 `worker_done` 应报告:改了哪些文件、跑了哪些命令、自己如何复现验收路径、仍可能有的风险。Test 的复测报告同理:跑了哪些命令、命中/缺失了哪些验收信号、实际观察到什么、证据(snapshot / API 结果)。两者的"结论"都不作数,只有 Coordinator 落盘的 `tasks.yaml` 才是事实。
|
||||||
|
|
||||||
|
不要写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
已完成,应该可以了。
|
||||||
|
```
|
||||||
|
|
||||||
|
要写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
修改 web/app/fix/page.tsx。已运行 npm run build、go test ./...。
|
||||||
|
手测 /fix?fileId=1:搜索、选择候选、预览、确认区都可见。
|
||||||
|
风险:未执行真实写文件 apply。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 验证权在 Test,Coordinator 只信证据
|
||||||
|
|
||||||
|
即使 Developer 的 worker_done 写了"全部通过",也必须由独立的 Test 复测(验证者 ≠ 实现者)。完整复测步骤与 worktree/服务对齐见 `closed-loop.md`。独立复测可避免三类假通过:测错服务实例、测到旧构建产物、开发只验证静态文本没验证真实交互。
|
||||||
|
|
||||||
|
Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它覆盖了每条验收信号且符合原始意图,避免"过了字面没过意图"。终检不通过就回写 `failed_retest`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 三轮失败策略(SSOT)
|
||||||
|
|
||||||
|
每个任务最多自动派发三轮:
|
||||||
|
|
||||||
|
```text
|
||||||
|
round 1: 常规修复
|
||||||
|
round 2: 带复测失败证据的定向修复
|
||||||
|
round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
|
||||||
|
failed after round 3: 标记 leftover,继续下一个任务
|
||||||
|
```
|
||||||
|
|
||||||
|
三轮失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、Worker 对问题模型理解错了、UI 自动化与实际浏览器状态有差异、需要人工观察或调试工具介入。
|
||||||
|
|
||||||
|
留档字段(结构见 `templates/tasks.schema.json`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: leftover
|
||||||
|
resolution:
|
||||||
|
leftoverReason: "failed after 3 supervised developer rounds"
|
||||||
|
dispatch:
|
||||||
|
rounds:
|
||||||
|
- round: 1
|
||||||
|
result: failed
|
||||||
|
evidence: "<why failed>"
|
||||||
|
- round: 2
|
||||||
|
result: failed
|
||||||
|
evidence: "<why failed>"
|
||||||
|
- round: 3
|
||||||
|
result: failed
|
||||||
|
evidence: "<why failed>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 任务排序
|
||||||
|
|
||||||
|
1. P0 阻塞主流程。
|
||||||
|
2. P1 高频用户路径。
|
||||||
|
3. P1/P2 体验改进。
|
||||||
|
4. 重构和内部质量。
|
||||||
|
|
||||||
|
三轮失败的 P0 可以留档,但发布前需人工决策:降级范围、改验收标准、换新 Agent / 新 worktree 重做、人工修复。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 每轮派发只修一个明确问题
|
||||||
|
|
||||||
|
不要一次让 Developer Worker 修太多失败项,除非它们共享同一根因:
|
||||||
|
|
||||||
|
```text
|
||||||
|
one dispatch = one bug = one acceptance path
|
||||||
|
```
|
||||||
|
|
||||||
|
优点:复测更快、失败原因更清楚、三轮失败留档更准确。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 把失败证据写给下一轮 worker
|
||||||
|
|
||||||
|
第二轮以后,派发 prompt 应突出"上一轮为什么没过",而非重复原始描述。模板见 `prompt-templates.md` §「复测失败再派发模板」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 优先让测试可执行化
|
||||||
|
|
||||||
|
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。这类可执行测试由 Test 拥有并维护(见 `roles-and-permissions.md` 权限表的 `<integration_test_paths>`)。优先级:
|
||||||
|
|
||||||
|
1. API smoke。
|
||||||
|
2. 浏览器脚本或 case 文档。
|
||||||
|
3. 单元测试。
|
||||||
|
4. 人工检查清单。
|
||||||
|
|
||||||
|
目标不是全部自动化,而是把最容易反复误判的路径自动化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 结束条件
|
||||||
|
|
||||||
|
一轮闭环结束时,必须能回答:
|
||||||
|
|
||||||
|
- 哪些任务 verified?哪些 leftover?
|
||||||
|
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
|
||||||
|
- 当前工作树有哪些未提交改动?
|
||||||
|
- 是否还有 open / failed_retest 未处理?
|
||||||
|
|
||||||
|
答不清楚,闭环就还没结束。
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# Orca 适配器(稳定核心,一种编排实现)
|
||||||
|
|
||||||
|
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
|
||||||
|
|
||||||
|
所有结论仍以 `tasks.yaml` 为准,Orca 消息不是最终记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 何时用 Orca 编排
|
||||||
|
|
||||||
|
用:需要监督、等待结果、闭环修复、三轮重试;有多个可独立派发的任务;需要 `worker_done` 后再复测。
|
||||||
|
|
||||||
|
不用:单次问答;用户只要求完整 handoff 不需监督;不需要复测的小改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 运行前检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca status --json
|
||||||
|
orca terminal list --json
|
||||||
|
orca orchestration task-list --json
|
||||||
|
orca orchestration inbox --limit 20 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
确认:Orca runtime 可达;Coordinator、Developer、Test 三个终端都存在;Developer 在正确 worktree;当前没有冲突的活跃编排任务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 解析并复用 worker
|
||||||
|
|
||||||
|
**默认复用,创建是兜底。** 每次向 Developer 或 Test 派发前都先解析 worker,不能直接照后文示例执行 `terminal create`:
|
||||||
|
|
||||||
|
1. 运行 `orca terminal list --json`,筛选 `connected=true`、`writable=true`、目标 `worktreePath` 一致、标题角色和配置一致的终端。稳定标题使用 `ACK-<ROLE>-<CLI>-<TIER>-<N>`,例如 `ACK-DEV-CODEX-TERRA-1`、`ACK-TEST-CODEX-LUNA-1`、`ACK-DEV-CURSOR-AUTO-1`;强档示例为 `ACK-DEV-CODEX-SOL-1`。
|
||||||
|
2. 运行 `orca orchestration task-list --status dispatched --json`。若候选 handle 出现在活跃任务的 `assignee_handle`,视为 busy,不复用;不要仅凭终端预览文本或最近输出时间猜忙闲。
|
||||||
|
3. 对空闲候选运行 `orca terminal show --terminal <handle> --json`,确认仍存活且 worktree、角色和 Agent CLI / 模型档位符合本次要求。符合就直接把该 handle 作为 `dispatch --to` 的目标。
|
||||||
|
4. 只有不存在兼容的空闲候选时才创建。若同角色同配置已有 busy worker,使用下一个未占用序号,例如 `ACK-DEV-CODEX-TERRA-2`;不要创建另一个同名终端。
|
||||||
|
5. dispatch 成功后,把实际 handle 写入对应 `tasks.yaml` 条目的 `dispatch.worker`。下一轮先尝试该 handle;若它已消失、断开、不可写、worktree/角色/档位不兼容或正忙,再回到第 1 步选择其它 worker。
|
||||||
|
|
||||||
|
`tasks.yaml.dispatch.worker` 记录“这个任务实际用了谁”,便于续跑和审计;`orca terminal list` 记录“谁现在还活着”,是运行时存活状态的事实源。不要另建一份永久 worker 池,因为终端关闭后其中的 handle 会过期。
|
||||||
|
|
||||||
|
复用决策:
|
||||||
|
|
||||||
|
| 情况 | 动作 |
|
||||||
|
|------|------|
|
||||||
|
| 任务已记录 worker,且该 handle 存活、兼容、空闲 | 优先复用原 handle |
|
||||||
|
| 有其它同 worktree、同角色、兼容的空闲 worker | 复用该 handle |
|
||||||
|
| 同角色 worker 存在但都 busy | 创建下一编号的临时并发 worker |
|
||||||
|
| worker 已关闭、断开或不存在 | 创建新 worker |
|
||||||
|
| 模型升级或 worktree 不同 | 不复用不兼容 worker,创建对应档位/路径的新 worker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给 worker 终端固定模型
|
||||||
|
|
||||||
|
**编排层不设模型。** `task-create` / `dispatch` 都没有 `--model`;`dispatch --to <handle>` 只是把任务投给一个已存在的终端,用的是那个终端里 agent 会话启动时的模型。要固定模型,就在 **创建 worker 终端** 时用 agent CLI 的模型参数:
|
||||||
|
|
||||||
|
默认沿用 Coordinator 当前所在的 Agent CLI:Cursor Coordinator 创建 Cursor worker,Codex Coordinator 创建 Codex worker。不要通过询问模型来猜运行环境;以当前 CLI / 终端环境为准。除非项目 overlay 或用户明确指定,否则不跨 Agent CLI 创建 worker。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cursor:Test / Developer worker 用 auto 模型,并按项目要求启用 YOLO
|
||||||
|
orca terminal create --worktree path:<dev_worktree> \
|
||||||
|
--command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||||
|
|
||||||
|
# 需要更强模型时改成具体模型
|
||||||
|
orca terminal create --worktree path:<dev_worktree> \
|
||||||
|
--command "cursor-agent --yolo --model claude-opus-4-8-thinking-high" --title "ACK-DEV-CURSOR-STRONG-1" --json
|
||||||
|
|
||||||
|
# Codex:Developer worker
|
||||||
|
orca terminal create --worktree path:<dev_worktree> \
|
||||||
|
--command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium" \
|
||||||
|
--title "ACK-DEV-CODEX-TERRA-1" --json
|
||||||
|
|
||||||
|
# Codex:Test worker
|
||||||
|
orca terminal create --worktree path:<test_worktree> \
|
||||||
|
--command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low" \
|
||||||
|
--title "ACK-TEST-CODEX-LUNA-1" --json
|
||||||
|
|
||||||
|
# Codex:复杂 Developer 任务升级
|
||||||
|
orca terminal create --worktree path:<dev_worktree> \
|
||||||
|
--command "codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high" \
|
||||||
|
--title "ACK-DEV-CODEX-SOL-1" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
这些创建命令只在复用流程找不到空闲兼容 worker 时执行。拿到返回的 handle 后再 `task-create` + `dispatch --to <handle>`,并把 handle 写入 `tasks.yaml` 的 `dispatch.worker`。模型档位与选型策略见 `model-routing.md`。
|
||||||
|
|
||||||
|
需要隔离/并行时,先建新 worktree 再在其中起 worker(是否新建见 `closed-loop.md` §「子任务放哪」):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca worktree create --name <feature> --base-branch <base> --json
|
||||||
|
orca terminal create --worktree path:<new_worktree> --command "cursor-agent --yolo --model auto" --title "ACK-DEV-CURSOR-AUTO-1" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cursor-agent --list-models` 可列出合法模型;`auto` 表示由 Cursor 自动选型。
|
||||||
|
- Codex 用 `-m / --model` 指定模型,用 `-c model_reasoning_effort=<effort>` 固定推理档位;不要把省略 `-m` 当作 Cursor `auto` 的等价物。
|
||||||
|
- 本项目要求 Cursor worker 带 `--yolo`,Codex worker 带 `--dangerously-bypass-approvals-and-sandbox`;创建前先运行项目覆盖层指定的校验器。
|
||||||
|
- 其它 CLI(opencode 等)用各自的模型参数或配置,`--command` 相应替换。
|
||||||
|
- 若 worker 是已在跑的会话(用 `--inject` 投递),模型已由该会话启动时决定,无法在 dispatch 时改;要换模型需新建终端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 创建父任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration task-create --spec "$(cat <<'EOF'
|
||||||
|
Goal: Complete <release_or_feature> with supervised Coordinator -> Developer -> Test loop.
|
||||||
|
|
||||||
|
Coordinator (PM): owns task board, decomposition, dispatch, and final gate (reads Test evidence, does not test).
|
||||||
|
Developer: owns implementation and white-box verification.
|
||||||
|
Test: owns independent black-box retest and evidence (verifier != implementer).
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- Each issue can be dispatched at most 3 rounds.
|
||||||
|
- If still failing after 3 rounds, record as leftover and continue next issue.
|
||||||
|
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml.
|
||||||
|
EOF
|
||||||
|
)" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建子任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration task-create --parent <parent_task_id> --spec "$(cat <<'EOF'
|
||||||
|
Fix <task_id>: <title>
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
- Path: <repo_path>
|
||||||
|
- Worktree: <dev_worktree>
|
||||||
|
|
||||||
|
Read: <overlay_file> (project overlay), tasks.yaml, <relevant_spec_or_test_doc>
|
||||||
|
Failure evidence: <copy latest Test evidence>
|
||||||
|
Acceptance: <copy expected behavior + verification commands>
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Follow the overlay file path scope.
|
||||||
|
- Do not write tasks.yaml, do not mark verified.
|
||||||
|
- Do not commit or push unless user asks.
|
||||||
|
EOF
|
||||||
|
)" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 派发给 Developer Worker
|
||||||
|
|
||||||
|
终端是 Orca 可识别的 Agent CLI 时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration dispatch --task <task_id> --to <developer_handle> --inject --json
|
||||||
|
```
|
||||||
|
|
||||||
|
不能 `--inject` 时,先登记 dispatch,再手动投递 `prompt-templates.md` §1 的初始派发 prompt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration dispatch --task <task_id> --to <developer_handle> --json
|
||||||
|
orca terminal send --terminal <developer_handle> --text "$(cat <<'EOF'
|
||||||
|
<粘贴 prompt-templates.md §1 初始派发模板,已填占位符>
|
||||||
|
EOF
|
||||||
|
)" --enter --json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 派发给 Test 复测
|
||||||
|
|
||||||
|
Developer 回报 worker_done、Coordinator 写回 `fixed_by_dev` 后,把复测任务发给 Test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration dispatch --task <task_id> --to <test_handle> --inject --json
|
||||||
|
```
|
||||||
|
|
||||||
|
不能 `--inject` 时,先登记再手动投递 `prompt-templates.md` §3 的复测派发 prompt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration dispatch --task <task_id> --to <test_handle> --json
|
||||||
|
orca terminal send --terminal <test_handle> --text "$(cat <<'EOF'
|
||||||
|
<粘贴 prompt-templates.md §3 复测派发模板,已填占位符>
|
||||||
|
EOF
|
||||||
|
)" --enter --json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 等待结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration check \
|
||||||
|
--terminal <coordinator_handle> \
|
||||||
|
--wait \
|
||||||
|
--types worker_done,retest_result,escalation,decision_gate \
|
||||||
|
--timeout-ms 900000 \
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
等待超时不等于失败。长任务可继续等待,或检查 worker 终端活性。`worker_done` 来自 Developer,`retest_result`(无该类型时用 `worker_done` + subject 区分)来自 Test。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer 回报 worker_done
|
||||||
|
|
||||||
|
字段含义见 `prompt-templates.md` §3:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration send \
|
||||||
|
--to <coordinator_handle> \
|
||||||
|
--type worker_done \
|
||||||
|
--subject "<task_id> fix ready round <n>" \
|
||||||
|
--body "<修了什么。跑了哪些验证。如何复现。还有什么风险。>" \
|
||||||
|
--payload '{
|
||||||
|
"taskId": "<orca_task_id>",
|
||||||
|
"dispatchId": "<orca_dispatch_id>",
|
||||||
|
"filesModified": ["<file_a>", "<file_b>"],
|
||||||
|
"verification": ["<command_a>: passed", "<command_b>: passed"],
|
||||||
|
"risk": "<remaining risk or none>"
|
||||||
|
}' \
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
收到 worker_done 后,Coordinator 写回 `fixed_by_dev`,再按上文派发给 Test 复测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test 回报复测结果
|
||||||
|
|
||||||
|
字段含义见 `prompt-templates.md` §5:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orca orchestration send \
|
||||||
|
--to <coordinator_handle> \
|
||||||
|
--type retest_result \
|
||||||
|
--subject "<task_id> retest round <n>" \
|
||||||
|
--body "<逐条验收信号结论 + 实际观察 + 证据>" \
|
||||||
|
--payload '{
|
||||||
|
"taskId": "<orca_task_id>",
|
||||||
|
"dispatchId": "<orca_dispatch_id>",
|
||||||
|
"env": {"worktree": "<path>", "branch": "<branch>", "commit": "<sha>", "baseUrl": "<base_url>"},
|
||||||
|
"signals": ["<signal 1>: pass", "<signal 2>: fail (<evidence>)"],
|
||||||
|
"conclusion": "all-signals-pass | signals-failed"
|
||||||
|
}' \
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>` 区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# 派发 Prompt 模板(稳定核心)
|
||||||
|
|
||||||
|
Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发复测。复制时替换尖括号占位符。模板与编排工具无关:Orca 模式用 `orca-adapter.md` 的命令投递,手动模式直接把文本发给对应 Agent。
|
||||||
|
|
||||||
|
角色分工见 `roles-and-permissions.md`;闭环顺序见 `closed-loop.md`。
|
||||||
|
|
||||||
|
派发时除了具体任务,Coordinator 应把对应角色的**能力要求**一并带上(`roles-and-permissions.md` §「三角色能力清单」的 Must Do / Must Not)。下面模板已内置关键条目,复制即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 可选 skills 路由(装了才用,不阻塞)
|
||||||
|
|
||||||
|
如果 worker 所在环境已安装以下 skill,可在对应环节调用以获得更强 playbook;未安装则按 `roles-and-permissions.md` 的能力清单执行:
|
||||||
|
|
||||||
|
- Coordinator 规划复杂需求:`/think` 或 `superpowers:brainstorming` / `writing-plans`。
|
||||||
|
- Developer 排查缺陷:`/hunt` 或 `superpowers:systematic-debugging`;行为变更:`superpowers:test-driven-development`。
|
||||||
|
- Test 复测 / 合并前检查:`/check` 或 `superpowers:verification-before-completion`。
|
||||||
|
|
||||||
|
派发时可加一行:「若已安装 <skill>,本环节可用它;未安装按 ack 角色能力清单执行。」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 初始派发给 Developer
|
||||||
|
|
||||||
|
```text
|
||||||
|
你现在接到一个受监督的协作任务。请在 <dev_worktree> 开发工作树内完成。
|
||||||
|
|
||||||
|
任务:
|
||||||
|
- 修复 <task_id>: <task_title>
|
||||||
|
|
||||||
|
请先读取:
|
||||||
|
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile)
|
||||||
|
- tasks.yaml
|
||||||
|
- <relevant_spec_or_test_doc>
|
||||||
|
|
||||||
|
当前失败证据:
|
||||||
|
<copy latest Test evidence>
|
||||||
|
|
||||||
|
验收标准(可观测信号,见 optimization-method.md §1):
|
||||||
|
1. <expected behavior 1>
|
||||||
|
2. <expected behavior 2>
|
||||||
|
3. <expected behavior 3>
|
||||||
|
|
||||||
|
能力要求(见 roles-and-permissions.md §三角色能力清单 · Developer):
|
||||||
|
- 动手前先复现失败现象,或先写一个会失败的测试再修。
|
||||||
|
- bug 修复配可复现的失败用例;行为变更配单元测试。
|
||||||
|
- 完成前亲自走一遍验收路径,不只满足静态文案。
|
||||||
|
- 若是网站 / 常驻服务,改完重启服务并确认生效,别让 Test 测到旧进程或旧构建。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 只修改 Developer 可写路径(见覆盖层文件的权限表)。
|
||||||
|
- 不要修改产品规格和集成测试文件(分别由 Coordinator 与 Test 拥有),除非任务明确要求。
|
||||||
|
- 不要写 tasks.yaml,不要标记 verified。
|
||||||
|
- 不要提交或推送,除非用户明确要求。
|
||||||
|
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
|
||||||
|
|
||||||
|
完成前必须运行:
|
||||||
|
- <test command 1>
|
||||||
|
- <test command 2>
|
||||||
|
|
||||||
|
完成后回报一次 worker_done(格式见 §4)。如果阻塞,请发送 escalation 或 ask。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 复测失败再派发给 Developer
|
||||||
|
|
||||||
|
```text
|
||||||
|
第 <n> 轮复测未通过,请继续修 <task_id>。
|
||||||
|
|
||||||
|
上一轮开发声称:
|
||||||
|
<worker_done summary>
|
||||||
|
|
||||||
|
Test 独立复测结果:
|
||||||
|
- 运行命令:<commands>
|
||||||
|
- 失败页面/API:<page or endpoint>
|
||||||
|
- 仍缺失:<missing expected checks>
|
||||||
|
- 实际观察:<actual evidence>
|
||||||
|
|
||||||
|
关键提示:
|
||||||
|
- 不要只满足静态文案,必须满足交互后的真实状态。
|
||||||
|
- 如果后端/API 正常而 UI 不更新,请优先排查事件链、状态重置、异步请求、构建产物和服务 worktree。
|
||||||
|
- 修复后请自己用浏览器或脚本复现 Test 的完整步骤。
|
||||||
|
|
||||||
|
验收不变:
|
||||||
|
<copy acceptance criteria>
|
||||||
|
|
||||||
|
完成后回报 worker_done,subject 使用:"<task_id> fix ready round <n>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 派发给 Test 复测
|
||||||
|
|
||||||
|
Developer 回报 worker_done 后,Coordinator 把复测任务发给 Test。
|
||||||
|
|
||||||
|
```text
|
||||||
|
请对 <task_id>: <task_title> 做独立黑盒复测。
|
||||||
|
|
||||||
|
请先读取:
|
||||||
|
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile)
|
||||||
|
- tasks.yaml(该任务的验收信号)
|
||||||
|
- <relevant_spec_or_test_doc>
|
||||||
|
|
||||||
|
Developer 本轮声称(仅供参考,不作数):
|
||||||
|
- 改动文件:<files>
|
||||||
|
- 自测命令:<commands>
|
||||||
|
|
||||||
|
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
||||||
|
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
|
||||||
|
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
||||||
|
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
||||||
|
1. <observable signal 1>
|
||||||
|
2. <observable signal 2>
|
||||||
|
3. <observable signal 3>
|
||||||
|
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 只读源码,不修改应用代码,不写 tasks.yaml。
|
||||||
|
- 只回传证据和逐条结论,最终判定由 Coordinator 终检后落盘。
|
||||||
|
|
||||||
|
完成后回报一次复测报告(格式见 §5),subject:"<task_id> retest round <n>"。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Developer worker_done 回报格式
|
||||||
|
|
||||||
|
无论 Orca 还是手动,回报都应包含以下字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
subject: <task_id> fix ready round <n>
|
||||||
|
filesModified: [<file_a>, <file_b>]
|
||||||
|
verification:
|
||||||
|
- <command_a>: passed
|
||||||
|
- <command_b>: passed
|
||||||
|
reproduce: 我如何复现验收路径 <steps>
|
||||||
|
risk: <remaining risk or none>
|
||||||
|
```
|
||||||
|
|
||||||
|
Orca 模式下用 `orca-adapter.md` §「Developer 回报 worker_done」的命令发送同样的字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Test 复测报告格式
|
||||||
|
|
||||||
|
Test 只回传证据和逐条结论,不下最终判定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
subject: <task_id> retest round <n>
|
||||||
|
env:
|
||||||
|
worktree: <path>
|
||||||
|
branch: <branch>
|
||||||
|
commit: <short sha>
|
||||||
|
baseUrl: <base_url>
|
||||||
|
commands:
|
||||||
|
- <command_a>: passed/failed
|
||||||
|
signals:
|
||||||
|
- <signal 1>: pass/fail (<evidence>)
|
||||||
|
- <signal 2>: pass/fail (<evidence>)
|
||||||
|
browser: <snapshot / DOM / API evidence>
|
||||||
|
conclusion: all-signals-pass / signals-failed
|
||||||
|
notes: <observations, suspected cause if failed>
|
||||||
|
```
|
||||||
|
|
||||||
|
Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发送同样的字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Coordinator 最终报告模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
本轮闭环完成。
|
||||||
|
|
||||||
|
已验证(Test 复测通过 + 终检):
|
||||||
|
- <task_id>: <what passed>
|
||||||
|
|
||||||
|
遗留:
|
||||||
|
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
|
||||||
|
|
||||||
|
验证命令:
|
||||||
|
- <command>: passed
|
||||||
|
|
||||||
|
工作树状态:
|
||||||
|
- <repo_path>: <git status summary>
|
||||||
|
- <dev_worktree>: <git status summary>
|
||||||
|
```
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 角色与权限(稳定核心)
|
||||||
|
|
||||||
|
本文件是**角色模型、路径权限、任务状态机、完成定义**的单一事实源(SSOT)。其它文件只引用本文件,不重复定义。
|
||||||
|
|
||||||
|
目标:让每个 Agent 只处理自己能验证的事情,减少上下文污染和越权修改。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 角色模型(三角色)
|
||||||
|
|
||||||
|
本 kit 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。
|
||||||
|
|
||||||
|
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
||||||
|
|------|----------|----------|------------|
|
||||||
|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、写 `tasks.yaml`、向 Developer/Test 派发、跑三轮闭环、做最终 gate | 读 Test 证据并对齐原始意图(不亲自跑测试) | 修改源码、亲自复测、凭 worker_done 直接标 `verified` |
|
||||||
|
| Test | 黑盒复测、回归验证、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` |
|
||||||
|
| Developer | 实现修复、写单元测试、运行构建和白盒验证 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、标记 `verified`、绕过测试声称完成 |
|
||||||
|
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||||
|
|
||||||
|
**独立验证权归 Test。** Coordinator 不亲自复测——它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。
|
||||||
|
|
||||||
|
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三角色能力清单(SSOT)
|
||||||
|
|
||||||
|
上面的表定义了**边界**(谁能碰什么),这一节定义**能力**(每个角色到底该怎么做好自己的事)。每个角色用同一骨架描述:`Outcome`(产出什么)/ `Must Do`(必须做)/ `Must Not`(不能做)/ `Evidence`(拿什么证明)/ `Output`(交付格式)。派发 prompt 会引用这里,见 `prompt-templates.md`。
|
||||||
|
|
||||||
|
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `docs/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
|
||||||
|
|
||||||
|
### Coordinator (PM) —— 拆解与终检
|
||||||
|
|
||||||
|
- **Outcome**:把一句话需求变成可执行、验收可观测的任务集,并跑完闭环得到明确结论(verified / leftover)。
|
||||||
|
- **Must Do**
|
||||||
|
- 先澄清意图再动手:目标、成功标准、约束、明确「不做什么」。歧义有多解或多来源冲突时,先问清再拆。
|
||||||
|
- 每个任务写**可观测验收信号**(可见文本 / API 结果 / 交互结果,见 `optimization-method.md` §1),而不是「功能正常」。
|
||||||
|
- 拆任务时点明最脆弱的假设:「本任务假设 X,若 X 不成立则 Y」;列出被否掉的方案与原因。
|
||||||
|
- 拆分/验收先给用户确认,再派发(`kickoff.md` 第 1 步的停顿点)。
|
||||||
|
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮。
|
||||||
|
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
|
||||||
|
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发。
|
||||||
|
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
|
||||||
|
- **Output**:确认前给「产品文档 + 任务拆分 + 验收信号」;闭环结束给最终报告(`prompt-templates.md` §6)。
|
||||||
|
- **可选 skills**:复杂需求可先用 `/think` 或 `superpowers:brainstorming` / `writing-plans` 收敛设计与计划。
|
||||||
|
|
||||||
|
### Developer —— 实现与白盒验证
|
||||||
|
|
||||||
|
- **Outcome**:在授权路径内做出满足验收信号的最小改动,并用白盒证据证明它可复现。
|
||||||
|
- **Must Do**
|
||||||
|
- 动手前先读覆盖层文件、`tasks.yaml` 对应任务、相关规格;复现失败现象或先写会失败的测试。
|
||||||
|
- 最小 diff,只改一个明确问题的根因,不顺手重构无关代码。
|
||||||
|
- 行为变更配单元测试;bug 修复先有一个能复现的失败用例再修。
|
||||||
|
- 完成前跑覆盖层里规定的命令(构建 / 单测 / 本地运行),亲自走一遍验收路径。
|
||||||
|
- **网站 / 常驻服务**:改完重启服务(或触发热更并确认生效),保证运行实例跑的是新代码,避免 Test 测到旧进程 / 旧构建。
|
||||||
|
- **Must Not**:改产品规格与集成测试、写 `tasks.yaml`、标 `verified`、绕过测试声称完成、把 bug 修复扩成大重构(需要就先停下说明并请示)。
|
||||||
|
- **Evidence**:改了哪些文件、跑了哪些命令及结果、如何复现验收路径、残留风险。
|
||||||
|
- **Output**:一次 `worker_done`,字段见 `prompt-templates.md` §4(只报证据,不下最终结论)。
|
||||||
|
- **可选 skills**:排查用 `/hunt` 或 `superpowers:systematic-debugging`(先根因后修);实现行为变更用 `superpowers:test-driven-development`。
|
||||||
|
|
||||||
|
### Test —— 独立黑盒复测
|
||||||
|
|
||||||
|
- **Outcome**:以独立视角复现验收路径,逐条给出通过/失败的可观测证据,供 Coordinator 终检。
|
||||||
|
- **Must Do**
|
||||||
|
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 `closed-loop.md`),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
|
||||||
|
- 逐条验证验收信号,验证交互后的真实状态,而不是只看静态文案。
|
||||||
|
- **网站类任务优先用浏览器复测**真实交互(点击 / 跳转 / 渲染),其次才是 API / 脚本;纯后端 / CLI 则以 API smoke 或脚本为主。
|
||||||
|
- 把最容易反复误判的路径沉淀成可执行测试(`optimization-method.md` §8)。
|
||||||
|
- 只回传证据 + 逐条结论,最终判定留给 Coordinator。
|
||||||
|
- **Must Not**:改应用源码、改产品规格、写 `tasks.yaml`、凭 Developer 的 `worker_done` 直接下结论。
|
||||||
|
- **Evidence**:运行环境快照、命令结果、每条信号 pass/fail + 证据(snapshot / DOM / API 结果)。
|
||||||
|
- **Output**:一次复测报告,字段见 `prompt-templates.md` §5。
|
||||||
|
- **可选 skills**:合并 / 发版前检查可用 `/check` 或 `superpowers:verification-before-completion`(证据先于结论)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 路径权限模板
|
||||||
|
|
||||||
|
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `docs/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
|
||||||
|
|
||||||
|
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||||
|
|------|:-----------:|:----:|:---------:|------|
|
||||||
|
| `<spec_paths>` | R/W | Read-only | Read-only | PRD、API spec、设计文档,Coordinator(PM)拥有 |
|
||||||
|
| `<integration_test_paths>` | Read-only | R/W | Read-only | 浏览器用例、API smoke、回归清单,Test 拥有 |
|
||||||
|
| `<test_records_path>` | Read-only | R/W | Read-only | 复测记录,通常可 gitignore |
|
||||||
|
| `<source_paths>` | Read-only | Read-only | R/W | 应用源码 |
|
||||||
|
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
||||||
|
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||||
|
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||||
|
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「任务板写入约定」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务状态机(SSOT)
|
||||||
|
|
||||||
|
```text
|
||||||
|
open
|
||||||
|
-> dispatched (派发给 Developer)
|
||||||
|
-> fixed_by_dev (Developer 声称已修)
|
||||||
|
-> retesting (派发给 Test 复测)
|
||||||
|
-> verified (Test 通过 + Coordinator 终检)
|
||||||
|
```
|
||||||
|
|
||||||
|
失败分支:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dispatched -> blocked
|
||||||
|
retesting -> failed_retest -> dispatched
|
||||||
|
failed_retest(累计 3 轮) -> leftover
|
||||||
|
```
|
||||||
|
|
||||||
|
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
|
||||||
|
|
||||||
|
| 状态 | 依据来源 | 含义 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `open` | Coordinator 自己发现/记录 | 已发现,等待处理 |
|
||||||
|
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
|
||||||
|
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
|
||||||
|
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
|
||||||
|
| `failed_retest` | Test 的复测报告 | 复测失败,可继续派发 Developer |
|
||||||
|
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
|
||||||
|
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
|
||||||
|
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
|
||||||
|
|
||||||
|
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务板写入约定(并发安全)
|
||||||
|
|
||||||
|
`tasks.yaml` 是持久事实源,为避免多 Agent 并发写冲突:
|
||||||
|
|
||||||
|
- **只有 Coordinator 写 `tasks.yaml`**。Test 与 Developer 对它都是只读的。
|
||||||
|
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
||||||
|
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
|
||||||
|
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成定义(Definition of Done)
|
||||||
|
|
||||||
|
一个任务只有同时满足以下条件,才能标记 `verified`:
|
||||||
|
|
||||||
|
- Developer 已提供修改文件和白盒验证证据(`worker_done`)。
|
||||||
|
- Test 在正确 worktree 和正确服务实例上独立复测通过(对齐检查见 `closed-loop.md`),并产出可观测证据。
|
||||||
|
- 相关单元测试、构建、集成或浏览器检查通过。
|
||||||
|
- **Coordinator 终检**:读 Test 的证据,确认它满足任务的原始意图与验收信号(不是重测,是审证据 + 对齐意图;避免"过了字面没过意图")。
|
||||||
|
- `tasks.yaml` 中记录了复测证据与 `resolution.verifiedBy`。
|
||||||
|
- 用户可见行为符合验收标准。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
|
> 本项目基于 ack v0.8.1。
|
||||||
|
> 稳定规范引用 `docs/ack/kit/core/`,本文件只填项目差异。
|
||||||
|
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||||
|
> 目录下只有一个软链接 `kit/` + `project.md` + `tasks.yaml`。
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
|
- 项目:`notes-web`
|
||||||
|
- 技术栈:`TypeScript + React (Vite) + Go`
|
||||||
|
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
|
||||||
|
- Base URL:`http://localhost:5173`
|
||||||
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
|
- 覆盖层文件:`docs/ack/project.md`
|
||||||
|
|
||||||
|
## 稳定规范(引用,不重复)
|
||||||
|
|
||||||
|
- 开始一个需求(启动手册):`docs/ack/kit/core/kickoff.md`
|
||||||
|
- 角色 / 权限 / 状态机 / 完成定义:`docs/ack/kit/core/roles-and-permissions.md`
|
||||||
|
- 模型档位与升级规则:`docs/ack/kit/core/model-routing.md`
|
||||||
|
- 闭环流程(含手动模式、worktree 对齐):`docs/ack/kit/core/closed-loop.md`
|
||||||
|
- 优化方法(验收信号、三轮策略):`docs/ack/kit/core/optimization-method.md`
|
||||||
|
- 派发 prompt 模板:`docs/ack/kit/core/prompt-templates.md`
|
||||||
|
- Orca 编排命令:`docs/ack/kit/core/orca-adapter.md`
|
||||||
|
|
||||||
|
## 模型档位
|
||||||
|
|
||||||
|
| 角色 | 默认档位 | 本项目实际 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Coordinator (PM) | 强模型 | claude-sonnet-5-thinking-high |
|
||||||
|
| Test | 中低模型 | 默认(中低) |
|
||||||
|
| Developer | 中低模型 | 默认(中低),架构任务临时升级 |
|
||||||
|
|
||||||
|
## 路径权限
|
||||||
|
|
||||||
|
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||||
|
|------|:-----------:|:----:|:---------:|------|
|
||||||
|
| `docs/spec/**` | R/W | Read-only | Read-only | 产品规格(PM 拥有) |
|
||||||
|
| `tests/browser/**` | Read-only | R/W | Read-only | 浏览器回归用例(Test 拥有) |
|
||||||
|
| `.qa-records/**` | Read-only | R/W | Read-only | 复测记录(gitignore) |
|
||||||
|
| `web/**`、`server/**` | Read-only | Read-only | R/W | 应用源码 |
|
||||||
|
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
|
||||||
|
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||||
|
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
Developer 白盒验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
go test ./...
|
||||||
|
npm run dev # 本地起前端
|
||||||
|
```
|
||||||
|
|
||||||
|
Test 黑盒复测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:5173/api/health
|
||||||
|
curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
|
||||||
|
# 浏览器回归:tests/browser/cases/*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
任务板校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 硬规则(其余见 kit/core/)
|
||||||
|
|
||||||
|
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
|
||||||
|
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 kit/core/model-routing.md)。
|
||||||
|
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
|
- 只有 Coordinator 写 `tasks.yaml`;Test 与 Developer 只读。
|
||||||
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
||||||
|
- 不提交或推送,除非用户明确要求。
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# 填好的最小示例(虚构的 notes-web 项目)。可用 scripts/validate_tasks.py 校验通过。
|
||||||
|
version: 1
|
||||||
|
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||||
|
source: "Coordinator (PM) Agent"
|
||||||
|
kitVersion: "0.3.0"
|
||||||
|
project:
|
||||||
|
name: "notes-web"
|
||||||
|
repoPath: "/home/dev/notes-web"
|
||||||
|
baseUrl: "http://localhost:5173"
|
||||||
|
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
||||||
|
|
||||||
|
summary:
|
||||||
|
verified: ["BUG-002"]
|
||||||
|
open: []
|
||||||
|
failedRetest: []
|
||||||
|
leftovers: ["BUG-003"]
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- id: "BUG-002"
|
||||||
|
type: "bug"
|
||||||
|
title: "预览变更点击后不显示 diff 行"
|
||||||
|
priority: "P0"
|
||||||
|
status: "verified"
|
||||||
|
assignee: "developer"
|
||||||
|
component: "web/app/fix/page.tsx"
|
||||||
|
specRefs:
|
||||||
|
- "docs/spec/fix-preview.md#preview"
|
||||||
|
testRefs:
|
||||||
|
- "tests/browser/cases/01-preview.md"
|
||||||
|
description: >
|
||||||
|
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
||||||
|
stepsToReproduce:
|
||||||
|
- "打开 /fix?fileId=1"
|
||||||
|
- "搜索并选择候选项"
|
||||||
|
- "点击“预览变更”"
|
||||||
|
expected: >
|
||||||
|
确认区出现 title、修改前值、修改后值、coverChanged 提示四行。
|
||||||
|
actual: >
|
||||||
|
API 返回 code=0,但页面确认区为空。
|
||||||
|
evidence:
|
||||||
|
browser: "确认区 DOM 为空,无 diff 行"
|
||||||
|
api: "POST /api/fix/preview -> code=0, 含 diff 字段"
|
||||||
|
logs: "无报错"
|
||||||
|
verification:
|
||||||
|
commands:
|
||||||
|
- "npm run build"
|
||||||
|
- "curl -s -X POST localhost:5173/api/fix/preview -d @fixtures/preview.json"
|
||||||
|
browser:
|
||||||
|
page: "/fix?fileId=1"
|
||||||
|
checks:
|
||||||
|
- "预览后出现 4 行 diff:title/before/after/coverChanged"
|
||||||
|
dispatch:
|
||||||
|
taskId: "orca-task-88"
|
||||||
|
dispatchId: "orca-disp-91"
|
||||||
|
worker: "dev-worker-1"
|
||||||
|
rounds:
|
||||||
|
- round: 1
|
||||||
|
result: failed
|
||||||
|
evidence: "只渲染了 title,缺少 before/after/coverChanged"
|
||||||
|
- round: 2
|
||||||
|
result: passed
|
||||||
|
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
|
||||||
|
resolution:
|
||||||
|
fixedBy: "dev-worker-1"
|
||||||
|
verifiedBy: "test-worker-1"
|
||||||
|
verifiedAt: "2026-07-06T09:38:00+08:00"
|
||||||
|
leftoverReason: null
|
||||||
|
|
||||||
|
- id: "BUG-003"
|
||||||
|
type: "bug"
|
||||||
|
title: "并发保存偶发覆盖他人修改"
|
||||||
|
priority: "P1"
|
||||||
|
status: "leftover"
|
||||||
|
assignee: "developer"
|
||||||
|
component: "server/store/notes.go"
|
||||||
|
specRefs:
|
||||||
|
- "docs/spec/concurrency.md"
|
||||||
|
testRefs: []
|
||||||
|
description: >
|
||||||
|
两个会话同时保存同一条 note 时,后写覆盖先写,无冲突提示。
|
||||||
|
stepsToReproduce:
|
||||||
|
- "会话 A、B 同时打开 note 42"
|
||||||
|
- "A 保存,B 保存"
|
||||||
|
expected: >
|
||||||
|
B 保存时应检测到版本变化并提示冲突,而非静默覆盖。
|
||||||
|
actual: >
|
||||||
|
B 直接覆盖 A 的修改,无提示。
|
||||||
|
evidence:
|
||||||
|
api: "两次 PUT /api/notes/42 均返回 200,无版本校验"
|
||||||
|
verification:
|
||||||
|
commands:
|
||||||
|
- "go test ./server/store/..."
|
||||||
|
browser:
|
||||||
|
page: "/notes/42"
|
||||||
|
checks:
|
||||||
|
- "并发保存时后写方收到冲突提示"
|
||||||
|
dispatch:
|
||||||
|
taskId: "orca-task-90"
|
||||||
|
dispatchId: "orca-disp-95"
|
||||||
|
worker: "dev-worker-1"
|
||||||
|
rounds:
|
||||||
|
- round: 1
|
||||||
|
result: failed
|
||||||
|
evidence: "加了版本号但未在写入路径校验"
|
||||||
|
- round: 2
|
||||||
|
result: failed
|
||||||
|
evidence: "校验只覆盖 API,未覆盖批量导入路径"
|
||||||
|
- round: 3
|
||||||
|
result: failed
|
||||||
|
evidence: "乐观锁与前端重试逻辑冲突,需重新设计"
|
||||||
|
resolution:
|
||||||
|
fixedBy: null
|
||||||
|
verifiedBy: null
|
||||||
|
verifiedAt: null
|
||||||
|
leftoverReason: "failed after 3 supervised developer rounds; 需重新设计并发模型"
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# 在新项目初始化 ack(给 Agent 的操作手册)
|
||||||
|
|
||||||
|
本文件指导 Agent 把 `ack` 接入一个目标项目。它是**执行手册**:按步骤在目标项目创建 `docs/ack/`、挂载 kit、生成项目覆盖层和任务板,并完成一次结构校验。
|
||||||
|
|
||||||
|
若只想人工核对是否接入完整,看 `adoption-checklist.md`。若要开始一个真实需求,看 `core/kickoff.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 前提与安全边界
|
||||||
|
|
||||||
|
开始前先确认三件事:
|
||||||
|
|
||||||
|
1. 目标项目根目录:运行或读取当前环境的项目根,不要假设路径。
|
||||||
|
2. ack kit 目录:本框架目录的绝对路径,例如 `<ack_kit_path>`。
|
||||||
|
3. 分发方式:默认用**单软链接引用**,只有用户明确要求时才整份复制。
|
||||||
|
|
||||||
|
硬规则:
|
||||||
|
|
||||||
|
- 不要覆盖已有 `docs/ack/project.md`、`docs/ack/tasks.yaml` 或团队现有 `AGENTS.md`。如果文件已存在,先读内容并向用户说明差异,再询问是否合并。
|
||||||
|
- 不要把 `core/` 内容复制进项目覆盖层。项目覆盖层只写项目差异。
|
||||||
|
- 不要提交、推送或改 git 配置,除非用户明确要求。
|
||||||
|
- 不要把私有配置、token、`.env` 内容写进 `tasks.yaml` 或 `project.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 建目录并挂载 kit
|
||||||
|
|
||||||
|
在目标项目根目录下创建推荐布局:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/ack/
|
||||||
|
kit -> <ack_kit_path>
|
||||||
|
project.md
|
||||||
|
tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
默认方式(推荐,可升级):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p docs/ack
|
||||||
|
ln -s <ack_kit_path> docs/ack/kit
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `docs/ack/kit` 已存在:
|
||||||
|
|
||||||
|
- 是指向同一个 ack 目录的软链接:继续。
|
||||||
|
- 是指向旧版本的软链接:报告当前指向与目标指向,询问是否更新。
|
||||||
|
- 是普通目录:不要删除;询问用户是保留、备份还是改用复制模式。
|
||||||
|
|
||||||
|
复制模式(仅用户明确要求):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p docs/ack
|
||||||
|
cp -R <ack_kit_path> docs/ack/kit
|
||||||
|
```
|
||||||
|
|
||||||
|
复制模式接入后,后续升级需要人工对比 `docs/ack/kit/VERSION`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 生成项目覆盖层 `project.md`
|
||||||
|
|
||||||
|
从模板生成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp docs/ack/kit/templates/project.template.md docs/ack/project.md
|
||||||
|
```
|
||||||
|
|
||||||
|
然后填实际项目差异:
|
||||||
|
|
||||||
|
- 项目名、技术栈、运行命令、Base URL。
|
||||||
|
- 覆盖层文件路径:默认 `docs/ack/project.md`。
|
||||||
|
- 三角色模型档位:默认 Coordinator 强模型,Developer/Test 中低模型。
|
||||||
|
- 路径权限:规格文档、集成测试、复测记录、源码、单元测试、配置模板、本地私有配置。
|
||||||
|
- Developer 白盒验证命令:单测、构建、服务启动或重启。
|
||||||
|
- Test 黑盒复测命令:preflight、API smoke、浏览器回归。
|
||||||
|
|
||||||
|
如果是网站项目,务必在命令里写清:
|
||||||
|
|
||||||
|
- Developer 改完后如何重启服务或确认热更新生效。
|
||||||
|
- Test 使用哪个 Base URL。
|
||||||
|
- Test 浏览器复测入口、用例目录或手工检查路径。
|
||||||
|
|
||||||
|
可选:若希望 Agent 自动加载覆盖层,在目标项目 `AGENTS.md` 加一行指向它:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
本项目使用 ack 三角色协作协议,项目覆盖层见 `docs/ack/project.md`。
|
||||||
|
```
|
||||||
|
|
||||||
|
如果目标项目已有 `AGENTS.md`,只追加这一行或一个短小段落;不要重写原文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 生成任务板 `tasks.yaml`
|
||||||
|
|
||||||
|
从模板生成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp docs/ack/kit/templates/tasks.template.yaml docs/ack/tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
至少替换这些字段:
|
||||||
|
|
||||||
|
- `updatedAt`:当前时间,带时区。
|
||||||
|
- `kitVersion`:读取 `docs/ack/kit/VERSION`。
|
||||||
|
- `project.name`:项目名。
|
||||||
|
- `project.repoPath`:目标项目根目录绝对路径。
|
||||||
|
- `project.baseUrl`:网站或 API 的默认访问地址;非服务项目可写 `n/a`。
|
||||||
|
- `project.devWorktree`:默认当前 worktree;如果会用独立 worktree,写实际路径。
|
||||||
|
- `project.overlayFile`:默认 `docs/ack/project.md`。
|
||||||
|
|
||||||
|
任务可以先保留一个低风险示例任务,但更推荐写入真实首个任务。每个任务的验收必须是可观测信号:
|
||||||
|
|
||||||
|
- 可见文本:页面或 CLI 输出出现什么。
|
||||||
|
- API 结果:状态码、字段、响应值。
|
||||||
|
- 交互结果:点击、跳转、提交、取消后的真实状态。
|
||||||
|
|
||||||
|
不要只写“功能正常”“体验更好”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 校验结构
|
||||||
|
|
||||||
|
在目标项目根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
通过后继续;失败则按错误修正 `tasks.yaml`,不要绕过校验。
|
||||||
|
|
||||||
|
同时人工检查:
|
||||||
|
|
||||||
|
- `docs/ack/kit` 是否能访问 `core/`、`templates/`、`scripts/`。
|
||||||
|
- `docs/ack/project.md` 是否只写项目差异,没有复制大段 core 文本。
|
||||||
|
- `docs/ack/tasks.yaml` 的 `project.overlayFile` 是否指向真实文件。
|
||||||
|
- 路径权限是否能防止 Developer 改规格、Test 改源码、多人写 `tasks.yaml`。
|
||||||
|
- 网站项目是否写清服务重启和浏览器复测方式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 给用户的初始化报告
|
||||||
|
|
||||||
|
初始化完成后,Agent 应给用户一段短报告:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ack 初始化完成。
|
||||||
|
|
||||||
|
创建/确认:
|
||||||
|
- docs/ack/kit -> <ack_kit_path>
|
||||||
|
- docs/ack/project.md
|
||||||
|
- docs/ack/tasks.yaml
|
||||||
|
|
||||||
|
已填项目差异:
|
||||||
|
- 技术栈:<...>
|
||||||
|
- Developer 验证命令:<...>
|
||||||
|
- Test 复测命令:<...>
|
||||||
|
- Base URL:<...>
|
||||||
|
|
||||||
|
校验:
|
||||||
|
- python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml: passed
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
- 用 docs/ack/kit/core/kickoff.md 开一个真实需求;或
|
||||||
|
- 选一个低风险 bug 跑一次完整三角色闭环。
|
||||||
|
```
|
||||||
|
|
||||||
|
如果有未完成项,不要说“初始化完成”。改用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ack 初始化已部分完成,阻塞在:<具体原因>。
|
||||||
|
需要用户决定:<选项或缺失信息>。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 一句话口令
|
||||||
|
|
||||||
|
当用户在新项目里说“初始化 ack”时,Agent 可以按下面执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
请在当前项目初始化 ack:确认项目根和 ack kit 路径;创建 docs/ack/kit 软链接;由 kit/templates 生成 docs/ack/project.md 与 docs/ack/tasks.yaml;填入项目实际路径、命令、Base URL、模型档位和首个任务;运行 validate_tasks.py;不要覆盖已有文件,不要提交或推送,完成后报告创建文件、校验结果和下一步。
|
||||||
|
```
|
||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""校验 tasks.yaml 是否符合 ack 任务板结构。
|
||||||
|
|
||||||
|
权威结构是同目录上层的 templates/tasks.schema.json(跨语言可用)。
|
||||||
|
本脚本是参考实现:
|
||||||
|
- 若安装了 jsonschema,则用 schema 做完整校验;
|
||||||
|
- 否则回退到内置的关键规则校验(必填字段、状态枚举、三轮上限、leftover 留档)。
|
||||||
|
YAML 解析优先用 pyyaml;未安装时给出提示而非崩溃。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 validate_tasks.py [tasks.yaml]
|
||||||
|
python3 validate_tasks.py --schema path/to/tasks.schema.json tasks.yaml
|
||||||
|
|
||||||
|
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATUS_ENUM = {
|
||||||
|
"open",
|
||||||
|
"dispatched",
|
||||||
|
"fixed_by_dev",
|
||||||
|
"retesting",
|
||||||
|
"failed_retest",
|
||||||
|
"verified",
|
||||||
|
"blocked",
|
||||||
|
"leftover",
|
||||||
|
}
|
||||||
|
MAX_ROUNDS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write(
|
||||||
|
"需要 PyYAML 才能解析 YAML:pip install pyyaml\n"
|
||||||
|
"(或把任务板导出为 JSON 后再校验)\n"
|
||||||
|
)
|
||||||
|
raise SystemExit(2)
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
except yaml.YAMLError as exc: # type: ignore
|
||||||
|
sys.stderr.write(f"YAML 解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
sys.stderr.write("任务板顶层必须是对象(mapping)\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||||
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
errors = []
|
||||||
|
for err in sorted(validator.iter_errors(data), key=lambda e: list(e.path)):
|
||||||
|
loc = "/".join(str(p) for p in err.path) or "<root>"
|
||||||
|
errors.append(f"[schema] {loc}: {err.message}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_builtin(data: dict) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not isinstance(data.get("version"), int) or data.get("version", 0) < 1:
|
||||||
|
errors.append("version 必须是 >=1 的整数")
|
||||||
|
project = data.get("project")
|
||||||
|
if not isinstance(project, dict) or not project.get("name"):
|
||||||
|
errors.append("project.name 必填")
|
||||||
|
|
||||||
|
tasks = data.get("tasks")
|
||||||
|
if not isinstance(tasks, list):
|
||||||
|
errors.append("tasks 必须是列表")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for i, task in enumerate(tasks):
|
||||||
|
where = f"tasks[{i}]"
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
tid = task.get("id")
|
||||||
|
title = task.get("title")
|
||||||
|
status = task.get("status")
|
||||||
|
if not tid:
|
||||||
|
errors.append(f"{where}: id 必填")
|
||||||
|
else:
|
||||||
|
where = f"tasks[{i}] {tid}"
|
||||||
|
if tid in seen_ids:
|
||||||
|
errors.append(f"{where}: id 重复")
|
||||||
|
seen_ids.add(tid)
|
||||||
|
if not title:
|
||||||
|
errors.append(f"{where}: title 必填")
|
||||||
|
if status not in STATUS_ENUM:
|
||||||
|
errors.append(
|
||||||
|
f"{where}: status={status!r} 非法,应为 {sorted(STATUS_ENUM)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatch = task.get("dispatch") or {}
|
||||||
|
rounds = dispatch.get("rounds") or []
|
||||||
|
if isinstance(rounds, list):
|
||||||
|
if len(rounds) > MAX_ROUNDS:
|
||||||
|
errors.append(
|
||||||
|
f"{where}: 派发轮次 {len(rounds)} 超过上限 {MAX_ROUNDS}"
|
||||||
|
)
|
||||||
|
for r in rounds:
|
||||||
|
if isinstance(r, dict) and r.get("result") not in {"passed", "failed"}:
|
||||||
|
errors.append(f"{where}: round.result 必须是 passed/failed")
|
||||||
|
|
||||||
|
if status == "leftover":
|
||||||
|
resolution = task.get("resolution") or {}
|
||||||
|
if not resolution.get("leftoverReason"):
|
||||||
|
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 tasks.yaml 结构")
|
||||||
|
parser.add_argument("tasks", nargs="?", default="tasks.yaml", help="任务板路径")
|
||||||
|
parser.add_argument("--schema", help="tasks.schema.json 路径(默认自动探测)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
tasks_path = Path(args.tasks)
|
||||||
|
if not tasks_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data = load_yaml(tasks_path)
|
||||||
|
|
||||||
|
schema_path = Path(args.schema) if args.schema else (
|
||||||
|
Path(__file__).resolve().parent.parent / "templates" / "tasks.schema.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = "内置规则"
|
||||||
|
try:
|
||||||
|
import jsonschema # type: ignore # noqa: F401
|
||||||
|
|
||||||
|
if schema_path.is_file():
|
||||||
|
errors = validate_with_schema(data, schema_path)
|
||||||
|
mode = f"schema ({schema_path.name})"
|
||||||
|
else:
|
||||||
|
errors = validate_builtin(data)
|
||||||
|
mode = "内置规则(未找到 schema 文件)"
|
||||||
|
except ImportError:
|
||||||
|
errors = validate_builtin(data)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write(f"任务板校验失败({mode}),共 {len(errors)} 项:\n")
|
||||||
|
for e in errors:
|
||||||
|
sys.stderr.write(f" - {e}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write(f"任务板校验通过({mode}):{tasks_path}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""校验 Music Pilot 的 Developer/Test worker 启动命令。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 validate_worker_command.py --role developer --command '<command>'
|
||||||
|
python3 validate_worker_command.py --role test --command '<command>'
|
||||||
|
python3 validate_worker_command.py --role developer --upgraded --command '<command>'
|
||||||
|
python3 validate_worker_command.py --self-test
|
||||||
|
|
||||||
|
退出码:0 通过 / 1 规则不通过 / 2 用法或命令解析错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CODEX_YOLO = "--dangerously-bypass-approvals-and-sandbox"
|
||||||
|
CURSOR_YOLO = "--yolo"
|
||||||
|
|
||||||
|
|
||||||
|
def option_value(tokens: list[str], *names: str) -> str | None:
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
for name in names:
|
||||||
|
if token == name and index + 1 < len(tokens):
|
||||||
|
return tokens[index + 1]
|
||||||
|
prefix = f"{name}="
|
||||||
|
if token.startswith(prefix):
|
||||||
|
return token[len(prefix) :]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def codex_effort(tokens: list[str]) -> str | None:
|
||||||
|
configs: list[str] = []
|
||||||
|
for index, token in enumerate(tokens):
|
||||||
|
if token in {"-c", "--config"} and index + 1 < len(tokens):
|
||||||
|
configs.append(tokens[index + 1])
|
||||||
|
elif token.startswith("--config="):
|
||||||
|
configs.append(token.split("=", 1)[1])
|
||||||
|
for config in configs:
|
||||||
|
if config.startswith("model_reasoning_effort="):
|
||||||
|
return config.split("=", 1)[1].strip('"\'')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate(role: str, command: str, upgraded: bool = False) -> list[str]:
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command)
|
||||||
|
except ValueError as exc:
|
||||||
|
return [f"命令无法解析:{exc}"]
|
||||||
|
if not tokens:
|
||||||
|
return ["启动命令不能为空"]
|
||||||
|
|
||||||
|
executable = Path(tokens[0]).name
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if upgraded and role != "developer":
|
||||||
|
errors.append("只有 Developer 可以使用 --upgraded")
|
||||||
|
|
||||||
|
if executable == "codex":
|
||||||
|
if CODEX_YOLO not in tokens:
|
||||||
|
errors.append(f"Codex worker 必须包含 {CODEX_YOLO}")
|
||||||
|
|
||||||
|
model = option_value(tokens, "-m", "--model")
|
||||||
|
effort = codex_effort(tokens)
|
||||||
|
if upgraded:
|
||||||
|
expected_model = "gpt-5.6-sol"
|
||||||
|
allowed_efforts = {"high", "xhigh"}
|
||||||
|
elif role == "developer":
|
||||||
|
expected_model = "gpt-5.6-terra"
|
||||||
|
allowed_efforts = {"medium"}
|
||||||
|
else:
|
||||||
|
expected_model = "gpt-5.6-luna"
|
||||||
|
allowed_efforts = {"low"}
|
||||||
|
|
||||||
|
if model != expected_model:
|
||||||
|
errors.append(
|
||||||
|
f"Codex {role} 模型应为 {expected_model},实际为 {model or '未指定'}"
|
||||||
|
)
|
||||||
|
if effort not in allowed_efforts:
|
||||||
|
expected = "/".join(sorted(allowed_efforts))
|
||||||
|
errors.append(
|
||||||
|
f"Codex {role} reasoning effort 应为 {expected},实际为 {effort or '未指定'}"
|
||||||
|
)
|
||||||
|
elif executable == "cursor-agent":
|
||||||
|
if upgraded:
|
||||||
|
errors.append("Cursor worker 不使用 Codex --upgraded 映射")
|
||||||
|
if CURSOR_YOLO not in tokens:
|
||||||
|
errors.append(f"Cursor worker 必须显式包含 {CURSOR_YOLO}")
|
||||||
|
model = option_value(tokens, "--model")
|
||||||
|
if model != "auto":
|
||||||
|
errors.append(f"Cursor {role} 模型应为 auto,实际为 {model or '未指定'}")
|
||||||
|
else:
|
||||||
|
errors.append(f"不支持的 worker CLI:{executable};只允许 codex 或 cursor-agent")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def run_self_test() -> int:
|
||||||
|
cases = [
|
||||||
|
(
|
||||||
|
"codex developer",
|
||||||
|
"developer",
|
||||||
|
f"codex {CODEX_YOLO} -m gpt-5.6-terra -c model_reasoning_effort=medium",
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"codex test",
|
||||||
|
"test",
|
||||||
|
f"codex {CODEX_YOLO} -m gpt-5.6-luna -c model_reasoning_effort=low",
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"codex upgraded developer",
|
||||||
|
"developer",
|
||||||
|
f"codex {CODEX_YOLO} -m gpt-5.6-sol -c model_reasoning_effort=high",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
("cursor worker", "test", "cursor-agent --yolo --model auto", False, True),
|
||||||
|
("naked codex", "developer", "codex", False, False),
|
||||||
|
(
|
||||||
|
"wrong codex role model",
|
||||||
|
"test",
|
||||||
|
f"codex {CODEX_YOLO} -m gpt-5.6-terra -c model_reasoning_effort=medium",
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
("cursor without yolo", "developer", "cursor-agent --model auto", False, False),
|
||||||
|
]
|
||||||
|
failures: list[str] = []
|
||||||
|
for name, role, command, upgraded, expected_pass in cases:
|
||||||
|
passed = not validate(role, command, upgraded)
|
||||||
|
if passed != expected_pass:
|
||||||
|
failures.append(name)
|
||||||
|
if failures:
|
||||||
|
sys.stderr.write("worker 命令校验器自测失败:" + ", ".join(failures) + "\n")
|
||||||
|
return 1
|
||||||
|
print(f"worker 命令校验器自测通过:{len(cases)} 项")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 Developer/Test worker 启动命令")
|
||||||
|
parser.add_argument("--role", choices=("developer", "test"))
|
||||||
|
parser.add_argument("--command")
|
||||||
|
parser.add_argument("--upgraded", action="store_true", help="校验升级后的 Codex Developer")
|
||||||
|
parser.add_argument("--self-test", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.self_test:
|
||||||
|
return run_self_test()
|
||||||
|
if not args.role or not args.command:
|
||||||
|
parser.error("非自测模式必须同时提供 --role 和 --command")
|
||||||
|
|
||||||
|
errors = validate(args.role, args.command, args.upgraded)
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write("worker 启动命令校验失败:\n")
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f" - {error}\n")
|
||||||
|
return 1
|
||||||
|
print(f"worker 启动命令校验通过:role={args.role}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# <项目名> Agent 协作协议(项目覆盖层)
|
||||||
|
|
||||||
|
> 本项目基于 ack v<kit_version>(见 `docs/ack/kit/VERSION`)。
|
||||||
|
> 稳定规范引用 `docs/ack/kit/core/`,不复制其内容;本文件只填项目自己的差异。
|
||||||
|
>
|
||||||
|
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`,
|
||||||
|
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
|
||||||
|
> 若希望 Agent 自动加载,可在项目 `AGENTS.md` 里加一行指向本文件,或直接把本文件命名为 `AGENTS.md`。
|
||||||
|
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||||
|
>
|
||||||
|
> 目录约定:`docs/ack/` 下只有一个软链接 `kit/`(指向共享框架),加本项目 `project.md` + `tasks.yaml`。
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
|
||||||
|
- 项目:`<project_name>`
|
||||||
|
- 技术栈:`<tech_stack>`
|
||||||
|
- 运行命令:`<run_command>`
|
||||||
|
- Base URL:`<base_url>`
|
||||||
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
|
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
||||||
|
|
||||||
|
## 稳定规范(不在此重复,直接引用)
|
||||||
|
|
||||||
|
- 开始一个需求(启动手册):`docs/ack/kit/core/kickoff.md`
|
||||||
|
- 角色模型 / 权限 / 状态机 / 完成定义:`docs/ack/kit/core/roles-and-permissions.md`
|
||||||
|
- 模型档位与升级规则:`docs/ack/kit/core/model-routing.md`
|
||||||
|
- 闭环流程(含手动模式、worktree 对齐):`docs/ack/kit/core/closed-loop.md`
|
||||||
|
- 优化方法(验收信号、三轮策略):`docs/ack/kit/core/optimization-method.md`
|
||||||
|
- 派发 prompt 模板:`docs/ack/kit/core/prompt-templates.md`
|
||||||
|
- Orca 编排命令(可选):`docs/ack/kit/core/orca-adapter.md`
|
||||||
|
|
||||||
|
## 模型档位(项目可覆盖,默认见 core/model-routing.md)
|
||||||
|
|
||||||
|
| 角色 | 默认档位 | 本项目实际 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| Coordinator (PM) | 强模型 | `<model_or_default>` |
|
||||||
|
| Test | 中低模型 | `<model_or_default>` |
|
||||||
|
| Developer | 中低模型 | `<model_or_default>` |
|
||||||
|
|
||||||
|
## 路径权限(项目覆盖层,必须填实际路径)
|
||||||
|
|
||||||
|
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||||
|
|------|:-----------:|:----:|:---------:|------|
|
||||||
|
| `<spec_paths>` | R/W | Read-only | Read-only | 产品规格、API 文档、计划(PM 拥有) |
|
||||||
|
| `<integration_test_paths>` | Read-only | R/W | Read-only | 浏览器/API 回归(Test 拥有) |
|
||||||
|
| `<test_records_path>` | Read-only | R/W | Read-only | 复测记录 |
|
||||||
|
| `<source_paths>` | Read-only | Read-only | R/W | 应用源码 |
|
||||||
|
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
||||||
|
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||||
|
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
|
|
||||||
|
## 命令(项目覆盖层)
|
||||||
|
|
||||||
|
Developer 白盒验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<unit_test_command>
|
||||||
|
<build_command>
|
||||||
|
<local_run_command>
|
||||||
|
```
|
||||||
|
|
||||||
|
Test 黑盒复测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<preflight_command>
|
||||||
|
<api_smoke_command>
|
||||||
|
<browser_regression_command>
|
||||||
|
```
|
||||||
|
|
||||||
|
任务板校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 硬规则(其余见 kit/core/)
|
||||||
|
|
||||||
|
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
||||||
|
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 kit/core/model-routing.md)。
|
||||||
|
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
|
- 只有 Coordinator 写 `tasks.yaml`;Developer 与 Test 都只读,通过消息回报。
|
||||||
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||||
|
- 不提交或推送,除非用户明确要求。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://git.yumee.top/laily/skills/kits/ack/tasks.schema.json",
|
||||||
|
"title": "Agent Collaboration Kit task board",
|
||||||
|
"description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "project", "tasks"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "integer", "minimum": 1 },
|
||||||
|
"updatedAt": { "type": "string" },
|
||||||
|
"source": { "type": "string" },
|
||||||
|
"kitVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "接入时所基于的 ack 版本,便于日后 diff 升级"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"repoPath": { "type": "string" },
|
||||||
|
"baseUrl": { "type": "string" },
|
||||||
|
"devWorktree": { "type": "string" },
|
||||||
|
"overlayFile": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "项目覆盖层文件路径,默认 docs/ack/project.md,可自定义"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"verified": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"open": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"failedRetest": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"leftovers": { "type": "array", "items": { "type": "string" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statusReference": { "type": "object" },
|
||||||
|
"tasks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/task" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"open",
|
||||||
|
"dispatched",
|
||||||
|
"fixed_by_dev",
|
||||||
|
"retesting",
|
||||||
|
"failed_retest",
|
||||||
|
"verified",
|
||||||
|
"blocked",
|
||||||
|
"leftover"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"round": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["round", "result"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"round": { "type": "integer", "minimum": 1 },
|
||||||
|
"result": { "type": "string", "enum": ["passed", "failed"] },
|
||||||
|
"evidence": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "title", "status"],
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"type": { "type": "string" },
|
||||||
|
"title": { "type": "string", "minLength": 1 },
|
||||||
|
"priority": { "type": "string" },
|
||||||
|
"status": { "$ref": "#/definitions/status" },
|
||||||
|
"assignee": { "type": "string" },
|
||||||
|
"component": { "type": "string" },
|
||||||
|
"specRefs": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"testRefs": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"expected": { "type": "string" },
|
||||||
|
"actual": { "type": "string" },
|
||||||
|
"evidence": { "type": "object" },
|
||||||
|
"verification": { "type": "object" },
|
||||||
|
"dispatch": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"taskId": { "type": ["string", "null"] },
|
||||||
|
"dispatchId": { "type": ["string", "null"] },
|
||||||
|
"worker": { "type": ["string", "null"] },
|
||||||
|
"rounds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/round" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"fixedBy": { "type": ["string", "null"] },
|
||||||
|
"verifiedBy": { "type": ["string", "null"] },
|
||||||
|
"verifiedAt": { "type": ["string", "null"] },
|
||||||
|
"leftoverReason": { "type": ["string", "null"] },
|
||||||
|
"evidence": { "type": "object" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": { "properties": { "status": { "const": "leftover" } } },
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"resolution": {
|
||||||
|
"required": ["leftoverReason"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["resolution"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 复制为 docs/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
|
||||||
|
version: 1
|
||||||
|
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||||
|
source: "Coordinator (PM) Agent"
|
||||||
|
kitVersion: "<接入时的 ack 版本,见 kit 根 VERSION>"
|
||||||
|
project:
|
||||||
|
name: "<project_name>"
|
||||||
|
repoPath: "<repo_path>"
|
||||||
|
baseUrl: "<base_url>"
|
||||||
|
devWorktree: "<dev_worktree>"
|
||||||
|
overlayFile: "docs/ack/project.md"
|
||||||
|
|
||||||
|
summary:
|
||||||
|
verified: []
|
||||||
|
open: []
|
||||||
|
failedRetest: []
|
||||||
|
leftovers: []
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- id: "BUG-001"
|
||||||
|
type: "bug"
|
||||||
|
title: "<short title>"
|
||||||
|
priority: "P0"
|
||||||
|
status: "open"
|
||||||
|
assignee: "developer"
|
||||||
|
component: "<source file or module>"
|
||||||
|
specRefs:
|
||||||
|
- "<docs/spec.md#section>"
|
||||||
|
testRefs:
|
||||||
|
- "<tests/browser/cases/01-case.md>"
|
||||||
|
|
||||||
|
description: >
|
||||||
|
<What is wrong, in user-visible terms.>
|
||||||
|
|
||||||
|
stepsToReproduce:
|
||||||
|
- "<step 1>"
|
||||||
|
- "<step 2>"
|
||||||
|
|
||||||
|
expected: >
|
||||||
|
<Expected behavior.>
|
||||||
|
|
||||||
|
actual: >
|
||||||
|
<Actual behavior observed by Test.>
|
||||||
|
|
||||||
|
evidence:
|
||||||
|
browser: "<snapshot text, screenshot path, or UI finding>"
|
||||||
|
api: "<curl result or API mismatch>"
|
||||||
|
logs: "<relevant log line if any>"
|
||||||
|
|
||||||
|
verification:
|
||||||
|
commands:
|
||||||
|
- "<unit/build command>"
|
||||||
|
- "<browser/API smoke command>"
|
||||||
|
browser:
|
||||||
|
page: "<page path>"
|
||||||
|
checks:
|
||||||
|
- "<visible text or interaction expected>"
|
||||||
|
|
||||||
|
dispatch:
|
||||||
|
taskId: null
|
||||||
|
dispatchId: null
|
||||||
|
worker: null
|
||||||
|
rounds: []
|
||||||
|
|
||||||
|
resolution:
|
||||||
|
fixedBy: null
|
||||||
|
verifiedBy: null
|
||||||
|
verifiedAt: null
|
||||||
|
leftoverReason: null
|
||||||
+21
-7
@@ -4,14 +4,28 @@
|
|||||||
# repo: <git-url>
|
# repo: <git-url>
|
||||||
# ref: <branch|tag> (default: main)
|
# ref: <branch|tag> (default: main)
|
||||||
# path: <subpath> (default: .)
|
# path: <subpath> (default: .)
|
||||||
|
# description: <text> (optional, shown by `skiff select`)
|
||||||
|
# tags: (optional)
|
||||||
|
# - <tag>
|
||||||
#
|
#
|
||||||
# Example:
|
# Single-skill example (`path` contains SKILL.md):
|
||||||
# superpowers:
|
# example-skills:
|
||||||
# repo: https://github.com/obra/superpowers
|
# repo: https://github.com/example/skills
|
||||||
# ref: main
|
# ref: main
|
||||||
# path: .
|
# path: .
|
||||||
|
#
|
||||||
|
# Collection example (`path` contains skill directories):
|
||||||
|
# example-collection:
|
||||||
|
# repo: https://github.com/example/skill-collection
|
||||||
|
# ref: main
|
||||||
|
# path: skills
|
||||||
|
|
||||||
superpowers:
|
waza:
|
||||||
repo: https://github.com/obra/superpowers
|
repo: https://github.com/tw93/Waza.git
|
||||||
ref: main
|
ref: main
|
||||||
path: .
|
path: skills
|
||||||
|
description: Waza engineering workflow skills for planning, UI, review, debugging, writing, research, reading, and agent health.
|
||||||
|
tags:
|
||||||
|
- engineering
|
||||||
|
- workflow
|
||||||
|
- skill-collection
|
||||||
|
|||||||
+121
-24
@@ -9,41 +9,74 @@ cd /path/to/skills # 本仓库根目录
|
|||||||
./install.sh # 软链到 ~/.local/bin/skiff
|
./install.sh # 软链到 ~/.local/bin/skiff
|
||||||
```
|
```
|
||||||
|
|
||||||
确保 `~/.local/bin` 在 `PATH` 中。开发时也可直接运行:
|
确保 `~/.local/bin` 在 `PATH` 中。
|
||||||
|
|
||||||
|
## 命令风格
|
||||||
|
|
||||||
|
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
|
||||||
|
统一管理 **~/.skills 自研 skill**、命名 custom source 和 registry 外部 skill。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 浏览可用自研 skill
|
||||||
|
skiff add --list
|
||||||
|
|
||||||
|
# 装到当前项目 / 全局
|
||||||
|
skiff add discussion-notes -a cursor -y
|
||||||
|
skiff add discussion-notes -a cursor -g -y
|
||||||
|
|
||||||
|
# 卸载
|
||||||
|
skiff remove discussion-notes -a cursor -y
|
||||||
|
skiff rm discussion-notes -g -y
|
||||||
|
|
||||||
|
# 改完 skill 后提交推送(在任意目录执行,操作 ~/.skills)
|
||||||
|
skiff publish skills/discussion-notes -m "update discussion-notes" --push
|
||||||
|
```
|
||||||
|
|
||||||
|
开发时也可直接运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
||||||
```
|
```
|
||||||
|
|
||||||
## 首次配置
|
## 首次安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
|
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
||||||
|
~/.skills/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
若仓库本身就在 `~/.skills`(开发场景),`setup` 会自动识别,无需额外操作。
|
`install.sh` 会安装 CLI,并自动执行 `skiff bootstrap`,将本仓库的 `skiff` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
## 命令参考
|
## 命令参考
|
||||||
|
|
||||||
### 仓库关联
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff setup <path>` | 关联 `~/.skills` 到 skills 仓库 |
|
|
||||||
|
|
||||||
### 查看
|
### 查看
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `skiff list` | 列出自研 skill 与 registry 中的外部 skill |
|
| `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
||||||
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
||||||
|
|
||||||
|
### 项目初始化
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
|
||||||
|
|
||||||
### 全局安装(自研 skill)
|
### 全局安装(自研 skill)
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `skiff install <name> [--target all]` | 软链到 Agent 全局目录 |
|
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
|
||||||
| `skiff uninstall <name> [--target all]` | 移除软链 |
|
| `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
|
||||||
|
| `skiff remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
|
||||||
|
| `skiff add --list` | 列出可用自研 skill |
|
||||||
|
| `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push |
|
||||||
|
|
||||||
|
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
|
||||||
|
|
||||||
全局目标路径:
|
全局目标路径:
|
||||||
|
|
||||||
@@ -57,9 +90,66 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `skiff add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
|
| `skiff registry add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
|
||||||
| `skiff fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` |
|
| `skiff fetch <name>` | 克隆或更新外部仓库缓存 |
|
||||||
| `skiff install-external <name> [--target all]` | 安装外部 skill 到 Agent 目录 |
|
| `skiff add <name> [-g] [-a AGENT...]` | 安装 registry 中的单个 skill 或完整 collection(缺失时自动 fetch) |
|
||||||
|
| `skiff add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
|
||||||
|
|
||||||
|
`registry.yaml` 条目可额外提供 `description` 和 `tags`。`description`
|
||||||
|
会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含
|
||||||
|
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
|
||||||
|
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add <name>`
|
||||||
|
安装全部,`skiff select` 则展开为 `<name>/<skill>` 供分别勾选。同一
|
||||||
|
`repo` 与 `ref` 共享一份 Git checkout。
|
||||||
|
|
||||||
|
### 交互式批量安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff select # 当前项目,全部 Agent
|
||||||
|
skiff select -a codex # 当前项目,仅 Codex
|
||||||
|
skiff select -g # 全局安装
|
||||||
|
skiff select --project ~/code/app # 指定项目
|
||||||
|
```
|
||||||
|
|
||||||
|
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
|
||||||
|
取消。已经安装到目标范围的 skill 默认勾选;取消勾选不会卸载已有 skill。
|
||||||
|
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
|
||||||
|
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
|
||||||
|
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
|
||||||
|
|
||||||
|
### Custom source(多-skill 仓库)
|
||||||
|
|
||||||
|
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Git 仓库,默认 clone 到 ~/.local/share/skiff/sources/company
|
||||||
|
skiff source add company \
|
||||||
|
git@git.company.com:platform/agent-skills.git \
|
||||||
|
--ref main \
|
||||||
|
--skills-path internal/skills
|
||||||
|
|
||||||
|
# 或接入已有本地仓库
|
||||||
|
skiff source add company \
|
||||||
|
--local ~/code/company-agent-skills \
|
||||||
|
--skills-path skills
|
||||||
|
|
||||||
|
skiff source list
|
||||||
|
skiff source fetch company
|
||||||
|
skiff list --source company
|
||||||
|
skiff add company/code-review -g -a codex
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `skiff source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
|
||||||
|
| `skiff source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
|
||||||
|
| `skiff source list` / `show <name>` | 查看 source |
|
||||||
|
| `skiff source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
|
||||||
|
| `skiff source remove <name>` | 移除配置并保留 checkout |
|
||||||
|
|
||||||
|
配置保存在 `~/.config/skiff/config.yaml`。Git/SSH 认证复用本机 Git 配置,
|
||||||
|
skiff 不保存 token。可以使用 `company/code-review`,也可以使用
|
||||||
|
`skiff add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
|
||||||
|
|
||||||
### 项目级
|
### 项目级
|
||||||
|
|
||||||
@@ -81,7 +171,9 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `skiff create <name>` | 从 `skills/_template/` 创建自研 skill |
|
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建草稿到 `~/.skills/.drafts/` |
|
||||||
|
| `skiff check <name>` | 校验草稿或正式 skill |
|
||||||
|
| `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` |
|
||||||
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
||||||
|
|
||||||
## 常用工作流
|
## 常用工作流
|
||||||
@@ -89,18 +181,20 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
|
|||||||
### 新建并全局启用自研 skill
|
### 新建并全局启用自研 skill
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff create my-skill
|
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||||
# 编辑 skills/my-skill/SKILL.md
|
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
|
||||||
skiff install my-skill --target cursor
|
skiff check my-skill
|
||||||
skiff doctor --target cursor
|
skiff finalize my-skill
|
||||||
|
skiff publish skills/my-skill -m "add my-skill" --push
|
||||||
|
skiff add my-skill -a cursor -g -y
|
||||||
|
skiff doctor -a cursor
|
||||||
```
|
```
|
||||||
|
|
||||||
### 在项目中启用 skill
|
### 在项目中启用 skill
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/code/my-app
|
cd ~/code/my-app
|
||||||
skiff enable declarative-openspec-loop --target cursor
|
skiff add declarative-openspec-loop -a cursor -y
|
||||||
skiff sync
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装外部 Git skill
|
### 安装外部 Git skill
|
||||||
@@ -121,6 +215,7 @@ skiff/
|
|||||||
├── paths.py # 路径常量与 Agent 目标
|
├── paths.py # 路径常量与 Agent 目标
|
||||||
├── skills.py # 自研 skill 发现
|
├── skills.py # 自研 skill 发现
|
||||||
├── registry.py # registry.yaml 读写
|
├── registry.py # registry.yaml 读写
|
||||||
|
├── sources.py # custom source 配置、发现与 Git 管理
|
||||||
├── project.py # .skills.yaml 管理
|
├── project.py # .skills.yaml 管理
|
||||||
├── symlinks.py # 软链创建/检查/修复
|
├── symlinks.py # 软链创建/检查/修复
|
||||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||||
@@ -135,7 +230,9 @@ skiff/
|
|||||||
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
||||||
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
|
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
|
||||||
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
|
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
|
||||||
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库 |
|
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库;新条目按 repo/ref 共享缓存 |
|
||||||
|
| `CONFIG_FILE` | `~/.config/skiff/config.yaml` | custom source 配置 |
|
||||||
|
| `SOURCES_DIR` | `~/.local/share/skiff/sources/` | custom Git source 默认 checkout |
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
"""skiff — Agent Skills 安装与管理 CLI。"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.5.0"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Agent 名称解析(兼容 Vercel skills CLI 别名)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from skiff.paths import ALL_TARGETS
|
||||||
|
|
||||||
|
AGENT_ALIASES: dict[str, str] = {
|
||||||
|
"cursor": "cursor",
|
||||||
|
"claude": "claude",
|
||||||
|
"claude-code": "claude",
|
||||||
|
"codex": "codex",
|
||||||
|
"*": "*",
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_agent_args(groups: list[list[str]] | None) -> list[str] | None:
|
||||||
|
if not groups:
|
||||||
|
return None
|
||||||
|
flat = [item for group in groups for item in group]
|
||||||
|
return flat or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_agent_args(agents: list[str] | None) -> list[str]:
|
||||||
|
if not agents or agents == ["*"] or "*" in agents:
|
||||||
|
return list(ALL_TARGETS)
|
||||||
|
|
||||||
|
resolved: list[str] = []
|
||||||
|
for raw in agents:
|
||||||
|
key = raw.lower()
|
||||||
|
if key == "*":
|
||||||
|
return list(ALL_TARGETS)
|
||||||
|
target = AGENT_ALIASES.get(key)
|
||||||
|
if target is None:
|
||||||
|
choices = ", ".join(AGENT_CHOICES)
|
||||||
|
raise SystemExit(f"未知 agent: {raw!r},可选: {choices}")
|
||||||
|
if target != "*" and target not in resolved:
|
||||||
|
resolved.append(target)
|
||||||
|
return resolved
|
||||||
+942
-151
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
"""~/.skills 仓库内的 git 操作。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from skiff.paths import SKILLS_HOME
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", str(repo), *args],
|
||||||
|
check=check,
|
||||||
|
text=True,
|
||||||
|
capture_output=not sys.stdout.isatty(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_git_repo(repo: Path) -> None:
|
||||||
|
if not (repo / ".git").exists():
|
||||||
|
raise SystemExit(f"不是 git 仓库: {repo}")
|
||||||
|
|
||||||
|
|
||||||
|
def has_staged_changes(repo: Path) -> bool:
|
||||||
|
result = _run_git(repo, "diff", "--cached", "--quiet", check=False)
|
||||||
|
return result.returncode == 1
|
||||||
|
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
*,
|
||||||
|
paths: list[str],
|
||||||
|
message: str | None,
|
||||||
|
push: bool,
|
||||||
|
no_commit: bool,
|
||||||
|
) -> None:
|
||||||
|
repo = SKILLS_HOME.resolve()
|
||||||
|
ensure_git_repo(repo)
|
||||||
|
|
||||||
|
_run_git(repo, "add", "--", *paths)
|
||||||
|
|
||||||
|
if not has_staged_changes(repo):
|
||||||
|
print("没有可提交的变更", file=sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
if no_commit:
|
||||||
|
print(f"已暂存变更: {', '.join(paths)}", file=sys.stdout)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
raise SystemExit("提交需要 -m/--message")
|
||||||
|
|
||||||
|
_run_git(repo, "commit", "-m", message)
|
||||||
|
print(f"已提交: {message}", file=sys.stdout)
|
||||||
|
|
||||||
|
if push:
|
||||||
|
_run_git(repo, "push")
|
||||||
|
print("已推送到远程", file=sys.stdout)
|
||||||
+4
-1
@@ -8,8 +8,11 @@ HOME = Path.home()
|
|||||||
SKILLS_HOME = HOME / ".skills"
|
SKILLS_HOME = HOME / ".skills"
|
||||||
SKILLS_DIR = SKILLS_HOME / "skills"
|
SKILLS_DIR = SKILLS_HOME / "skills"
|
||||||
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||||
|
DRAFTS_DIR = SKILLS_HOME / ".drafts"
|
||||||
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
||||||
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
|
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
|
||||||
|
CONFIG_FILE = HOME / ".config" / "skiff" / "config.yaml"
|
||||||
|
SOURCES_DIR = HOME / ".local" / "share" / "skiff" / "sources"
|
||||||
PROJECT_MANIFEST = ".skills.yaml"
|
PROJECT_MANIFEST = ".skills.yaml"
|
||||||
|
|
||||||
AGENT_GLOBAL: dict[str, Path] = {
|
AGENT_GLOBAL: dict[str, Path] = {
|
||||||
@@ -44,5 +47,5 @@ def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
|
|||||||
def ensure_skills_home() -> None:
|
def ensure_skills_home() -> None:
|
||||||
if not SKILLS_HOME.is_dir():
|
if not SKILLS_HOME.is_dir():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"~/.skills 未配置。请先运行: skiff setup <skills-repo-path>"
|
"~/.skills 不存在。请将 skills 仓库克隆到 ~/.skills"
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-4
@@ -65,13 +65,16 @@ def add_skill_to_manifest(
|
|||||||
file_path = path
|
file_path = path
|
||||||
|
|
||||||
entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
|
entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
|
||||||
if any(e["name"] == name for e in entries):
|
|
||||||
return
|
|
||||||
|
|
||||||
item: dict[str, Any] = {"name": name, "source": source}
|
item: dict[str, Any] = {"name": name, "source": source}
|
||||||
if extra:
|
if extra:
|
||||||
item.update(extra)
|
item.update(extra)
|
||||||
entries.append(item)
|
existing_index = next((i for i, entry in enumerate(entries) if entry["name"] == name), None)
|
||||||
|
if existing_index is None:
|
||||||
|
entries.append(item)
|
||||||
|
elif entries[existing_index] == item:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
entries[existing_index] = item
|
||||||
|
|
||||||
data["skills"] = [_entry_to_yaml(e) for e in entries]
|
data["skills"] = [_entry_to_yaml(e) for e in entries]
|
||||||
save_manifest(file_path, data)
|
save_manifest(file_path, data)
|
||||||
@@ -98,4 +101,6 @@ def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
|
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
|
||||||
name = entry["name"]
|
name = entry["name"]
|
||||||
source = entry.get("source", "owned")
|
source = entry.get("source", "owned")
|
||||||
|
if source == "registry" and entry.get("registry"):
|
||||||
|
source = f"registry:{entry['registry']}"
|
||||||
return resolve_skill_source(name, source=source)
|
return resolve_skill_source(name, source=source)
|
||||||
|
|||||||
+76
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,9 +26,82 @@ def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) ->
|
|||||||
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
def external_repo_path(entry: dict[str, Any]) -> Path:
|
||||||
|
"""Return the shared checkout path for a repo/ref pair."""
|
||||||
from skiff.paths import EXTERNALS_DIR
|
from skiff.paths import EXTERNALS_DIR
|
||||||
|
|
||||||
|
repo = str(entry.get("repo", ""))
|
||||||
|
ref = str(entry.get("ref", "main"))
|
||||||
|
digest = hashlib.sha256(f"{repo}\0{ref}".encode()).hexdigest()[:16]
|
||||||
|
return EXTERNALS_DIR / "_repos" / digest
|
||||||
|
|
||||||
|
|
||||||
|
def external_checkout_path(name: str, entry: dict[str, Any]) -> Path:
|
||||||
|
"""Prefer an existing pre-shared-cache checkout for compatibility."""
|
||||||
|
from skiff.paths import EXTERNALS_DIR
|
||||||
|
|
||||||
|
legacy = EXTERNALS_DIR / name
|
||||||
|
return legacy if legacy.is_dir() else external_repo_path(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
||||||
entry = entry or load_registry().get(name, {})
|
entry = entry or load_registry().get(name, {})
|
||||||
subpath = entry.get("path", ".") or "."
|
subpath = entry.get("path", ".") or "."
|
||||||
return (EXTERNALS_DIR / name / subpath).resolve()
|
checkout = external_checkout_path(name, entry).resolve()
|
||||||
|
skill_path = (checkout / subpath).resolve()
|
||||||
|
try:
|
||||||
|
skill_path.relative_to(checkout)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
f"registry 条目 {name!r} 的 path 超出外部仓库: {subpath!r}"
|
||||||
|
) from exc
|
||||||
|
return skill_path
|
||||||
|
|
||||||
|
|
||||||
|
def discover_external_skills(
|
||||||
|
name: str,
|
||||||
|
entry: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Path]:
|
||||||
|
"""Discover a single registry skill or a collection of sibling skills."""
|
||||||
|
entry = entry or load_registry().get(name, {})
|
||||||
|
root = external_skill_path(name, entry)
|
||||||
|
if (root / "SKILL.md").is_file():
|
||||||
|
return {name: root}
|
||||||
|
if not root.is_dir():
|
||||||
|
return {}
|
||||||
|
resolved_root = root.resolve()
|
||||||
|
skills: dict[str, Path] = {}
|
||||||
|
for item in sorted(root.iterdir()):
|
||||||
|
skill_md = item / "SKILL.md"
|
||||||
|
if (
|
||||||
|
item.name.startswith("_")
|
||||||
|
or item.is_symlink()
|
||||||
|
or not item.is_dir()
|
||||||
|
or skill_md.is_symlink()
|
||||||
|
or not skill_md.is_file()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
resolved_item = item.resolve()
|
||||||
|
resolved_skill_md = skill_md.resolve()
|
||||||
|
try:
|
||||||
|
resolved_item.relative_to(resolved_root)
|
||||||
|
resolved_skill_md.relative_to(resolved_item)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
skills[item.name] = item
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def external_collection_skill_path(
|
||||||
|
collection: str,
|
||||||
|
skill_name: str,
|
||||||
|
entry: dict[str, Any] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
skills = discover_external_skills(collection, entry)
|
||||||
|
if skill_name not in skills:
|
||||||
|
available = ", ".join(skills) or "(无)"
|
||||||
|
raise SystemExit(
|
||||||
|
f"registry collection {collection!r} 中找不到 skill {skill_name!r}。"
|
||||||
|
f"可用: {available}"
|
||||||
|
)
|
||||||
|
return skills[skill_name]
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Terminal multi-select UI for skills."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillChoice:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
description: str = ""
|
||||||
|
installed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
|
||||||
|
needle = query.casefold().strip()
|
||||||
|
if not needle:
|
||||||
|
return list(choices)
|
||||||
|
return [
|
||||||
|
choice
|
||||||
|
for choice in choices
|
||||||
|
if needle in choice.name.casefold()
|
||||||
|
or needle in choice.kind.casefold()
|
||||||
|
or needle in choice.description.casefold()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def select_skills(
|
||||||
|
choices: list[SkillChoice],
|
||||||
|
*,
|
||||||
|
wrapper: Callable[..., set[str] | None] | None = None,
|
||||||
|
) -> set[str] | None:
|
||||||
|
"""Open the selector. Return names, or None when cancelled."""
|
||||||
|
if not choices:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import curses
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
|
||||||
|
|
||||||
|
selected = {choice.name for choice in choices if choice.installed}
|
||||||
|
|
||||||
|
def draw(stdscr: object) -> set[str] | None:
|
||||||
|
curses.curs_set(0)
|
||||||
|
stdscr.keypad(True)
|
||||||
|
current = 0
|
||||||
|
query = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
visible = filter_choices(choices, query)
|
||||||
|
current = min(current, max(0, len(visible) - 1))
|
||||||
|
stdscr.erase()
|
||||||
|
height, width = stdscr.getmaxyx()
|
||||||
|
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消"
|
||||||
|
stdscr.addnstr(0, 0, header, max(0, width - 1))
|
||||||
|
if query:
|
||||||
|
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
|
||||||
|
|
||||||
|
rows = max(1, height - 4)
|
||||||
|
start = max(0, current - rows + 1)
|
||||||
|
for row, choice in enumerate(visible[start : start + rows], start=2):
|
||||||
|
index = start + row - 2
|
||||||
|
mark = "x" if choice.name in selected else " "
|
||||||
|
suffix = f" · {choice.description}" if choice.description else ""
|
||||||
|
line = f"[{mark}] {choice.name} {choice.kind}{suffix}"
|
||||||
|
attr = curses.A_REVERSE if index == current else curses.A_NORMAL
|
||||||
|
stdscr.addnstr(row, 0, line, max(0, width - 1), attr)
|
||||||
|
|
||||||
|
footer = f"已选择 {len(selected)} 项"
|
||||||
|
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1))
|
||||||
|
stdscr.refresh()
|
||||||
|
key = stdscr.get_wch()
|
||||||
|
|
||||||
|
if key in ("q", "Q", "\x1b"):
|
||||||
|
return None
|
||||||
|
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||||
|
return set(selected)
|
||||||
|
if key == curses.KEY_UP and visible:
|
||||||
|
current = (current - 1) % len(visible)
|
||||||
|
elif key == curses.KEY_DOWN and visible:
|
||||||
|
current = (current + 1) % len(visible)
|
||||||
|
elif key == " " and visible:
|
||||||
|
name = visible[current].name
|
||||||
|
selected.symmetric_difference_update({name})
|
||||||
|
elif key == "/":
|
||||||
|
curses.curs_set(1)
|
||||||
|
query = _read_query(stdscr, curses, width)
|
||||||
|
curses.curs_set(0)
|
||||||
|
current = 0
|
||||||
|
|
||||||
|
runner = wrapper or curses.wrapper
|
||||||
|
return runner(draw)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_query(stdscr: object, curses: object, width: int) -> str:
|
||||||
|
query = ""
|
||||||
|
while True:
|
||||||
|
stdscr.move(1, 0)
|
||||||
|
stdscr.clrtoeol()
|
||||||
|
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
|
||||||
|
stdscr.refresh()
|
||||||
|
key = stdscr.get_wch()
|
||||||
|
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||||
|
return query
|
||||||
|
if key == "\x1b":
|
||||||
|
return ""
|
||||||
|
if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE:
|
||||||
|
query = query[:-1]
|
||||||
|
elif isinstance(key, str) and key.isprintable():
|
||||||
|
query += key
|
||||||
+195
-11
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
||||||
from skiff.registry import external_skill_path, load_registry
|
from skiff.registry import (
|
||||||
|
external_collection_skill_path,
|
||||||
|
external_skill_path,
|
||||||
|
load_registry,
|
||||||
|
)
|
||||||
|
from skiff.sources import list_source_skills, load_sources, source_skills_root
|
||||||
|
|
||||||
|
|
||||||
def list_owned_skills() -> list[str]:
|
def list_owned_skills() -> list[str]:
|
||||||
@@ -30,29 +36,142 @@ def owned_skill_path(name: str) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]:
|
||||||
|
if "/" not in spec:
|
||||||
|
return spec, source
|
||||||
|
qualified_source, name = spec.split("/", 1)
|
||||||
|
if not qualified_source or not name or "/" in name:
|
||||||
|
raise SystemExit(f"skill 限定名称无效: {spec!r}(应为 source/name)")
|
||||||
|
if source and source != qualified_source:
|
||||||
|
raise SystemExit(
|
||||||
|
f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致"
|
||||||
|
)
|
||||||
|
return name, qualified_source
|
||||||
|
|
||||||
|
|
||||||
|
def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
|
||||||
|
sources = load_sources()
|
||||||
|
if source:
|
||||||
|
if source not in sources:
|
||||||
|
raise SystemExit(f"未配置 source: {source}")
|
||||||
|
return {source: list_source_skills(source, sources[source])}
|
||||||
|
return {name: list_source_skills(name, entry) for name, entry in sources.items()}
|
||||||
|
|
||||||
|
|
||||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||||
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
|
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
||||||
ensure_skills_home()
|
ensure_skills_home()
|
||||||
|
name, source = split_skill_spec(name, source)
|
||||||
|
|
||||||
owned = SKILLS_DIR / name
|
owned = SKILLS_DIR / name
|
||||||
if source in (None, "owned") and (owned / "SKILL.md").is_file():
|
if source == "owned":
|
||||||
|
if not (owned / "SKILL.md").is_file():
|
||||||
|
raise SystemExit(f"owned source 中找不到 skill: {name}")
|
||||||
return owned, "owned"
|
return owned, "owned"
|
||||||
|
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
if source in (None, "registry") and name in registry:
|
sources = load_sources()
|
||||||
path = external_skill_path(name, registry[name])
|
registry_collection = None
|
||||||
if not path.exists():
|
if source and source.startswith("registry:"):
|
||||||
raise SystemExit(
|
registry_collection = source.split(":", 1)[1]
|
||||||
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
|
elif (
|
||||||
)
|
source
|
||||||
return path, "external"
|
and source not in ("owned", "registry")
|
||||||
|
and source in registry
|
||||||
|
and source not in sources
|
||||||
|
):
|
||||||
|
registry_collection = source
|
||||||
|
if registry_collection:
|
||||||
|
path = external_collection_skill_path(
|
||||||
|
registry_collection,
|
||||||
|
name,
|
||||||
|
registry[registry_collection],
|
||||||
|
)
|
||||||
|
return path, f"registry:{registry_collection}"
|
||||||
|
|
||||||
|
if source == "registry":
|
||||||
|
if name not in registry:
|
||||||
|
raise SystemExit(f"registry 中找不到 skill: {name}")
|
||||||
|
path = external_skill_path(name, registry[name])
|
||||||
|
if not (path / "SKILL.md").is_file():
|
||||||
|
raise SystemExit(
|
||||||
|
f"外部 skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
|
||||||
|
f"请运行: skiff fetch {name}"
|
||||||
|
)
|
||||||
|
return path, "registry"
|
||||||
|
|
||||||
|
if source:
|
||||||
|
if source not in sources:
|
||||||
|
raise SystemExit(
|
||||||
|
f"项目依赖 source {source!r},但本机尚未配置。"
|
||||||
|
f"请运行: skiff source add {source} <repo>"
|
||||||
|
)
|
||||||
|
path = source_skills_root(source, sources[source]) / name
|
||||||
|
if not (path / "SKILL.md").is_file():
|
||||||
|
raise SystemExit(f"source {source!r} 中找不到 skill: {name}")
|
||||||
|
return path, source
|
||||||
|
|
||||||
|
candidates: list[tuple[Path, str]] = []
|
||||||
if (owned / "SKILL.md").is_file():
|
if (owned / "SKILL.md").is_file():
|
||||||
return owned, "owned"
|
candidates.append((owned, "owned"))
|
||||||
|
if name in registry:
|
||||||
|
candidates.append((external_skill_path(name, registry[name]), "registry"))
|
||||||
|
for source_name, entry in sources.items():
|
||||||
|
path = source_skills_root(source_name, entry) / name
|
||||||
|
if (path / "SKILL.md").is_file():
|
||||||
|
candidates.append((path, source_name))
|
||||||
|
|
||||||
|
if len(candidates) > 1:
|
||||||
|
choices = ", ".join(f"{candidate_source}/{name}" for _, candidate_source in candidates)
|
||||||
|
raise SystemExit(f"skill 名称存在多个来源,请明确指定: {choices}")
|
||||||
|
if candidates:
|
||||||
|
path, resolved_source = candidates[0]
|
||||||
|
if resolved_source == "registry" and not path.exists():
|
||||||
|
raise SystemExit(f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}")
|
||||||
|
return path, resolved_source
|
||||||
|
|
||||||
raise SystemExit(f"找不到 skill: {name}")
|
raise SystemExit(f"找不到 skill: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def read_skill_meta(skill_dir: Path) -> dict[str, str]:
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.is_file():
|
||||||
|
return {}
|
||||||
|
text = skill_md.read_text(encoding="utf-8")
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return {}
|
||||||
|
end = text.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}
|
||||||
|
frontmatter = text[3:end]
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
|
||||||
|
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||||
|
if name_match:
|
||||||
|
meta["name"] = name_match.group(1).strip().strip("\"'")
|
||||||
|
|
||||||
|
desc_match = re.search(
|
||||||
|
r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)",
|
||||||
|
frontmatter,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
if desc_match:
|
||||||
|
lines = [line.strip() for line in desc_match.group(1).splitlines() if line.strip()]
|
||||||
|
meta["description"] = " ".join(lines)
|
||||||
|
else:
|
||||||
|
inline = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||||
|
if inline:
|
||||||
|
meta["description"] = inline.group(1).strip().strip("\"'")
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def skill_description(name: str) -> str | None:
|
||||||
|
meta = read_skill_meta(SKILLS_DIR / name)
|
||||||
|
desc = meta.get("description")
|
||||||
|
return desc.strip() if desc else None
|
||||||
|
|
||||||
|
|
||||||
def validate_skill_name(name: str) -> None:
|
def validate_skill_name(name: str) -> None:
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -62,3 +181,68 @@ def validate_skill_name(name: str) -> None:
|
|||||||
)
|
)
|
||||||
if name == "_template":
|
if name == "_template":
|
||||||
raise SystemExit("不能使用保留名 _template")
|
raise SystemExit("不能使用保留名 _template")
|
||||||
|
|
||||||
|
|
||||||
|
_PLACEHOLDER_PATTERNS = (
|
||||||
|
(r"^name:\s*skill-name\s*$", "skill-name"),
|
||||||
|
(r"^#\s+Skill 名称\s*$", "Skill 名称"),
|
||||||
|
(r"简要描述 skill 做什么、何时触发", "简要描述 skill 做什么"),
|
||||||
|
(r"^-\s*触发场景 1\s*$", "触发场景 1"),
|
||||||
|
(r"^\d+\.\s*第一步\s*$", "第一步"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]:
|
||||||
|
"""返回 skill 目录中的校验问题;空列表表示通过。"""
|
||||||
|
issues: list[str] = []
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.is_file():
|
||||||
|
return ["缺少 SKILL.md"]
|
||||||
|
|
||||||
|
text = skill_md.read_text(encoding="utf-8")
|
||||||
|
if not text.strip():
|
||||||
|
return ["SKILL.md 为空"]
|
||||||
|
if not text.startswith("---\n"):
|
||||||
|
return ["SKILL.md 缺少 YAML frontmatter"]
|
||||||
|
|
||||||
|
end = text.find("\n---", 4)
|
||||||
|
if end == -1:
|
||||||
|
return ["SKILL.md frontmatter 未闭合"]
|
||||||
|
frontmatter = text[4:end]
|
||||||
|
keys = re.findall(r"^([A-Za-z0-9_-]+):", frontmatter, re.MULTILINE)
|
||||||
|
unexpected = sorted(set(keys) - {"name", "description"})
|
||||||
|
missing = sorted({"name", "description"} - set(keys))
|
||||||
|
if missing:
|
||||||
|
issues.append(f"frontmatter 缺少字段: {', '.join(missing)}")
|
||||||
|
if unexpected:
|
||||||
|
issues.append(f"frontmatter 只允许 name、description,发现: {', '.join(unexpected)}")
|
||||||
|
|
||||||
|
meta = read_skill_meta(skill_dir)
|
||||||
|
if meta.get("name") != expected_name:
|
||||||
|
issues.append(
|
||||||
|
f"目录名与 frontmatter name 不一致: {expected_name} != {meta.get('name', '(缺失)')}"
|
||||||
|
)
|
||||||
|
description = meta.get("description", "").strip()
|
||||||
|
if not description:
|
||||||
|
issues.append("description 不能为空")
|
||||||
|
|
||||||
|
found_placeholders = [
|
||||||
|
label
|
||||||
|
for pattern, label in _PLACEHOLDER_PATTERNS
|
||||||
|
if re.search(pattern, text, re.MULTILINE | re.IGNORECASE)
|
||||||
|
]
|
||||||
|
if found_placeholders:
|
||||||
|
issues.append(f"存在模板占位内容: {', '.join(found_placeholders)}")
|
||||||
|
|
||||||
|
link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
||||||
|
for target in link_pattern.findall(text):
|
||||||
|
target = target.strip().split("#", 1)[0]
|
||||||
|
if not target or "://" in target or target.startswith(("mailto:", "/")):
|
||||||
|
continue
|
||||||
|
if not (skill_dir / target).resolve().is_file():
|
||||||
|
issues.append(f"引用文件不存在: {target}")
|
||||||
|
|
||||||
|
for draft_file in ("brief.yaml",):
|
||||||
|
if (skill_dir / draft_file).exists():
|
||||||
|
issues.append(f"正式 skill 不应包含草稿文件: {draft_file}")
|
||||||
|
return issues
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""命名 custom skill source 的配置、发现与 Git 管理。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from skiff import yaml_io
|
||||||
|
from skiff.paths import CONFIG_FILE, SOURCES_DIR
|
||||||
|
|
||||||
|
RESERVED_SOURCES = {"owned", "registry"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source_name(name: str) -> None:
|
||||||
|
from skiff.skills import validate_skill_name
|
||||||
|
|
||||||
|
validate_skill_name(name)
|
||||||
|
if name in RESERVED_SOURCES:
|
||||||
|
raise SystemExit(f"source 名称为保留字: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||||
|
path = path or CONFIG_FILE
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"skiff 配置格式错误: {path}")
|
||||||
|
raw = data.get("sources", {})
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise SystemExit(f"skiff 配置 sources 格式错误: {path}")
|
||||||
|
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||||
|
path = path or CONFIG_FILE
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
|
||||||
|
local_path = entry.get("local_path")
|
||||||
|
if local_path:
|
||||||
|
return Path(str(local_path)).expanduser().resolve()
|
||||||
|
checkout = entry.get("checkout")
|
||||||
|
if checkout:
|
||||||
|
return Path(str(checkout)).expanduser().resolve()
|
||||||
|
return (SOURCES_DIR / name).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def source_skills_root(name: str, entry: dict[str, Any]) -> Path:
|
||||||
|
checkout = source_checkout(name, entry)
|
||||||
|
raw_subpath = str(entry.get("skills_path", "skills") or "skills")
|
||||||
|
subpath = Path(raw_subpath)
|
||||||
|
if subpath.is_absolute():
|
||||||
|
raise SystemExit(f"source {name!r} 的 skills_path 必须是仓库内相对路径")
|
||||||
|
root = (checkout / subpath).resolve()
|
||||||
|
try:
|
||||||
|
root.relative_to(checkout)
|
||||||
|
except ValueError:
|
||||||
|
raise SystemExit(f"source {name!r} 的 skills_path 不能超出仓库目录") from None
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
|
||||||
|
root = source_skills_root(name, entry)
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
item.name
|
||||||
|
for item in sorted(root.iterdir())
|
||||||
|
if item.is_dir() and not item.name.startswith("_") and (item / "SKILL.md").is_file()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_source(name: str, entry: dict[str, Any]) -> Path:
|
||||||
|
if entry.get("local_path"):
|
||||||
|
checkout = source_checkout(name, entry)
|
||||||
|
if not checkout.is_dir():
|
||||||
|
raise SystemExit(f"source {name!r} 的本地仓库不存在: {checkout}")
|
||||||
|
return checkout
|
||||||
|
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if not repo:
|
||||||
|
raise SystemExit(f"source {name!r} 缺少 repo 或 local_path")
|
||||||
|
checkout = source_checkout(name, entry)
|
||||||
|
ref = str(entry.get("ref", "main") or "main")
|
||||||
|
if checkout.exists():
|
||||||
|
if not (checkout / ".git").exists():
|
||||||
|
raise SystemExit(f"source checkout 已存在但不是 Git 仓库: {checkout}")
|
||||||
|
subprocess.run(["git", "-C", str(checkout), "fetch", "--all", "--tags"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(checkout), "checkout", ref], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(checkout), "pull", "--ff-only"], check=True)
|
||||||
|
else:
|
||||||
|
checkout.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "clone", "--branch", ref, "--", str(repo), str(checkout)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return checkout
|
||||||
+3
-2
@@ -236,11 +236,12 @@ def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
|
|||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
first = True
|
first = True
|
||||||
for key, value in item.items():
|
for key, value in item.items():
|
||||||
prefix = f"{pad}- " if first else f"{pad} "
|
is_first = first
|
||||||
|
prefix = f"{pad}- " if is_first else f"{pad} "
|
||||||
first = False
|
first = False
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
lines.append(f"{prefix}{key}:")
|
lines.append(f"{prefix}{key}:")
|
||||||
nested = _dump(value, indent + 4 if first else indent + 2)
|
nested = _dump(value, indent + 2 if is_first else indent + 4)
|
||||||
lines.append(nested.rstrip())
|
lines.append(nested.rstrip())
|
||||||
else:
|
else:
|
||||||
lines.append(f"{prefix}{key}: {_scalar(value)}")
|
lines.append(f"{prefix}{key}: {_scalar(value)}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: skill-name
|
name: skill-name
|
||||||
description: >-
|
description: >-
|
||||||
简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically.
|
简要描述 skill 做什么、何时触发。写入用户可能使用的触发表达,让 Agent 能自动匹配。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Skill 名称
|
# Skill 名称
|
||||||
@@ -15,6 +15,10 @@ description: >-
|
|||||||
- 触发场景 1
|
- 触发场景 1
|
||||||
- 触发场景 2
|
- 触发场景 2
|
||||||
|
|
||||||
|
## 不适用
|
||||||
|
|
||||||
|
- 不应触发的相近场景
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 步骤
|
## 步骤
|
||||||
@@ -28,3 +32,7 @@ description: >-
|
|||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 约束或边界条件
|
- 约束或边界条件
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 说明如何确认 skill 的执行结果正确
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: publish-docker-image
|
||||||
|
description: >-
|
||||||
|
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
||||||
|
$publish-docker-image 或明确说“使用 publish-docker-image skill”时使用;
|
||||||
|
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 发布 Docker 镜像
|
||||||
|
|
||||||
|
安全、可复现地构建当前提交对应的 Docker 镜像,并按用户指定的目标上传。
|
||||||
|
|
||||||
|
## 执行流程
|
||||||
|
|
||||||
|
1. 读取项目的 `AGENTS.md`、Dockerfile、构建脚本和相关发布文档。
|
||||||
|
2. 收集目标 registry、repository、tag、platform、构建上下文和 Dockerfile。优先使用用户已明确提供的值;缺少会改变发布结果的值时,停止并询问。
|
||||||
|
3. 检查 Git 工作区与当前提交。若存在未提交修改,明确说明镜像将包含哪些修改。
|
||||||
|
4. 按 [registry.md](references/registry.md) 检查仓库规则和认证状态。
|
||||||
|
5. 在执行外部写操作前,向用户展示完整镜像引用、platform、Dockerfile、构建上下文和源 commit。只有用户已明确要求上传到该目标时才继续。
|
||||||
|
6. 使用 [publish.sh](scripts/publish.sh) 构建并上传。不要自行拼接包含凭据的命令。
|
||||||
|
7. 检查命令退出状态,并尽可能获取远端 digest。
|
||||||
|
8. 汇报完整镜像引用、digest、platform、源 commit,以及是否包含未提交修改。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
默认构建并上传:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/publish.sh \
|
||||||
|
--registry REGISTRY \
|
||||||
|
--repository NAMESPACE/IMAGE \
|
||||||
|
--tag TAG \
|
||||||
|
--platform PLATFORM
|
||||||
|
```
|
||||||
|
|
||||||
|
先验证而不构建或上传:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/publish.sh \
|
||||||
|
--registry REGISTRY \
|
||||||
|
--repository NAMESPACE/IMAGE \
|
||||||
|
--tag TAG \
|
||||||
|
--platform PLATFORM \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
仅当用户明确要求本地构建时使用 `--load`。多平台镜像不能使用 `--load`。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 不把密码、访问令牌或 Docker 配置写入 skill、项目文件、命令参数或输出。
|
||||||
|
- 不主动执行 `docker login`;认证缺失时让用户通过交互式登录或其凭据管理器完成。
|
||||||
|
- 不覆盖已存在的 release tag,除非用户明确授权。无法可靠检查远端 tag 时说明这一限制。
|
||||||
|
- 不把 `latest` 作为隐含默认 tag。
|
||||||
|
- 不上传用户未指定的附加 tag。
|
||||||
|
- 不擅自修改 Dockerfile、发布配置、仓库权限或镜像保留策略。
|
||||||
|
- 若仓库、tag、platform 或目标环境存在歧义,在上传前询问用户。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 确认 `docker buildx build` 成功且启用了 `--push`。
|
||||||
|
- 优先用 `docker buildx imagetools inspect FULL_IMAGE_REF` 验证远端引用及平台。
|
||||||
|
- 记录远端 digest;若仓库不允许检查,明确报告只验证了 push 命令成功。
|
||||||
|
- 将发布所用的 Git commit 与工作区状态一并报告。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 镜像仓库规则
|
||||||
|
|
||||||
|
执行发布前,从用户输入和当前项目文档中确定以下信息:
|
||||||
|
|
||||||
|
| 字段 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| Registry | 必须显式确定,例如 `registry.example.com` |
|
||||||
|
| Repository | 必须包含项目约定的 namespace,例如 `team/service` |
|
||||||
|
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
|
||||||
|
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
|
||||||
|
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
|
||||||
|
| Context | 默认当前项目根目录 |
|
||||||
|
|
||||||
|
## 信息来源优先级
|
||||||
|
|
||||||
|
1. 用户本次请求中明确给出的值。
|
||||||
|
2. 当前项目的 `AGENTS.md` 和发布文档。
|
||||||
|
3. `Makefile`、CI 配置、Compose 文件或现有构建脚本中一致且无歧义的配置。
|
||||||
|
4. 询问用户。
|
||||||
|
|
||||||
|
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
|
||||||
|
|
||||||
|
不要读取、打印或复制以下内容:
|
||||||
|
|
||||||
|
- registry 密码或访问令牌
|
||||||
|
- `~/.docker/config.json` 中的认证字段
|
||||||
|
- CI secret 的值
|
||||||
|
- 包含凭据的环境变量值
|
||||||
|
|
||||||
|
## Tag 策略
|
||||||
|
|
||||||
|
- release tag(如 `v1.2.3`)默认视为不可变。
|
||||||
|
- Git SHA tag 应对应当前源 commit。
|
||||||
|
- `latest`、`stable` 等浮动 tag 只有在用户明确要求时才发布。
|
||||||
|
- 用户未给 tag 且项目没有唯一明确规则时,必须询问,不要自行选择。
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,22p' "$0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build and publish a Docker image with buildx.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# publish.sh --registry HOST --repository PATH --tag TAG --platform PLATFORMS [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --registry HOST Registry host, without a URL scheme
|
||||||
|
# --repository PATH Repository path, such as team/service
|
||||||
|
# --tag TAG Image tag
|
||||||
|
# --platform LIST Comma-separated platforms
|
||||||
|
# --file PATH Dockerfile path (default: Dockerfile)
|
||||||
|
# --context PATH Build context (default: .)
|
||||||
|
# --builder NAME Existing buildx builder
|
||||||
|
# --load Load a single-platform image instead of pushing
|
||||||
|
# --dry-run Print the resolved build without executing it
|
||||||
|
# --help Show this help
|
||||||
|
|
||||||
|
registry=
|
||||||
|
repository=
|
||||||
|
tag=
|
||||||
|
platform=
|
||||||
|
dockerfile=Dockerfile
|
||||||
|
build_context=.
|
||||||
|
builder=
|
||||||
|
mode=push
|
||||||
|
dry_run=false
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--registry) registry=${2-}; shift 2 ;;
|
||||||
|
--repository) repository=${2-}; shift 2 ;;
|
||||||
|
--tag) tag=${2-}; shift 2 ;;
|
||||||
|
--platform) platform=${2-}; shift 2 ;;
|
||||||
|
--file) dockerfile=${2-}; shift 2 ;;
|
||||||
|
--context) build_context=${2-}; shift 2 ;;
|
||||||
|
--builder) builder=${2-}; shift 2 ;;
|
||||||
|
--load) mode=load; shift ;;
|
||||||
|
--dry-run) dry_run=true; shift ;;
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
for required_name in registry repository tag platform; do
|
||||||
|
if [[ -z ${!required_name} ]]; then
|
||||||
|
printf 'Missing required option: --%s\n' "$required_name" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $registry == *://* || $registry == */* ]]; then
|
||||||
|
printf '%s\n' 'Registry must be a host without a URL scheme or path.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ $repository == /* || $repository == */ || $repository != */* ]]; then
|
||||||
|
printf '%s\n' 'Repository must look like namespace/image.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ $tag == *:* || $tag == */* ]]; then
|
||||||
|
printf '%s\n' 'Tag must not contain ":" or "/".' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ $mode == load && $platform == *,* ]]; then
|
||||||
|
printf '%s\n' '--load supports only one platform.' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -f $dockerfile ]]; then
|
||||||
|
printf 'Dockerfile not found: %s\n' "$dockerfile" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ ! -d $build_context ]]; then
|
||||||
|
printf 'Build context not found: %s\n' "$build_context" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ $dry_run == false ]] && ! command -v docker >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' 'docker is not installed or not available in PATH.' >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
image_ref="${registry}/${repository}:${tag}"
|
||||||
|
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
|
||||||
|
if [[ -n $builder ]]; then
|
||||||
|
build_cmd+=(--builder "$builder")
|
||||||
|
fi
|
||||||
|
if [[ $mode == push ]]; then
|
||||||
|
build_cmd+=(--push)
|
||||||
|
else
|
||||||
|
build_cmd+=(--load)
|
||||||
|
fi
|
||||||
|
build_cmd+=("$build_context")
|
||||||
|
|
||||||
|
printf 'Image: %s\n' "$image_ref"
|
||||||
|
printf 'Platform: %s\n' "$platform"
|
||||||
|
printf 'Dockerfile: %s\n' "$dockerfile"
|
||||||
|
printf 'Context: %s\n' "$build_context"
|
||||||
|
printf 'Mode: %s\n' "$mode"
|
||||||
|
|
||||||
|
if [[ $dry_run == true ]]; then
|
||||||
|
printf 'Command:'
|
||||||
|
printf ' %q' "${build_cmd[@]}"
|
||||||
|
printf '\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${build_cmd[@]}"
|
||||||
|
|
||||||
|
if [[ $mode == push ]]; then
|
||||||
|
docker buildx imagetools inspect "$image_ref"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
name: skiff
|
||||||
|
description: >-
|
||||||
|
创建和维护 ~/.skills 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布,
|
||||||
|
或用 skiff add/remove 在项目及全局挂卸 skill。触发词:skiff、自研 skill、创建 skill、
|
||||||
|
想做一个 skill、publish skill、安装自研 skill、更新 skill 到项目。
|
||||||
|
---
|
||||||
|
|
||||||
|
# skiff 自研 Skill 工作流
|
||||||
|
|
||||||
|
SSOT 固定在 `~/.skills/skills/<name>/`。内容通过 **symlink** 分发到各 agent,改 SSOT 即全项目生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 在项目中使用 skill
|
||||||
|
|
||||||
|
先浏览可用的 owned skill,再安装到当前项目:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff add --list
|
||||||
|
skiff add <name> -a cursor -a claude -a codex -y
|
||||||
|
```
|
||||||
|
|
||||||
|
`skiff add <name>` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.skills/skills/<name>/` 的软链,不要在 Agent 目录创建副本。
|
||||||
|
|
||||||
|
## 创建新的 skill
|
||||||
|
|
||||||
|
### 从项目想法创建 skill
|
||||||
|
|
||||||
|
当用户在项目开发中提出“想创建一个 skill”时:
|
||||||
|
|
||||||
|
1. 用一句话确认它要解决的重复问题,并建议符合小写连字符规范的名称;信息足够时不要为了形式追问。
|
||||||
|
2. 读取当前项目中与想法直接相关的代码和规范,区分可复用工作流与项目私有事实。
|
||||||
|
3. 创建草稿:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff create <name> --idea "<用户原始想法>" --from-project .
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 编辑 `~/.skills/.drafts/<name>/SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。
|
||||||
|
5. 不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
|
||||||
|
6. 运行校验并修复所有问题:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff check <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
7. 向用户展示名称、description、核心步骤和验证方式。获得确认后再转正:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff finalize <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
转正不会自动 commit、push 或安装。用户明确要求后再执行 `skiff publish` 或 `skiff add`。
|
||||||
|
|
||||||
|
## 问题或优化回流
|
||||||
|
|
||||||
|
在项目里使用 skills 遇到问题,或者发现可复用的优化时:
|
||||||
|
|
||||||
|
1. 先记录最小证据:触发用户表达、使用的 skill 名称、实际结果、期望结果,以及能复现问题的必要项目上下文。
|
||||||
|
2. 判断归属:
|
||||||
|
- 通用工作流、触发条件或验证缺陷:回流 owned skill。
|
||||||
|
- 仅当前项目成立的命令、路径、业务规则:留在项目文档或项目配置,不写回通用 skill。
|
||||||
|
- CLI 安装、软链或校验行为异常:修改 `~/.skills/skiff/` 中的 CLI 和测试。
|
||||||
|
- 第三方 skill:不要复制成 owned skill 或直接改安装目录;整理证据反馈上游,除非用户明确决定维护 fork。
|
||||||
|
3. 确认真实来源。Agent 目录通常是软链,owned skill 的 SSOT 固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.skills/skills/<name>/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 修改 SSOT。行为修复应先补能复现问题的测试或示例,再改 `SKILL.md`、引用文件或脚本。
|
||||||
|
5. 校验并在原项目重跑最初失败的场景:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff check <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 汇报修改内容、验证结果和影响范围。只有用户明确要求提交或推送时才运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff publish skills/<name> -m "update <name>" --push
|
||||||
|
```
|
||||||
|
|
||||||
|
软链正确时无需重新安装;SSOT 保存后项目立即读取新内容。
|
||||||
|
|
||||||
|
## 安装与维护
|
||||||
|
|
||||||
|
安装本项目的 `skiff` skill 到所有 Agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
安装其他 skill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 当前项目
|
||||||
|
cd ~/code/my-app
|
||||||
|
skiff add discussion-notes -a cursor -y
|
||||||
|
|
||||||
|
# 全局(所有项目)
|
||||||
|
skiff add discussion-notes -a cursor -g -y
|
||||||
|
|
||||||
|
# 多个 agent
|
||||||
|
skiff add discussion-notes -a cursor -a codex -g -y
|
||||||
|
```
|
||||||
|
|
||||||
|
registry 条目既可以指向单个 skill,也可以指向包含多个 skill 目录的
|
||||||
|
collection。安装 collection 全部内容或其中一个:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff add waza -a codex -g -y
|
||||||
|
skiff add waza/think -a codex -g -y
|
||||||
|
```
|
||||||
|
|
||||||
|
`skiff select` 会把 collection 展开为 `waza/think`、`waza/ui` 等候选项。
|
||||||
|
|
||||||
|
卸载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff remove discussion-notes -a cursor -y # 当前项目
|
||||||
|
skiff remove discussion-notes -g -a cursor -y # 全局
|
||||||
|
skiff rm discussion-notes -g -y # rm 别名
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览可用自研 skill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
skiff add --list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 Vercel `npx skills` 的分工
|
||||||
|
|
||||||
|
| 场景 | 工具 |
|
||||||
|
|------|------|
|
||||||
|
| 自研 skill(~/.skills) | **skiff** |
|
||||||
|
| 社区 skill(GitHub 任意仓库) | `npx skills add` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 命令对照
|
||||||
|
|
||||||
|
| skiff | 说明 |
|
||||||
|
|-------|------|
|
||||||
|
| `bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
|
||||||
|
| `add <name> [-g] [-a AGENT...] [-y]` | 安装 |
|
||||||
|
| `remove <name> [-g] [-a AGENT...] [-y]` | 卸载(`rm` / `r` 别名) |
|
||||||
|
| `add --list` | 列出可用自研 skill |
|
||||||
|
| `publish [paths] -m MSG [--push]` | git add / commit / push |
|
||||||
|
| `list` | 列出 ~/.skills 目录结构 |
|
||||||
|
| `status` | 查看软链安装状态 |
|
||||||
|
| `create <name> --idea TEXT [--from-project PATH]` | 创建自研 skill 草稿 |
|
||||||
|
| `check <name>` | 校验草稿或正式 skill |
|
||||||
|
| `finalize <name>` | 校验草稿并转为正式 skill |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.skills/skills/` 再 `publish`
|
||||||
|
- 未完成的内容保留在 `~/.skills/.drafts/`,不要直接放进正式 `skills/`
|
||||||
|
- symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容
|
||||||
|
- 社区 skill 用 `npx skills add`,不要用 skiff `registry add` 除非团队要 pin 版本
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWorkflowTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.home = Path(self.temp_dir.name)
|
||||||
|
self.skills_home = self.home / ".skills"
|
||||||
|
template = self.skills_home / "skills" / "_template"
|
||||||
|
template.mkdir(parents=True)
|
||||||
|
template.joinpath("SKILL.md").write_text(
|
||||||
|
"---\nname: skill-name\ndescription: >-\n"
|
||||||
|
" 简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically.\n"
|
||||||
|
"---\n\n# Skill 名称\n\n## 步骤\n\n1. 第一步\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
project_skill = self.skills_home / "skills" / "skiff"
|
||||||
|
project_skill.mkdir()
|
||||||
|
project_skill.joinpath("SKILL.md").write_text(
|
||||||
|
"---\nname: skiff\ndescription: >-\n"
|
||||||
|
" 创建和维护自研 skill。用于创建、安装、反馈或更新 skill 时。\n"
|
||||||
|
"---\n\n# skiff\n\n## 步骤\n\n1. 维护 skill。\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(self.home)
|
||||||
|
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "skiff", *args],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_setup_command_is_not_exposed(self) -> None:
|
||||||
|
help_result = self.run_skiff("--help")
|
||||||
|
setup_result = self.run_skiff("setup", str(self.skills_home))
|
||||||
|
|
||||||
|
self.assertEqual(help_result.returncode, 0, help_result.stderr)
|
||||||
|
self.assertNotIn("setup", help_result.stdout)
|
||||||
|
self.assertNotEqual(setup_result.returncode, 0)
|
||||||
|
|
||||||
|
def test_bootstrap_installs_project_skill_globally_for_all_agents(self) -> None:
|
||||||
|
first = self.run_skiff("bootstrap")
|
||||||
|
second = self.run_skiff("bootstrap")
|
||||||
|
|
||||||
|
self.assertEqual(first.returncode, 0, first.stderr)
|
||||||
|
self.assertEqual(second.returncode, 0, second.stderr)
|
||||||
|
expected = (self.skills_home / "skills" / "skiff").resolve()
|
||||||
|
for relative in (
|
||||||
|
".cursor/skills/skiff",
|
||||||
|
".claude/skills/skiff",
|
||||||
|
".codex/skills/skiff",
|
||||||
|
):
|
||||||
|
link = self.home / relative
|
||||||
|
self.assertTrue(link.is_symlink(), relative)
|
||||||
|
self.assertEqual(link.resolve(), expected)
|
||||||
|
self.assertIn("cursor", first.stdout)
|
||||||
|
self.assertIn("claude", first.stdout)
|
||||||
|
self.assertIn("codex", first.stdout)
|
||||||
|
|
||||||
|
def test_create_writes_draft_and_brief_without_registering_owned_skill(self) -> None:
|
||||||
|
project = self.home / "project"
|
||||||
|
project.mkdir()
|
||||||
|
|
||||||
|
result = self.run_skiff(
|
||||||
|
"create",
|
||||||
|
"migration-review",
|
||||||
|
"--idea",
|
||||||
|
"检查数据库迁移",
|
||||||
|
"--from-project",
|
||||||
|
str(project),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
draft = self.skills_home / ".drafts" / "migration-review"
|
||||||
|
self.assertTrue((draft / "SKILL.md").is_file())
|
||||||
|
brief = (draft / "brief.yaml").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("idea: 检查数据库迁移", brief)
|
||||||
|
self.assertIn(f"source_project: {project.resolve()}", brief)
|
||||||
|
self.assertFalse((self.skills_home / "skills" / "migration-review").exists())
|
||||||
|
self.assertIn("请完善 skiff 草稿 migration-review", result.stdout)
|
||||||
|
|
||||||
|
def test_check_rejects_template_placeholders(self) -> None:
|
||||||
|
created = self.run_skiff("create", "migration-review", "--idea", "检查数据库迁移")
|
||||||
|
self.assertEqual(created.returncode, 0, created.stderr)
|
||||||
|
|
||||||
|
result = self.run_skiff("check", "migration-review")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("模板占位", result.stderr)
|
||||||
|
|
||||||
|
def test_finalize_moves_valid_draft_and_removes_brief(self) -> None:
|
||||||
|
created = self.run_skiff("create", "migration-review", "--idea", "检查数据库迁移")
|
||||||
|
self.assertEqual(created.returncode, 0, created.stderr)
|
||||||
|
draft = self.skills_home / ".drafts" / "migration-review"
|
||||||
|
draft.joinpath("SKILL.md").write_text(
|
||||||
|
"---\nname: migration-review\ndescription: >-\n"
|
||||||
|
" 检查数据库迁移文件的安全性。用于用户修改数据库结构、迁移或回滚方案时。\n"
|
||||||
|
"---\n\n# 数据库迁移检查\n\n## 步骤\n\n1. 检查迁移与回滚路径。\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_skiff("finalize", "migration-review")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
final = self.skills_home / "skills" / "migration-review"
|
||||||
|
self.assertTrue((final / "SKILL.md").is_file())
|
||||||
|
self.assertFalse((final / "brief.yaml").exists())
|
||||||
|
self.assertFalse(draft.exists())
|
||||||
|
|
||||||
|
def test_check_rejects_missing_local_reference(self) -> None:
|
||||||
|
draft = self.skills_home / ".drafts" / "reference-check"
|
||||||
|
draft.mkdir(parents=True)
|
||||||
|
draft.joinpath("SKILL.md").write_text(
|
||||||
|
"---\nname: reference-check\ndescription: >-\n"
|
||||||
|
" 检查本地参考资料。用于用户要求核对参考文档时。\n"
|
||||||
|
"---\n\n# 参考检查\n\n读取 [规范](references/rules.md)。\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_skiff("check", "reference-check")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("引用文件不存在", result.stderr)
|
||||||
|
|
||||||
|
def test_finalize_does_not_overwrite_existing_owned_skill(self) -> None:
|
||||||
|
created = self.run_skiff("create", "collision-check", "--idea", "检查名称冲突")
|
||||||
|
self.assertEqual(created.returncode, 0, created.stderr)
|
||||||
|
final = self.skills_home / "skills" / "collision-check"
|
||||||
|
final.mkdir()
|
||||||
|
final.joinpath("marker").write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_skiff("finalize", "collision-check")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertEqual(final.joinpath("marker").read_text(encoding="utf-8"), "keep")
|
||||||
|
self.assertTrue((self.skills_home / ".drafts" / "collision-check" / "brief.yaml").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def write_skill(root: Path, name: str) -> Path:
|
||||||
|
skill = root / name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
skill.joinpath("SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: >-\n"
|
||||||
|
f" 测试 {name} skill。\n---\n\n# {name}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
class CustomSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.home = Path(self.temp_dir.name)
|
||||||
|
self.skills_home = self.home / ".skills"
|
||||||
|
(self.skills_home / "skills").mkdir(parents=True)
|
||||||
|
(self.skills_home / "registry.yaml").write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(self.home)
|
||||||
|
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "skiff", *args],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_source_lists_and_installs_qualified_skill(self) -> None:
|
||||||
|
company = self.home / "company"
|
||||||
|
expected = write_skill(company / "internal" / "skills", "code-review")
|
||||||
|
|
||||||
|
added = self.run_skiff(
|
||||||
|
"source",
|
||||||
|
"add",
|
||||||
|
"company",
|
||||||
|
"--local",
|
||||||
|
str(company),
|
||||||
|
"--skills-path",
|
||||||
|
"internal/skills",
|
||||||
|
)
|
||||||
|
listed = self.run_skiff("list", "--source", "company")
|
||||||
|
installed = self.run_skiff("add", "company/code-review", "-g", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
self.assertEqual(listed.returncode, 0, listed.stderr)
|
||||||
|
self.assertIn("code-review", listed.stdout)
|
||||||
|
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||||
|
link = self.home / ".codex" / "skills" / "code-review"
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
self.assertEqual(link.resolve(), expected.resolve())
|
||||||
|
config = self.home / ".config" / "skiff" / "config.yaml"
|
||||||
|
self.assertIn("company:", config.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_project_add_persists_resolved_source_in_manifest(self) -> None:
|
||||||
|
company = self.home / "company"
|
||||||
|
expected = write_skill(company / "skills", "code-review")
|
||||||
|
project = self.home / "project"
|
||||||
|
project.mkdir()
|
||||||
|
added = self.run_skiff("source", "add", "company", "--local", str(company))
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
|
||||||
|
result = self.run_skiff(
|
||||||
|
"add",
|
||||||
|
"company/code-review",
|
||||||
|
"--project",
|
||||||
|
str(project),
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
manifest = project.joinpath(".skills.yaml").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('name: "code-review"', manifest)
|
||||||
|
self.assertIn("source: company", manifest)
|
||||||
|
self.assertEqual(
|
||||||
|
(project / ".agents" / "skills" / "code-review").resolve(),
|
||||||
|
expected.resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_project_add_updates_manifest_when_source_changes(self) -> None:
|
||||||
|
write_skill(self.skills_home / "skills", "code-review")
|
||||||
|
company = self.home / "company"
|
||||||
|
write_skill(company / "skills", "code-review")
|
||||||
|
project = self.home / "project"
|
||||||
|
project.mkdir()
|
||||||
|
project.joinpath(".skills.yaml").write_text(
|
||||||
|
"skills:\n - code-review\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
added = self.run_skiff("source", "add", "company", "--local", str(company))
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
|
||||||
|
result = self.run_skiff(
|
||||||
|
"add",
|
||||||
|
"company/code-review",
|
||||||
|
"--project",
|
||||||
|
str(project),
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
manifest = project.joinpath(".skills.yaml").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("source: company", manifest)
|
||||||
|
|
||||||
|
def test_unqualified_duplicate_requires_explicit_source(self) -> None:
|
||||||
|
write_skill(self.skills_home / "skills", "code-review")
|
||||||
|
company = self.home / "company"
|
||||||
|
write_skill(company / "skills", "code-review")
|
||||||
|
added = self.run_skiff("source", "add", "company", "--local", str(company))
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
|
||||||
|
result = self.run_skiff("add", "code-review", "-g", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("owned/code-review", result.stderr)
|
||||||
|
self.assertIn("company/code-review", result.stderr)
|
||||||
|
|
||||||
|
def test_custom_source_namespace_is_not_shadowed_by_registry_collection(self) -> None:
|
||||||
|
company = self.home / "company"
|
||||||
|
expected = write_skill(company / "skills", "code-review")
|
||||||
|
registry_repo = self.home / "registry-repo"
|
||||||
|
write_skill(registry_repo / "skills", "other-skill")
|
||||||
|
self.skills_home.joinpath("registry.yaml").write_text(
|
||||||
|
"company:\n"
|
||||||
|
f" repo: {registry_repo}\n"
|
||||||
|
" ref: main\n"
|
||||||
|
" path: skills\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
added = self.run_skiff("source", "add", "company", "--local", str(company))
|
||||||
|
|
||||||
|
result = self.run_skiff(
|
||||||
|
"add",
|
||||||
|
"company/code-review",
|
||||||
|
"-g",
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
(self.home / ".codex" / "skills" / "code-review").resolve(),
|
||||||
|
expected.resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sync_uses_manifest_source(self) -> None:
|
||||||
|
company = self.home / "company"
|
||||||
|
expected = write_skill(company / "skills", "code-review")
|
||||||
|
project = self.home / "project"
|
||||||
|
project.mkdir()
|
||||||
|
project.joinpath(".skills.yaml").write_text(
|
||||||
|
"skills:\n - name: code-review\n source: company\n"
|
||||||
|
"targets:\n - codex\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
added = self.run_skiff("source", "add", "company", "--local", str(company))
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
|
||||||
|
result = self.run_skiff("sync", "--project", str(project), "-a", "codex")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
link = project / ".agents" / "skills" / "code-review"
|
||||||
|
self.assertEqual(link.resolve(), expected.resolve())
|
||||||
|
|
||||||
|
def test_sync_reports_missing_machine_source(self) -> None:
|
||||||
|
project = self.home / "project"
|
||||||
|
project.mkdir()
|
||||||
|
project.joinpath(".skills.yaml").write_text(
|
||||||
|
"skills:\n - name: code-review\n source: company\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_skiff("sync", "--project", str(project), "-a", "codex")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("本机尚未配置", result.stderr)
|
||||||
|
self.assertIn("skiff source add company", result.stderr)
|
||||||
|
|
||||||
|
def test_git_source_is_cloned_to_default_checkout(self) -> None:
|
||||||
|
upstream = self.home / "upstream"
|
||||||
|
write_skill(upstream / "skills", "release-check")
|
||||||
|
subprocess.run(["git", "init", "-b", "main", str(upstream)], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "-C", str(upstream), "add", "."], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(upstream),
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.com",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"initial",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_skiff("source", "add", "company", str(upstream))
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
checkout = self.home / ".local" / "share" / "skiff" / "sources" / "company"
|
||||||
|
self.assertTrue((checkout / ".git").is_dir())
|
||||||
|
self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class InstallScriptTests(unittest.TestCase):
|
||||||
|
def test_install_bootstraps_skiff_skill_for_all_agents(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
home = Path(temp)
|
||||||
|
(home / ".skills").symlink_to(REPO_ROOT, target_is_directory=True)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(home)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(REPO_ROOT / "install.sh")],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
for relative in (
|
||||||
|
".cursor/skills/skiff",
|
||||||
|
".claude/skills/skiff",
|
||||||
|
".codex/skills/skiff",
|
||||||
|
):
|
||||||
|
link = home / relative
|
||||||
|
self.assertTrue(link.is_symlink(), relative)
|
||||||
|
self.assertEqual(link.resolve(), (REPO_ROOT / "skills" / "skiff").resolve())
|
||||||
|
self.assertIn("已安装项目 skill", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSkillContentTests(unittest.TestCase):
|
||||||
|
def test_skiff_skill_documents_project_usage_and_feedback_loop(self) -> None:
|
||||||
|
content = (REPO_ROOT / "skills" / "skiff" / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for expected in (
|
||||||
|
"skiff add <name>",
|
||||||
|
"问题或优化回流",
|
||||||
|
"实际结果",
|
||||||
|
"期望结果",
|
||||||
|
"skiff check <name>",
|
||||||
|
"~/.skills/skills/<name>/",
|
||||||
|
"第三方 skill",
|
||||||
|
):
|
||||||
|
self.assertIn(expected, content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def write_skill(root: Path, name: str) -> Path:
|
||||||
|
skill = root / name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
skill.joinpath("SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: >-\n"
|
||||||
|
f" Test {name} skill.\n---\n\n# {name}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryCollectionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.home = Path(self.temp_dir.name)
|
||||||
|
self.skills_home = self.home / ".skills"
|
||||||
|
(self.skills_home / "skills").mkdir(parents=True)
|
||||||
|
|
||||||
|
self.upstream = self.home / "upstream"
|
||||||
|
self.first = write_skill(self.upstream / "skills", "first-skill")
|
||||||
|
self.second = write_skill(self.upstream / "skills", "second-skill")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "-b", "main", str(self.upstream)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(["git", "-C", str(self.upstream), "add", "."], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(self.upstream),
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.com",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"initial",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.skills_home.joinpath("registry.yaml").write_text(
|
||||||
|
"test-pack:\n"
|
||||||
|
f" repo: {self.upstream}\n"
|
||||||
|
" ref: main\n"
|
||||||
|
" path: skills\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(self.home)
|
||||||
|
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "skiff", *args],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_add_collection_installs_every_discovered_skill(self) -> None:
|
||||||
|
result = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
skill_dir = self.home / ".codex" / "skills"
|
||||||
|
self.assertTrue((skill_dir / "first-skill").is_symlink())
|
||||||
|
self.assertTrue((skill_dir / "first-skill" / "SKILL.md").is_file())
|
||||||
|
self.assertTrue((skill_dir / "second-skill").is_symlink())
|
||||||
|
self.assertTrue((skill_dir / "second-skill" / "SKILL.md").is_file())
|
||||||
|
self.assertFalse((skill_dir / "test-pack").exists())
|
||||||
|
|
||||||
|
def test_add_qualified_collection_skill_installs_only_that_skill(self) -> None:
|
||||||
|
result = self.run_skiff("add", "test-pack/second-skill", "-g", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
skill_dir = self.home / ".codex" / "skills"
|
||||||
|
self.assertFalse((skill_dir / "first-skill").exists())
|
||||||
|
self.assertTrue((skill_dir / "second-skill").is_symlink())
|
||||||
|
self.assertTrue((skill_dir / "second-skill" / "SKILL.md").is_file())
|
||||||
|
|
||||||
|
def test_add_collection_with_registry_source_installs_all(self) -> None:
|
||||||
|
result = self.run_skiff(
|
||||||
|
"add",
|
||||||
|
"test-pack",
|
||||||
|
"--source",
|
||||||
|
"registry",
|
||||||
|
"-g",
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
skill_dir = self.home / ".codex" / "skills"
|
||||||
|
self.assertTrue((skill_dir / "first-skill").is_symlink())
|
||||||
|
self.assertTrue((skill_dir / "second-skill").is_symlink())
|
||||||
|
|
||||||
|
def test_project_add_records_collection_for_sync(self) -> None:
|
||||||
|
project = self.home / "project"
|
||||||
|
project.mkdir()
|
||||||
|
|
||||||
|
added = self.run_skiff(
|
||||||
|
"add",
|
||||||
|
"test-pack/first-skill",
|
||||||
|
"--project",
|
||||||
|
str(project),
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
link = project / ".agents" / "skills" / "first-skill"
|
||||||
|
link.unlink()
|
||||||
|
shutil.rmtree(self.home / ".local" / "share" / "skills" / "externals")
|
||||||
|
synced = self.run_skiff(
|
||||||
|
"sync",
|
||||||
|
"--project",
|
||||||
|
str(project),
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
self.assertIn('registry: "test-pack"', project.joinpath(".skills.yaml").read_text())
|
||||||
|
self.assertEqual(synced.returncode, 0, synced.stderr)
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
self.assertTrue((link / "SKILL.md").is_file())
|
||||||
|
|
||||||
|
def test_collection_preflight_prevents_partial_install(self) -> None:
|
||||||
|
blocked = self.home / ".codex" / "skills" / "second-skill"
|
||||||
|
blocked.mkdir(parents=True)
|
||||||
|
|
||||||
|
result = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertFalse((self.home / ".codex" / "skills" / "first-skill").exists())
|
||||||
|
self.assertTrue(blocked.is_dir())
|
||||||
|
|
||||||
|
def test_remove_collection_removes_all_child_links(self) -> None:
|
||||||
|
installed = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
|
||||||
|
removed = self.run_skiff("remove", "test-pack", "-g", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||||
|
self.assertEqual(removed.returncode, 0, removed.stderr)
|
||||||
|
skill_dir = self.home / ".codex" / "skills"
|
||||||
|
self.assertFalse((skill_dir / "first-skill").exists())
|
||||||
|
self.assertFalse((skill_dir / "second-skill").exists())
|
||||||
|
|
||||||
|
def test_status_expands_collection_children(self) -> None:
|
||||||
|
installed = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
|
||||||
|
status = self.run_skiff("status", "-a", "codex")
|
||||||
|
|
||||||
|
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||||
|
self.assertEqual(status.returncode, 0, status.stderr)
|
||||||
|
self.assertIn("[registry:test-pack] first-skill", status.stdout)
|
||||||
|
self.assertIn("[registry:test-pack] second-skill", status.stdout)
|
||||||
|
self.assertNotIn("[registry] test-pack\n (未 fetch)", status.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from skiff import cli
|
||||||
|
from skiff import yaml_io
|
||||||
|
from skiff.registry import external_repo_path, external_skill_path
|
||||||
|
from skiff.selector import SkillChoice, filter_choices, select_skills
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class SelectorTests(unittest.TestCase):
|
||||||
|
def test_manifest_entry_targets_round_trip(self) -> None:
|
||||||
|
data = {
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"name": "external-one",
|
||||||
|
"source": "registry",
|
||||||
|
"targets": ["codex"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(yaml_io.safe_load(yaml_io.safe_dump(data)), data)
|
||||||
|
|
||||||
|
def test_filter_matches_name_kind_and_description(self) -> None:
|
||||||
|
choices = [
|
||||||
|
SkillChoice("frontend-design", "external", "创建界面"),
|
||||||
|
SkillChoice("discussion-notes", "owned", "维护讨论笔记"),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual([c.name for c in filter_choices(choices, "front")], ["frontend-design"])
|
||||||
|
self.assertEqual([c.name for c in filter_choices(choices, "owned")], ["discussion-notes"])
|
||||||
|
self.assertEqual([c.name for c in filter_choices(choices, "界面")], ["frontend-design"])
|
||||||
|
|
||||||
|
def test_selector_preserves_preselected_items(self) -> None:
|
||||||
|
choices = [
|
||||||
|
SkillChoice("already-there", "owned", installed=True),
|
||||||
|
SkillChoice("new-skill", "external"),
|
||||||
|
]
|
||||||
|
screen = Mock()
|
||||||
|
screen.getmaxyx.return_value = (24, 100)
|
||||||
|
screen.get_wch.return_value = "\n"
|
||||||
|
|
||||||
|
with patch.object(curses, "curs_set"):
|
||||||
|
selected = select_skills(
|
||||||
|
choices,
|
||||||
|
wrapper=lambda draw: draw(screen),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected, {"already-there"})
|
||||||
|
|
||||||
|
def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
|
||||||
|
first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"}
|
||||||
|
second = {"repo": "https://example.test/skills.git", "ref": "main", "path": "b"}
|
||||||
|
|
||||||
|
self.assertEqual(external_repo_path(first), external_repo_path(second))
|
||||||
|
|
||||||
|
def test_external_skill_path_rejects_checkout_escape(self) -> None:
|
||||||
|
entry = {
|
||||||
|
"repo": "https://example.test/skills.git",
|
||||||
|
"ref": "main",
|
||||||
|
"path": "../../outside",
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(SystemExit, "超出外部仓库"):
|
||||||
|
external_skill_path("unsafe-skill", entry)
|
||||||
|
|
||||||
|
def test_collection_discovery_ignores_symlinked_skill(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
checkout = root / "checkout"
|
||||||
|
collection = checkout / "skills"
|
||||||
|
outside = root / "outside"
|
||||||
|
collection.mkdir(parents=True)
|
||||||
|
outside.mkdir()
|
||||||
|
outside.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
|
||||||
|
collection.joinpath("escaped").symlink_to(outside, target_is_directory=True)
|
||||||
|
entry = {
|
||||||
|
"repo": "https://example.test/skills.git",
|
||||||
|
"ref": "main",
|
||||||
|
"path": "skills",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("skiff.registry.external_checkout_path", return_value=checkout):
|
||||||
|
from skiff.registry import discover_external_skills
|
||||||
|
|
||||||
|
self.assertEqual(discover_external_skills("unsafe", entry), {})
|
||||||
|
|
||||||
|
|
||||||
|
class SelectCommandTests(unittest.TestCase):
|
||||||
|
def test_non_tty_exits_with_add_guidance(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
home = Path(temp)
|
||||||
|
(home / ".skills" / "skills").mkdir(parents=True)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(home)
|
||||||
|
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "skiff", "select"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("skiff add <name>", result.stderr)
|
||||||
|
|
||||||
|
def test_project_selection_installs_new_and_records_all_selected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
project = Path(temp)
|
||||||
|
external = project / "external-one"
|
||||||
|
external.mkdir()
|
||||||
|
external.joinpath("SKILL.md").write_text("---\n", encoding="utf-8")
|
||||||
|
args = argparse.Namespace(
|
||||||
|
agents=[["codex"]],
|
||||||
|
global_scope=False,
|
||||||
|
project=str(project),
|
||||||
|
yes=False,
|
||||||
|
)
|
||||||
|
stdin = Mock()
|
||||||
|
stdout = Mock()
|
||||||
|
stdin.isatty.return_value = True
|
||||||
|
stdout.isatty.return_value = True
|
||||||
|
installed: list[str] = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cli.sys, "stdin", stdin),
|
||||||
|
patch.object(cli.sys, "stdout", stdout),
|
||||||
|
patch.object(cli, "ensure_skills_home"),
|
||||||
|
patch.object(cli, "list_owned_skills", return_value=["owned-one"]),
|
||||||
|
patch.object(cli, "skill_description", return_value="owned"),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"load_registry",
|
||||||
|
return_value={
|
||||||
|
"external-one": {
|
||||||
|
"repo": "https://example.test/skills.git",
|
||||||
|
"ref": "main",
|
||||||
|
"path": "external-one",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch.object(cli, "_registry_skill_names", return_value=["external-one"]),
|
||||||
|
patch.object(cli, "external_skill_path", return_value=external),
|
||||||
|
patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"_is_fully_installed",
|
||||||
|
side_effect=lambda name, expected, project_root, targets: name == "owned-one",
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"select_skills",
|
||||||
|
return_value={"owned-one", "external-one"},
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"_install_skill",
|
||||||
|
side_effect=lambda name, targets, project_root, **kwargs: installed.append(name),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
cli.cmd_select(args)
|
||||||
|
|
||||||
|
manifest = (project / ".skills.yaml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(installed, ["external-one"])
|
||||||
|
self.assertIn("owned-one", manifest)
|
||||||
|
self.assertIn('name: "external-one"', manifest)
|
||||||
|
self.assertIn("source: registry", manifest)
|
||||||
|
self.assertIn("targets:", manifest)
|
||||||
|
self.assertIn("codex", manifest)
|
||||||
|
|
||||||
|
def test_select_rejects_invalid_registry_name_before_rendering(self) -> None:
|
||||||
|
args = argparse.Namespace(
|
||||||
|
agents=None,
|
||||||
|
global_scope=True,
|
||||||
|
project=None,
|
||||||
|
yes=False,
|
||||||
|
)
|
||||||
|
stdin = Mock()
|
||||||
|
stdout = Mock()
|
||||||
|
stdin.isatty.return_value = True
|
||||||
|
stdout.isatty.return_value = True
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cli.sys, "stdin", stdin),
|
||||||
|
patch.object(cli.sys, "stdout", stdout),
|
||||||
|
patch.object(cli, "ensure_skills_home"),
|
||||||
|
patch.object(cli, "list_owned_skills", return_value=[]),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"load_registry",
|
||||||
|
return_value={"../../victim": {"repo": "https://example.test/repo.git"}},
|
||||||
|
),
|
||||||
|
patch.object(cli, "select_skills") as selector,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(SystemExit, "skill 名称无效"):
|
||||||
|
cli.cmd_select(args)
|
||||||
|
|
||||||
|
selector.assert_not_called()
|
||||||
|
|
||||||
|
def test_select_expands_registry_collection_choices(self) -> None:
|
||||||
|
args = argparse.Namespace(
|
||||||
|
agents=[["codex"]],
|
||||||
|
global_scope=True,
|
||||||
|
project=None,
|
||||||
|
yes=False,
|
||||||
|
)
|
||||||
|
stdin = Mock()
|
||||||
|
stdout = Mock()
|
||||||
|
stdin.isatty.return_value = True
|
||||||
|
stdout.isatty.return_value = True
|
||||||
|
selected_choices: list[SkillChoice] = []
|
||||||
|
installed: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
|
def choose(choices: list[SkillChoice]) -> set[str]:
|
||||||
|
selected_choices.extend(choices)
|
||||||
|
return {"waza/think"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cli.sys, "stdin", stdin),
|
||||||
|
patch.object(cli.sys, "stdout", stdout),
|
||||||
|
patch.object(cli, "ensure_skills_home"),
|
||||||
|
patch.object(cli, "list_owned_skills", return_value=[]),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"load_registry",
|
||||||
|
return_value={
|
||||||
|
"waza": {
|
||||||
|
"repo": "https://example.test/waza.git",
|
||||||
|
"ref": "main",
|
||||||
|
"path": "skills",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"_registry_skill_names",
|
||||||
|
return_value=["think", "ui"],
|
||||||
|
),
|
||||||
|
patch.object(cli, "_list_fully_installed_names", return_value=[]),
|
||||||
|
patch.object(cli, "_is_fully_installed", return_value=False),
|
||||||
|
patch.object(cli, "select_skills", side_effect=choose),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"_install_skill",
|
||||||
|
side_effect=lambda name, targets, project_root, **kwargs: installed.append(
|
||||||
|
(name, kwargs.get("source"))
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
cli.cmd_select(args)
|
||||||
|
|
||||||
|
self.assertEqual([choice.name for choice in selected_choices], ["waza/think", "waza/ui"])
|
||||||
|
self.assertEqual(installed, [("think", "registry:waza")])
|
||||||
|
|
||||||
|
def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
skill = root / "source"
|
||||||
|
skill.mkdir()
|
||||||
|
skill.joinpath("SKILL.md").write_text("---\\n", encoding="utf-8")
|
||||||
|
links = {
|
||||||
|
"cursor": root / "cursor" / "demo",
|
||||||
|
"claude": root / "claude" / "demo",
|
||||||
|
}
|
||||||
|
links["claude"].mkdir(parents=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cli, "_ensure_source_fetched"),
|
||||||
|
patch.object(cli, "resolve_skill_source", return_value=(skill, "owned")),
|
||||||
|
patch.object(
|
||||||
|
cli,
|
||||||
|
"agent_skill_dir",
|
||||||
|
side_effect=lambda target, project_root=None: links[target].parent,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
cli._install_skill("demo", ["cursor", "claude"])
|
||||||
|
|
||||||
|
self.assertFalse(links["cursor"].exists())
|
||||||
|
self.assertTrue(links["claude"].is_dir())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user