26 Commits

Author SHA1 Message Date
laily 076d87e303 refactor: fold ack kit into skill 2026-07-30 11:54:02 +08:00
laily e308d6a04c feat: add reusable deb upload script 2026-07-30 10:19:28 +08:00
laily e667f58bf1 add README guidance to skill workflow 2026-07-29 21:08:04 +08:00
laily 262c138bee feat: add shallow fetch and grouped skill selection 2026-07-29 10:57:53 +08:00
laily 18ebb70911 feat: add self-update and improve skill selector 2026-07-29 10:29:48 +08:00
laily afbdde157f feat: add explicit ack workflow skill 2026-07-28 17:08:33 +08:00
laily 5f90758e22 feat: add kit project initializer 2026-07-28 16:41:41 +08:00
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
56 changed files with 6946 additions and 250 deletions
+76 -31
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,21 @@ skiff status
```
skills/
├── _template/ # 新建 skill 的脚手架
├── declarative-openspec-loop/ # 自研 skill
├── ack/ # 完整 ACK skill,含 references/templates/scripts
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
│ ├── SKILL.md
│ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md
│ └── reference.md
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 来源目录
AGENTS.md # 本文档
```
**本仓库包含**:`skills/`、`registry.yaml`、`AGENTS.md`
**本仓库不包含**:CLI 代码、各项目的 skill 启用清单
**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md`
**本仓库不包含**:各项目的 skill 启用清单
---
@@ -57,12 +59,22 @@ AGENTS.md # 本文档
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
| [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。
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
项目状态,不复制或链接 Skill 内容:
```bash
skiff init ack
```
### 外部(External Git)
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
@@ -78,6 +90,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 +128,8 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **仓库分离** — skill 内容与 CLI 工具分仓库,互不影响更新
4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
5. **一体维护** — skill 与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
---
@@ -103,6 +138,7 @@ npx skills find typescript
```
skills 仓库(本仓库) skiff CLI
skills/<name>/ ←── skiff install / enable
skiff/ ←── python3 -m skiff
registry.yaml ←── skiff add / fetch
↑
~/.skills(symlink)
@@ -121,8 +157,15 @@ 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 资料分类
| 类型 | 位置 | 维护方式 |
| --- | --- | --- |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
### 多 Agent 路径
@@ -147,6 +190,8 @@ skills:
- name: superpowers
source: registry
ref: main
- name: internal-review
source: company
targets: # 可选,默认 all
- cursor
@@ -163,7 +208,7 @@ targets: # 可选,默认 all
| `skiff sync` | `npm ci` |
项目级命令(skiff 待实现):
项目级命令:
```bash
cd ~/code/my-app
@@ -183,7 +228,7 @@ skiff sync
| 命令 | 说明 |
| -------------------------------------- | ------------------- |
| `skiff setup <path>` | 关联 `~/.skills` 到本仓库 |
| `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent |
| `skiff list` | 列出所有 skill |
| `skiff status` | 安装状态总览 |
| `skiff install <name>` | 全局安装(symlink) |
@@ -191,15 +236,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 +279,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 +311,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 +334,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 +352,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)
+24 -10
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,7 @@ skiff status
## 仓库结构
```
skills/ # 自研 skill(SSOT)
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 目录
@@ -27,7 +27,7 @@ AGENTS.md # 详细规范与架构说明
| 路径 | 说明 |
|------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
@@ -36,17 +36,30 @@ AGENTS.md # 详细规范与架构说明
| Skill | 说明 |
|-------|------|
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 |
| [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 笔记 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
```bash
skiff init ack
skiff init ack --project ~/app
```
新建 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。
## 安装方式
### 全局(用户级)
@@ -117,7 +130,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 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
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"
+29 -6
View File
@@ -4,14 +4,37 @@
# 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
skills:
repo: ~/.skills
ref: main
path: .
path: skills
description: 本地自研 Agent Skills 集合,由 skiff 统一创建、维护和安装。
tags:
- skill-management
- owned
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
+126 -24
View File
@@ -9,41 +9,76 @@ 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 |
| `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `skiff init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
### 全局安装(自研 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 +92,69 @@ 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
取消。普通 `skiff select` 只安装到项目,并在每一项旁只读显示各 Agent 的
全局安装状态;`skiff select -g` 只安装到全局。已经安装到目标范围的 skill
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `skiff remove`。如果全局
存在同名但指向其它来源的 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 +176,9 @@ skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
| 命令 | 说明 |
|------|------|
| `skiff create <name>` | 从 `skills/_template/` 创建自研 skill |
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
| `skiff check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
| `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` |
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
## 常用工作流
@@ -89,18 +186,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 完善草稿中的 SKILL.md 和 README.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 +220,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 +235,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
+1086 -142
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"
)
+8 -3
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)
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)
+87 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
@@ -25,9 +26,93 @@ 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 registry_repo(entry: dict[str, Any]) -> str:
"""Expand a local home-relative repo while leaving remote URLs unchanged."""
repo = str(entry.get("repo", ""))
return str(Path(repo).expanduser()) if repo.startswith("~") else repo
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:
"""Use a local repo directly, otherwise return its external checkout."""
from skiff.paths import EXTERNALS_DIR
configured_repo = str(entry.get("repo", ""))
local_repo = Path(registry_repo(entry))
if configured_repo.startswith("~") and local_repo.is_dir():
return local_repo.resolve()
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]
+228
View File
@@ -0,0 +1,228 @@
"""Terminal multi-select UI for skills."""
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True)
class SkillChoice:
name: str
kind: str
description: str = ""
installed: bool = False
readonly_status: str = ""
children: tuple[str, ...] = ()
indent: int = 0
def fit_to_width(text: str, width: int) -> str:
"""Trim text to terminal display width without splitting wide characters."""
if width <= 0:
return ""
result: list[str] = []
used = 0
for char in text:
if unicodedata.combining(char):
char_width = 0
else:
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
if used + char_width > width:
break
result.append(char)
used += char_width
return "".join(result)
def display_width(text: str) -> int:
return sum(
0
if unicodedata.combining(char)
else 2
if unicodedata.east_asian_width(char) in {"W", "F"}
else 1
for char in text
)
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()
or needle in choice.readonly_status.casefold()
]
def select_skills(
choices: list[SkillChoice],
*,
scope_label: str = "当前作用域",
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 and not choice.children
}
def mark_for(choice: SkillChoice) -> str:
if not choice.children:
return "x" if choice.name in selected else " "
selected_children = selected.intersection(choice.children)
if len(selected_children) == len(choice.children):
return "x"
return "-" if selected_children else " "
def draw(stdscr: object) -> set[str] | None:
curses.curs_set(0)
stdscr.keypad(True)
current = 0
query = ""
source_attr = curses.A_NORMAL
status_attr = curses.A_NORMAL
description_attr = curses.A_DIM
try:
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
source_attr = curses.color_pair(1)
status_attr = curses.color_pair(2)
except curses.error:
pass
while True:
visible = filter_choices(choices, query)
current = min(current, max(0, len(visible) - 1))
stdscr.erase()
height, width = stdscr.getmaxyx()
text_width = max(0, width - 1)
stdscr.addnstr(
0,
0,
fit_to_width(f"安装目标: {scope_label}", text_width),
text_width,
)
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
if query:
stdscr.addnstr(
2,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
item_capacity = max(1, (height - 4) // 3)
start = max(0, current - item_capacity + 1)
for offset, choice in enumerate(visible[start : start + item_capacity]):
index = start + offset
row = 3 + offset * 3
mark = mark_for(choice)
description = f" {choice.description}" if choice.description else ""
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
parts = [
(f"{' ' * choice.indent}[{mark}] ", focus_attr),
(choice.name, focus_attr | curses.A_BOLD),
(f" {choice.kind}", focus_attr | source_attr),
]
if choice.readonly_status:
parts.append(
(f" {choice.readonly_status}", focus_attr | status_attr)
)
column = 0
for text, attr in parts:
available = text_width - column
if available <= 0:
break
fitted = fit_to_width(text, available)
stdscr.addnstr(row, column, fitted, available, attr)
column += display_width(fitted)
stdscr.addnstr(
row + 1,
0,
fit_to_width(description, text_width),
text_width,
focus_attr | description_attr,
)
footer = (
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
)
stdscr.addnstr(
height - 1,
0,
fit_to_width(footer, text_width),
text_width,
)
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:
choice = visible[current]
if choice.children:
children = set(choice.children)
if children.issubset(selected):
selected.difference_update(children)
else:
selected.update(children)
else:
selected.symmetric_difference_update({choice.name})
elif key == "/":
curses.curs_set(1)
query = _read_query(stdscr, curses, width, row=2)
curses.curs_set(0)
current = 0
runner = wrapper or curses.wrapper
return runner(draw)
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
query = ""
while True:
stdscr.move(row, 0)
stdscr.clrtoeol()
text_width = max(0, width - 1)
stdscr.addnstr(
row,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
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
+210 -10
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}"
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, "external"
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,84 @@ 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"
readme = skill_dir / "README.md"
if not skill_md.is_file():
return ["缺少 SKILL.md"]
if not readme.is_file():
issues.append("缺少 README.md(面向人类的使用说明)")
else:
readme_text = readme.read_text(encoding="utf-8")
if not readme_text.strip():
issues.append("README.md 为空")
if re.search(r"^#\s+skill-name\s*$", readme_text, re.MULTILINE):
issues.append("README.md 存在模板占位内容: skill-name")
for placeholder in (
"用一句话告诉使用者这个 skill 能解决什么问题",
"给出用户可以直接说出的典型请求",
"给出一条可以直接交给 Agent 的示例请求",
):
if placeholder in readme_text:
issues.append(f"README.md 存在模板占位内容: {placeholder}")
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
+219
View File
@@ -0,0 +1,219 @@
# Skill 来源模型
## 背景
skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
`registry` 外部仓库和用户配置的 custom source。
这些名称混合了不同维度:
- `owned` 表示所有权。
- `registry` 表示来源配置存放在哪里。
- `external` 表示内容不在当前仓库。
- `collection` 表示一个来源中包含多个 Skill。
它们不能作为同一层级的互斥类型。例如 custom source 既可能来自外部 Git 仓库,
也可能是 collection;因此 `custom` 与 `external` 并不互斥。
本文建议将用户可见的来源注册方式统一为三类:
- `builtin`:随当前 skiff 仓库提供的 Skill。
- `catalog:<name>`:由 skiff 自带目录预先登记的来源。
- `custom:<name>`:用户在本机显式配置的命名来源。
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
来源都可以包含一个或多个 Skill;除 builtin 外,catalog 和 custom 都可以使用
Git 仓库或本地目录。
## 当前模型
```mermaid
flowchart TD
S[skiff 可发现的 Skills]
S --> O["owned<br/>~/.skills/skills/*"]
S --> C["custom source<br/>~/.config/skiff/config.yaml"]
S --> R["registry<br/>registry.yaml"]
O --> O1["本仓库维护<br/>随 skiff 一起分发"]
C --> C1["本地目录<br/>--local PATH"]
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
R --> R1["外部单 Skill 仓库"]
R --> R2["外部 Skill Collection"]
R1 --> E["~/.local/share/skills/externals/"]
R2 --> E
```
当前实现中:
- `list` 和 `status` 支持 owned、registry 和 custom source。
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
- `.skills.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
## 推荐模型
```mermaid
flowchart TD
A[Skill Provider]
A --> B["builtin<br/>仓库内隐式注册"]
A --> C["catalog:waza<br/>skiff 预置目录"]
A --> D["custom:company<br/>用户本机配置"]
B --> B1["skills/ack"]
B --> B2["skills/skiff"]
B --> B3["skills/deb-publisher"]
C --> C1["Git 或本地目录"]
C1 --> C2["单 Skill"]
C1 --> C3["Collection"]
C3 --> C4["waza/think"]
C3 --> C5["waza/check"]
D --> D1["Git 或本地目录"]
D1 --> D2["单 Skill"]
D1 --> D3["Collection"]
D3 --> D4["company/security-review"]
D3 --> D5["company/release"]
```
### 来源注册方式
| 类型 | 含义 | 配置来源 | 用户界面展示 |
| --- | --- | --- | --- |
| `builtin` | 随当前 skiff 仓库提供 | `skills/` | `builtin` |
| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml`,迁移前为 `registry.yaml` | `catalog:<name>` |
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/skiff/config.yaml` | `custom:<name>` |
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
方式,而不是仓库的所有权关系。
`catalog` 比 `registry` 更准确:当前文件只是 skiff 随仓库维护的预置来源目录,
并不是远程注册中心,也不是一种 Skill 来源协议。
### 三个正交维度
每个 provider 应由三个维度描述:
```text
registration: builtin | catalog | custom
transport: bundled | local | git
layout: single | collection
```
custom source 示例:
```text
registration: custom
name: company
transport: local | git
layout: single | collection
```
catalog entry 示例:
```text
registration: catalog
name: waza
transport: local | git
layout: single | collection
```
`external` 只适合描述物理位置,可作为 catalog 和部分 custom provider 的统称,
不应成为与 builtin、custom 并列的持久化类型。
## `select` 展示
`skiff select` 应同时展示 builtin、catalog 和 custom Skill:
```text
[ ] ack builtin
[ ] deb-publisher builtin
[-] company custom source
[ ] company/release custom:company
[ ] company/security-review custom:company
[-] waza catalog source
[ ] waza/check catalog:waza
[ ] waza/think catalog:waza
```
建议遵循以下规则:
- builtin Skill 直接显示 Skill 名称。
- catalog 和 custom collection 都使用来源名称作为父节点。
- catalog 和 custom Skill 都使用 `<source>/<skill>` 作为选择器键。
- 单 Skill 来源可以省略父节点,但仍显示注册方式和来源名称。
- 不同来源安装后名称相同时,继续拒绝同时选择,避免覆盖同一安装路径。
## 配置表示
新写入的 `.skills.yaml` 使用以下形式:
```yaml
skills:
- name: ack
source: builtin
- name: security-review
source: company
- name: think
source: catalog:waza
```
custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以
独立配置仓库地址,而项目只依赖稳定的来源名称。
## 兼容迁移
这是一次用户可见术语调整,应提供兼容层,避免已有项目立即失效:
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
`custom:<name>`。
2. 新生成的 `.skills.yaml` 对内置 Skill 写入 `source: builtin`。
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
`catalog:<name>`。
6. `registry.yaml` 可以先保留文件名,仅将用户界面术语改为 catalog;单独迁移为
`catalog.yaml` 时,应兼容读取旧文件。
7. custom source 的逻辑名称和现有 `config.yaml` 结构保持不变。
8. 将 `builtin`、`catalog` 和兼容别名 `owned`、`registry` 设为 custom source
保留字。
9. `select` 同步接入 custom Skill,并让 custom collection 与 catalog collection
使用相同的父子展示逻辑。
## 影响范围
实施时预计涉及:
- `skiff/skills.py`:来源解析、归一化和 builtin 命名。
- `skiff/project.py`:manifest 默认值、序列化与旧值兼容。
- `skiff/sources.py`:来源保留字。
- `skiff/registry.py`:逐步重命名为 catalog 概念。
- `skiff/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
- `skiff/selector.py`:统一 catalog/custom collection 的父子展示。
- CLI 与来源解析测试。
- 根 README、`skiff/README.md`、`skills/skiff/SKILL.md` 和相关示例。
## 验证要求
实施完成后至少覆盖:
- builtin Skill 可以通过新名称列出、选择、安装和写入 manifest。
- 旧的 `source: owned` manifest 仍可同步。
- custom local source 和 custom Git source 都出现在 selector 中。
- catalog 单 Skill 和 Collection 都保持可选择。
- custom 单 Skill 和 Collection 都保持可选择。
- 多来源同名 Skill 必须显式指定来源。
- 两个选择最终安装为同一个名称时,selector 拒绝冲突。
- `list`、`status`、`select` 对同一来源使用一致术语。
## 设计前提
本方案假设 `registry.yaml` 当前的真实职责是维护 skiff 预置的来源目录,而不是提供
远程发布、版本解析或可信签名等注册中心能力。因此推荐逐步将用户可见概念改为
`catalog`。如果未来实现真正的远程 registry,再单独定义其协议和与 catalog 的同步
关系,不复用当前含义模糊的名称。
+116
View File
@@ -0,0 +1,116 @@
"""命名 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",
"--depth",
"1",
"--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)}")
+28
View File
@@ -0,0 +1,28 @@
# skill-name
用一句话告诉使用者这个 skill 能解决什么问题。
## 什么时候使用
- 给出用户可以直接说出的典型请求。
- 说明适用范围。
## 使用前准备
- 列出需要用户提前提供或确认的信息、权限和依赖。
## 使用示例
```text
给出一条可以直接交给 Agent 的示例请求。
```
## Agent 会做什么
1. 用面向使用者的语言概括主要步骤。
2. 说明可能发生的外部写操作或重要边界。
## 如何判断完成
- 说明使用者最终会得到什么,以及如何确认结果正确。
+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 的执行结果正确
+85
View File
@@ -0,0 +1,85 @@
# ACK
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
- Coordinator 拆解需求、派发任务并终检。
- Developer 实现并执行白盒验证。
- Test 独立执行黑盒复测。
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
`leftover`,然后继续处理其它任务。
## 安装
全局安装:
```bash
skiff add ack -g
```
或只安装到当前项目:
```bash
skiff add ack
```
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
## 初始化项目
```bash
skiff init ack
skiff init ack --project ~/code/my-app
```
初始化后,项目只保存自己的 ACK 状态:
```text
docs/ack/
├── project.md
└── tasks.yaml
```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
目录读取。
## Skill 结构
```text
skills/ack/
├── SKILL.md
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md 与 tasks.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # tasks.yaml 与 worker 命令校验器
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`docs/ack/tasks.yaml` 保存当前任务状态。
## 检查任务板
Agent 会从当前 ACK Skill 目录解析校验脚本:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
## 开始一个需求
初始化完成后可以直接说:
```text
/ack 处理这个需求:<一句话需求>
```
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中记录 `ackVersion`。旧项目的
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
+98
View File
@@ -0,0 +1,98 @@
---
name: ack
description: >-
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或指挥 Coordinator/Developer/Test
工作时使用。
---
# ACK 项目协作入口
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md` 和
`tasks.yaml`,不要复制或链接 Skill 内容。
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
脚本都相对此目录访问,不依赖固定的全局安装路径。
## 选择模式
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
或其它 Agent 指令文件。
## 初始化
1. 确认 `skiff` 可执行,并检查 `<project>/docs/ack` 是否存在。
2. 不存在时执行:
```bash
skiff init ack --project <project-root>
```
该命令从本 Skill 的 `templates/` 生成项目状态,不会在项目中创建 Skill
软链接或资源副本。
3. 如果 `docs/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”,报告缺失项并
只补用户授权且能安全确定的内容。
4. 读取项目的公开配置和文档,例如 README、语言清单、包管理清单、测试配置与
CI,确定项目名、技术栈、源码/规格/测试路径及真实可执行命令。
5. 完善 `docs/ack/project.md`:
- 用实际项目值替换全部占位符。
- 无服务地址时把 Base URL 写为 `n/a`,不要虚构端口。
- 无法从项目证据确定的命令写为 `n/a`,并在结果中列为待配置项。
- 只写项目差异,不复制 `references/` 中的通用规范。
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
7. 更新 `updatedAt`,并运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
8. 检查 `project.md` 与 `tasks.yaml` 是否仍有 `<...>` 占位符。结构校验通过且
必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出缺失值。
9. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
不推送。
## 检查
1. 检查以下路径:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
`ackVersion` 时仍可读取,但建议迁移为 `ackVersion`。
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
黑盒命令和 Base URL。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板。只报告证据明确的
问题,不因可选字段缺失而宣称失败。
5. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
## 工作
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
2. 依次读取:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- `<ack-skill-dir>/references/kickoff.md`
- kickoff 指定且与当前任务相关的 references 文件
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
worker 复用规则。项目覆盖层优先于通用示例命令。
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
然后交给用户确认;确认前不派发实现。
5. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。
6. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
`leftover` 并继续其它任务。
## 边界
- 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
- 不猜测项目命令、服务地址、worker handle 或模型名称。
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
- 项目只保存 `docs/ack/project.md` 和 `docs/ack/tasks.yaml`;通用资源始终从当前
ACK Skill 目录读取。
+1
View File
@@ -0,0 +1 @@
0.8.1
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "ACK"
short_description: "初始化、检查并运行项目里的 ACK 三角色协作闭环"
default_prompt: "Use $ack to initialize ACK for this project or coordinate work from docs/ack."
policy:
allow_implicit_invocation: false
+75
View File
@@ -0,0 +1,75 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.8.1。
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> `docs/ack/` 只保存 `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`
## 稳定规范(引用,不重复)
- 开始一个需求(启动手册):`references/kickoff.md`
- 角色 / 权限 / 状态机 / 完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`references/closed-loop.md`
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/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
```
任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
## 硬规则(其余见 references/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/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"
ackVersion: "0.8.1"
project:
name: "notes-web"
repoPath: "/home/dev/notes-web"
baseUrl: "http://localhost:5173"
devWorktree: "/home/dev/notes-web-wt/fix-preview"
summary:
verified: ["BUG-002"]
open: []
failedRetest: []
leftovers: ["BUG-003"]
tasks:
- id: "BUG-002"
type: "bug"
title: "预览变更点击后不显示 diff 行"
priority: "P0"
status: "verified"
assignee: "developer"
component: "web/app/fix/page.tsx"
specRefs:
- "docs/spec/fix-preview.md#preview"
testRefs:
- "tests/browser/cases/01-preview.md"
description: >
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
stepsToReproduce:
- "打开 /fix?fileId=1"
- "搜索并选择候选项"
- "点击“预览变更”"
expected: >
确认区出现 title、修改前值、修改后值、coverChanged 提示四行。
actual: >
API 返回 code=0,但页面确认区为空。
evidence:
browser: "确认区 DOM 为空,无 diff 行"
api: "POST /api/fix/preview -> code=0, 含 diff 字段"
logs: "无报错"
verification:
commands:
- "npm run build"
- "curl -s -X POST localhost:5173/api/fix/preview -d @fixtures/preview.json"
browser:
page: "/fix?fileId=1"
checks:
- "预览后出现 4 行 diff:title/before/after/coverChanged"
dispatch:
taskId: "orca-task-88"
dispatchId: "orca-disp-91"
worker: "dev-worker-1"
rounds:
- round: 1
result: failed
evidence: "只渲染了 title,缺少 before/after/coverChanged"
- round: 2
result: passed
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
resolution:
fixedBy: "dev-worker-1"
verifiedBy: "test-worker-1"
verifiedAt: "2026-07-06T09:38:00+08:00"
leftoverReason: null
- id: "BUG-003"
type: "bug"
title: "并发保存偶发覆盖他人修改"
priority: "P1"
status: "leftover"
assignee: "developer"
component: "server/store/notes.go"
specRefs:
- "docs/spec/concurrency.md"
testRefs: []
description: >
两个会话同时保存同一条 note 时,后写覆盖先写,无冲突提示。
stepsToReproduce:
- "会话 A、B 同时打开 note 42"
- "A 保存,B 保存"
expected: >
B 保存时应检测到版本变化并提示冲突,而非静默覆盖。
actual: >
B 直接覆盖 A 的修改,无提示。
evidence:
api: "两次 PUT /api/notes/42 均返回 200,无版本校验"
verification:
commands:
- "go test ./server/store/..."
browser:
page: "/notes/42"
checks:
- "并发保存时后写方收到冲突提示"
dispatch:
taskId: "orca-task-90"
dispatchId: "orca-disp-95"
worker: "dev-worker-1"
rounds:
- round: 1
result: failed
evidence: "加了版本号但未在写入路径校验"
- round: 2
result: failed
evidence: "校验只覆盖 API,未覆盖批量导入路径"
- round: 3
result: failed
evidence: "乐观锁与前端重试逻辑冲突,需重新设计"
resolution:
fixedBy: null
verifiedBy: null
verifiedAt: null
leftoverReason: "failed after 3 supervised developer rounds; 需重新设计并发模型"
@@ -0,0 +1,51 @@
# ACK 接入清单
## 安装与初始化
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `skiff init ack --project <project-root>`。
- [ ] `docs/ack/` 只包含项目自己的 `project.md` 与 `tasks.yaml`。
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
## 项目覆盖层
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
## 路径权限
- [ ] Coordinator 可写规格和任务板。
- [ ] Test 可写集成测试与复测记录,但不能改源码。
- [ ] Developer 可写源码与单元测试,但不能改规格或黑盒验收。
- [ ] 私有配置只读且不提交。
- [ ] `tasks.yaml` 只有 Coordinator 写。
## 任务板
- [ ] 已替换项目名、仓库路径、Base URL、worktree 和覆盖层路径。
- [ ] 没有真实任务时使用 `tasks: []`。
- [ ] 真实任务的验收是可观测信号。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
## 编排
- [ ] 已选择 Orca 或手动模式。
- [ ] 派发前优先复用同 worktree、同角色、同配置的空闲 worker。
- [ ] Developer 与 Test 的启动命令通过校验。
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml`。
- [ ] Test 使用的服务来自正确 worktree。
## 闭环
- [ ] `worker_done` 不直接视为完成。
- [ ] Test 独立复测后由 Coordinator 读取证据终检。
- [ ] 每轮写入 `dispatch.rounds`。
- [ ] 三轮失败后标记 `leftover` 并填写原因。
- [ ] `leftover` 不阻塞其它任务。
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
或 `scripts/`,并更新 `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>"
```
+92
View File
@@ -0,0 +1,92 @@
# 在新项目初始化 ACK
本文件说明如何把 ACK 的项目状态初始化到目标项目。ACK Skill 自身已经通过 Agent
的 Skill 机制安装;项目不复制、不链接 Skill 内容。
## 前提与边界
开始前确认:
1. 目标项目根目录。
2. ACK Skill 已全局安装或安装到当前项目。
3. `skiff` 命令可用。
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、`AGENTS.md` 或其它
Agent 指令文件。不要把 token、`.env` 内容或其它私有配置写入 ACK 项目状态。
## 初始化
在目标项目执行:
```bash
skiff init ack
```
或显式指定项目:
```bash
skiff init ack --project <project-root>
```
命令从 ACK Skill 自带的 `templates/` 生成:
```text
docs/ack/
├── project.md
└── tasks.yaml
```
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
`framework` 或其它指向 Skill 的软链接。
## 完善项目覆盖层
编辑 `docs/ack/project.md`,填入:
- 项目名、技术栈、运行命令和 Base URL。
- Coordinator、Developer、Test 的实际模型档位。
- 规格、集成测试、源码、单元测试和私有配置的路径权限。
- Developer 白盒验证命令。
- Test 黑盒复测命令。
无法从项目证据确定的值写为 `n/a`,不要猜测。
## 完善任务板
编辑 `docs/ack/tasks.yaml`:
- `ackVersion` 使用 ACK Skill 的 `VERSION`。
- `updatedAt` 使用当前带时区时间。
- `project.name`、`repoPath`、`devWorktree` 和 `overlayFile` 使用真实值。
- 非服务项目的 `baseUrl` 写为 `n/a`。
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
每个真实任务的验收必须是可观测信号,例如可见文本、API 状态和字段,或明确的交互
结果;不要只写“功能正常”。
## 校验
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
```
同时确认:
- `project.md` 和 `tasks.yaml` 没有未替换的 `<...>` 占位符。
- `project.overlayFile` 指向真实文件。
- Developer 与 Test 的验证命令可执行。
- 网站或 API 项目写清服务启动、重启和 Base URL。
## 初始化报告
完成后报告:
- 创建或确认的两个项目文件。
- 检测到的技术栈和验证命令。
- 任务板校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
具体阻塞项。除非用户明确要求,不提交、不推送。
+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),按 ACK Skill 的 references 规范执行:
1. 先读 docs/ack/project.md、references/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 <ack-skill-dir>/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 <ack-skill-dir>/scripts/validate_worker_command.py --role developer --command "$DEV_CMD"
python3 <ack-skill-dir>/scripts/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 <ack-skill-dir>/scripts/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` 的前置条件。
**ACK 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。
### Codex 默认映射
| 角色 | 模型 | reasoning effort |
|------|------|------------------|
| Coordinator (PM) | `gpt-5.6-sol` | `high` |
| Developer | `gpt-5.6-terra` | `medium` |
| Test | `gpt-5.6-luna` | `low` |
| Developer 升级 | `gpt-5.6-sol` | `high`;极复杂任务可用 `xhigh` |
Codex worker 应明确指定模型和 reasoning effort,不把“未指定模型”当作 Cursor `auto` 的等价物。Codex 未指定模型时使用产品推荐模型,但推荐值可能随版本更新,也不保证符合 Test / Developer 的成本档位。具体模型若失效或被弃用,应保持上面的角色档位不变,只更新本映射;项目也可在 overlay 中覆盖映射。
### Worker 固定启动命令
以下是 core 的默认完整命令。Developer/Test worker 禁止裸启动;必须同时固定 Agent CLI、角色模型和执行模式。
| Agent CLI | 角色 | 完整启动命令 |
|-----------|------|--------------|
| Codex | Developer | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium` |
| Codex | Test | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low` |
| Codex | Developer 升级 | `codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-sol -c model_reasoning_effort=high` |
| Cursor | Developer / Test | `cursor-agent --yolo --model auto` |
硬规则:
1. Codex worker 必须带 `--dangerously-bypass-approvals-and-sandbox`;Cursor worker 必须带 `--yolo`。
2. 禁止用裸 `codex`、裸 `cursor-agent` 或只指定 CLI 而不指定角色模型。
3. 创建终端前先运行项目提供的 worker 命令校验器;校验失败时不得执行 `orca terminal create`。
4. 创建终端后、dispatch 前读取启动信息,确认实际 CLI、模型和 reasoning effort 与命令一致。
5. Developer 升级到强模型必须由 Coordinator 记录原因;Test 不得复用 Developer 的强模型终端。
6. YOLO 会跳过确认和沙箱,只能在用户已明确授权、外层运行环境受控时启用;项目覆盖层可以收紧执行模式,不能静默放宽。
项目可以覆盖模型名称或收紧权限,但必须在 `project.md` 中写出完整替代命令,不能只写“auto”“默认模型”或依赖当前 CLI 的隐式默认值。
---
## 成本原则
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、改动文件清单。
这样把昂贵推理挡在重复执行之外。
---
## 一句话
Coordinator 是脑,Test 是眼,Developer 是手。脑用最强的模型且不做机械测试,眼和手用便宜模型,只有常规闭环卡住时才升级。
@@ -0,0 +1,150 @@
# 优化方法(稳定核心)
减少"开发声称完成但复测不过"的循环成本。本文件是**验收信号写法**和**三轮失败策略**的 SSOT,其它文件引用这里。
三角色分工(Coordinator 编排 / Test 验证 / Developer 实现)见 `roles-and-permissions.md`;复测步骤、Coordinator 终检与环境对齐见 `closed-loop.md`。
---
## 1. 先把验收写成可观测信号(SSOT)
不要只写"页面可用""体验更好"。每个任务至少写三类信号:
| 类型 | 示例 |
|------|------|
| 可见文本 | 页面出现 `确认应用标签`、`取消`、`将修改` |
| API 结果 | `POST /api/...` 返回 `code=0` 且字段存在 |
| 交互结果 | 点击按钮后出现确认区;取消后不触发写入 |
好的验收:
```text
点击"预览变更"后,页面必须出现 API 返回的 diff 行:
- title
- 修改前值
- 修改后值
- coverChanged 提示
```
不好的验收:
```text
预览功能正常。
```
---
## 2. 回报必须带证据,不带结论
Developer 的 `worker_done` 应报告:改了哪些文件、跑了哪些命令、自己如何复现验收路径、仍可能有的风险。Test 的复测报告同理:跑了哪些命令、命中/缺失了哪些验收信号、实际观察到什么、证据(snapshot / API 结果)。两者的"结论"都不作数,只有 Coordinator 落盘的 `tasks.yaml` 才是事实。
不要写:
```text
已完成,应该可以了。
```
要写:
```text
修改 web/app/fix/page.tsx。已运行 npm run build、go test ./...。
手测 /fix?fileId=1:搜索、选择候选、预览、确认区都可见。
风险:未执行真实写文件 apply。
```
---
## 3. 验证权在 Test,Coordinator 只信证据
即使 Developer 的 worker_done 写了"全部通过",也必须由独立的 Test 复测(验证者 ≠ 实现者)。完整复测步骤与 worktree/服务对齐见 `closed-loop.md`。独立复测可避免三类假通过:测错服务实例、测到旧构建产物、开发只验证静态文本没验证真实交互。
Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它覆盖了每条验收信号且符合原始意图,避免"过了字面没过意图"。终检不通过就回写 `failed_retest`。
---
## 4. 三轮失败策略(SSOT)
每个任务最多自动派发三轮:
```text
round 1: 常规修复
round 2: 带复测失败证据的定向修复
round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
failed after round 3: 标记 leftover,继续下一个任务
```
三轮失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、Worker 对问题模型理解错了、UI 自动化与实际浏览器状态有差异、需要人工观察或调试工具介入。
留档字段(结构见 `templates/tasks.schema.json`):
```yaml
status: leftover
resolution:
leftoverReason: "failed after 3 supervised developer rounds"
dispatch:
rounds:
- round: 1
result: failed
evidence: "<why failed>"
- round: 2
result: failed
evidence: "<why failed>"
- round: 3
result: failed
evidence: "<why failed>"
```
---
## 5. 任务排序
1. P0 阻塞主流程。
2. P1 高频用户路径。
3. P1/P2 体验改进。
4. 重构和内部质量。
三轮失败的 P0 可以留档,但发布前需人工决策:降级范围、改验收标准、换新 Agent / 新 worktree 重做、人工修复。
---
## 6. 每轮派发只修一个明确问题
不要一次让 Developer Worker 修太多失败项,除非它们共享同一根因:
```text
one dispatch = one bug = one acceptance path
```
优点:复测更快、失败原因更清楚、三轮失败留档更准确。
---
## 7. 把失败证据写给下一轮 worker
第二轮以后,派发 prompt 应突出"上一轮为什么没过",而非重复原始描述。模板见 `prompt-templates.md` §「复测失败再派发模板」。
---
## 8. 优先让测试可执行化
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。这类可执行测试由 Test 拥有并维护(见 `roles-and-permissions.md` 权限表的 `<integration_test_paths>`)。优先级:
1. API smoke。
2. 浏览器脚本或 case 文档。
3. 单元测试。
4. 人工检查清单。
目标不是全部自动化,而是把最容易反复误判的路径自动化。
---
## 9. 结束条件
一轮闭环结束时,必须能回答:
- 哪些任务 verified?哪些 leftover?
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
- 当前工作树有哪些未提交改动?
- 是否还有 open / failed_retest 未处理?
答不清楚,闭环就还没结束。
+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>
```
@@ -0,0 +1,149 @@
# 角色与权限(稳定核心)
本文件是**角色模型、路径权限、任务状态机、完成定义**的单一事实源(SSOT)。其它文件只引用本文件,不重复定义。
目标:让每个 Agent 只处理自己能验证的事情,减少上下文污染和越权修改。
---
## 角色模型(三角色)
ACK 默认三个独立 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`。
- 用户可见行为符合验收标准。
+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())
+79
View File
@@ -0,0 +1,79 @@
# <项目名> Agent 协作协议(项目覆盖层)
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
> `references/` 读取;本文件只保存项目差异。
>
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`,
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
> 若希望 Agent 自动加载,可在项目 `AGENTS.md` 里加一行指向本文件,或直接把本文件命名为 `AGENTS.md`。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
> `docs/ack/` 只保存本项目的 `project.md` 与 `tasks.yaml`,不复制或链接 Skill。
## 项目概览
- 项目:`<project_name>`
- 技术栈:`<tech_stack>`
- 运行命令:`<run_command>`
- Base URL:`<base_url>`
- 任务板:`docs/ack/tasks.yaml`
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
## 通用规范(由 ACK Skill 按需读取)
- 开始需求:`references/kickoff.md`
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令(可选):`references/orca-adapter.md`
## 模型档位(项目可覆盖,默认见 references/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>
```
任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
## 硬规则(其余见 references/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`;Developer 与 Test 都只读,通过消息回报。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
- 不提交或推送,除非用户明确要求。
+136
View File
@@ -0,0 +1,136 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/tasks.schema.json",
"title": "ACK 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" },
"ackVersion": {
"type": "string",
"description": "接入时所基于的 ack 版本,便于日后 diff 升级"
},
"kitVersion": {
"type": "string",
"description": "旧版 ACK 版本字段;兼容读取,新项目应使用 ackVersion"
},
"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"
ackVersion: "<接入时的 ack skill 版本>"
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
+80
View File
@@ -0,0 +1,80 @@
# deb-publisher
帮助 Agent 复用项目已有的 DEB 打包与发布方式,完成构建、包检查、上传和发布验证。
## 什么时候使用
当你希望 Agent 处理以下任务时使用:
- “帮我构建这个项目的 DEB 包”
- “把 1.2.3 版本的 DEB 发布到包仓库”
- “看看项目里的 DEB 发布流程”
- “检查这个 DEB 是否可以发布”
只想在本机安装一个 `.deb`,或者要构建 RPM、Docker 镜像时,不需要使用这个 skill。
## 使用前准备
请准备或确认:
- 要构建或发布的版本号。
- 项目已经配置好构建脚本、Make 目标或 `debian/` 目录;上传脚本由 skill 自带,
不需要放进项目。
- 发布所需的令牌已经放入项目约定的环境变量或密钥系统。
- 如果要真实发布,明确告诉 Agent 目标仓库以及是否允许覆盖同版本。
不要把令牌直接粘贴到对话、命令参数或项目文件中。
## 使用示例
仅分析,不产生或上传包:
```text
看看这个项目的 DEB 是怎么构建和发布的。
```
只构建和检查,不上传:
```text
使用 deb-publisher 构建 1.2.3 的 DEB,检查包元数据和内容,不要上传。
```
构建并发布:
```text
使用 deb-publisher 构建并发布 1.2.3 的 amd64 DEB 到项目已配置的测试仓库。
```
发布已有产物:
```text
使用 deb-publisher 检查并发布 ./dist/example_1.2.3_amd64.deb。
```
## Agent 会做什么
Agent 会优先发现和复用项目已有入口,然后:
1. 确认版本、架构、产物路径、目标仓库和授权范围。
2. 构建 DEB,或定位你指定的已有产物。
3. 检查包的元数据、内容和 SHA-256。
4. 在你明确要求发布时,通过 skill 自带的 `scripts/upload_deb.sh` 上传。
5. 检查服务端响应,并在仓库支持时确认该版本已经可见。
## 如何判断完成
结果中应包含包名、版本、架构、产物路径、SHA-256,以及构建、上传和仓库可见性的
独立状态。异步索引尚未完成时,Agent 应明确说明“上传已接受,索引待更新”。
## 上传协议
自带脚本适用于接收 multipart 字段 `package`、`token` 和 `repository_name` 的
HTTP DEB 仓库,默认路径是 `/api/v2/upload/package`。使用以下环境变量:
- `DEB_SERVER_URL`:仓库服务地址。
- `DEB_TOKEN`:认证令牌。
- `DEB_REPOSITORY`:目标仓库名。
- `DEB_UPLOAD_PATH`:可选的上传路径覆盖。
令牌只从环境变量读取,不作为命令行参数传递。若你的仓库使用其他 API 协议,应先告诉
Agent,不要直接套用该脚本。
+150
View File
@@ -0,0 +1,150 @@
---
name: deb-publisher
description: >-
构建并发布 Debian DEB 包:发现项目已有的 Makefile 和打包入口,使用 skill 自带的
通用上传脚本提交包,校验包元数据与内容,并验证发布结果。触发词:构建 deb、
发布 deb、上传 deb、提交 deb、推送 apt 仓库、打 Debian 包。仅分析打包逻辑时也可使用,
但不会在未获授权时执行上传。
---
# DEB Publisher
复用项目已有发布约定,安全地完成“发现入口 → 构建 → 检查 → 上传 → 验证”。
## 何时使用
- 用户要求构建、发布、上传或提交 `.deb` 包。
- 用户要求梳理或接通项目现有的 DEB 发布流程。
- 用户要求把已经生成的 `.deb` 推送到 APT/DEB 包仓库。
## 不适用
- 只需要安装或卸载本地 DEB 包。
- 目标是 RPM、APK、容器镜像或语言包管理器。
- 用户只要求设计全新的 Debian 打包体系;此时应先完成方案设计。
## 工作流
### 1. 发现项目约定
从项目根目录查找,不预设文件位置:
```bash
rg -n -i --hidden --glob '!.git' \
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
```
重点检查:
- `Makefile`、CI 配置、`debian/`、构建脚本和发布文档。
- 版本号、包名、架构、产物目录和仓库名如何传入。
- 发布端点、认证方式以及发布是否由构建目标自动触发。
- 当前工作树和目标版本是否匹配。
优先复用已有构建入口。上传默认使用本 skill 的 `scripts/upload_deb.sh`,不要把它
复制到项目中;仅当目标仓库协议不兼容时才复用或修改项目专属上传逻辑。
### 2. 确认发布边界
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;若用户只要求查看、
诊断或构建,则停在相应阶段。
执行上传前确认:
- 目标服务和仓库来自项目配置或用户输入,不猜测生产端点。
- 认证令牌已通过环境变量或密钥系统提供。
- 目标版本、架构和产物路径能够从构建配置推导。
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问用户。
绝不把令牌写入命令输出、文件、提交或最终回复。不要用 `set -x` 执行含凭据的脚本。
### 3. 构建包
使用项目声明的构建目标,并显式传入版本。例如项目提供 Make 目标时:
```bash
make build-deb VERSION="$RELEASE_VERSION"
```
如果构建目标会自动上传,而当前仅获构建授权,应改用其纯构建子目标。执行前检查
所需工具和环境,例如 Docker、`dpkg-deb`、编译器、SSH 访问或前端工具链。
不得擅自清理宽泛目录。若脚本包含 `rm -rf`,先解析并确认目标是明确、受限的构建目录。
### 4. 上传前检查
定位唯一目标产物;若匹配多个包,不凭文件时间猜测:
```bash
find <artifact-dir> -maxdepth 2 -type f -name '*.deb' -print
dpkg-deb --info <package.deb>
dpkg-deb --contents <package.deb>
```
至少验证:
- 文件存在、非空且 `dpkg-deb --info` 成功。
- `Package`、`Version`、`Architecture` 与本次发布一致。
- 包内容包含预期的主程序或关键文件。
- maintainer scripts 存在时权限正确,且没有明显的宿主机破坏性操作。
建议记录 SHA-256:
```bash
sha256sum <package.deb>
```
### 5. 发布
解析当前 `SKILL.md` 所在目录,以绝对路径调用随 skill 分发的脚本:
```bash
DEB_SERVER_URL="$DEB_SERVER_URL" \
DEB_TOKEN="$DEB_TOKEN" \
DEB_REPOSITORY="$DEB_REPOSITORY" \
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
```
不要把脚本复制进当前项目,也不要将 token 作为命令行参数。脚本默认请求
`/api/v2/upload/package`,以 multipart 字段 `package`、`token`、
`repository_name` 上传,接受 `200` 和 `201` 为成功。端点路径不同时可设置
`DEB_UPLOAD_PATH`。
调用前确认目标服务使用上述协议;不兼容时不要强行调用。传入刚刚校验过的确切路径,
不要使用宽泛 glob。项目已有 `make upload-deb` 时,检查它是否只是包装了同一协议:
如果是,直接使用 skill 脚本;若 CI 或其他人仍依赖 Make 目标,可将目标改为调用已安装
skill 的脚本,但不要提交脚本副本。
脚本支持多个确切文件路径,会汇总每个文件的结果,并在任一失败时返回非零。
### 6. 验证与汇报
发布成功不能只依据“curl 已执行”。综合检查:
- 上传命令退出码为零。
- HTTP 状态和响应体明确表示成功。
- 若仓库提供只读查询、索引或下载地址,再确认该包和版本已可见。
- 若索引更新是异步的,报告“上传已接受,索引尚待更新”,不要声称已完全可用。
最终回复给出:
- 包名、版本、架构。
- 产物路径和 SHA-256。
- 目标服务/仓库的非敏感标识。
- 构建、上传及仓库可见性各自的验证结果。
- 任何未完成项或回滚/覆盖风险。
## 修改已有发布逻辑时
- 保持项目现有变量名和调用入口,避免无关重构。
- 修复行为缺陷时增加最小静态检查或可离线运行的测试。
- 可用 `bash -n` 检查脚本语法;项目有 ShellCheck 时一并运行。
- skill 自带上传脚本是 SSOT;通用上传行为的修改应落在
`skills/deb-publisher/scripts/upload_deb.sh`,不要同步复制到业务项目。
- 不通过真实生产上传来测试脚本,除非用户明确授权并给出测试版本或测试仓库。
## 完成标准
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
- 仅构建:DEB 已生成,元数据、内容和校验和通过检查,未发生上传。
- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
DEB_SERVER_URL=https://deb.example.com \
DEB_TOKEN=secret \
DEB_REPOSITORY=main \
upload_deb.sh FILE.deb [FILE.deb ...]
Options:
-s SERVER_URL Override DEB_SERVER_URL
-n REPOSITORY Override DEB_REPOSITORY
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
-h Show help
The endpoint must accept multipart fields named package, token, and
repository_name. Authentication is read only from DEB_TOKEN so it is not
exposed in the process command line.
EOF
}
server_url=${DEB_SERVER_URL:-}
repository=${DEB_REPOSITORY:-}
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
token=${DEB_TOKEN:-}
while getopts ":s:n:p:h" option; do
case "$option" in
s) server_url=$OPTARG ;;
n) repository=$OPTARG ;;
p) upload_path=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "Error: -$OPTARG requires a value" >&2; usage >&2; exit 2 ;;
\?) echo "Error: unknown option -$OPTARG" >&2; usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
if [[ -z "$server_url" || -z "$repository" || -z "$token" || $# -eq 0 ]]; then
echo "Error: DEB_SERVER_URL, DEB_TOKEN, DEB_REPOSITORY, and at least one file are required." >&2
usage >&2
exit 2
fi
if [[ "$upload_path" != /* ]]; then
echo "Error: upload path must start with /" >&2
exit 2
fi
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required." >&2
exit 2
fi
server_url=${server_url%/}
success_count=0
fail_count=0
response_file=
cleanup() {
if [[ -n "$response_file" && -f "$response_file" ]]; then
rm -f -- "$response_file"
fi
}
trap cleanup EXIT
print_response() {
local file=$1
if command -v jq >/dev/null 2>&1 && jq -e . "$file" >/dev/null 2>&1; then
jq . "$file"
else
cat -- "$file"
fi
}
for package_file in "$@"; do
if [[ ! -f "$package_file" ]]; then
echo "Skip: file not found: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
if [[ "$package_file" != *.deb ]]; then
echo "Skip: not a .deb file: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
if [[ ! -s "$package_file" ]]; then
echo "Skip: empty file: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
response_file=$(mktemp)
echo "Uploading $(basename -- "$package_file") to $server_url (repository $repository)..."
http_code=000
if http_code=$(curl --silent --show-error \
--output "$response_file" \
--write-out "%{http_code}" \
--request POST \
"$server_url$upload_path" \
--form "package=@${package_file};type=application/vnd.debian.binary-package" \
--form "token=${token}" \
--form "repository_name=${repository}"); then
:
else
echo "Failed (transport error): $(basename -- "$package_file")" >&2
print_response "$response_file" >&2
fail_count=$((fail_count + 1))
cleanup
response_file=
continue
fi
if [[ "$http_code" == 200 || "$http_code" == 201 ]]; then
echo "Success ($http_code): $(basename -- "$package_file")"
print_response "$response_file"
success_count=$((success_count + 1))
else
echo "Failed ($http_code): $(basename -- "$package_file")" >&2
print_response "$response_file" >&2
fail_count=$((fail_count + 1))
fi
cleanup
response_file=
done
echo "Done. Success: $success_count, Failed: $fail_count"
if ((fail_count > 0)); then
exit 1
fi
+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
+67
View File
@@ -0,0 +1,67 @@
# skiff
`skiff` 用于创建、维护、安装和发布团队自研的 Agent Skill。Skill 的唯一来源位于
`~/.skills/skills/<name>/`,安装到各 Agent 时使用软链接。
## 什么时候使用
- 想把项目里的重复工作沉淀成一个 skill。
- 想完善、校验、转正或发布已有 skill。
- 想把自研 skill 安装到当前项目或全局 Agent。
- 想检查并修复 skill 软链接。
## 创建一个 skill
```bash
skiff create my-skill \
--idea "描述这个 skill 要解决的重复问题" \
--from-project .
```
命令会在 `~/.skills/.drafts/my-skill/` 创建:
- `SKILL.md`:给 Agent 阅读的工作流与约束。
- `README.md`:给人类阅读的用途、准备事项、示例和完成标准。
- `brief.yaml`:草稿来源信息,转正时自动移除。
完善 `SKILL.md` 和 `README.md` 后运行:
```bash
skiff check my-skill
skiff finalize my-skill
```
## 提交和发布
只提交:
```bash
skiff publish skills/my-skill -m "add my-skill"
```
提交并推送:
```bash
skiff publish skills/my-skill -m "add my-skill" --push
```
## 安装
安装到当前项目的 Codex:
```bash
skiff add my-skill -a codex -y
```
安装到全局 Codex:
```bash
skiff add my-skill -a codex -g -y
```
## 如何判断完成
- `skiff check <name>` 输出校验通过。
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
- `skiff status` 或 `skiff doctor` 显示目标软链接正常。
+185
View File
@@ -0,0 +1,185 @@
---
name: skiff
description: >-
创建和维护 ~/.skills 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布,
用 skiff add/remove 在项目及全局挂卸 skill,或用 skiff init 初始化 skill 项目状态。
触发词:skiff、自研 skill、创建 skill、想做一个 skill、publish skill、安装自研 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`,完善触发条件、不适用场景、步骤、边界与验证方法。
5. 编辑同目录的 `README.md`,用面向人类的语言说明用途、准备事项、可直接复制的请求示例、Agent 会做什么以及如何判断完成。README 不应复述 Agent 内部指令。
6. 仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
7. 运行校验并修复所有问题:
```bash
skiff check <name>
```
8. 向用户展示名称、description、README 的人类使用方式、核心步骤和验证方式。获得确认后再转正:
```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` 仓库会选中其
全部子 skill,也可以只选择 `waza/think`、`waza/ui` 中的若干项。
普通 `skiff select` 只向项目安装,并只读显示每个 Agent 的全局安装状态;
`skiff select -g` 只向全局安装。取消已勾选项不会卸载,卸载继续使用
`skiff remove`。
卸载:
```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
```
使用 Skill 自带模板初始化项目状态:
```bash
skiff init ack
skiff init ack --project ~/app
```
`skiff` 只负责可靠地生成项目状态文件,不复制或链接 Skill。需要分析项目并完善
ACK 配置、检查接入状态或
运行三角色闭环时,显式调用全局 `/ack` skill。
---
## 与 Vercel `npx skills` 的分工
| 场景 | 工具 |
|------|------|
| 自研 skill(~/.skills) | **skiff** |
| 社区 skill(GitHub 任意仓库) | `npx skills add` |
---
## 命令对照
| skiff | 说明 |
|-------|------|
| `bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
| `update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `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 |
| `init <name> [--project DIR]` | 使用 skill 模板初始化项目状态 |
---
## 注意
- 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.skills/skills/` 再 `publish`
- 未完成的内容保留在 `~/.skills/.drafts/`,不要直接放进正式 `skills/`
- symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容
- 社区 skill 用 `npx skills add`,不要用 skiff `registry add` 除非团队要 pin 版本
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
class AckSkillContentTests(unittest.TestCase):
def test_ack_skill_owns_resources_without_modifying_agent_instructions(self) -> None:
content = (REPO_ROOT / "skills" / "ack" / "SKILL.md").read_text(encoding="utf-8")
for expected in (
"skiff init ack --project <project-root>",
"docs/ack/project.md",
"docs/ack/tasks.yaml",
"tasks: []",
"validate_tasks.py",
"references/kickoff.md",
"不要修改项目的 `AGENTS.md`",
"当前会话担任 Coordinator",
):
self.assertIn(expected, content)
def test_ack_skill_is_explicit_only(self) -> None:
metadata = (REPO_ROOT / "skills" / "ack" / "agents" / "openai.yaml").read_text(
encoding="utf-8"
)
self.assertIn('display_name: "ACK"', metadata)
self.assertIn("allow_implicit_invocation: false", metadata)
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from skiff import cli
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",
)
template.joinpath("README.md").write_text(
"# skill-name\n\n"
"用一句话告诉使用者这个 skill 能解决什么问题。\n\n"
"给出用户可以直接说出的典型请求。\n\n"
"给出一条可以直接交给 Agent 的示例请求。\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",
)
project_skill.joinpath("README.md").write_text(
"# skiff\n\n供人类使用的 skiff 说明。\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_update_pulls_skills_home(self) -> None:
with (
patch.object(cli, "SKILLS_HOME", self.skills_home),
patch.object(cli, "ensure_skills_home"),
patch.object(cli.subprocess, "run") as run,
):
cli.cmd_update(argparse.Namespace())
run.assert_called_once_with(
["git", "-C", str(self.skills_home), "pull"],
check=True,
)
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())
self.assertEqual(
(draft / "README.md").read_text(encoding="utf-8").splitlines()[0],
"# migration-review",
)
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",
)
draft.joinpath("README.md").write_text(
"# migration-review\n\n"
"用于检查数据库迁移安全性。\n\n"
"## 使用示例\n\n请检查这次数据库迁移。\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.assertTrue((final / "README.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",
)
draft.joinpath("README.md").write_text(
"# reference-check\n\n用于检查本地参考资料。\n",
encoding="utf-8",
)
result = self.run_skiff("check", "reference-check")
self.assertNotEqual(result.returncode, 0)
self.assertIn("引用文件不存在", result.stderr)
def test_check_rejects_missing_readme(self) -> None:
draft = self.skills_home / ".drafts" / "missing-readme"
draft.mkdir(parents=True)
draft.joinpath("SKILL.md").write_text(
"---\nname: missing-readme\ndescription: >-\n"
" 检查 skill 是否包含人类使用说明。用于维护 skill 结构时。\n"
"---\n\n# README 检查\n\n1. 检查 README。\n",
encoding="utf-8",
)
result = self.run_skiff("check", "missing-readme")
self.assertNotEqual(result.returncode, 0)
self.assertIn("缺少 README.md", 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()
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from skiff.sources import fetch_source
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())
def test_git_source_clone_is_shallow(self) -> None:
checkout = self.home / "checkout"
entry = {
"repo": "https://example.test/company-skills.git",
"ref": "main",
"checkout": str(checkout),
}
with patch("skiff.sources.subprocess.run") as run:
fetch_source("company", entry)
run.assert_called_once_with(
[
"git",
"clone",
"--depth",
"1",
"--branch",
"main",
"--",
"https://example.test/company-skills.git",
str(checkout),
],
check=True,
)
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()
+29
View File
@@ -0,0 +1,29 @@
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",
"skiff init ack",
"显式调用全局 `/ack` skill",
):
self.assertIn(expected, content)
if __name__ == "__main__":
unittest.main()
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from skiff.registry import external_checkout_path, registry_repo
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_registry_repo_expands_home_relative_local_path(self) -> None:
with patch.dict(os.environ, {"HOME": str(self.home)}):
self.assertEqual(
registry_repo({"repo": "~/.skills"}),
str(self.skills_home),
)
self.assertEqual(
external_checkout_path("skills", {"repo": "~/.skills"}),
self.skills_home.resolve(),
)
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()
+558
View File
@@ -0,0 +1,558 @@
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, fit_to_width, select_skills
REPO_ROOT = Path(__file__).resolve().parents[1]
class SelectorTests(unittest.TestCase):
def test_fit_to_width_counts_cjk_as_two_columns(self) -> None:
self.assertEqual(fit_to_width("a中文b", 4), "a中")
self.assertEqual(fit_to_width("初始化 ACK", 8), "初始化 A")
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",
"维护讨论笔记",
readonly_status="全局: codex",
),
]
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"])
self.assertEqual([c.name for c in filter_choices(choices, "codex")], ["discussion-notes"])
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"})
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
self.assertTrue(any("取消选择不卸载" in line for line in rendered))
def test_selector_renders_scope_and_readonly_global_status(self) -> None:
choices = [
SkillChoice(
"ack",
"owned",
installed=False,
readonly_status="全局: cursor,claude,codex",
)
]
screen = Mock()
screen.getmaxyx.return_value = (24, 120)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
selected = select_skills(
choices,
scope_label="项目 /code/app(全局状态只读)",
wrapper=lambda draw: draw(screen),
)
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
self.assertEqual(selected, set())
self.assertTrue(any("安装目标: 项目 /code/app" in line for line in rendered))
title = "".join(
call.args[2]
for call in screen.addnstr.call_args_list
if call.args[0] == 3
)
self.assertIn("[ ] ack", title)
self.assertIn("全局: cursor,claude,codex", title)
self.assertNotIn("初始化", title)
def test_selector_renders_description_on_indented_second_line(self) -> None:
choices = [
SkillChoice(
"ack",
"owned",
description="初始化、检查并运行 ACK",
readonly_status="全局: codex",
)
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
select_skills(choices, wrapper=lambda draw: draw(screen))
title_calls = [
call for call in screen.addnstr.call_args_list if call.args[0] == 3
]
description_call = next(
call for call in screen.addnstr.call_args_list if call.args[0] == 4
)
self.assertEqual(
"".join(call.args[2] for call in title_calls),
"[ ] ack owned 全局: codex",
)
self.assertEqual(description_call.args[2], " 初始化、检查并运行 ACK")
name_call = next(call for call in title_calls if call.args[2] == "ack")
self.assertTrue(name_call.args[4] & curses.A_BOLD)
self.assertTrue(description_call.args[4] & curses.A_DIM)
def test_repository_choice_toggles_all_child_skills(self) -> None:
choices = [
SkillChoice(
"waza",
"repository",
children=("waza/think", "waza/ui"),
),
SkillChoice("waza/think", "external:waza", indent=1),
SkillChoice("waza/ui", "external:waza", indent=1),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.side_effect = [" ", "\n"]
with patch.object(curses, "curs_set"):
selected = select_skills(choices, wrapper=lambda draw: draw(screen))
self.assertEqual(selected, {"waza/think", "waza/ui"})
def test_repository_choice_renders_partial_selection(self) -> None:
choices = [
SkillChoice(
"waza",
"repository",
children=("waza/think", "waza/ui"),
),
SkillChoice("waza/think", "external:waza", installed=True, indent=1),
SkillChoice("waza/ui", "external:waza", indent=1),
]
screen = Mock()
screen.getmaxyx.return_value = (24, 100)
screen.get_wch.return_value = "\n"
with patch.object(curses, "curs_set"):
select_skills(choices, wrapper=lambda draw: draw(screen))
first_row = "".join(
call.args[2]
for call in screen.addnstr.call_args_list
if call.args[0] == 3
)
self.assertIn("[-] waza", first_row)
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), {})
def test_registry_clone_is_shallow(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
checkout = root / "checkout"
skill_root = checkout / "skills"
entry = {
"repo": "https://example.test/skills.git",
"ref": "main",
"path": "skills",
}
with (
patch.object(cli, "external_skill_path", return_value=skill_root),
patch.object(cli, "external_checkout_path", return_value=checkout),
patch.object(
cli,
"discover_external_skills",
side_effect=[{}, {"demo": skill_root / "demo"}],
),
patch.object(cli.subprocess, "run") as run,
):
cli._ensure_registry_fetched("demo-pack", entry)
run.assert_called_once_with(
[
"git",
"clone",
"--depth",
"1",
"--branch",
"main",
"--",
"https://example.test/skills.git",
str(checkout),
],
check=True,
)
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] = []
selected_choices: list[SkillChoice] = []
selected_scope: list[str] = []
def choose(
choices: list[SkillChoice],
*,
scope_label: str,
) -> set[str]:
selected_choices.extend(choices)
selected_scope.append(scope_label)
return {"owned-one", "external-one"}
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,
"_global_installation_note",
return_value="全局: codex",
),
patch.object(
cli,
"_is_fully_installed",
side_effect=lambda name, expected, project_root, targets: name == "owned-one",
),
patch.object(
cli,
"select_skills",
side_effect=choose,
),
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)
self.assertEqual(
[choice.readonly_status for choice in selected_choices],
["全局: codex", "全局: codex"],
)
self.assertIn("全局状态只读", selected_scope[0])
def test_global_installation_note_reports_agents_and_source_conflicts(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
expected = root / "expected"
other = root / "other"
expected.mkdir()
other.mkdir()
global_dirs = {
target: root / target / "skills"
for target in ("cursor", "claude", "codex")
}
for directory in global_dirs.values():
directory.mkdir(parents=True)
(global_dirs["cursor"] / "ack").symlink_to(expected, target_is_directory=True)
(global_dirs["claude"] / "ack").symlink_to(other, target_is_directory=True)
with patch.object(
cli,
"agent_skill_dir",
side_effect=lambda target, project_root=None: global_dirs[target],
):
note = cli._global_installation_note(
"ack",
expected,
["cursor", "claude", "codex"],
)
self.assertEqual(note, "全局: cursor;全局同名冲突: claude")
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], **_: object) -> 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", "waza/think", "waza/ui"],
)
self.assertEqual(selected_choices[0].children, ("waza/think", "waza/ui"))
self.assertEqual([choice.indent for choice in selected_choices[1:]], [1, 1])
self.assertEqual(installed, [("think", "registry:waza")])
def test_select_deduplicates_local_registry_collection_from_owned(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
with tempfile.TemporaryDirectory() as temp:
skills_root = Path(temp) / "skills"
(skills_root / "ack").mkdir(parents=True)
selected_choices: list[SkillChoice] = []
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices)
return set()
with (
patch.object(cli.sys, "stdin", stdin),
patch.object(cli.sys, "stdout", stdout),
patch.object(cli, "SKILLS_DIR", skills_root),
patch.object(cli, "ensure_skills_home"),
patch.object(cli, "list_owned_skills", return_value=["ack"]),
patch.object(cli, "skill_description", return_value="owned ack"),
patch.object(
cli,
"load_registry",
return_value={
"skills": {
"repo": "~/.skills",
"ref": "main",
"path": "skills",
}
},
),
patch.object(cli, "_registry_skill_names", return_value=["ack"]),
patch.object(cli, "external_skill_path", return_value=skills_root),
patch.object(cli, "_is_fully_installed", return_value=False),
patch.object(cli, "select_skills", side_effect=choose),
):
cli.cmd_select(args)
self.assertEqual([choice.name for choice in selected_choices], ["ack"])
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()
+102
View File
@@ -0,0 +1,102 @@
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 SkillInitTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.home = Path(self.temp_dir.name)
self.skills_home = self.home / ".skills"
skill = self.skills_home / "skills" / "ack"
(skill / "templates").mkdir(parents=True)
(skill / "scripts").mkdir()
(skill / "SKILL.md").write_text("---\nname: ack\n---\n", encoding="utf-8")
(skill / "VERSION").write_text("1.2.3\n", encoding="utf-8")
(skill / "templates" / "project.template.md").write_text(
"# <project_name>\nversion=<ack_version>\npath=<overlay_file_path>\n",
encoding="utf-8",
)
(skill / "templates" / "tasks.template.yaml").write_text(
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'ackVersion: "<接入时的 ack skill 版本>"\n'
'project:\n'
' name: "<project_name>"\n'
' repoPath: "<repo_path>"\n'
' devWorktree: "<dev_worktree>"\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_init_creates_only_rendered_project_files(self) -> None:
project = self.home / "sample-app"
project.mkdir()
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertEqual(result.returncode, 0, result.stderr)
target = project / "docs" / "ack"
self.assertFalse((target / "kit").exists())
self.assertFalse((target / "framework").exists())
project_content = (target / "project.md").read_text(encoding="utf-8")
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
self.assertIn("# sample-app", project_content)
self.assertIn("version=1.2.3", project_content)
self.assertIn(f'repoPath: "{project}"', tasks_content)
self.assertNotIn("<project_name>", tasks_content)
def test_init_refuses_to_overwrite_existing_files(self) -> None:
project = self.home / "existing-app"
target = project / "docs" / "ack"
target.mkdir(parents=True)
existing = target / "project.md"
existing.write_text("keep me", encoding="utf-8")
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝覆盖已有路径", result.stderr)
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
self.assertFalse((target / "tasks.yaml").exists())
def test_init_rejects_missing_project_directory(self) -> None:
project = self.home / "missing-app"
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("项目目录不存在", result.stderr)
self.assertFalse(project.exists())
def test_legacy_kit_command_is_not_exposed(self) -> None:
result = self.run_skiff("kit", "init", "ack")
self.assertNotEqual(result.returncode, 0)
self.assertIn("invalid choice", result.stderr)
if __name__ == "__main__":
unittest.main()