19 Commits

Author SHA1 Message Date
laily 983183d5b3 feat: support registry collections and docker publishing 2026-07-27 23:25:46 +08:00
laily b78126830b merge: custom skill sources
# Conflicts:
#	skiff/README.md
#	skiff/cli.py
#	skiff/project.py
2026-07-27 18:11:31 +08:00
laily 3411e54b13 feat: support custom skill sources 2026-07-27 18:09:13 +08:00
laily b9963b66de feat: add interactive skill selector 2026-07-27 18:07:39 +08:00
laily 86cd1fa36d feat: add skill draft workflow 2026-07-27 14:03:19 +08:00
laily 8b82539f6f fix: 复用 agent 2026-07-15 01:29:56 +08:00
laily b3765ecad6 feat(ack): enforce worker launch policy 2026-07-12 17:19:46 +08:00
laily ae05845ee7 docs(ack): add Codex model routing 2026-07-12 14:53:50 +08:00
laily af823e867f feat: add init-new-project 2026-07-07 09:50:04 +08:00
laily 61ef55c514 feat: modify ack 2026-07-07 09:44:55 +08:00
laily d11f90fd66 feat(ack): worker 模型固定与 worktree 决策说明 (v0.6.2) 2026-07-07 01:45:55 +08:00
laily f33992a0aa feat: rename 2026-07-07 01:33:48 +08:00
laily 6bd03c2f6d feat: 2026-07-07 00:53:54 +08:00
laily d7a74578dc refactor(agent-collaboration-kit): split stable core from project overrides
- Reorganize flat files into core/ templates/ examples/ scripts/ with VERSION
- Deduplicate: single-source state machine, three-round policy, acceptance signals
- Abstract orchestration in closed-loop.md with manual mode; move Orca commands to orca-adapter.md
- Add tasks.schema.json + validate_tasks.py (jsonschema or builtin rules, no hard deps)
- Add filled examples, concurrency write rule, kitVersion tracking; unify to Chinese

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:01:09 +08:00
laily 58de2650c2 docs: separate agent collaboration kit from skills
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:32:50 +08:00
laily c4c68572c3 feat: add agent-collaboration-kit 2026-07-06 09:24:22 +08:00
laily f1ed320991 feat(skiff): align CLI with Vercel skills and fix entry symlink
Replace install/uninstall with add/remove, add publish for git ops in
~/.skills, and resolve bin/skiff symlinks so commands work from any cwd.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:11:53 +08:00
laily 9a3285fba6 Merge pull request 'feat: add skiff' (#2) from feat/discussion-notes into main
Reviewed-on: laily/.skills#2
2026-07-04 10:52:53 +08:00
laily af0efc7870 Merge pull request 'feat: add discussion-notes skill' (#1) from feat/discussion-notes into main
Reviewed-on: laily/.skills#1
2026-07-03 00:36:22 +08:00
46 changed files with 5544 additions and 247 deletions
+87 -33
View File
@@ -1,12 +1,12 @@
# 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 内容与规范 |
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | 安装、symlink、健康检查 |
| 仓库 | 地址 | 职责 |
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
| **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) | skiff CLI 的独立来源 / 上游同步参考 |
---
@@ -15,13 +15,12 @@
```bash
# 1. 克隆并关联
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
git clone https://git.yumee.top/laily/skiff.git ~/code/gitea/skiff
cd ~/code/gitea/skiff && ./install.sh
skiff setup ~/code/gitea/skills
git clone https://git.yumee.top/laily/skills.git ~/.skills
cd ~/.skills && ./install.sh
# 2. 全局安装 skill
skiff install declarative-openspec-loop
# install.sh 会自动把 skiff 项目 skill 安装到所有 Agent
# 2. 安装其他 skill
skiff add declarative-openspec-loop -g
# 3. 查看状态
skiff list
@@ -35,18 +34,22 @@ skiff status
```
skills/
├── _template/ # 新建 skill 的脚手架
├── declarative-openspec-loop/ # 自研 skill
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
│ ├── SKILL.md
│ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md
│ └── reference.md
kits/
└── agent-collaboration-kit/ # 可复制到项目的协作规范包,不是 skill
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 来源目录
AGENTS.md # 本文档
```
**本仓库包含**:`skills/`、`registry.yaml`、`AGENTS.md`
**本仓库不包含**:CLI 代码、各项目的 skill 启用清单
**本仓库包含**:`skills/`、`kits/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md`
**本仓库不包含**:各项目的 skill 启用清单
---
@@ -57,12 +60,28 @@ AGENTS.md # 本文档
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
新建 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)
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
@@ -78,6 +97,28 @@ skiff fetch 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)
推荐使用 Vercel CLI 安装第三方 skill:
@@ -94,7 +135,8 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
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/<name>/ ←── skiff install / enable
kits/<name>/ ←── 手动复制 / 项目文档引用
skiff/ ←── python3 -m skiff
registry.yaml ←── skiff add / fetch
↑
~/.skills(symlink)
@@ -121,8 +165,16 @@ registry.yaml ←── skiff add / fetch
| ---------------- | ---------------------------------- | ------------------------------- |
| **Owned** | `skills/<name>/` | 本仓库 commit |
| **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` |
### 非 Skill 资料分类
| 类型 | 位置 | 维护方式 |
| --- | --- | --- |
| **Kit** | `kits/<name>/` | 本仓库 commit;复制到目标项目 `docs/` 或由项目 `AGENTS.md` 引用 |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
### 多 Agent 路径
@@ -147,6 +199,8 @@ skills:
- name: superpowers
source: registry
ref: main
- name: internal-review
source: company
targets: # 可选,默认 all
- cursor
@@ -163,7 +217,7 @@ targets: # 可选,默认 all
| `skiff sync` | `npm ci` |
项目级命令(skiff 待实现):
项目级命令:
```bash
cd ~/code/my-app
@@ -183,7 +237,7 @@ skiff sync
| 命令 | 说明 |
| -------------------------------------- | ------------------- |
| `skiff setup <path>` | 关联 `~/.skills` 到本仓库 |
| `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent |
| `skiff list` | 列出所有 skill |
| `skiff status` | 安装状态总览 |
| `skiff install <name>` | 全局安装(symlink) |
@@ -191,15 +245,15 @@ skiff sync
| `skiff add / fetch / install-external` | 外部 Git skill |
### 待实现
### 草稿与健康检查
| 命令 | 说明 |
| ------------------------ | --------------------------- |
| `skiff enable / disable` | 项目级启用/关闭 |
| `skiff sync` | 按 `.skills.yaml` 重建 symlink |
| `skiff create` | 从 `_template/` 脚手架创建 skill |
| `skiff doctor` | symlink 健康检查 |
| 命令 | 说明 |
| --- | --- |
| `skiff create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
| `skiff check <name>` | 校验草稿或正式 skill |
| `skiff finalize <name>` | 校验并将草稿转为正式 skill |
| `skiff doctor [--fix]` | symlink 健康检查与修复 |
---
@@ -234,11 +288,12 @@ description: >-
### 新建流程
1. `cp -r skills/_template skills/my-skill`
2. 编辑 `skills/my-skill/SKILL.md`
3. `skiff install my-skill --target cursor` 验证
4. 在本仓库 commit
5. 各项目 `skiff enable my-skill`(待实现)
1. `skiff create my-skill --idea "..." --from-project .`
2. Agent 编辑 `~/.skills/.drafts/my-skill/SKILL.md`
3. `skiff check my-skill`
4. 用户确认后执行 `skiff finalize my-skill`
5. `skiff add my-skill -a cursor -g -y` 验证
6. 在本仓库 commit;需要的项目再用 `skiff add my-skill` 启用
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
@@ -265,7 +320,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| -------------- | ----------------------------------------------------- |
| Cursor / Codex | symlink,正常 |
| 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` | 任意 |
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 |
| 首次安装 | `git clone <repo> ~/.skills && ~/.skills/install.sh` | 任意 |
| 新建 skill | `skiff create` → Agent 完善 → `check/finalize` | 任意项目 |
| 全局启用 | `skiff install <name>` | 任意 |
| 项目启用 | `skiff enable <name>` | 项目目录 |
| 看状态 | `skiff status` | 任意 |
@@ -306,4 +361,3 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
- [skills.sh](https://skills.sh)
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
+26 -9
View File
@@ -5,12 +5,12 @@
## 快速开始
```bash
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
cd ~/code/gitea/skills
./install.sh
skiff setup ~/code/gitea/skills
git clone https://git.yumee.top/laily/skills.git ~/.skills
cd ~/.skills
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
skiff install declarative-openspec-loop
skiff select # 交互式选择并批量安装
skiff list
skiff status
```
@@ -18,7 +18,8 @@ skiff status
## 仓库结构
```
skills/ # 自研 skill(SSOT)
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
kits/ # 可复制到项目的规范包 / 模板包,不是 Agent Skill
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 目录
@@ -28,6 +29,7 @@ AGENTS.md # 详细规范与架构说明
| 路径 | 说明 |
|------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
| [kits/](kits/) | 项目规范包、协作模板、可复制资料,不由 skiff 安装 |
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
@@ -36,17 +38,30 @@ AGENTS.md # 详细规范与架构说明
| Skill | 说明 |
|-------|------|
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [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:
```bash
skiff create my-skill # 从 _template/ 脚手架创建
# 编辑 skills/my-skill/SKILL.md
skiff install my-skill # 全局安装验证
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
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
├── kits/<name>/ ←── 手动复制 / 项目文档引用
├── registry.yaml ←── skiff add / fetch
└── skiff/ ←── python3 -m skiff
↑
@@ -117,7 +133,8 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
2. **项目自治** — 各项目自行维护 `.skills.yaml`
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
4. **规范包分离** — 非 skill 的流程规范、模板包放在 `kits/<name>/`,不混入 `skills/`
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
## 文档
+10 -1
View File
@@ -1,5 +1,14 @@
#!/usr/bin/env bash
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}"
exec python3 -m skiff "$@"
+2
View File
@@ -9,6 +9,8 @@ mkdir -p "$BIN_DIR"
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff"
echo "已安装 skiff -> $BIN_DIR/skiff"
PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}" python3 -m skiff bootstrap
path_already_configured() {
local file="$1"
[[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file"
+175
View 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 独立复测并终检;每个问题最多修三轮,三轮仍不过就记录为遗留,然后继续下一个。
```
+1
View File
@@ -0,0 +1 @@
0.8.1
+71
View File
@@ -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`。
+198
View File
@@ -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>"
```
+97
View File
@@ -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`。
+122
View File
@@ -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 是手。脑用最强的模型且不做机械测试,眼和手用便宜模型,只有常规闭环卡住时才升级。
+150
View File
@@ -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 未处理?
答不清楚,闭环就还没结束。
+243
View File
@@ -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`。
+187
View File
@@ -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>
```
+149
View File
@@ -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`。
- 用户可见行为符合验收标准。
+79
View File
@@ -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` 并继续。
- 不提交或推送,除非用户明确要求。
+115
View File
@@ -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; 需重新设计并发模型"
+183
View File
@@ -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;不要覆盖已有文件,不要提交或推送,完成后报告创建文件、校验结果和下一步。
```
+167
View File
@@ -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())
+172
View File
@@ -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())
+84
View File
@@ -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` 并继续下一个。
- 不提交或推送,除非用户明确要求。
+132
View File
@@ -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"]
}
}
]
}
}
}
+69
View File
@@ -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
View File
@@ -4,14 +4,28 @@
# repo: <git-url>
# ref: <branch|tag> (default: main)
# path: <subpath> (default: .)
# description: <text> (optional, shown by `skiff select`)
# tags: (optional)
# - <tag>
#
# Example:
# superpowers:
# repo: https://github.com/obra/superpowers
# Single-skill example (`path` contains SKILL.md):
# example-skills:
# repo: https://github.com/example/skills
# ref: main
# path: .
#
# Collection example (`path` contains skill directories):
# example-collection:
# repo: https://github.com/example/skill-collection
# ref: main
# path: skills
superpowers:
repo: https://github.com/obra/superpowers
ref: main
path: .
waza:
repo: https://github.com/tw93/Waza.git
ref: main
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
View File
@@ -9,41 +9,74 @@ cd /path/to/skills # 本仓库根目录
./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
PYTHONPATH=/path/to/skills python3 -m skiff <command>
```
## 首次配置
## 首次安装
```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 bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
### 全局安装(自研 skill)
| 命令 | 说明 |
|------|------|
| `skiff install <name> [--target all]` | 软链到 Agent 全局目录 |
| `skiff uninstall <name> [--target all]` | 移除软链 |
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
| `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 fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` |
| `skiff install-external <name> [--target all]` | 安装外部 skill 到 Agent 目录 |
| `skiff registry add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
| `skiff fetch <name>` | 克隆或更新外部仓库缓存 |
| `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` 自动修复 |
## 常用工作流
@@ -89,18 +181,20 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
### 新建并全局启用自研 skill
```bash
skiff create my-skill
# 编辑 skills/my-skill/SKILL.md
skiff install my-skill --target cursor
skiff doctor --target cursor
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
skiff check my-skill
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
```bash
cd ~/code/my-app
skiff enable declarative-openspec-loop --target cursor
skiff sync
skiff add declarative-openspec-loop -a cursor -y
```
### 安装外部 Git skill
@@ -121,6 +215,7 @@ skiff/
├── paths.py # 路径常量与 Agent 目标
├── skills.py # 自研 skill 发现
├── registry.py # registry.yaml 读写
├── sources.py # custom source 配置、发现与 Git 管理
├── project.py # .skills.yaml 管理
├── symlinks.py # 软链创建/检查/修复
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
@@ -135,7 +230,9 @@ skiff/
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 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
View File
@@ -1,3 +1,3 @@
"""skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.1.0"
__version__ = "0.5.0"
+40
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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
View File
@@ -8,8 +8,11 @@ HOME = Path.home()
SKILLS_HOME = HOME / ".skills"
SKILLS_DIR = SKILLS_HOME / "skills"
TEMPLATE_DIR = SKILLS_DIR / "_template"
DRAFTS_DIR = SKILLS_HOME / ".drafts"
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
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"
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:
if not SKILLS_HOME.is_dir():
raise SystemExit(
f"~/.skills 未配置。请先运行: skiff setup <skills-repo-path>"
"~/.skills 不存在。请将 skills 仓库克隆到 ~/.skills"
)
+9 -4
View File
@@ -65,13 +65,16 @@ def add_skill_to_manifest(
file_path = path
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}
if 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]
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]:
name = entry["name"]
source = entry.get("source", "owned")
if source == "registry" and entry.get("registry"):
source = f"registry:{entry['registry']}"
return resolve_skill_source(name, source=source)
+76 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
from pathlib import Path
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")
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
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, {})
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]
+113
View File
@@ -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
View File
@@ -2,10 +2,16 @@
from __future__ import annotations
import re
from pathlib import Path
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]:
@@ -30,29 +36,142 @@ def owned_skill_path(name: str) -> 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]:
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
ensure_skills_home()
name, source = split_skill_spec(name, source)
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"
registry = load_registry()
if source in (None, "registry") and name in registry:
path = external_skill_path(name, registry[name])
if not path.exists():
raise SystemExit(
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
)
return path, "external"
sources = load_sources()
registry_collection = None
if source and source.startswith("registry:"):
registry_collection = source.split(":", 1)[1]
elif (
source
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():
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}")
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:
import re
@@ -62,3 +181,68 @@ def validate_skill_name(name: str) -> None:
)
if name == "_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
+106
View File
@@ -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
View File
@@ -236,11 +236,12 @@ def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
lines: list[str] = []
first = True
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
if isinstance(value, (dict, list)):
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())
else:
lines.append(f"{prefix}{key}: {_scalar(value)}")
+9 -1
View File
@@ -1,7 +1,7 @@
---
name: skill-name
description: >-
简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically.
简要描述 skill 做什么、何时触发。写入用户可能使用的触发表达,让 Agent 能自动匹配。
---
# Skill 名称
@@ -15,6 +15,10 @@ description: >-
- 触发场景 1
- 触发场景 2
## 不适用
- 不应触发的相近场景
---
## 步骤
@@ -28,3 +32,7 @@ description: >-
## 注意事项
- 约束或边界条件
## 验证
- 说明如何确认 skill 的执行结果正确
+64
View File
@@ -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
View File
@@ -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
+166
View File
@@ -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 版本
+159
View File
@@ -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()
+233
View File
@@ -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()
+43
View File
@@ -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()
+27
View File
@@ -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()
+179
View File
@@ -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()
+300
View File
@@ -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()