7 Commits

46 changed files with 1973 additions and 574 deletions
+11 -20
View File
@@ -34,21 +34,20 @@ skiff status
``` ```
skills/ skills/
├── _template/ # 新建 skill 的脚手架 ├── _template/ # 新建 skill 的脚手架
├── ack/ # 完整 ACK skill,含 references/templates/scripts
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md ├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
│ ├── SKILL.md │ ├── SKILL.md
│ └── reference.md │ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记 ├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md │ ├── SKILL.md
│ └── reference.md │ └── reference.md
kits/
└── agent-collaboration-kit/ # 可复制到项目的协作规范包,不是 skill
skiff/ # CLI 源码(Python 3) skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口 bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 来源目录 registry.yaml # 外部 Git skill 来源目录
AGENTS.md # 本文档 AGENTS.md # 本文档
``` ```
**本仓库包含**:`skills/`、`kits/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md` **本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`registry.yaml`、`AGENTS.md`
**本仓库不包含**:各项目的 skill 启用清单 **本仓库不包含**:各项目的 skill 启用清单
--- ---
@@ -60,6 +59,7 @@ AGENTS.md # 本文档
| Skill | 说明 | | Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- | | ---------------------------------------------------------------------- | ------------------------------------------------- |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill | | [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 owned skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
@@ -67,20 +67,13 @@ AGENTS.md # 本文档
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。 新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
### 规范包(Kits,不是 Skill) Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
项目状态,不复制或链接 Skill 内容:
`kits/` 放可复制到项目的流程规范、模板、检查清单和示例资料。它们不是 Agent Skill: ```bash
skiff init ack
- 不放 `SKILL.md` frontmatter ```
- 不由 `skiff install` / `skiff add` 安装
- 不会被 Agent 自动触发加载
- 使用方式是复制到目标项目文档目录,或在目标项目 `AGENTS.md` 中引用
| Kit | 说明 | 推荐落地位置 |
| --- | --- | --- |
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包 | `docs/agent-collaboration-kit/` |
新建规范包:创建 `kits/<name>/README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。
### 外部(External Git) ### 外部(External Git)
@@ -135,8 +128,8 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建 1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单 2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT 3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **类型分离** — `skills/` 只放可安装 skill;非 skill 的规范包、模板包放 `kits/` 4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
5. **一体维护** — skill、规范包与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步 5. **一体维护** — skill 与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
--- ---
@@ -145,7 +138,6 @@ npx skills find typescript
``` ```
skills 仓库(本仓库) skiff CLI skills 仓库(本仓库) skiff CLI
skills/<name>/ ←── skiff install / enable skills/<name>/ ←── skiff install / enable
kits/<name>/ ←── 手动复制 / 项目文档引用
skiff/ ←── python3 -m skiff skiff/ ←── python3 -m skiff
registry.yaml ←── skiff add / fetch registry.yaml ←── skiff add / fetch
↑ ↑
@@ -172,7 +164,6 @@ registry.yaml ←── skiff add / fetch
| 类型 | 位置 | 维护方式 | | 类型 | 位置 | 维护方式 |
| --- | --- | --- | | --- | --- | --- |
| **Kit** | `kits/<name>/` | 本仓库 commit;复制到目标项目 `docs/` 或由项目 `AGENTS.md` 引用 |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 | | **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
+8 -11
View File
@@ -19,7 +19,6 @@ skiff status
``` ```
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
kits/ # 可复制到项目的规范包 / 模板包,不是 Agent Skill
skiff/ # CLI 源码(Python 3) skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口 bin/skiff # CLI 入口
registry.yaml # 外部 Git skill 目录 registry.yaml # 外部 Git skill 目录
@@ -28,8 +27,7 @@ AGENTS.md # 详细规范与架构说明
| 路径 | 说明 | | 路径 | 说明 |
|------|------| |------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` | | [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
| [kits/](kits/) | 项目规范包、协作模板、可复制资料,不由 skiff 安装 |
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI | | [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 | | [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 | | [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
@@ -38,17 +36,17 @@ AGENTS.md # 详细规范与架构说明
| Skill | 说明 | | Skill | 说明 |
|-------|------| |-------|------|
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 |
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill | | [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 owned skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
## 规范包(不是 Skill) ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
`kits/` 用来维护可复制到项目里的规范说明、模板和检查清单。它们没有 `SKILL.md`,不会被 `skiff install`、`skiff add` 或 Agent 自动加载。 ```bash
skiff init ack
| Kit | 说明 | skiff init ack --project ~/app
|-----|------| ```
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包,复制到项目 `docs/agent-collaboration-kit/` 后按项目情况填写 |
新建 skill: 新建 skill:
@@ -115,7 +113,6 @@ npx skills find typescript
``` ```
本仓库 本仓库
├── skills/<name>/ ←── skiff install / enable ├── skills/<name>/ ←── skiff install / enable
├── kits/<name>/ ←── 手动复制 / 项目文档引用
├── registry.yaml ←── skiff add / fetch ├── registry.yaml ←── skiff add / fetch
└── skiff/ ←── python3 -m skiff └── skiff/ ←── python3 -m skiff
↑ ↑
@@ -133,7 +130,7 @@ npx skills find typescript
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/` 1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
2. **项目自治** — 各项目自行维护 `.skills.yaml` 2. **项目自治** — 各项目自行维护 `.skills.yaml`
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT 3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **规范包分离** — 非 skill 的流程规范、模板包放在 `kits/<name>/`,不混入 `skills/` 4. **能力内聚** — Skill 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译 5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
## 文档 ## 文档
-175
View File
@@ -1,175 +0,0 @@
# ack — Agent Collaboration Kit
`ack`(Agent Collaboration Kit)是一套可复用到其它项目的多 Agent 协作规范,默认三个独立角色:**Coordinator (PM) 拆解需求并调度闭环,Developer 实现与白盒验证,Test 独立黑盒复测**。关键属性是验证者 ≠ 实现者。角色默认按成本分层:Coordinator 用强模型(且不亲自跑测试),Test/Developer 用中低模型(见 `core/model-routing.md`)。
当前版本见 `VERSION`。这不是 Agent Skill(无 `SKILL.md`),不由 skiff 安装,而是复制/引用到目标项目。
---
## 适用场景
- 多个 Agent 分工协作,而非单个 Agent 从需求写到代码。
- 需要区分规格、测试、实现、复测的责任边界(Coordinator / Test / Developer 三角色)。
- 需要把失败项派发给 Developer,`worker_done` 后由独立的 Test 复测、Coordinator 终检。
- 需要连续修复多个问题,并把三轮仍未修好的问题留档。
---
## 目录结构
```text
ack/
VERSION # kit 版本,接入时记入项目
README.md
init-new-project.md # 给 Agent 的新项目初始化手册
adoption-checklist.md
core/ # 稳定核心:跨项目通用,随 kit 升级,尽量别改
kickoff.md # 如何开始一个需求(启动手册 + 开场指令模板)
roles-and-permissions.md # 角色/权限/状态机/完成定义(SSOT)
model-routing.md # 三角色默认模型档位 + 升级规则(SSOT)
closed-loop.md # 编排无关闭环 + 手动模式 + worktree 对齐
optimization-method.md # 验收信号 + 三轮失败策略(SSOT)
prompt-templates.md # 派发 prompt 模板
orca-adapter.md # Orca 具体命令(一种编排实现,可选)
templates/ # 项目覆盖层:复制一次并填空
project.template.md # 项目覆盖层模板(文件名可配置,默认不占用 AGENTS.md)
tasks.template.yaml
tasks.schema.json # 任务板权威结构(跨语言)
examples/ # 填好的最小可跑示例
project.example.md
tasks.example.yaml
scripts/
validate_tasks.py # 校验 tasks.yaml(参考实现)
```
**核心原则**:`core/` 是稳定核心,每个概念只定义一次;`templates/` 是项目覆盖层。二者分离,让 kit 升级和项目定制互不干扰。
---
## 项目目录布局(推荐)
每个项目在 `docs/ack/` 下只放**一个软链接**加两个项目文件,稳定核心全部走软链接:
```text
<project>/docs/ack/
kit -> <此框架目录> # 唯一软链接:core/templates/examples/scripts 全在里面
project.md # 项目覆盖层(差异),可改名,见下
tasks.yaml # 项目任务板
```
引用稳定核心时统一走 `kit/` 前缀,例如 `docs/ack/kit/core/roles-and-permissions.md`。
这样「一个项目一个软链接」,kit 升级自动生效,项目定制(`project.md` + `tasks.yaml`)互不干扰。
## 分发方式(二选一)
### 方式 A:单软链接引用(推荐,可升级)
把本框架目录整体 symlink 成项目的 `docs/ack/kit`:
```bash
mkdir -p <project>/docs/ack
ln -s <此框架绝对路径> <project>/docs/ack/kit
```
也可用 git submodule / sparse checkout 达到同样效果。项目只维护 `project.md` 与 `tasks.yaml`。
### 方式 B:整份复制(简单,手动升级)
把本框架整目录复制成 `docs/ack/kit/`(或直接铺平到 `docs/ack/`,此时引用去掉 `kit/` 前缀)。**务必**在 `project.md` 与 `tasks.yaml` 的 `kitVersion` 记录来源版本,日后照 `VERSION` diff 升级。
### 覆盖层文件名(重要)
项目差异(路径、命令、handle、模型档位)写在**项目覆盖层文件**里,**默认 `docs/ack/project.md`,不占用 `AGENTS.md`**,以免与团队已有的 `AGENTS.md` 约定冲突。文件名可配置:想让 Agent 自动加载,可在 `AGENTS.md` 加一行指向它,或直接命名为 `AGENTS.md`。无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。覆盖层只填差异,不复制 `core/` 内容。
---
## 快速接入
让 Agent 在新项目执行初始化时,优先给它 `init-new-project.md`。
1. 建 `docs/ack/`,按方式 A 建单软链接 `kit`。
2. 复制 `kit/templates/project.template.md` → `docs/ack/project.md`,填项目差异与模型档位,记 `kitVersion`。
3. 复制 `kit/templates/tasks.template.yaml` → `docs/ack/tasks.yaml`,填 `project`(含 `overlayFile`)与首个任务。
4. 跑 `python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml` 确认结构。
5. 按 `kit/core/closed-loop.md` 跑闭环(Orca 见 `kit/core/orca-adapter.md`,无 Orca 用手动模式)。
6. 逐项对照 `kit/adoption-checklist.md`。
参考 `examples/` 里填好的 `project.example.md` 与 `tasks.example.yaml`。
---
## 常用使用方法
### 开一个新需求(最常用)
你(强模型会话)就是 Coordinator(PM)。完整手册见 `core/kickoff.md`,开场指令:
```text
我要做一个新需求:<一句话需求>。你作为 ack 的 Coordinator(PM),按 docs/ack/kit/core 规范:
先读 project.md 与 core/*;写产品文档到 docs/ 并把需求拆成带可观测验收信号的任务写进 docs/ack/tasks.yaml;
先给我确认产品文档+任务拆分,再按 project.md 优先复用空闲 DEV/TEST worker,没有时才校验并创建,核对实际模型后循环派发/复测/终检;
每个任务最多三轮,三轮不过记 leftover。
```
### 复用或启动 worker
先按 `core/orca-adapter.md` §「解析并复用 worker」运行 `terminal list` 和活跃任务查询。只有没有同 worktree、同角色、配置兼容的空闲 worker 时,才执行下面的创建命令:
```bash
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium'
TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
python3 docs/ack/validate_worker_command.py --role developer --command "$DEV_CMD"
python3 docs/ack/validate_worker_command.py --role test --command "$TEST_CMD"
orca terminal create --worktree active --command "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
# Cursor worker 使用同一项目校验器
CURSOR_CMD='cursor-agent --yolo --model auto'
python3 docs/ack/validate_worker_command.py --role developer --command "$CURSOR_CMD"
```
派发成功后把实际 handle 写入任务的 `dispatch.worker`,下次优先复用。需要隔离/并行时先建 worktree:`orca worktree create --name <feature> --base-branch <base> --json`(选择依据见 `core/closed-loop.md`)。
### 派发与等待(Orca)
```bash
orca orchestration task-create --spec "<任务与验收>" --json
orca orchestration dispatch --task <task_id> --to <dev_handle> --json
orca orchestration check --terminal <coordinator_handle> --wait \
--types worker_done,retest_result,escalation,decision_gate --timeout-ms 900000 --json
```
不能 `--inject` 时手动投递 `core/prompt-templates.md` 的模板;命令细节见 `core/orca-adapter.md`。
### 校验任务板结构
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
### 只处理已有未通过项
用「默认口令」(见下)让 Coordinator 直接扫 `tasks.yaml` 里的 `open` / `failed_retest` 继续闭环。
### 速查:想做什么 → 看哪个文件
| 想做的事 | 文件 |
|----------|------|
| 开一个新需求怎么起步 | `core/kickoff.md` |
| 角色/权限/状态机/完成定义 | `core/roles-and-permissions.md` |
| 三角色各自该怎么做(规划/写测试/复测能力清单) | `core/roles-and-permissions.md` §三角色能力清单 |
| 用哪个模型、怎么固定、何时升级 | `core/model-routing.md` |
| 闭环步骤、手动模式、worktree 决策与对齐 | `core/closed-loop.md` |
| 验收信号写法、三轮失败策略 | `core/optimization-method.md` |
| 派发/复测/回报的 prompt 文案 | `core/prompt-templates.md` |
| Orca 具体命令 | `core/orca-adapter.md` |
| 让 Agent 在新项目初始化 ack | `init-new-project.md` |
| 新项目接入步骤 | `adoption-checklist.md` |
---
## 默认口令
```text
用三角色协作闭环处理 docs/ack/tasks.yaml 里的未通过项:Coordinator 派发给 Developer 修复,再交给 Test 独立复测并终检;每个问题最多修三轮,三轮仍不过就记录为遗留,然后继续下一个。
```
-71
View File
@@ -1,71 +0,0 @@
# 接入清单
把本工具包引用/复制到新项目后,按此清单接入。术语与规则详见 `core/`。
## 1. 分发与版本
- [ ] 选定分发方式(README §「分发方式」A 单软链接 / B 复制)。
- [ ] 在 `docs/ack/` 下建单软链接 `kit`(指向本框架)。
- [ ] 覆盖层文件与 `tasks.yaml` 记录 `kitVersion`(对齐 `kit/VERSION`)。
## 2. 项目级规范(覆盖层)
- [ ] 由 `kit/templates/project.template.md` 生成覆盖层文件(默认 `docs/ack/project.md`,**不占用 `AGENTS.md`**)。
- [ ] 在 `tasks.yaml` 的 `project.overlayFile` 记录覆盖层实际路径。
- [ ] 覆盖层引用 `docs/ack/kit/core/`,不复制其内容。
- [ ] 填清项目简介、技术栈、运行/构建/单测/集成测试命令。
- [ ] 若要 Agent 自动加载覆盖层:在 `AGENTS.md` 加一行指向它,或直接命名为 `AGENTS.md`(可选)。
## 2b. 模型档位
- [ ] Coordinator 默认强模型且不亲自跑测试。
- [ ] Test、Developer 默认中低模型。
- [ ] 记录升级规则:三轮失败升级 Coordinator 复盘;复杂实现升级 Developer 档位(见 `kit/core/model-routing.md`)。
## 3. 路径权限
- [ ] 填实际的 Coordinator (PM) 可写路径(规格)。
- [ ] 填实际的 Test 可写路径(集成测试、复测记录)。
- [ ] 填实际的 Developer 可写路径(源码、单元测试、配置模板)。
- [ ] 本地私有配置标记只读或不提交。
- [ ] 确认 `tasks.yaml` 只有 Coordinator 写(见 `kit/core/roles-and-permissions.md`)。
## 4. 任务板
- [ ] 由 `kit/templates/tasks.template.yaml` 生成 `docs/ack/tasks.yaml`。
- [ ] 替换 `<project_name>`、`<repo_path>`、`<base_url>`、`<dev_worktree>`、`overlayFile`。
- [ ] 至少加一个真实任务,验收写成可观测信号(见 `kit/core/optimization-method.md` §1)。
- [ ] 跑 `python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml` 通过。
## 5. 编排
- [ ] 决定用 Orca(`kit/core/orca-adapter.md`)还是手动模式(`kit/core/closed-loop.md`)。
- [ ] Orca:`orca status`、`terminal list` 可用,Coordinator / Developer / Test 三个终端都在,回报能发回 Coordinator。
- [ ] 派发前会先查空闲 worker:按 `ACK-<ROLE>-<CLI>-<TIER>-<N>` 复用同 worktree、同角色、同配置终端;仅在不存在或都 busy 时新建,并把实际 handle 写入 `dispatch.worker`。
- [ ] 决定子任务放当前 worktree 起子 agent,还是新建隔离 worktree(决策速查见 `kit/core/closed-loop.md` §「子任务放哪」)。
- [ ] 多 worktree 时确认 `tasks.yaml`(SSOT)只保留一个权威副本,由 Coordinator 单写。
## 6. 验证命令
- [ ] Test 有黑盒复测命令,Developer 有白盒验证命令。
- [ ] 构建产物与服务启动命令写清。
- [ ] 浏览器测试 `BASE_URL` 写清,复测前能确认服务来自正确 worktree。
## 7. 三轮失败策略
- [ ] 每个任务记录 `dispatch.rounds`。
- [ ] 三轮失败标记 `leftover` 并填 `resolution.leftoverReason`。
- [ ] `leftover` 不阻塞其它任务;最终报告列出所有 `leftover` 与最后失败证据。
## 8. 第一次试运行
选一个低风险 bug 跑完整闭环,结束后检查是否出现:
- Developer 修改了越权路径,或 Test 改了源码。
- worker_done 或复测报告没有验证证据。
- Test 复测到了旧服务。
- Coordinator 没做终检就直接 verified。
- 任务板字段不够记录失败原因。
- 三轮失败策略没被执行。
把发现的问题回写到项目覆盖层文件;若属通用问题,回流到 kit 的 `kit/core/` 并升 `VERSION`。
-183
View File
@@ -1,183 +0,0 @@
# 在新项目初始化 ack(给 Agent 的操作手册)
本文件指导 Agent 把 `ack` 接入一个目标项目。它是**执行手册**:按步骤在目标项目创建 `docs/ack/`、挂载 kit、生成项目覆盖层和任务板,并完成一次结构校验。
若只想人工核对是否接入完整,看 `adoption-checklist.md`。若要开始一个真实需求,看 `core/kickoff.md`。
---
## 0. 前提与安全边界
开始前先确认三件事:
1. 目标项目根目录:运行或读取当前环境的项目根,不要假设路径。
2. ack kit 目录:本框架目录的绝对路径,例如 `<ack_kit_path>`。
3. 分发方式:默认用**单软链接引用**,只有用户明确要求时才整份复制。
硬规则:
- 不要覆盖已有 `docs/ack/project.md`、`docs/ack/tasks.yaml` 或团队现有 `AGENTS.md`。如果文件已存在,先读内容并向用户说明差异,再询问是否合并。
- 不要把 `core/` 内容复制进项目覆盖层。项目覆盖层只写项目差异。
- 不要提交、推送或改 git 配置,除非用户明确要求。
- 不要把私有配置、token、`.env` 内容写进 `tasks.yaml` 或 `project.md`。
---
## 1. 建目录并挂载 kit
在目标项目根目录下创建推荐布局:
```text
docs/ack/
kit -> <ack_kit_path>
project.md
tasks.yaml
```
默认方式(推荐,可升级):
```bash
mkdir -p docs/ack
ln -s <ack_kit_path> docs/ack/kit
```
如果 `docs/ack/kit` 已存在:
- 是指向同一个 ack 目录的软链接:继续。
- 是指向旧版本的软链接:报告当前指向与目标指向,询问是否更新。
- 是普通目录:不要删除;询问用户是保留、备份还是改用复制模式。
复制模式(仅用户明确要求):
```bash
mkdir -p docs/ack
cp -R <ack_kit_path> docs/ack/kit
```
复制模式接入后,后续升级需要人工对比 `docs/ack/kit/VERSION`。
---
## 2. 生成项目覆盖层 `project.md`
从模板生成:
```bash
cp docs/ack/kit/templates/project.template.md docs/ack/project.md
```
然后填实际项目差异:
- 项目名、技术栈、运行命令、Base URL。
- 覆盖层文件路径:默认 `docs/ack/project.md`。
- 三角色模型档位:默认 Coordinator 强模型,Developer/Test 中低模型。
- 路径权限:规格文档、集成测试、复测记录、源码、单元测试、配置模板、本地私有配置。
- Developer 白盒验证命令:单测、构建、服务启动或重启。
- Test 黑盒复测命令:preflight、API smoke、浏览器回归。
如果是网站项目,务必在命令里写清:
- Developer 改完后如何重启服务或确认热更新生效。
- Test 使用哪个 Base URL。
- Test 浏览器复测入口、用例目录或手工检查路径。
可选:若希望 Agent 自动加载覆盖层,在目标项目 `AGENTS.md` 加一行指向它:
```markdown
本项目使用 ack 三角色协作协议,项目覆盖层见 `docs/ack/project.md`。
```
如果目标项目已有 `AGENTS.md`,只追加这一行或一个短小段落;不要重写原文件。
---
## 3. 生成任务板 `tasks.yaml`
从模板生成:
```bash
cp docs/ack/kit/templates/tasks.template.yaml docs/ack/tasks.yaml
```
至少替换这些字段:
- `updatedAt`:当前时间,带时区。
- `kitVersion`:读取 `docs/ack/kit/VERSION`。
- `project.name`:项目名。
- `project.repoPath`:目标项目根目录绝对路径。
- `project.baseUrl`:网站或 API 的默认访问地址;非服务项目可写 `n/a`。
- `project.devWorktree`:默认当前 worktree;如果会用独立 worktree,写实际路径。
- `project.overlayFile`:默认 `docs/ack/project.md`。
任务可以先保留一个低风险示例任务,但更推荐写入真实首个任务。每个任务的验收必须是可观测信号:
- 可见文本:页面或 CLI 输出出现什么。
- API 结果:状态码、字段、响应值。
- 交互结果:点击、跳转、提交、取消后的真实状态。
不要只写“功能正常”“体验更好”。
---
## 4. 校验结构
在目标项目根目录运行:
```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
通过后继续;失败则按错误修正 `tasks.yaml`,不要绕过校验。
同时人工检查:
- `docs/ack/kit` 是否能访问 `core/`、`templates/`、`scripts/`。
- `docs/ack/project.md` 是否只写项目差异,没有复制大段 core 文本。
- `docs/ack/tasks.yaml` 的 `project.overlayFile` 是否指向真实文件。
- 路径权限是否能防止 Developer 改规格、Test 改源码、多人写 `tasks.yaml`。
- 网站项目是否写清服务重启和浏览器复测方式。
---
## 5. 给用户的初始化报告
初始化完成后,Agent 应给用户一段短报告:
```text
ack 初始化完成。
创建/确认:
- docs/ack/kit -> <ack_kit_path>
- docs/ack/project.md
- docs/ack/tasks.yaml
已填项目差异:
- 技术栈:<...>
- Developer 验证命令:<...>
- Test 复测命令:<...>
- Base URL:<...>
校验:
- python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml: passed
下一步:
- 用 docs/ack/kit/core/kickoff.md 开一个真实需求;或
- 选一个低风险 bug 跑一次完整三角色闭环。
```
如果有未完成项,不要说“初始化完成”。改用:
```text
ack 初始化已部分完成,阻塞在:<具体原因>。
需要用户决定:<选项或缺失信息>。
```
---
## 6. 一句话口令
当用户在新项目里说“初始化 ack”时,Agent 可以按下面执行:
```text
请在当前项目初始化 ack:确认项目根和 ack kit 路径;创建 docs/ack/kit 软链接;由 kit/templates 生成 docs/ack/project.md 与 docs/ack/tasks.yaml;填入项目实际路径、命令、Base URL、模型档位和首个任务;运行 validate_tasks.py;不要覆盖已有文件,不要提交或推送,完成后报告创建文件、校验结果和下一步。
```
+9
View File
@@ -20,6 +20,15 @@
# ref: main # ref: main
# path: skills # path: skills
skills:
repo: ~/.skills
ref: main
path: skills
description: 本地自研 Agent Skills 集合,由 skiff 统一创建、维护和安装。
tags:
- skill-management
- owned
waza: waza:
repo: https://github.com/tw93/Waza.git repo: https://github.com/tw93/Waza.git
ref: main ref: main
+9 -4
View File
@@ -65,6 +65,8 @@ skiff bootstrap
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent | | `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
| `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `skiff init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
### 全局安装(自研 skill) ### 全局安装(自研 skill)
@@ -112,7 +114,10 @@ skiff select --project ~/code/app # 指定项目
``` ```
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc 使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
取消。已经安装到目标范围的 skill 默认勾选;取消勾选不会卸载已有 skill。 取消。普通 `skiff select` 只安装到项目,并在每一项旁只读显示各 Agent 的
全局安装状态;`skiff select -g` 只安装到全局。已经安装到目标范围的 skill
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `skiff remove`。如果全局
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用 项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的 `skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。 skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
@@ -171,8 +176,8 @@ skiff 不保存 token。可以使用 `company/code-review`,也可以使用
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建草稿到 `~/.skills/.drafts/` | | `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
| `skiff check <name>` | 校验草稿或正式 skill | | `skiff check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
| `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` | | `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` |
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 | | `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
@@ -182,7 +187,7 @@ skiff 不保存 token。可以使用 `company/code-review`,也可以使用
```bash ```bash
skiff create my-skill --idea "描述要解决的重复问题" --from-project . skiff create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md # 由 Agent 完善草稿中的 SKILL.md 和 README.md
skiff check my-skill skiff check my-skill
skiff finalize my-skill skiff finalize my-skill
skiff publish skills/my-skill -m "add my-skill" --push skiff publish skills/my-skill -m "add my-skill" --push
+179 -26
View File
@@ -36,6 +36,7 @@ from skiff.registry import (
external_checkout_path, external_checkout_path,
external_skill_path, external_skill_path,
load_registry, load_registry,
registry_repo,
save_registry, save_registry,
) )
from skiff.selector import SkillChoice, select_skills from skiff.selector import SkillChoice, select_skills
@@ -80,6 +81,13 @@ def _project_root(explicit: str | None = None) -> Path:
return find_repo_root() or Path.cwd() return find_repo_root() or Path.cwd()
def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None:
content = source.read_text(encoding="utf-8")
for placeholder, value in values.items():
content = content.replace(placeholder, value)
destination.write_text(content, encoding="utf-8")
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]: def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
names = list(positional or []) names = list(positional or [])
if flagged: if flagged:
@@ -130,7 +138,7 @@ def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
if (path / "SKILL.md").is_file() or discover_external_skills(name, entry): if (path / "SKILL.md").is_file() or discover_external_skills(name, entry):
return return
repo = str(entry["repo"]) repo = registry_repo(entry)
ref = entry.get("ref", "main") ref = entry.get("ref", "main")
dest = external_checkout_path(name, entry) dest = external_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
@@ -140,7 +148,7 @@ def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
) )
_print(f"拉取外部 skill: {name}") _print(f"拉取外部 skill: {name}")
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)], ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True, check=True,
) )
if not discover_external_skills(name, entry): if not discover_external_skills(name, entry):
@@ -277,6 +285,29 @@ def _is_fully_installed(
) )
def _global_installation_note(
name: str,
expected: Path,
targets: list[str],
) -> str:
installed: list[str] = []
conflicts: list[str] = []
for target in targets:
link = agent_skill_dir(target) / name
status = check_link(link, expected)
if status.ok:
installed.append(target)
elif link.exists() or link.is_symlink():
conflicts.append(target)
parts: list[str] = []
if installed:
parts.append(f"全局: {','.join(installed)}")
if conflicts:
parts.append(f"全局同名冲突: {','.join(conflicts)}")
return ";".join(parts)
def _remove_skill( def _remove_skill(
name: str, name: str,
targets: list[str], targets: list[str],
@@ -337,6 +368,13 @@ def cmd_bootstrap(args: argparse.Namespace) -> None:
_print("已安装项目 skill 到所有 agent") _print("已安装项目 skill 到所有 agent")
def cmd_update(args: argparse.Namespace) -> None:
del args
ensure_skills_home()
_print(f"更新 skiff: {SKILLS_HOME}")
subprocess.run(["git", "-C", str(SKILLS_HOME), "pull"], check=True)
def _installed_links( def _installed_links(
name: str, name: str,
targets: list[str], targets: list[str],
@@ -516,17 +554,40 @@ def cmd_select(args: argparse.Namespace) -> None:
for name in [*owned_names, *registry]: for name in [*owned_names, *registry]:
validate_skill_name(name) validate_skill_name(name)
choices = [ def make_choice(
SkillChoice( *,
name: str,
installed_name: str,
expected: Path,
kind: str,
description: str,
indent: int = 0,
) -> SkillChoice:
return SkillChoice(
name=name, name=name,
kind="owned", kind=kind,
description=skill_description(name) or "", description=description,
installed=_is_fully_installed( installed=_is_fully_installed(
name, installed_name,
SKILLS_DIR / name, expected,
project_root, project_root,
targets, targets,
), ),
readonly_status=(
_global_installation_note(installed_name, expected, targets)
if project_root is not None
else ""
),
indent=indent,
)
choices = [
make_choice(
name=name,
installed_name=name,
expected=SKILLS_DIR / name,
kind="owned",
description=skill_description(name) or "",
) )
for name in owned_names for name in owned_names
] ]
@@ -541,40 +602,60 @@ def cmd_select(args: argparse.Namespace) -> None:
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {package}") _err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {package}")
continue continue
choices.append( choices.append(
SkillChoice( make_choice(
name=package, name=package,
installed_name=package,
expected=root,
kind="external", kind="external",
description=str(entry.get("description", "")), description=str(entry.get("description", "")),
installed=_is_fully_installed(
package,
root,
project_root,
targets,
),
) )
) )
choice_requests[package] = (package, "registry") choice_requests[package] = (package, "registry")
continue continue
for skill_name in skill_names: for skill_name in skill_names:
expected = root / skill_name
if (
skill_name in owned_names
and expected.resolve() == (SKILLS_DIR / skill_name).resolve()
):
continue
choice_name = f"{package}/{skill_name}" choice_name = f"{package}/{skill_name}"
description = read_skill_meta(root / skill_name).get("description", "") description = read_skill_meta(expected).get("description", "")
choices.append( choices.append(
SkillChoice( make_choice(
name=choice_name, name=choice_name,
installed_name=skill_name,
expected=expected,
kind=f"external:{package}", kind=f"external:{package}",
description=description, description=description,
installed=_is_fully_installed( indent=1,
skill_name,
root / skill_name,
project_root,
targets,
),
) )
) )
choice_requests[choice_name] = (skill_name, f"registry:{package}") choice_requests[choice_name] = (skill_name, f"registry:{package}")
child_names = tuple(
f"{package}/{skill_name}"
for skill_name in skill_names
if f"{package}/{skill_name}" in choice_requests
)
if child_names:
first_child = len(choices) - len(child_names)
choices.insert(
first_child,
SkillChoice(
name=package,
kind="repository",
description=str(entry.get("description", "")),
children=child_names,
),
)
try: try:
selected = select_skills(choices) scope_label = (
"全局"
if project_root is None
else f"项目 {project_root}(全局状态只读)"
)
selected = select_skills(choices, scope_label=scope_label)
except (RuntimeError, OSError) as exc: except (RuntimeError, OSError) as exc:
raise SystemExit(str(exc)) from exc raise SystemExit(str(exc)) from exc
if selected is None: if selected is None:
@@ -729,7 +810,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
raise SystemExit(f"registry 中不存在: {args.name}") raise SystemExit(f"registry 中不存在: {args.name}")
entry = registry[args.name] entry = registry[args.name]
repo = entry["repo"] repo = registry_repo(entry)
ref = entry.get("ref", "main") ref = entry.get("ref", "main")
dest = external_checkout_path(args.name, entry) dest = external_checkout_path(args.name, entry)
@@ -743,7 +824,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
else: else:
_print(f"克隆: {repo} -> {dest}") _print(f"克隆: {repo} -> {dest}")
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)], ["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True, check=True,
) )
@@ -918,6 +999,16 @@ def cmd_create(args: argparse.Namespace) -> None:
flags=re.MULTILINE, flags=re.MULTILINE,
) )
skill_md.write_text(content, encoding="utf-8") skill_md.write_text(content, encoding="utf-8")
readme = dst / "README.md"
if readme.is_file():
readme_content = re.sub(
r"^#\s+skill-name\s*$",
f"# {args.name}",
readme.read_text(encoding="utf-8"),
count=1,
flags=re.MULTILINE,
)
readme.write_text(readme_content, encoding="utf-8")
brief = { brief = {
"name": args.name, "name": args.name,
"idea": args.idea or "", "idea": args.idea or "",
@@ -1039,6 +1130,60 @@ def cmd_doctor(args: argparse.Namespace) -> None:
sys.exit(1) sys.exit(1)
def cmd_init(args: argparse.Namespace) -> None:
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
ensure_skills_home()
skill_source = SKILLS_DIR / args.name
if not (skill_source / "SKILL.md").is_file():
raise SystemExit(f"builtin skill 不存在: {args.name}")
project = _project_root(args.project)
if not project.is_dir():
raise SystemExit(f"项目目录不存在: {project}")
destination = project / "docs" / args.name
project_file = destination / "project.md"
tasks_file = destination / "tasks.yaml"
managed_targets = (project_file, tasks_file)
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
project_template = skill_source / "templates" / "project.template.md"
tasks_template = skill_source / "templates" / "tasks.template.yaml"
missing = [path for path in (project_template, tasks_template) if not path.is_file()]
if missing:
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
raise SystemExit(f"skill 缺少初始化模板: {paths}")
destination.mkdir(parents=True, exist_ok=True)
version_file = skill_source / "VERSION"
ack_version = version_file.read_text(encoding="utf-8").strip() if version_file.is_file() else "unknown"
now = datetime.now().astimezone().isoformat(timespec="seconds")
values = {
"<project_name>": project.name,
"<repo_path>": str(project),
"<dev_worktree>": str(project),
"<overlay_file_path>": f"docs/{args.name}/project.md",
"<ack_version>": ack_version,
"<接入时的 ack skill 版本>": ack_version,
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
}
_render_template(project_template, project_file, values)
_render_template(tasks_template, tasks_file, values)
validator = skill_source / "scripts" / "validate_tasks.py"
if validator.is_file():
subprocess.run([sys.executable, str(validator), str(tasks_file)], check=True)
_print(f"✓ skill 项目状态初始化完成: {args.name}")
_print(f" 项目: {project}")
_print(f" 覆盖层: {project_file}")
_print(f" 任务板: {tasks_file}")
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
def _add_common_flags(parser: argparse.ArgumentParser) -> None: def _add_common_flags(parser: argparse.ArgumentParser) -> None:
parser.add_argument( parser.add_argument(
"-a", "-a",
@@ -1075,6 +1220,9 @@ def build_parser() -> argparse.ArgumentParser:
) )
p_bootstrap.set_defaults(func=cmd_bootstrap) p_bootstrap.set_defaults(func=cmd_bootstrap)
p_update = sub.add_parser("update", help="通过 git pull 更新 skiff 自身")
p_update.set_defaults(func=cmd_update)
p_list = sub.add_parser("list", help="列出所有 source 中的 skill") p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
p_list.add_argument("--source", help="只列出指定来源(owned、registry 或 custom source)") p_list.add_argument("--source", help="只列出指定来源(owned、registry 或 custom source)")
p_list.set_defaults(func=cmd_list) p_list.set_defaults(func=cmd_list)
@@ -1202,6 +1350,11 @@ def build_parser() -> argparse.ArgumentParser:
p_finalize.add_argument("name", help="skill 名称") p_finalize.add_argument("name", help="skill 名称")
p_finalize.set_defaults(func=cmd_finalize) p_finalize.set_defaults(func=cmd_finalize)
p_init = sub.add_parser("init", help="使用 skill 模板初始化项目状态")
p_init.add_argument("name", help="skill 名称")
p_init.add_argument("--project", help="项目根目录(默认自动检测或当前目录)")
p_init.set_defaults(func=cmd_init)
p_doctor = sub.add_parser("doctor", help="软链健康检查") p_doctor = sub.add_parser("doctor", help="软链健康检查")
p_doctor.add_argument("-a", "--agent", dest="agents", nargs="+", action="append") p_doctor.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链") p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
+12 -1
View File
@@ -26,6 +26,12 @@ def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) ->
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8") path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def 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: def external_repo_path(entry: dict[str, Any]) -> Path:
"""Return the shared checkout path for a repo/ref pair.""" """Return the shared checkout path for a repo/ref pair."""
from skiff.paths import EXTERNALS_DIR from skiff.paths import EXTERNALS_DIR
@@ -37,9 +43,14 @@ def external_repo_path(entry: dict[str, Any]) -> Path:
def external_checkout_path(name: str, entry: dict[str, Any]) -> Path: def external_checkout_path(name: str, entry: dict[str, Any]) -> Path:
"""Prefer an existing pre-shared-cache checkout for compatibility.""" """Use a local repo directly, otherwise return its external checkout."""
from skiff.paths import EXTERNALS_DIR 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 legacy = EXTERNALS_DIR / name
return legacy if legacy.is_dir() else external_repo_path(entry) return legacy if legacy.is_dir() else external_repo_path(entry)
+136 -21
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Iterable from typing import Callable, Iterable
@@ -12,6 +13,38 @@ class SkillChoice:
kind: str kind: str
description: str = "" description: str = ""
installed: bool = False 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]: def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
@@ -24,12 +57,14 @@ def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoi
if needle in choice.name.casefold() if needle in choice.name.casefold()
or needle in choice.kind.casefold() or needle in choice.kind.casefold()
or needle in choice.description.casefold() or needle in choice.description.casefold()
or needle in choice.readonly_status.casefold()
] ]
def select_skills( def select_skills(
choices: list[SkillChoice], choices: list[SkillChoice],
*, *,
scope_label: str = "当前作用域",
wrapper: Callable[..., set[str] | None] | None = None, wrapper: Callable[..., set[str] | None] | None = None,
) -> set[str] | None: ) -> set[str] | None:
"""Open the selector. Return names, or None when cancelled.""" """Open the selector. Return names, or None when cancelled."""
@@ -41,36 +76,103 @@ def select_skills(
except ImportError as exc: except ImportError as exc:
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
selected = {choice.name for choice in choices if choice.installed} 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: def draw(stdscr: object) -> set[str] | None:
curses.curs_set(0) curses.curs_set(0)
stdscr.keypad(True) stdscr.keypad(True)
current = 0 current = 0
query = "" 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: while True:
visible = filter_choices(choices, query) visible = filter_choices(choices, query)
current = min(current, max(0, len(visible) - 1)) current = min(current, max(0, len(visible) - 1))
stdscr.erase() stdscr.erase()
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消" text_width = max(0, width - 1)
stdscr.addnstr(0, 0, header, 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: if query:
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1)) stdscr.addnstr(
2,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
rows = max(1, height - 4) item_capacity = max(1, (height - 4) // 3)
start = max(0, current - rows + 1) start = max(0, current - item_capacity + 1)
for row, choice in enumerate(visible[start : start + rows], start=2): for offset, choice in enumerate(visible[start : start + item_capacity]):
index = start + row - 2 index = start + offset
mark = "x" if choice.name in selected else " " row = 3 + offset * 3
suffix = f" · {choice.description}" if choice.description else "" mark = mark_for(choice)
line = f"[{mark}] {choice.name} {choice.kind}{suffix}" description = f" {choice.description}" if choice.description else ""
attr = curses.A_REVERSE if index == current else curses.A_NORMAL focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
stdscr.addnstr(row, 0, line, max(0, width - 1), attr) 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)} 项" footer = (
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1)) f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
)
stdscr.addnstr(
height - 1,
0,
fit_to_width(footer, text_width),
text_width,
)
stdscr.refresh() stdscr.refresh()
key = stdscr.get_wch() key = stdscr.get_wch()
@@ -83,11 +185,18 @@ def select_skills(
elif key == curses.KEY_DOWN and visible: elif key == curses.KEY_DOWN and visible:
current = (current + 1) % len(visible) current = (current + 1) % len(visible)
elif key == " " and visible: elif key == " " and visible:
name = visible[current].name choice = visible[current]
selected.symmetric_difference_update({name}) 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 == "/": elif key == "/":
curses.curs_set(1) curses.curs_set(1)
query = _read_query(stdscr, curses, width) query = _read_query(stdscr, curses, width, row=2)
curses.curs_set(0) curses.curs_set(0)
current = 0 current = 0
@@ -95,12 +204,18 @@ def select_skills(
return runner(draw) return runner(draw)
def _read_query(stdscr: object, curses: object, width: int) -> str: def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
query = "" query = ""
while True: while True:
stdscr.move(1, 0) stdscr.move(row, 0)
stdscr.clrtoeol() stdscr.clrtoeol()
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1)) text_width = max(0, width - 1)
stdscr.addnstr(
row,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
stdscr.refresh() stdscr.refresh()
key = stdscr.get_wch() key = stdscr.get_wch()
if key in ("\n", "\r") or key == curses.KEY_ENTER: if key in ("\n", "\r") or key == curses.KEY_ENTER:
+16
View File
@@ -196,8 +196,24 @@ def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]:
"""返回 skill 目录中的校验问题;空列表表示通过。""" """返回 skill 目录中的校验问题;空列表表示通过。"""
issues: list[str] = [] issues: list[str] = []
skill_md = skill_dir / "SKILL.md" skill_md = skill_dir / "SKILL.md"
readme = skill_dir / "README.md"
if not skill_md.is_file(): if not skill_md.is_file():
return ["缺少 SKILL.md"] 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") text = skill_md.read_text(encoding="utf-8")
if not text.strip(): if not text.strip():
+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 的同步
关系,不复用当前含义模糊的名称。
+11 -1
View File
@@ -100,7 +100,17 @@ def fetch_source(name: str, entry: dict[str, Any]) -> Path:
else: else:
checkout.parent.mkdir(parents=True, exist_ok=True) checkout.parent.mkdir(parents=True, exist_ok=True)
subprocess.run( subprocess.run(
["git", "clone", "--branch", ref, "--", str(repo), str(checkout)], [
"git",
"clone",
"--depth",
"1",
"--branch",
ref,
"--",
str(repo),
str(checkout),
],
check=True, check=True,
) )
return checkout return checkout
+28
View File
@@ -0,0 +1,28 @@
# skill-name
用一句话告诉使用者这个 skill 能解决什么问题。
## 什么时候使用
- 给出用户可以直接说出的典型请求。
- 说明适用范围。
## 使用前准备
- 列出需要用户提前提供或确认的信息、权限和依赖。
## 使用示例
```text
给出一条可以直接交给 Agent 的示例请求。
```
## Agent 会做什么
1. 用面向使用者的语言概括主要步骤。
2. 说明可能发生的外部写操作或重要边界。
## 如何判断完成
- 说明使用者最终会得到什么,以及如何确认结果正确。
+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 目录读取。
+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
@@ -1,9 +1,9 @@
# notes-web Agent 协作协议(示例,项目覆盖层) # notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.8.1。 > 本项目基于 ack v0.8.1。
> 稳定规范引用 `docs/ack/kit/core/`,本文件只填项目差异。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> 目录下只有一个软链接 `kit/` + `project.md` + `tasks.yaml`。 > `docs/ack/` 只保存 `project.md` 与 `tasks.yaml`。
## 项目概览 ## 项目概览
@@ -16,13 +16,13 @@
## 稳定规范(引用,不重复) ## 稳定规范(引用,不重复)
- 开始一个需求(启动手册):`docs/ack/kit/core/kickoff.md` - 开始一个需求(启动手册):`references/kickoff.md`
- 角色 / 权限 / 状态机 / 完成定义:`docs/ack/kit/core/roles-and-permissions.md` - 角色 / 权限 / 状态机 / 完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`docs/ack/kit/core/model-routing.md` - 模型档位与升级规则:`references/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`docs/ack/kit/core/closed-loop.md` - 闭环流程(含手动模式、worktree 对齐):`references/closed-loop.md`
- 优化方法(验收信号、三轮策略):`docs/ack/kit/core/optimization-method.md` - 优化方法(验收信号、三轮策略):`references/optimization-method.md`
- 派发 prompt 模板:`docs/ack/kit/core/prompt-templates.md` - 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`docs/ack/kit/core/orca-adapter.md` - Orca 编排命令:`references/orca-adapter.md`
## 模型档位 ## 模型档位
@@ -63,16 +63,12 @@ curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
# 浏览器回归:tests/browser/cases/*.md # 浏览器回归:tests/browser/cases/*.md
``` ```
任务板校验: 任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
```bash ## 硬规则(其余见 references/)
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
## 硬规则(其余见 kit/core/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。 - 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 kit/core/model-routing.md)。 - 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 - `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`;Test 与 Developer 只读。 - 只有 Coordinator 写 `tasks.yaml`;Test 与 Developer 只读。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。 - 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
@@ -2,7 +2,7 @@
version: 1 version: 1
updatedAt: "2026-07-06T09:40:00+08:00" updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent" source: "Coordinator (PM) Agent"
kitVersion: "0.3.0" ackVersion: "0.8.1"
project: project:
name: "notes-web" name: "notes-web"
repoPath: "/home/dev/notes-web" repoPath: "/home/dev/notes-web"
@@ -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`。
+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。
## 初始化报告
完成后报告:
- 创建或确认的两个项目文件。
- 检测到的技术栈和验证命令。
- 任务板校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
具体阻塞项。除非用户明确要求,不提交、不推送。
@@ -14,9 +14,9 @@
```text ```text
我要做一个新需求:<一句话需求>。 我要做一个新需求:<一句话需求>。
你作为 ack 的 Coordinator(PM),按 docs/ack/kit/core 的规范执行: 你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
1. 先读 docs/ack/project.md、docs/ack/kit/core/roles-and-permissions.md、closed-loop.md、optimization-method.md。 1. 先读 docs/ack/project.md、references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。 2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 把任务写进 docs/ack/tasks.yaml(只有你写),跑 validate 校验结构。 3. 把任务写进 docs/ack/tasks.yaml(只有你写),跑 validate 校验结构。
4. 先把「产品文档 + 任务拆分 + 验收信号」给我确认,不要急着派发。 4. 先把「产品文档 + 任务拆分 + 验收信号」给我确认,不要急着派发。
@@ -34,7 +34,7 @@
3. 校验结构: 3. 校验结构:
```bash ```bash
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
``` ```
4. **停下来给人确认**——这是强模型该花时间的地方,不要跳过。 4. **停下来给人确认**——这是强模型该花时间的地方,不要跳过。
@@ -58,14 +58,14 @@ python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
# Codex 示例(模型与执行模式以项目覆盖层为准) # Codex 示例(模型与执行模式以项目覆盖层为准)
DEV_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-terra -c model_reasoning_effort=medium' 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' TEST_CMD='codex --dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna -c model_reasoning_effort=low'
python3 docs/ack/validate_worker_command.py --role developer --command "$DEV_CMD" python3 <ack-skill-dir>/scripts/validate_worker_command.py --role developer --command "$DEV_CMD"
python3 docs/ack/validate_worker_command.py --role test --command "$TEST_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 "$DEV_CMD" --title "ACK-DEV-CODEX-TERRA-1" --json
orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json orca terminal create --worktree active --command "$TEST_CMD" --title "ACK-TEST-CODEX-LUNA-1" --json
# Cursor 示例 # Cursor 示例
CURSOR_CMD='cursor-agent --yolo --model auto' CURSOR_CMD='cursor-agent --yolo --model auto'
python3 docs/ack/validate_worker_command.py --role developer --command "$CURSOR_CMD" 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 orca terminal create --worktree active --command "$CURSOR_CMD" --title "ACK-DEV-CURSOR-AUTO-1" --json
``` ```
@@ -71,7 +71,7 @@
执行模式也必须在创建 worker 时固定,并服从项目覆盖层或用户的明确要求。Cursor 的 YOLO 参数是 `--yolo`;Codex 的等价参数是 `--dangerously-bypass-approvals-and-sandbox`。不要把 Cursor 的 `--yolo` 原样传给 Codex,也不要把裸启动 `codex` 当作“自动选择了正确角色模型”。如果项目提供 worker 命令校验脚本,校验通过是 `terminal create` 的前置条件。 执行模式也必须在创建 worker 时固定,并服从项目覆盖层或用户的明确要求。Cursor 的 YOLO 参数是 `--yolo`;Codex 的等价参数是 `--dangerously-bypass-approvals-and-sandbox`。不要把 Cursor 的 `--yolo` 原样传给 Codex,也不要把裸启动 `codex` 当作“自动选择了正确角色模型”。如果项目提供 worker 命令校验脚本,校验通过是 `terminal create` 的前置条件。
**本 kit 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。 **ACK 对 Cursor 的默认建议**:Test 与 Developer worker 用 `cursor-agent --yolo --model auto`(自动选型,天然偏向高效模型,符合"中低档位"意图,并按当前项目授权启用 YOLO);需要更强时改成具体强模型并保留 `--yolo`。Coordinator 作为强模型脑,通常就是发起编排的那个会话本身。
### Codex 默认映射 ### Codex 默认映射
@@ -8,7 +8,7 @@
## 角色模型(三角色) ## 角色模型(三角色)
本 kit 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。 ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。
| 角色 | 主要职责 | 验证方式 | 不应做的事 | | 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------| |------|----------|----------|------------|
@@ -1,14 +1,13 @@
# <项目名> Agent 协作协议(项目覆盖层) # <项目名> Agent 协作协议(项目覆盖层)
> 本项目基于 ack v<kit_version>(见 `docs/ack/kit/VERSION`)。 > 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
> 稳定规范引用 `docs/ack/kit/core/`,不复制其内容;本文件只填项目自己的差异。 > `references/` 读取;本文件只保存项目差异。
> >
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`, > **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`,
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。 > 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
> 若希望 Agent 自动加载,可在项目 `AGENTS.md` 里加一行指向本文件,或直接把本文件命名为 `AGENTS.md`。 > 若希望 Agent 自动加载,可在项目 `AGENTS.md` 里加一行指向本文件,或直接把本文件命名为 `AGENTS.md`。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。 > 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
> > `docs/ack/` 只保存本项目的 `project.md` 与 `tasks.yaml`,不复制或链接 Skill。
> 目录约定:`docs/ack/` 下只有一个软链接 `kit/`(指向共享框架),加本项目 `project.md` + `tasks.yaml`。
## 项目概览 ## 项目概览
@@ -19,17 +18,17 @@
- 任务板:`docs/ack/tasks.yaml` - 任务板:`docs/ack/tasks.yaml`
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`) - 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
## 稳定规范(不在此重复,直接引用) ## 通用规范(由 ACK Skill 按需读取)
- 开始一个需求(启动手册):`docs/ack/kit/core/kickoff.md` - 开始需求:`references/kickoff.md`
- 角色模型 / 权限 / 状态机 / 完成定义:`docs/ack/kit/core/roles-and-permissions.md` - 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`docs/ack/kit/core/model-routing.md` - 模型档位与升级规则:`references/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`docs/ack/kit/core/closed-loop.md` - 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 优化方法(验收信号、三轮策略):`docs/ack/kit/core/optimization-method.md` - 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`docs/ack/kit/core/prompt-templates.md` - 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令(可选):`docs/ack/kit/core/orca-adapter.md` - Orca 编排命令(可选):`references/orca-adapter.md`
## 模型档位(项目可覆盖,默认见 core/model-routing.md) ## 模型档位(项目可覆盖,默认见 references/model-routing.md)
| 角色 | 默认档位 | 本项目实际 | | 角色 | 默认档位 | 本项目实际 |
|------|----------|------------| |------|----------|------------|
@@ -68,16 +67,12 @@ Test 黑盒复测:
<browser_regression_command> <browser_regression_command>
``` ```
任务板校验: 任务板校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 执行。
```bash ## 硬规则(其余见 references/)
python3 docs/ack/kit/scripts/validate_tasks.py docs/ack/tasks.yaml
```
## 硬规则(其余见 kit/core/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。 - 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 kit/core/model-routing.md)。 - 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。 - `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`;Developer 与 Test 都只读,通过消息回报。 - 只有 Coordinator 写 `tasks.yaml`;Developer 与 Test 都只读,通过消息回报。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。 - 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
@@ -1,7 +1,7 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/skills/kits/ack/tasks.schema.json", "$id": "https://git.yumee.top/laily/skills/skills/ack/templates/tasks.schema.json",
"title": "Agent Collaboration Kit task board", "title": "ACK task board",
"description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。", "description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。",
"type": "object", "type": "object",
"required": ["version", "project", "tasks"], "required": ["version", "project", "tasks"],
@@ -10,10 +10,14 @@
"version": { "type": "integer", "minimum": 1 }, "version": { "type": "integer", "minimum": 1 },
"updatedAt": { "type": "string" }, "updatedAt": { "type": "string" },
"source": { "type": "string" }, "source": { "type": "string" },
"kitVersion": { "ackVersion": {
"type": "string", "type": "string",
"description": "接入时所基于的 ack 版本,便于日后 diff 升级" "description": "接入时所基于的 ack 版本,便于日后 diff 升级"
}, },
"kitVersion": {
"type": "string",
"description": "旧版 ACK 版本字段;兼容读取,新项目应使用 ackVersion"
},
"project": { "project": {
"type": "object", "type": "object",
"required": ["name"], "required": ["name"],
@@ -2,7 +2,7 @@
version: 1 version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>" updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
source: "Coordinator (PM) Agent" source: "Coordinator (PM) Agent"
kitVersion: "<接入时的 ack 版本,见 kit 根 VERSION>" ackVersion: "<接入时的 ack skill 版本>"
project: project:
name: "<project_name>" name: "<project_name>"
repoPath: "<repo_path>" repoPath: "<repo_path>"
+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
+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` 显示目标软链接正常。
+26 -7
View File
@@ -2,8 +2,9 @@
name: skiff name: skiff
description: >- description: >-
创建和维护 ~/.skills 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布, 创建和维护 ~/.skills 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布,
或用 skiff add/remove 在项目及全局挂卸 skill。触发词:skiff、自研 skill、创建 skill、 用 skiff add/remove 在项目及全局挂卸 skill,或用 skiff init 初始化 skill 项目状态。
想做一个 skill、publish skill、安装自研 skill、更新 skill 到项目。 触发词:skiff、自研 skill、创建 skill、想做一个 skill、publish skill、安装自研 skill、
更新 skill 到项目、初始化 skill。
--- ---
# skiff 自研 Skill 工作流 # skiff 自研 Skill 工作流
@@ -37,15 +38,16 @@ skiff add <name> -a cursor -a claude -a codex -y
skiff create <name> --idea "<用户原始想法>" --from-project . skiff create <name> --idea "<用户原始想法>" --from-project .
``` ```
4. 编辑 `~/.skills/.drafts/<name>/SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。 4. 编辑 `~/.skills/.drafts/<name>/SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。
5. 不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。 5. 编辑同目录的 `README.md`,用面向人类的语言说明用途、准备事项、可直接复制的请求示例、Agent 会做什么以及如何判断完成。README 不应复述 Agent 内部指令。
6. 运行校验并修复所有问题: 6. 仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
7. 运行校验并修复所有问题:
```bash ```bash
skiff check <name> skiff check <name>
``` ```
7. 向用户展示名称、description、核心步骤和验证方式。获得确认后再转正: 8. 向用户展示名称、description、README 的人类使用方式、核心步骤和验证方式。获得确认后再转正:
```bash ```bash
skiff finalize <name> skiff finalize <name>
@@ -114,7 +116,11 @@ skiff add waza -a codex -g -y
skiff add waza/think -a codex -g -y skiff add waza/think -a codex -g -y
``` ```
`skiff select` 会把 collection 展开为 `waza/think`、`waza/ui` 等候选项。 `skiff select` 会把 collection 显示为两级菜单:选择 `waza` 仓库会选中其
全部子 skill,也可以只选择 `waza/think`、`waza/ui` 中的若干项。
普通 `skiff select` 只向项目安装,并只读显示每个 Agent 的全局安装状态;
`skiff select -g` 只向全局安装。取消已勾选项不会卸载,卸载继续使用
`skiff remove`。
卸载: 卸载:
@@ -130,6 +136,17 @@ skiff rm discussion-notes -g -y # rm 别名
skiff add --list skiff add --list
``` ```
使用 Skill 自带模板初始化项目状态:
```bash
skiff init ack
skiff init ack --project ~/app
```
`skiff` 只负责可靠地生成项目状态文件,不复制或链接 Skill。需要分析项目并完善
ACK 配置、检查接入状态或
运行三角色闭环时,显式调用全局 `/ack` skill。
--- ---
## 与 Vercel `npx skills` 的分工 ## 与 Vercel `npx skills` 的分工
@@ -146,6 +163,7 @@ skiff add --list
| skiff | 说明 | | skiff | 说明 |
|-------|------| |-------|------|
| `bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent | | `bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
| `update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `add <name> [-g] [-a AGENT...] [-y]` | 安装 | | `add <name> [-g] [-a AGENT...] [-y]` | 安装 |
| `remove <name> [-g] [-a AGENT...] [-y]` | 卸载(`rm` / `r` 别名) | | `remove <name> [-g] [-a AGENT...] [-y]` | 卸载(`rm` / `r` 别名) |
| `add --list` | 列出可用自研 skill | | `add --list` | 列出可用自研 skill |
@@ -155,6 +173,7 @@ skiff add --list
| `create <name> --idea TEXT [--from-project PATH]` | 创建自研 skill 草稿 | | `create <name> --idea TEXT [--from-project PATH]` | 创建自研 skill 草稿 |
| `check <name>` | 校验草稿或正式 skill | | `check <name>` | 校验草稿或正式 skill |
| `finalize <name>` | 校验草稿并转为正式 skill | | `finalize <name>` | 校验草稿并转为正式 skill |
| `init <name> [--project DIR]` | 使用 skill 模板初始化项目状态 |
--- ---
+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()
+58
View File
@@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
import argparse
import os import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from skiff import cli
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -24,6 +28,13 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# Skill 名称\n\n## 步骤\n\n1. 第一步\n", "---\n\n# Skill 名称\n\n## 步骤\n\n1. 第一步\n",
encoding="utf-8", 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 = self.skills_home / "skills" / "skiff"
project_skill.mkdir() project_skill.mkdir()
project_skill.joinpath("SKILL.md").write_text( project_skill.joinpath("SKILL.md").write_text(
@@ -32,6 +43,10 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# skiff\n\n## 步骤\n\n1. 维护 skill。\n", "---\n\n# skiff\n\n## 步骤\n\n1. 维护 skill。\n",
encoding="utf-8", encoding="utf-8",
) )
project_skill.joinpath("README.md").write_text(
"# skiff\n\n供人类使用的 skiff 说明。\n",
encoding="utf-8",
)
def tearDown(self) -> None: def tearDown(self) -> None:
self.temp_dir.cleanup() self.temp_dir.cleanup()
@@ -76,6 +91,19 @@ class CreateWorkflowTests(unittest.TestCase):
self.assertIn("claude", first.stdout) self.assertIn("claude", first.stdout)
self.assertIn("codex", 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: def test_create_writes_draft_and_brief_without_registering_owned_skill(self) -> None:
project = self.home / "project" project = self.home / "project"
project.mkdir() project.mkdir()
@@ -92,6 +120,10 @@ class CreateWorkflowTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
draft = self.skills_home / ".drafts" / "migration-review" draft = self.skills_home / ".drafts" / "migration-review"
self.assertTrue((draft / "SKILL.md").is_file()) 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") brief = (draft / "brief.yaml").read_text(encoding="utf-8")
self.assertIn("idea: 检查数据库迁移", brief) self.assertIn("idea: 检查数据库迁移", brief)
self.assertIn(f"source_project: {project.resolve()}", brief) self.assertIn(f"source_project: {project.resolve()}", brief)
@@ -117,12 +149,19 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# 数据库迁移检查\n\n## 步骤\n\n1. 检查迁移与回滚路径。\n", "---\n\n# 数据库迁移检查\n\n## 步骤\n\n1. 检查迁移与回滚路径。\n",
encoding="utf-8", 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") result = self.run_skiff("finalize", "migration-review")
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
final = self.skills_home / "skills" / "migration-review" final = self.skills_home / "skills" / "migration-review"
self.assertTrue((final / "SKILL.md").is_file()) self.assertTrue((final / "SKILL.md").is_file())
self.assertTrue((final / "README.md").is_file())
self.assertFalse((final / "brief.yaml").exists()) self.assertFalse((final / "brief.yaml").exists())
self.assertFalse(draft.exists()) self.assertFalse(draft.exists())
@@ -135,12 +174,31 @@ class CreateWorkflowTests(unittest.TestCase):
"---\n\n# 参考检查\n\n读取 [规范](references/rules.md)。\n", "---\n\n# 参考检查\n\n读取 [规范](references/rules.md)。\n",
encoding="utf-8", encoding="utf-8",
) )
draft.joinpath("README.md").write_text(
"# reference-check\n\n用于检查本地参考资料。\n",
encoding="utf-8",
)
result = self.run_skiff("check", "reference-check") result = self.run_skiff("check", "reference-check")
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0)
self.assertIn("引用文件不存在", result.stderr) 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: def test_finalize_does_not_overwrite_existing_owned_skill(self) -> None:
created = self.run_skiff("create", "collision-check", "--idea", "检查名称冲突") created = self.run_skiff("create", "collision-check", "--idea", "检查名称冲突")
self.assertEqual(created.returncode, 0, created.stderr) self.assertEqual(created.returncode, 0, created.stderr)
+28
View File
@@ -6,7 +6,9 @@ import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from skiff.sources import fetch_source
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -228,6 +230,32 @@ class CustomSourceTests(unittest.TestCase):
self.assertTrue((checkout / ".git").is_dir()) self.assertTrue((checkout / ".git").is_dir())
self.assertTrue((checkout / "skills" / "release-check" / "SKILL.md").is_file()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+2
View File
@@ -19,6 +19,8 @@ class ProjectSkillContentTests(unittest.TestCase):
"skiff check <name>", "skiff check <name>",
"~/.skills/skills/<name>/", "~/.skills/skills/<name>/",
"第三方 skill", "第三方 skill",
"skiff init ack",
"显式调用全局 `/ack` skill",
): ):
self.assertIn(expected, content) self.assertIn(expected, content)
+14
View File
@@ -7,6 +7,9 @@ import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path 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] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -79,6 +82,17 @@ class RegistryCollectionTests(unittest.TestCase):
check=False, 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: def test_add_collection_installs_every_discovered_skill(self) -> None:
result = self.run_skiff("add", "test-pack", "-g", "-a", "codex") result = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
+263 -5
View File
@@ -13,13 +13,17 @@ from unittest.mock import Mock, patch
from skiff import cli from skiff import cli
from skiff import yaml_io from skiff import yaml_io
from skiff.registry import external_repo_path, external_skill_path from skiff.registry import external_repo_path, external_skill_path
from skiff.selector import SkillChoice, filter_choices, select_skills from skiff.selector import SkillChoice, filter_choices, fit_to_width, select_skills
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
class SelectorTests(unittest.TestCase): 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: def test_manifest_entry_targets_round_trip(self) -> None:
data = { data = {
"skills": [ "skills": [
@@ -36,12 +40,18 @@ class SelectorTests(unittest.TestCase):
def test_filter_matches_name_kind_and_description(self) -> None: def test_filter_matches_name_kind_and_description(self) -> None:
choices = [ choices = [
SkillChoice("frontend-design", "external", "创建界面"), SkillChoice("frontend-design", "external", "创建界面"),
SkillChoice("discussion-notes", "owned", "维护讨论笔记"), 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, "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, "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, "界面")], ["frontend-design"])
self.assertEqual([c.name for c in filter_choices(choices, "codex")], ["discussion-notes"])
def test_selector_preserves_preselected_items(self) -> None: def test_selector_preserves_preselected_items(self) -> None:
choices = [ choices = [
@@ -59,6 +69,114 @@ class SelectorTests(unittest.TestCase):
) )
self.assertEqual(selected, {"already-there"}) 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: def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"} first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"}
@@ -97,6 +215,43 @@ class SelectorTests(unittest.TestCase):
self.assertEqual(discover_external_skills("unsafe", entry), {}) 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): class SelectCommandTests(unittest.TestCase):
def test_non_tty_exits_with_add_guidance(self) -> None: def test_non_tty_exits_with_add_guidance(self) -> None:
@@ -136,6 +291,17 @@ class SelectCommandTests(unittest.TestCase):
stdin.isatty.return_value = True stdin.isatty.return_value = True
stdout.isatty.return_value = True stdout.isatty.return_value = True
installed: list[str] = [] 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 ( with (
patch.object(cli.sys, "stdin", stdin), patch.object(cli.sys, "stdin", stdin),
@@ -157,6 +323,11 @@ class SelectCommandTests(unittest.TestCase):
patch.object(cli, "_registry_skill_names", return_value=["external-one"]), patch.object(cli, "_registry_skill_names", return_value=["external-one"]),
patch.object(cli, "external_skill_path", return_value=external), patch.object(cli, "external_skill_path", return_value=external),
patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]), patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]),
patch.object(
cli,
"_global_installation_note",
return_value="全局: codex",
),
patch.object( patch.object(
cli, cli,
"_is_fully_installed", "_is_fully_installed",
@@ -165,7 +336,7 @@ class SelectCommandTests(unittest.TestCase):
patch.object( patch.object(
cli, cli,
"select_skills", "select_skills",
return_value={"owned-one", "external-one"}, side_effect=choose,
), ),
patch.object( patch.object(
cli, cli,
@@ -183,6 +354,40 @@ class SelectCommandTests(unittest.TestCase):
self.assertIn("source: registry", manifest) self.assertIn("source: registry", manifest)
self.assertIn("targets:", manifest) self.assertIn("targets:", manifest)
self.assertIn("codex", 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: def test_select_rejects_invalid_registry_name_before_rendering(self) -> None:
args = argparse.Namespace( args = argparse.Namespace(
@@ -227,7 +432,7 @@ class SelectCommandTests(unittest.TestCase):
selected_choices: list[SkillChoice] = [] selected_choices: list[SkillChoice] = []
installed: list[tuple[str, str | None]] = [] installed: list[tuple[str, str | None]] = []
def choose(choices: list[SkillChoice]) -> set[str]: def choose(choices: list[SkillChoice], **_: object) -> set[str]:
selected_choices.extend(choices) selected_choices.extend(choices)
return {"waza/think"} return {"waza/think"}
@@ -265,9 +470,62 @@ class SelectCommandTests(unittest.TestCase):
): ):
cli.cmd_select(args) cli.cmd_select(args)
self.assertEqual([choice.name for choice in selected_choices], ["waza/think", "waza/ui"]) 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")]) 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: def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
+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()