Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fef8247fc | |||
| 2fcf16627d | |||
| 706c553778 | |||
| db92baf3a7 | |||
| fbf0ca8cd8 | |||
| 8d48d2d335 | |||
| d7ebb6ee85 | |||
| 19aac17a0a | |||
| 125bfe0c23 | |||
| 57879cffdd | |||
| 4814bc1026 | |||
| 7be8b9c3d9 | |||
| f8e490e9b5 | |||
| 95946d4278 | |||
| af6e139d30 | |||
| 93341b3c69 | |||
| 0eefc52b44 | |||
| 8390706df8 | |||
| d3b6732d40 | |||
| ab29598a9a | |||
| c9faedc39d | |||
| f05ac24c57 | |||
| de194370e6 | |||
| 299f6dd9b2 | |||
| 558c6facc2 | |||
| 32da57d19b | |||
| 51f7c2e60c | |||
| 544cbcca32 | |||
| a35cc916cb | |||
| 5ba1d8e3d6 | |||
| 0ec8ea81d8 | |||
| 824049f774 | |||
| 1100eec884 | |||
| cf78d0cab2 | |||
| 86ce602b28 | |||
| 130dcc36c5 | |||
| fd13dad2b0 | |||
| 38c4c7f91d | |||
| 147b63aae6 | |||
| 94c66bc695 | |||
| 2917708b10 | |||
| f469c8f5f5 | |||
| 44cee4be91 | |||
| 146182c1b9 | |||
| e60755f280 | |||
| eb2d0ce651 | |||
| b476176f3b | |||
| cc9af04a40 | |||
| d791f5584c | |||
| ca3156ed93 | |||
| bdfd2ff342 | |||
| ca090cd7f2 | |||
| 2783946cec | |||
| a47e899702 | |||
| 2ba027098f | |||
| aa7a265e76 | |||
| c248c3f0e3 | |||
| 9b3e16cb63 | |||
| 06865939e1 | |||
| 852dee3a69 | |||
| 83deb886b5 | |||
| 17643bc17b | |||
| 4ce08ec89e | |||
| b28acc66d8 | |||
| c2d6a5a0d7 | |||
| ef22c6829e | |||
| 2be0964d73 | |||
| 681aa9e237 | |||
| 10d8800f07 | |||
| e9d2b5fde6 | |||
| f0b71b5bec | |||
| 4423e7df1a | |||
| 12e00bd594 | |||
| f3cd56b78e | |||
| 03d6298145 | |||
| a7a201962a | |||
| ad6695245b | |||
| ee31278947 | |||
| 282a6809ac | |||
| a8efa5f359 |
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
# Python(skiff CLI 运行时产物)
|
# Python(pouch CLI 运行时产物)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
@@ -31,3 +31,4 @@ Thumbs.db
|
|||||||
# 本地环境配置(勿提交密钥)
|
# 本地环境配置(勿提交密钥)
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
.pouch
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||||
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
||||||
version: 1
|
version: 1
|
||||||
updatedAt: "2026-08-01T12:43:06+08:00"
|
updatedAt: "2026-08-01T12:43:06+08:00"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
|
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ACK Skill v0.18.0。通用规范由 `/ack` 从 Skill 自身的
|
> 本项目基于 ACK Skill v0.19.0。通用规范由 `/ack` 从 Skill 自身的
|
||||||
> `references/` 读取;本文件只保存当前仓库差异。
|
> `references/` 读取;本文件只保存当前仓库差异。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
- 技术栈:Python 3、Markdown、JSON Schema
|
- 技术栈:Python 3、Markdown、JSON Schema
|
||||||
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
|
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
|
||||||
- Base URL:`n/a`
|
- Base URL:`n/a`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`.pouch/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||||
- 覆盖层文件:`docs/ack/project.md`
|
- 覆盖层文件:`.pouch/ack/project.md`
|
||||||
|
|
||||||
## 通用规范(由 ACK Skill 按需读取)
|
## 通用规范(由 ACK Skill 按需读取)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
机器可校验的 worker profile、允许 worktree 和 receipt 只以
|
机器可校验的 worker profile、允许 worktree 和 receipt 只以
|
||||||
`docs/ack/tasks.yaml` 为准。本次 Developer 使用当前项目根 `/home/ace/.skills`;Test
|
`.pouch/ack/tasks.yaml` 为准。本次 Developer 使用当前项目根 `/home/ace/.skills`;Test
|
||||||
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
|
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
|
||||||
|
|
||||||
## 路径权限
|
## 路径权限
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||||
|------|:-----------:|:----:|:---------:|------|
|
|------|:-----------:|:----:|:---------:|------|
|
||||||
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
|
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
|
||||||
| `docs/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
|
| `.pouch/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
|
||||||
| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 |
|
| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 |
|
||||||
| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 |
|
| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 |
|
||||||
| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
|
| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
|
||||||
@@ -57,8 +57,8 @@ Test 黑盒复测:
|
|||||||
```bash
|
```bash
|
||||||
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
|
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
|
||||||
python3 -m unittest discover -s tests -p 'test_ack*.py'
|
python3 -m unittest discover -s tests -p 'test_ack*.py'
|
||||||
python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||||
python3 skills/ack/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
python3 skills/ack/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## 本次任务硬规则
|
## 本次任务硬规则
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updatedAt: "2026-08-23T20:30:26+08:00"
|
updatedAt: "2026-08-23T20:30:26+08:00"
|
||||||
source: "Coordinator (PM) Agent"
|
source: "Coordinator (PM) Agent"
|
||||||
ackVersion: "0.18.0"
|
ackVersion: "0.19.0"
|
||||||
project:
|
project:
|
||||||
name: "skills"
|
name: "skills"
|
||||||
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
|
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||||
baseUrl: "n/a"
|
baseUrl: "n/a"
|
||||||
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
|
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: ".pouch/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: ".pouch/ack/knowledge.yaml"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "orca"
|
mode: "orca"
|
||||||
@@ -455,7 +455,7 @@ tasks:
|
|||||||
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'"
|
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'"
|
||||||
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'"
|
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'"
|
||||||
- "python3 -m unittest discover -s tests -p 'test_ack*.py'"
|
- "python3 -m unittest discover -s tests -p 'test_ack*.py'"
|
||||||
- "python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml"
|
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
|
||||||
browser:
|
browser:
|
||||||
page: "n/a"
|
page: "n/a"
|
||||||
checks:
|
checks:
|
||||||
@@ -507,7 +507,7 @@ tasks:
|
|||||||
explicit tenant-b selection, trusted executable resolution,
|
explicit tenant-b selection, trusted executable resolution,
|
||||||
screenshot containment, stable opaque sourceRef deduplication and
|
screenshot containment, stable opaque sourceRef deduplication and
|
||||||
fail-closed handling for malformed, ambiguous, non-progressing and
|
fail-closed handling for malformed, ambiguous, non-progressing and
|
||||||
over-limit responses. Validators, skiff check, compileall and diff
|
over-limit responses. Validators, pouch check, compileall and diff
|
||||||
checks also passed.
|
checks also passed.
|
||||||
|
|
||||||
resolution:
|
resolution:
|
||||||
@@ -617,7 +617,7 @@ tasks:
|
|||||||
verification:
|
verification:
|
||||||
commands:
|
commands:
|
||||||
- "python3 -m unittest tests.test_ack_omp_worker tests.test_ack_worker_profiles tests.test_ack_launch_worker"
|
- "python3 -m unittest tests.test_ack_omp_worker tests.test_ack_worker_profiles tests.test_ack_launch_worker"
|
||||||
- "python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml"
|
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
|
||||||
browser:
|
browser:
|
||||||
page: "n/a"
|
page: "n/a"
|
||||||
checks:
|
checks:
|
||||||
@@ -809,8 +809,8 @@ tasks:
|
|||||||
verification:
|
verification:
|
||||||
commands:
|
commands:
|
||||||
- "python3 -m unittest discover -s tests -p 'test_ack_omp_e2e.py'"
|
- "python3 -m unittest discover -s tests -p 'test_ack_omp_e2e.py'"
|
||||||
- "python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml"
|
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
|
||||||
- "python3 skills/ack/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml"
|
- "python3 skills/ack/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml"
|
||||||
browser:
|
browser:
|
||||||
page: "n/a"
|
page: "n/a"
|
||||||
checks:
|
checks:
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
# Agent Skills 仓库
|
# pouch
|
||||||
|
|
||||||
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。Skill 内容、规范包与可运行的 skiff CLI 在本仓库一并维护。
|
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。仓库名、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。旧名是 `skills` / `pouch`。
|
||||||
|
|
||||||
|
|
||||||
| 仓库 | 地址 | 职责 |
|
| 仓库 | 地址 | 职责 |
|
||||||
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
|
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
|
||||||
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill、规范包、skiff CLI 的当前 SSOT |
|
| **pouch**(本仓库) | [https://git.yumee.top/laily/pouch](https://git.yumee.top/laily/pouch) | skill、规范包、pouch CLI 的当前 SSOT |
|
||||||
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | skiff CLI 的独立来源 / 上游同步参考 |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时:pouch skill 已安装则走其「安装 pouch」模式;否则读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行。不要只用 `pouch select`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆并关联
|
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||||
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
cd ~/.pouch && ./install.sh
|
||||||
cd ~/.skills && ./install.sh
|
|
||||||
|
|
||||||
# install.sh 会自动把 skiff 项目 skill 安装到所有 Agent
|
# install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
|
||||||
# 2. 安装其他 skill
|
pouch select -g
|
||||||
skiff add declarative-openspec-loop -g
|
pouch list
|
||||||
|
pouch status
|
||||||
# 3. 查看状态
|
|
||||||
skiff list
|
|
||||||
skiff status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -32,22 +29,14 @@ skiff status
|
|||||||
## 仓库结构
|
## 仓库结构
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/
|
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类)
|
||||||
├── _template/ # 新建 skill 的脚手架
|
pouch/ # CLI 源码(Python 3)
|
||||||
├── ack/ # 完整 ACK skill,含 references/templates/scripts
|
bin/pouch # CLI 入口
|
||||||
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
|
catalog.yaml # pouch 预置 Skill 来源目录
|
||||||
│ ├── SKILL.md
|
AGENTS.md # 本文档
|
||||||
│ └── reference.md
|
|
||||||
├── discussion-notes/ # 讨论沉淀笔记
|
|
||||||
│ ├── SKILL.md
|
|
||||||
│ └── reference.md
|
|
||||||
skiff/ # CLI 源码(Python 3)
|
|
||||||
bin/skiff # CLI 入口
|
|
||||||
catalog.yaml # skiff 预置 Skill 来源目录
|
|
||||||
AGENTS.md # 本文档
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`catalog.yaml`、`AGENTS.md`
|
**本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
|
||||||
**本仓库不包含**:各项目的 skill 启用清单
|
**本仓库不包含**:各项目的 skill 启用清单
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -57,38 +46,64 @@ AGENTS.md # 本文档
|
|||||||
### 内置(Builtin)
|
### 内置(Builtin)
|
||||||
|
|
||||||
|
|
||||||
| Skill | 说明 |
|
| Skill | 说明 |
|
||||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
| --- | --- |
|
||||||
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
|
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
|
||||||
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
|
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
|
||||||
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
|
||||||
|
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
|
||||||
|
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
|
||||||
|
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
|
||||||
|
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
|
||||||
|
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
|
||||||
|
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
|
||||||
|
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
|
||||||
|
| [previewer](skills/previewer/README.md) | 通过 Tailscale Serve 将静态文件暴露为 tailnet 内 HTTP 预览 |
|
||||||
|
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
|
||||||
|
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
|
||||||
|
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
|
||||||
|
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug,一直交到停点 |
|
||||||
|
| [long-ship](skills/long-ship/README.md) | 复杂需求:specifier PRD 批准后拆任务、集成回归并交付 |
|
||||||
|
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
|
||||||
|
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
|
||||||
|
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
|
||||||
|
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
|
||||||
|
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
|
||||||
|
|
||||||
|
改名后旧名保留一个纯占位 skill,避免已装软链悬空和旧项目 `.pouch.yaml` 失效。确认没有
|
||||||
|
项目再按旧名启用后,删除对应占位目录即可,不丢任何内容。
|
||||||
|
|
||||||
|
|
||||||
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
|
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
|
||||||
|
|
||||||
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
|
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
|
||||||
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
|
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
|
||||||
项目状态,不复制或链接 Skill 内容:
|
项目状态,不复制或链接 Skill 内容:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff init ack
|
pouch init ack
|
||||||
```
|
```
|
||||||
|
|
||||||
|
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(检查配置并引导补齐),
|
||||||
|
不要 `pouch init builder` / `pouch init deployer`。
|
||||||
|
|
||||||
### 预置目录(Catalog)
|
### 预置目录(Catalog)
|
||||||
|
|
||||||
在 `catalog.yaml` 中预置,通过 skiff 拉取安装:
|
在 `catalog.yaml` 中预置,通过 pouch 拉取安装:
|
||||||
|
|
||||||
|
|
||||||
| Source | 来源 |
|
| Source | 来源 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) |
|
| waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) |
|
||||||
|
| taste-skill | [https://github.com/Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) |
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff fetch waza
|
pouch fetch waza
|
||||||
skiff add waza/think -g
|
pouch add waza/think -g
|
||||||
|
pouch add taste-skill/taste-skill -g
|
||||||
```
|
```
|
||||||
|
|
||||||
### 自定义仓库(Custom Sources)
|
### 自定义仓库(Custom Sources)
|
||||||
@@ -96,21 +111,21 @@ skiff add waza/think -g
|
|||||||
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
|
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff source add company \
|
pouch source add company \
|
||||||
git@git.company.com:platform/agent-skills.git \
|
git@git.company.com:platform/agent-skills.git \
|
||||||
--skills-path skills
|
--skills-path skills
|
||||||
skiff list --source company
|
pouch list --source company
|
||||||
skiff add company/internal-review -g
|
pouch add company/internal-review -g
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以接入已有本地 checkout:
|
也可以接入已有本地 checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff source add company --local ~/code/company-skills --skills-path skills
|
pouch source add company --local ~/code/company-skills --skills-path skills
|
||||||
```
|
```
|
||||||
|
|
||||||
配置保存在 `~/.config/skiff/config.yaml`,Git source 默认 clone 到
|
配置保存在 `~/.config/pouch/config.yaml`,Git source 默认 clone 到
|
||||||
`~/.local/share/skiff/sources/<source>/`。项目 `.skills.yaml` 只记录逻辑
|
`~/.local/share/pouch/sources/<source>/`。项目 `.pouch.yaml` 只记录逻辑
|
||||||
source 名称,每台机器独立配置实际仓库地址。
|
source 名称,每台机器独立配置实际仓库地址。
|
||||||
|
|
||||||
### 社区(External NPM / GitHub)
|
### 社区(External NPM / GitHub)
|
||||||
@@ -127,29 +142,29 @@ npx skills find typescript
|
|||||||
## 设计原则
|
## 设计原则
|
||||||
|
|
||||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
||||||
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
|
2. **项目自治** — 每个项目自己维护 `.pouch.yaml`,本仓库不维护项目清单
|
||||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||||
4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
|
4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
|
||||||
5. **一体维护** — skill 与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
|
5. **一体维护** — skill 与 CLI 同仓库维护
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
skills 仓库(本仓库) skiff CLI
|
pouch 仓库(本仓库) pouch CLI
|
||||||
skills/<name>/ ←── skiff install / enable
|
skills/<name>/ ←── pouch install / enable
|
||||||
skiff/ ←── python3 -m skiff
|
pouch/ ←── python3 -m pouch
|
||||||
catalog.yaml ←── skiff add / fetch
|
catalog.yaml ←── pouch add / fetch
|
||||||
↑
|
↑
|
||||||
~/.skills(symlink)
|
~/.pouch(symlink)
|
||||||
│
|
│
|
||||||
┌────┴────┐
|
┌────┴────┐
|
||||||
▼ ▼
|
▼ ▼
|
||||||
~/.cursor/skills/ project/.agents/skills/
|
~/.cursor/skills/ project/.agents/skills/
|
||||||
~/.claude/skills/ project/.claude/skills/
|
~/.claude/skills/ project/.claude/skills/
|
||||||
~/.codex/skills/ project/.opencode/skills/
|
~/.codex/skills/ project/.pouch.yaml
|
||||||
~/.config/opencode/skills/ project/.skills.yaml
|
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Skill 三层分类
|
### Skill 三层分类
|
||||||
@@ -158,16 +173,10 @@ catalog.yaml ←── skiff add / fetch
|
|||||||
| 层级 | 位置 | 维护方式 |
|
| 层级 | 位置 | 维护方式 |
|
||||||
| ---------------- | ---------------------------------- | ------------------------------- |
|
| ---------------- | ---------------------------------- | ------------------------------- |
|
||||||
| **Builtin** | `skills/<name>/` | 本仓库 commit |
|
| **Builtin** | `skills/<name>/` | 本仓库 commit |
|
||||||
| **Catalog** | `catalog.yaml` + checkout 缓存 | `skiff catalog add / fetch` |
|
| **Catalog** | `catalog.yaml` + checkout 缓存 | `pouch catalog add / fetch` |
|
||||||
| **Custom Source** | `~/.local/share/skiff/sources/` 或本地路径 | `skiff source add/fetch` |
|
| **Custom Source** | `~/.local/share/pouch/sources/` 或本地路径 | `pouch source add/fetch` |
|
||||||
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
|
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
|
||||||
|
|
||||||
### 非 Skill 资料分类
|
|
||||||
|
|
||||||
| 类型 | 位置 | 维护方式 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
|
|
||||||
|
|
||||||
|
|
||||||
### 多 Agent 路径
|
### 多 Agent 路径
|
||||||
|
|
||||||
@@ -177,17 +186,17 @@ catalog.yaml ←── skiff add / fetch
|
|||||||
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
|
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
|
||||||
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
|
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
|
||||||
| Codex | `~/.codex/skills/` | `.agents/skills/` |
|
| Codex | `~/.codex/skills/` | `.agents/skills/` |
|
||||||
| OpenCode | `~/.config/opencode/skills/` | `.opencode/skills/` |
|
| Agents 标准 | `~/.agents/skills/` | `.agents/skills/`(与 cursor/codex 共用;覆盖 OMP) |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目级启用
|
## 项目级启用
|
||||||
|
|
||||||
每个项目**自己维护** `.skills.yaml`,不由本仓库管理:
|
每个项目**自己维护** `.pouch.yaml`,不由本仓库管理:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .skills.yaml(在项目根目录)
|
# .pouch.yaml(在项目根目录)
|
||||||
skills:
|
skills:
|
||||||
- name: declarative-openspec-loop
|
- name: declarative-openspec-loop
|
||||||
source: builtin
|
source: builtin
|
||||||
@@ -205,38 +214,39 @@ targets: # 可选,默认 all
|
|||||||
|
|
||||||
| 概念 | 类比 |
|
| 概念 | 类比 |
|
||||||
| -------------- | --------------------------- |
|
| -------------- | --------------------------- |
|
||||||
| skills 仓库 | npm registry |
|
| pouch 仓库 | npm registry |
|
||||||
| `.skills.yaml` | `package.json` dependencies |
|
| `.pouch.yaml` | `package.json` dependencies |
|
||||||
| `skiff enable` | `npm install` |
|
| `pouch enable` | `npm install` |
|
||||||
| `skiff sync` | `npm ci` |
|
| `pouch sync` | `npm ci` |
|
||||||
|
|
||||||
|
|
||||||
项目级命令:
|
项目级命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/code/my-app
|
cd ~/code/my-app
|
||||||
skiff enable declarative-openspec-loop
|
pouch enable declarative-openspec-loop
|
||||||
skiff disable declarative-openspec-loop
|
pouch disable declarative-openspec-loop
|
||||||
skiff sync
|
pouch sync
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## skiff 命令
|
## pouch 命令
|
||||||
|
|
||||||
详见 [skiff README](https://git.yumee.top/laily/skiff)。
|
详见 [pouch README](https://git.yumee.top/laily/pouch)。
|
||||||
|
|
||||||
### 已实现
|
### 已实现
|
||||||
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| -------------------------------------- | ------------------- |
|
| -------------------------------------- | ------------------- |
|
||||||
| `skiff bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent |
|
| `pouch bootstrap` | 将 pouch 项目 skill 全局安装到所有 Agent |
|
||||||
| `skiff list` | 列出所有 skill |
|
| `pouch list` | 列出所有 skill |
|
||||||
| `skiff status` | 安装状态总览 |
|
| `pouch status` | 安装状态总览 |
|
||||||
| `skiff add <name> [-g]` | 项目或全局安装(symlink) |
|
| `pouch add <name> [-g]` | 项目或全局安装(symlink) |
|
||||||
| `skiff remove <name> [-g]` | 移除 symlink |
|
| `pouch remove <name> [-g]` | 移除 symlink |
|
||||||
| `skiff catalog add` / `skiff fetch` | 管理和拉取 catalog source |
|
| `pouch catalog add` / `pouch fetch` | 管理和拉取 catalog source |
|
||||||
|
| `pouch update` | 更新 pouch 自身和已拉取的 catalog / custom source |
|
||||||
|
|
||||||
|
|
||||||
### 草稿与健康检查
|
### 草稿与健康检查
|
||||||
@@ -244,10 +254,10 @@ skiff sync
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `skiff create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
|
| `pouch create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
|
||||||
| `skiff check <name>` | 校验草稿或正式 skill |
|
| `pouch check <name>` | 校验草稿或正式 skill |
|
||||||
| `skiff finalize <name>` | 校验并将草稿转为正式 skill |
|
| `pouch finalize <name>` | 校验并将草稿转为正式 skill |
|
||||||
| `skiff doctor [--fix]` | symlink 健康检查与修复 |
|
| `pouch doctor [--fix]` | symlink 健康检查与修复 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -258,7 +268,8 @@ skiff sync
|
|||||||
|
|
||||||
```
|
```
|
||||||
skill-name/
|
skill-name/
|
||||||
├── SKILL.md # 必需
|
├── SKILL.md # 必需,给 Agent
|
||||||
|
├── README.md # 必需,给人类
|
||||||
├── reference.md # 可选
|
├── reference.md # 可选
|
||||||
├── examples.md # 可选
|
├── examples.md # 可选
|
||||||
└── scripts/ # 可选
|
└── scripts/ # 可选
|
||||||
@@ -282,12 +293,12 @@ description: >-
|
|||||||
|
|
||||||
### 新建流程
|
### 新建流程
|
||||||
|
|
||||||
1. `skiff create my-skill --idea "..." --from-project .`
|
1. `pouch create my-skill --idea "..." --from-project .`
|
||||||
2. Agent 编辑 `~/.skills/.drafts/my-skill/SKILL.md`
|
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md` 和 `README.md`
|
||||||
3. `skiff check my-skill`
|
3. `pouch check my-skill`
|
||||||
4. 用户确认后执行 `skiff finalize my-skill`
|
4. 用户确认后执行 `pouch finalize my-skill`
|
||||||
5. `skiff add my-skill -a cursor -g -y` 验证
|
5. `pouch add my-skill -a cursor -g -y` 验证
|
||||||
6. 在本仓库 commit;需要的项目再用 `skiff add my-skill` 启用
|
6. 在本仓库 commit;需要的项目再用 `pouch add my-skill` 启用
|
||||||
|
|
||||||
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
|
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
|
||||||
|
|
||||||
@@ -299,7 +310,7 @@ symlink 正确时,Agent 在项目里改 skill 文件 = 直接改 SSOT:
|
|||||||
|
|
||||||
```
|
```
|
||||||
project/.agents/skills/foo/SKILL.md
|
project/.agents/skills/foo/SKILL.md
|
||||||
→ ~/.skills/skills/foo/SKILL.md
|
→ ~/.pouch/skills/foo/SKILL.md
|
||||||
→ 在本仓库 commit
|
→ 在本仓库 commit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -314,7 +325,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
| -------------- | ----------------------------------------------------- |
|
| -------------- | ----------------------------------------------------- |
|
||||||
| Cursor / Codex | symlink,正常 |
|
| Cursor / Codex | symlink,正常 |
|
||||||
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
||||||
| symlink 被替换 | `skiff doctor --fix` → 重建 symlink |
|
| symlink 被替换 | `pouch doctor --fix` → 重建 symlink |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -324,7 +335,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
|
|
||||||
| 场景 | 工具 |
|
| 场景 | 工具 |
|
||||||
| -------------- | ---------------------------------------------------- |
|
| -------------- | ---------------------------------------------------- |
|
||||||
| 自研 skill 安装/管理 | **skiff** |
|
| 自研 skill 安装/管理 | **pouch** |
|
||||||
| 社区 skill 安装 | **Vercel `npx skills add`** |
|
| 社区 skill 安装 | **Vercel `npx skills add`** |
|
||||||
| NPM 包内 skill | **skills-npm** / **skill-indexer** |
|
| NPM 包内 skill | **skills-npm** / **skill-indexer** |
|
||||||
| 搜索发现 | **npx skills find** / [skills.sh](https://skills.sh) |
|
| 搜索发现 | **npx skills find** / [skills.sh](https://skills.sh) |
|
||||||
@@ -337,13 +348,15 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
|
|
||||||
| 我要… | 命令 | 在哪 |
|
| 我要… | 命令 | 在哪 |
|
||||||
| ---------- | --------------------------------- | ---- |
|
| ---------- | --------------------------------- | ---- |
|
||||||
| 首次安装 | `git clone <repo> ~/.skills && ~/.skills/install.sh` | 任意 |
|
| 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
|
||||||
| 新建 skill | `skiff create` → Agent 完善 → `check/finalize` | 任意项目 |
|
| 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 |
|
||||||
| 全局启用 | `skiff install <name>` | 任意 |
|
| 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 |
|
||||||
| 项目启用 | `skiff enable <name>` | 项目目录 |
|
| 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
|
||||||
| 看状态 | `skiff status` | 任意 |
|
| 全局启用 | `pouch add <name> -g` | 任意 |
|
||||||
|
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
|
||||||
|
| 看状态 | `pouch status` | 任意 |
|
||||||
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
|
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
|
||||||
| 更新外部 skill | `skiff fetch <name>` | 任意 |
|
| 更新外部 skill | `pouch fetch <name>` 或日常命令自动更新 | 任意 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -351,7 +364,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
|||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [Agent Skills 开放标准](https://agentskills.io)
|
- [Agent Skills 开放标准](https://agentskills.io)
|
||||||
- [skiff CLI](https://git.yumee.top/laily/skiff)
|
- [pouch CLI](https://git.yumee.top/laily/pouch)
|
||||||
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
||||||
- [skills.sh](https://skills.sh)
|
- [skills.sh](https://skills.sh)
|
||||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||||
|
|||||||
@@ -1,85 +1,155 @@
|
|||||||
# Agent Skills
|
# pouch
|
||||||
|
|
||||||
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。Skill 内容与 **skiff** CLI 在本仓库一并维护。
|
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
|
||||||
|
|
||||||
|
pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。
|
||||||
|
|
||||||
|
给同事看怎么装、怎么打版本、怎么构建,读 [docs/getting-started.md](docs/getting-started.md)。人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
- Python 3、git
|
||||||
|
- Cursor、Claude Code、Codex,或其它兼容 Agent Skills 的 Agent
|
||||||
|
- `~/.local/bin` 需要在 `PATH` 里(`install.sh` 会写入 bash / zsh / fish 的 rc,当前 shell 要重开或 `source` 一次才生效)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||||
cd ~/.skills
|
cd ~/.pouch
|
||||||
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
|
./install.sh
|
||||||
|
|
||||||
skiff add declarative-openspec-loop -g
|
# install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
|
||||||
skiff select # 交互式选择并批量安装
|
# 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
|
||||||
skiff list
|
|
||||||
skiff status
|
pouch select # 交互选择要装的 skill(默认当前项目)
|
||||||
|
pouch select -g # 或装到全局,所有项目都能用
|
||||||
|
pouch list
|
||||||
|
pouch status
|
||||||
|
```
|
||||||
|
|
||||||
|
从旧的 `~/.skills` / `pouch` 迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv ~/.skills ~/.pouch
|
||||||
|
~/.pouch/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
|
||||||
|
|
||||||
|
## 让 Agent 代装
|
||||||
|
|
||||||
|
对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。步骤只维护在
|
||||||
|
[skills/pouch/references/install.md](skills/pouch/references/install.md):
|
||||||
|
确认 python3/git → 处理 `~/.pouch` → `install.sh` → 用短清单介绍 skill → 问要装哪些。
|
||||||
|
不要用 `pouch select`。不要在未确认时覆盖已有的 `~/.pouch`。
|
||||||
|
|
||||||
|
自己装仍看上面的「快速开始」。
|
||||||
|
|
||||||
|
## 装完之后怎么用
|
||||||
|
|
||||||
|
1. 打开已安装目标的 Agent(Cursor / Claude Code / Codex 等)。
|
||||||
|
2. 项目级安装:打开那个项目再说话。全局安装(`-g`):任意项目都可以。
|
||||||
|
3. 按对应 skill 的 README,把示例请求发给 Agent。
|
||||||
|
|
||||||
|
`./install.sh` 只全局安装 `pouch`。其它 skill 都要 `pouch add` 或 `pouch select`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/code/my-app
|
||||||
|
pouch add ack # 当前项目
|
||||||
|
pouch add discussion-notes -g # 全局
|
||||||
```
|
```
|
||||||
|
|
||||||
## 仓库结构
|
## 仓库结构
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
|
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
|
||||||
skiff/ # CLI 源码(Python 3)
|
pouch/ # CLI 源码(Python 3)
|
||||||
bin/skiff # CLI 入口
|
bin/pouch # CLI 入口
|
||||||
catalog.yaml # skiff 预置 Skill 来源目录
|
catalog.yaml # pouch 预置 Skill 来源目录
|
||||||
AGENTS.md # 详细规范与架构说明
|
AGENTS.md # 详细规范与架构说明
|
||||||
```
|
```
|
||||||
|
|
||||||
| 路径 | 说明 |
|
|
||||||
|------|------|
|
| 路径 | 说明 |
|
||||||
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
|
| ---------------------------- | ----------------------------------------------------- |
|
||||||
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
|
| [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
|
||||||
| [catalog.yaml](catalog.yaml) | skiff 预置 Skill 来源目录 |
|
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
|
||||||
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
|
||||||
|
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
||||||
|
|
||||||
|
|
||||||
## 自研 Skill
|
## 自研 Skill
|
||||||
|
|
||||||
| Skill | 说明 |
|
|
||||||
|-------|------|
|
|
||||||
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
|
|
||||||
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
|
|
||||||
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
|
||||||
|
|
||||||
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
|
| Skill | 说明 |
|
||||||
|
| ----------------------------------------------------------------------- | ------------------------------------------- |
|
||||||
|
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
|
||||||
|
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
|
||||||
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
|
||||||
|
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
|
||||||
|
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
|
||||||
|
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
|
||||||
|
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
|
||||||
|
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
|
||||||
|
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
|
||||||
|
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
|
||||||
|
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
|
||||||
|
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
|
||||||
|
| [previewer](skills/previewer/README.md) | 用 Node 渲染 HTML/静态文件,并通过 Tailscale 返回访问 IP 和 HTTP 预览 |
|
||||||
|
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
|
||||||
|
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
|
||||||
|
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
|
||||||
|
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug |
|
||||||
|
| [long-ship](skills/long-ship/README.md) | 复杂需求:PRD 批准后拆任务、集成回归并交付 |
|
||||||
|
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
|
||||||
|
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
|
||||||
|
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
|
||||||
|
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
|
||||||
|
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
|
||||||
|
|
||||||
|
|
||||||
|
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff init ack
|
pouch init ack
|
||||||
skiff init ack --project ~/app
|
pouch init ack --project ~/app
|
||||||
```
|
```
|
||||||
|
|
||||||
初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护
|
初始化会生成默认关闭的 `.pouch/ack/delivery.yaml` 和空的 `.pouch/ack/regression.yaml`。
|
||||||
DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。
|
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
|
||||||
|
验证通过后收获回归用例。
|
||||||
|
|
||||||
|
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
|
||||||
|
|
||||||
新建 skill:
|
新建 skill:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
|
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||||
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
|
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md 和 README.md
|
||||||
skiff check my-skill
|
pouch check my-skill
|
||||||
skiff finalize my-skill
|
pouch finalize my-skill
|
||||||
skiff add my-skill -g # 全局安装验证
|
pouch add my-skill -g # 全局安装验证
|
||||||
```
|
```
|
||||||
|
|
||||||
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `skiff` skill 收集实际结果与期望结果,修改 `~/.skills/skills/<name>/` 的 SSOT,并执行 `skiff check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
|
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `pouch` skill 收集实际结果与期望结果,修改 `~/.pouch/skills/<name>/` 的 SSOT,并执行 `pouch check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
|
||||||
|
|
||||||
## 安装方式
|
## 安装方式
|
||||||
|
|
||||||
### 全局(用户级)
|
### 全局(用户级)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff add <name> -g # 安装到 ~/.cursor/skills/ 等
|
pouch add <name> -g # 安装到 ~/.cursor/skills/ 等
|
||||||
skiff add <name> -g -a cursor
|
pouch add <name> -g -a cursor
|
||||||
```
|
```
|
||||||
|
|
||||||
### 项目级
|
### 项目级
|
||||||
|
|
||||||
在项目根目录维护 `.skills.yaml`:
|
在项目根目录维护 `.pouch.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
skills:
|
skills:
|
||||||
- name: declarative-openspec-loop
|
- name: ack
|
||||||
source: builtin
|
source: builtin
|
||||||
- name: think
|
- name: think
|
||||||
source: catalog:waza
|
source: catalog:waza
|
||||||
@@ -91,9 +161,9 @@ targets: # 可选,默认 all
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff add declarative-openspec-loop
|
pouch add ack
|
||||||
skiff sync
|
pouch sync
|
||||||
skiff remove declarative-openspec-loop
|
pouch remove ack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Catalog 与 Custom Source
|
## Catalog 与 Custom Source
|
||||||
@@ -101,15 +171,17 @@ skiff remove declarative-openspec-loop
|
|||||||
安装 catalog 中预置的来源:
|
安装 catalog 中预置的来源:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff fetch waza
|
pouch fetch waza
|
||||||
skiff add waza/think -g
|
pouch add waza/think -g
|
||||||
|
pouch add taste-skill/taste-skill -g # 只要核心 frontend skill
|
||||||
|
pouch add taste-skill -g # 或装整个 Taste Skill collection
|
||||||
```
|
```
|
||||||
|
|
||||||
接入团队自己的本地目录或 Git 仓库:
|
接入团队自己的本地目录或 Git 仓库:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff source add company --local ~/code/company-skills --skills-path skills
|
pouch source add company --local ~/code/company-skills --skills-path skills
|
||||||
skiff add company/internal-review -g
|
pouch add company/internal-review -g
|
||||||
```
|
```
|
||||||
|
|
||||||
**社区来源**(Vercel CLI):
|
**社区来源**(Vercel CLI):
|
||||||
@@ -123,31 +195,34 @@ npx skills find typescript
|
|||||||
|
|
||||||
```
|
```
|
||||||
本仓库
|
本仓库
|
||||||
├── skills/<name>/ ←── skiff install / enable
|
├── skills/<name>/ ←── pouch add / select
|
||||||
├── catalog.yaml ←── 预置来源发现与 fetch
|
├── catalog.yaml ←── 预置来源发现与 fetch
|
||||||
└── skiff/ ←── python3 -m skiff
|
└── pouch/ ←── python3 -m pouch
|
||||||
↑
|
↑
|
||||||
~/.skills(symlink)
|
~/.pouch(symlink)
|
||||||
│
|
│
|
||||||
┌────┴────────────────┐
|
┌────┴────────────────┐
|
||||||
▼ ▼
|
▼ ▼
|
||||||
~/.cursor/skills/ project/.agents/skills/
|
~/.cursor/skills/ project/.agents/skills/
|
||||||
~/.claude/skills/ project/.claude/skills/
|
~/.claude/skills/ project/.claude/skills/
|
||||||
~/.codex/skills/ project/.skills.yaml
|
~/.codex/skills/ project/.pouch.yaml
|
||||||
|
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 设计原则
|
## 设计原则
|
||||||
|
|
||||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
|
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
|
||||||
2. **项目自治** — 各项目自行维护 `.skills.yaml`
|
2. **项目自治** — 各项目自行维护 `.pouch.yaml`
|
||||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||||
4. **能力内聚** — Skill 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
|
4. **能力内聚** — Skill 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
|
||||||
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
|
- [docs/getting-started.md](docs/getting-started.md):给同事的安装、打版本、构建教程
|
||||||
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
|
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
|
||||||
- [skiff/README.md](skiff/README.md) — CLI 命令参考与开发说明
|
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
|
||||||
|
- 各 skill 的人类说明见上表 `README.md`
|
||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
@@ -155,3 +230,4 @@ npx skills find typescript
|
|||||||
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
||||||
- [skills.sh](https://skills.sh)
|
- [skills.sh](https://skills.sh)
|
||||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# 跟随软链,确保从 ~/.local/bin/skiff 调用时仍能找到仓库根目录
|
# 跟随软链,确保从 ~/.local/bin/pouch 调用时仍能找到仓库根目录
|
||||||
SCRIPT="${BASH_SOURCE[0]}"
|
SCRIPT="${BASH_SOURCE[0]}"
|
||||||
while [ -L "$SCRIPT" ]; do
|
while [ -L "$SCRIPT" ]; do
|
||||||
link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||||
@@ -11,4 +11,4 @@ done
|
|||||||
REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
|
||||||
|
|
||||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
exec python3 -m skiff "$@"
|
exec python3 -m pouch "$@"
|
||||||
+11
-1
@@ -4,7 +4,7 @@
|
|||||||
# repo: <git-url>
|
# repo: <git-url>
|
||||||
# ref: <branch|tag> (default: main)
|
# ref: <branch|tag> (default: main)
|
||||||
# path: <subpath> (default: .)
|
# path: <subpath> (default: .)
|
||||||
# description: <text> (optional, shown by `skiff select`)
|
# description: <text> (optional, shown by `pouch select`)
|
||||||
# tags: (optional)
|
# tags: (optional)
|
||||||
# - <tag>
|
# - <tag>
|
||||||
#
|
#
|
||||||
@@ -29,3 +29,13 @@ waza:
|
|||||||
- engineering
|
- engineering
|
||||||
- workflow
|
- workflow
|
||||||
- skill-collection
|
- skill-collection
|
||||||
|
|
||||||
|
taste-skill:
|
||||||
|
repo: https://github.com/Leonxlnx/taste-skill.git
|
||||||
|
ref: main
|
||||||
|
path: skills
|
||||||
|
description: Taste Skill collection for anti-slop frontend, redesign, image generation, brand kits, and output enforcement.
|
||||||
|
tags:
|
||||||
|
- frontend
|
||||||
|
- design
|
||||||
|
- skill-collection
|
||||||
|
|||||||
+11
-11
@@ -17,7 +17,7 @@ updated: "2026-07-31T23:31:48+08:00"
|
|||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
本轮评审覆盖 `skills/ack/` 的入口、角色规范、闭环流程、项目模板、任务板
|
本轮评审覆盖 `skills/ack/` 的入口、角色规范、闭环流程、项目模板、任务板
|
||||||
schema、校验脚本、Orca 适配器,以及 `skiff init ack` 的真实运行路径。
|
schema、校验脚本、Orca 适配器,以及 `pouch init ack` 的真实运行路径。
|
||||||
|
|
||||||
评审主要回答两个问题:
|
评审主要回答两个问题:
|
||||||
|
|
||||||
@@ -216,15 +216,15 @@ Deferred,当前 launcher 必须 fail closed。
|
|||||||
|
|
||||||
## P0:初始化需要原子化
|
## P0:初始化需要原子化
|
||||||
|
|
||||||
`skiff init ack` 会先写 `project.md` 和 `tasks.yaml`,再运行任务板校验。缺少 PyYAML
|
`pouch init ack` 会先写 `project.md` 和 `tasks.yaml`,再运行任务板校验。缺少 PyYAML
|
||||||
时,命令会失败但保留两个文件。再次运行又会因为拒绝覆盖而失败。
|
时,命令会失败但保留两个文件。再次运行又会因为拒绝覆盖而失败。
|
||||||
|
|
||||||
相关位置:
|
相关位置:
|
||||||
|
|
||||||
- `requirements.txt:1`
|
- `requirements.txt:1`
|
||||||
- `skills/ack/scripts/validate_tasks.py:37`
|
- `skills/ack/scripts/validate_tasks.py:37`
|
||||||
- `skiff/cli.py:1144`
|
- `pouch/cli.py:1144`
|
||||||
- `skiff/cli.py:1156`
|
- `pouch/cli.py:1156`
|
||||||
|
|
||||||
本轮在不含第三方包的隔离 Python 环境中复现了这个状态。
|
本轮在不含第三方包的隔离 Python 环境中复现了这个状态。
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ Deferred,当前 launcher 必须 fail closed。
|
|||||||
|
|
||||||
- 在临时目录渲染和校验,全部通过后再原子 rename。
|
- 在临时目录渲染和校验,全部通过后再原子 rename。
|
||||||
- 失败时只清理由本次调用创建的临时文件。
|
- 失败时只清理由本次调用创建的临时文件。
|
||||||
- 提供 `skiff init ack --repair` 或等价恢复路径。
|
- 提供 `pouch init ack --repair` 或等价恢复路径。
|
||||||
- 默认模板使用 `tasks: []`,完整示例继续放在 `examples/`。
|
- 默认模板使用 `tasks: []`,完整示例继续放在 `examples/`。
|
||||||
- CLI 输出“脚手架已创建,待配置”,检查通过后再称为“初始化完成”。
|
- CLI 输出“脚手架已创建,待配置”,检查通过后再称为“初始化完成”。
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过
|
|||||||
2. 把 Orca adapter 改成每轮 Developer/Test 双任务。
|
2. 把 Orca adapter 改成每轮 Developer/Test 双任务。
|
||||||
3. 定义同 worktree 默认策略和跨 worktree transfer。
|
3. 定义同 worktree 默认策略和跨 worktree transfer。
|
||||||
4. 改造 worker launcher 与安全授权。
|
4. 改造 worker launcher 与安全授权。
|
||||||
5. 原子化 `skiff init ack`,空任务板作为默认模板。
|
5. 原子化 `pouch init ack`,空任务板作为默认模板。
|
||||||
6. 引入追加式 attempts、幂等恢复和失败分类。
|
6. 引入追加式 attempts、幂等恢复和失败分类。
|
||||||
7. 统一 schema 与语义校验,补齐对抗性 fixture。
|
7. 统一 schema 与语义校验,补齐对抗性 fixture。
|
||||||
8. 区分工作空间验证、集成验证和发布状态。
|
8. 区分工作空间验证、集成验证和发布状态。
|
||||||
@@ -342,7 +342,7 @@ ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过
|
|||||||
|
|
||||||
本轮执行了:
|
本轮执行了:
|
||||||
|
|
||||||
- `skiff check ack`:通过。这个命令只证明 Skill 的元数据和基础结构有效。
|
- `pouch check ack`:通过。这个命令只证明 Skill 的元数据和基础结构有效。
|
||||||
- `validate_tasks.py examples/tasks.example.yaml`:通过,当前机器使用内置规则。
|
- `validate_tasks.py examples/tasks.example.yaml`:通过,当前机器使用内置规则。
|
||||||
- `validate_worker_command.py --self-test`:7 项通过。
|
- `validate_worker_command.py --self-test`:7 项通过。
|
||||||
- `python3 -m unittest discover -s tests -v`:58 项通过。
|
- `python3 -m unittest discover -s tests -v`:58 项通过。
|
||||||
@@ -394,10 +394,10 @@ ACK 需要增加第三类项目事实,用来保存跨任务复用、会改变
|
|||||||
| `tasks.yaml` | 当前任务状态、attempt 和执行证据 | Coordinator |
|
| `tasks.yaml` | 当前任务状态、attempt 和执行证据 | Coordinator |
|
||||||
| `knowledge.yaml` | 跨任务复用的已验证经验 | Coordinator |
|
| `knowledge.yaml` | 跨任务复用的已验证经验 | Coordinator |
|
||||||
|
|
||||||
建议新增项目级 SSOT:`docs/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
|
建议新增项目级 SSOT:`.pouch/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
|
||||||
工具无关;ACK 负责在任务闭环中生产、选择和消费这些知识。
|
工具无关;ACK 负责在任务闭环中生产、选择和消费这些知识。
|
||||||
|
|
||||||
这项设计会修改当前“`docs/ack/` 只保存 `project.md` 与 `tasks.yaml`”的边界。
|
这项设计会修改当前“`.pouch/ack/` 只保存 `project.md` 与 `tasks.yaml`”的边界。
|
||||||
新增文件保存项目事实,不复制 ACK Skill 的通用规范,因此不违反 Skill 内容仍以
|
新增文件保存项目事实,不复制 ACK Skill 的通用规范,因此不违反 Skill 内容仍以
|
||||||
`skills/ack/` 为 SSOT 的原则。
|
`skills/ack/` 为 SSOT 的原则。
|
||||||
|
|
||||||
@@ -580,8 +580,8 @@ issue、日志和外部网页只能作为不可信 evidence。进入知识库前
|
|||||||
|
|
||||||
### 第一版范围
|
### 第一版范围
|
||||||
|
|
||||||
第一版只实现一个 `docs/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
|
第一版只实现一个 `.pouch/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
|
||||||
开始影响审阅和选择时,再平滑迁移为 `docs/ack/knowledge/index.yaml` 加独立知识卡,
|
开始影响审阅和选择时,再平滑迁移为 `.pouch/ack/knowledge/index.yaml` 加独立知识卡,
|
||||||
条目 schema 和引用格式保持不变。
|
条目 schema 和引用格式保持不变。
|
||||||
|
|
||||||
第一版包括:
|
第一版包括:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
## 项目配置契约
|
## 项目配置契约
|
||||||
|
|
||||||
可选配置位于 `docs/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
|
可选配置位于 `.pouch/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
|
||||||
配置存在时必须包含:
|
配置存在时必须包含:
|
||||||
|
|
||||||
| 字段 | 约束 |
|
| 字段 | 约束 |
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# pouch 上手:安装、打版本、构建
|
||||||
|
|
||||||
|
pouch 是团队自研 skill 的仓库和安装工具。Skill 是给 Agent 看的工作说明书。你对着 Cursor、Claude Code 或 Codex 说话,Agent 按说明书干活。这份仓库里还有 CLI,用来把 skill 装到各 Agent。
|
||||||
|
|
||||||
|
这篇只讲日常会碰到的三件事:把 pouch 装上、用 releaser 打正式版本、用 builder 构建 DEB 或 Docker 镜像。其它 skill 装上就能喊,需要时对 Agent 说「有哪些自研 skill」。
|
||||||
|
|
||||||
|
## pouch 怎么工作
|
||||||
|
|
||||||
|
Skill 的正文只存在一份,在 `~/.pouch/skills/<name>/`。装到 Cursor、Claude、Codex 时走软链,改一份全处生效。项目根的 `.pouch.yaml` 记录这个仓库启用了哪些 skill,类似 `package.json` 记依赖。
|
||||||
|
|
||||||
|
日常几乎不直接跑 skill 里的脚本。CLI 负责安装和维护,干活是对 Agent 说人话。授权写在请求里:没说 push、没说上传,Agent 就不做。
|
||||||
|
|
||||||
|
|
||||||
|
| 你想做的 | 对谁说 | 不要 |
|
||||||
|
| ------------------- | -------------- | ----------------------- |
|
||||||
|
| 打正式版本、annotated tag | releaser | 让 builder 选下一个 SemVer |
|
||||||
|
| 打 DEB 或 Docker 镜像 | builder | 让 releaser 去构建或上传 |
|
||||||
|
|
||||||
|
|
||||||
|
releaser 选定正式 SemVer 并打 annotated tag。builder 只读当前 HEAD:HEAD 恰好落在 `vX.Y.Z` 上就打正式包,否则打测试包。
|
||||||
|
|
||||||
|
## 安装 pouch
|
||||||
|
|
||||||
|
需要 Python 3、git,以及至少一种兼容 Agent Skills 的 Agent。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||||
|
cd ~/.pouch && ./install.sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
pouch status
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` 会把 `pouch` 命令放到 `~/.local/bin`,并把 pouch 这一个 skill 全局装到所有 Agent。其它 skill 不会自动全装。当前 shell 找不到 `pouch` 时,重开终端,或 `source` 对应 rc。
|
||||||
|
|
||||||
|
也可以对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。不要覆盖已经存在的 `~/.pouch`。
|
||||||
|
|
||||||
|
从旧目录迁过来:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv ~/.skills ~/.pouch
|
||||||
|
~/.pouch/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
装完看一眼:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch list
|
||||||
|
pouch status
|
||||||
|
pouch doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
软链异常就 `pouch doctor --fix`。日常用 pouch 命令时会按间隔自动更新自身和已拉取的外部 source;要立刻更新就 `pouch update`。
|
||||||
|
|
||||||
|
## 把要用的 skill 装上
|
||||||
|
|
||||||
|
发版本和构建这两个,全局装一次,所有项目都能喊:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch add releaser -g -y
|
||||||
|
pouch add builder -g -y
|
||||||
|
```
|
||||||
|
|
||||||
|
只想在当前仓库启用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/code/my-app
|
||||||
|
pouch add releaser -y
|
||||||
|
pouch add builder -y
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以对 Agent 说「把这个项目该用的 skill 接上」,它会按仓库信号建议,再 `pouch add`。builder 不要 `pouch init builder`,初始化是对 Agent 说「用 builder 初始化这个项目」。
|
||||||
|
|
||||||
|
交互多选:`pouch select -g`。取消勾选不会卸载,卸载用 `pouch remove`。
|
||||||
|
|
||||||
|
## 怎么对 Agent 说话
|
||||||
|
|
||||||
|
打开已安装目标的 Agent。项目级安装要打开那个项目再说话;全局安装任意项目都可以。请求里写清 skill 名、目标和授权范围。
|
||||||
|
|
||||||
|
```text
|
||||||
|
使用 releaser,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
|
||||||
|
用 builder 初始化这个项目。
|
||||||
|
用 builder 构建当前版本的 DEB,先不要上传。
|
||||||
|
```
|
||||||
|
|
||||||
|
人看各 skill 目录下的 `README.md`。`SKILL.md` 是给 Agent 的指令,不必通读。
|
||||||
|
|
||||||
|
## 用 releaser 打新版本
|
||||||
|
|
||||||
|
代码已经合进默认分支、测试过了,要打正式 tag 时用它。它不构建 DEB,也不推镜像。
|
||||||
|
|
||||||
|
### 还没决定版本号
|
||||||
|
|
||||||
|
```text
|
||||||
|
使用 releaser 检查当前版本和这次改动,建议下一个版本号。只分析,不修改文件。
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 会读项目版本规则、现有 tag 和实际改动,给出建议和依据。没有项目约定时,单一协调版本默认 SemVer,tag 形如 `v1.6.0`。用户输入 `v1.6.0` 时规范版本是 `1.6.0`,不要产生双前缀。
|
||||||
|
|
||||||
|
### 默认短路径:直接打 tag
|
||||||
|
|
||||||
|
项目以 Git tag 作为版本来源,且不需要改 VERSION 文件或把 CHANGELOG 写回仓库时,走这条:
|
||||||
|
|
||||||
|
```text
|
||||||
|
使用 releaser,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。根据上一个稳定 tag 到目标 commit 的实际变化生成更新说明,把说明写入 annotated tag,并验证远端 tag 的 commit 和说明。不要创建 Forge Release。
|
||||||
|
```
|
||||||
|
|
||||||
|
请求里写清目标版本、基线分支、打到哪一步。没写 push、没写创建平台 Release,Agent 就不会做。只发 Git tag 时不要求 GitHub / GitLab / Gitea CLI,但 Git remote 必须可读写。
|
||||||
|
|
||||||
|
Agent 会锁定远端目标 commit,生成带 Summary、Changes、Validation、Base commit 的更新说明,创建 annotated tag 并推送,再从远端核对。不会给未合并分支打正式 tag,也不会覆盖已有同名 tag。
|
||||||
|
|
||||||
|
### 需要改版本文件时走长路径
|
||||||
|
|
||||||
|
权威版本文件或仓库内 CHANGELOG 必须随发布改掉时,才创建 `release/v1.6.0` 这类分支和 PR。可以一次说完,也可以拆开授权:
|
||||||
|
|
||||||
|
```text
|
||||||
|
使用 releaser 完成 1.6.0 发布:从 main 创建分支,完成版本更新和验证,创建 PR,检查通过后合并,推送 v1.6.0 tag。不要创建 Forge Release。
|
||||||
|
```
|
||||||
|
|
||||||
|
中途停了,下次说「使用 releaser 检查 v1.6.0 当前发布状态,从尚未完成的步骤继续。不要重复创建分支、PR、tag 或 release。」
|
||||||
|
|
||||||
|
要在 Gitea / GitHub 上建平台 Release 时,在请求里写明。没写就不会建。Forge Release 复用 tag 里同一份说明。
|
||||||
|
|
||||||
|
### 判断打没打成
|
||||||
|
|
||||||
|
远端 tag 解引用后的 commit 就是目标 commit,tag object 里有完整更新说明。只分析时会返回建议版本和依据。卡在某步时会说明停在哪、下次怎么续,不会用「发布成功」盖住未完成的步骤。
|
||||||
|
|
||||||
|
## 用 builder 构建项目
|
||||||
|
|
||||||
|
builder 按统一契约打 DEB 和 Docker 镜像。项目根会多两个文件:`makefile.builder`(构建入口)和 `.env.builder`(发布凭据,不进 git)。它不改你已有的 `Makefile` 或 `.env`。
|
||||||
|
|
||||||
|
正式版本号仍由 releaser 打 tag。builder 只读当前 HEAD,不调用 releaser。
|
||||||
|
|
||||||
|
### 接入一个项目
|
||||||
|
|
||||||
|
需要 Python 3 和 `make`。打 DEB 还要 `dpkg-deb`,打镜像还要 Docker 和 buildx。在项目根对 Agent 说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 builder 初始化这个项目。
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 会探测轨道、写出 `makefile.builder`,缺发布配置时列出来。然后在 `.env.builder` 填仓库地址和 token:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DEB_SERVER_URL=https://deb.example.com
|
||||||
|
DEB_REPOSITORY=main
|
||||||
|
DEB_TOKEN=<token>
|
||||||
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
token 只放 `.env.builder` 或密钥系统,不要写进 `.env`,也不要进 git。检查契约:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 builder 检查这个项目的 makefile.builder 是否符合契约。
|
||||||
|
```
|
||||||
|
|
||||||
|
构建项通过就可以打本地包。缺发布键只挡住上传。
|
||||||
|
|
||||||
|
日常构建入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -f makefile.builder help
|
||||||
|
make -f makefile.builder version
|
||||||
|
make -f makefile.builder build
|
||||||
|
make -f makefile.builder deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### 版本从哪来
|
||||||
|
|
||||||
|
`make -f makefile.builder version` 输出一行规范版本,无 `v` 前缀。基线是 HEAD 祖先上最近的稳定 tag,稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc` 这类后缀。
|
||||||
|
|
||||||
|
HEAD 恰好是 `v1.6.0` 时,产物是正式的 `1.6.0`。其它 commit 打测试号:祖先最近稳定 tag 的 patch 机械 +1,再挂构建日、分支、距离、短 SHA。测试 DEB 用 `~`,于是 `1.6.0` < `1.6.1~…` < `1.6.1`。已装上一正式版的机器可以升到 bugfix 测试包;同号正式包仍盖住测试。
|
||||||
|
|
||||||
|
分支 `feat/login-v2`,相对 `v1.6.0` 第 7 个 commit,短 SHA `abc1234`,构建日 `20260908`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
规范 / DEB 1.6.1~20260908.feat-login-v2.7+gabc1234
|
||||||
|
Docker registry/ns/foo:1.6.1-20260908.feat-login-v2.7.gabc1234
|
||||||
|
```
|
||||||
|
|
||||||
|
不要自己 `git tag | sort -V | tail -1` 取全仓库最大号,也不要把 `git describe --dirty` 的原始字符串写进包名。
|
||||||
|
|
||||||
|
### 构建和发布
|
||||||
|
|
||||||
|
只构建:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
||||||
|
```
|
||||||
|
|
||||||
|
确认后再上传:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
|
||||||
|
```
|
||||||
|
|
||||||
|
自己跑也可以:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -f makefile.builder deb
|
||||||
|
make -f makefile.builder push-deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker 只有点名镜像时 Agent 才走。脏工作树默认拒绝发布。可能覆盖同版本时会先问你。token 不会出现在输出、提交或回复里。
|
||||||
|
|
||||||
|
## 一条完整发布链
|
||||||
|
|
||||||
|
代码合进 `main` 且检查通过之后,按这个顺序:
|
||||||
|
|
||||||
|
1. 对 Agent:`使用 releaser,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。`
|
||||||
|
2. 确认远端 `v1.6.0` 指向那次 commit。
|
||||||
|
3. checkout 到该 tag,或确保当前 HEAD 就是它。
|
||||||
|
4. 对 Agent:`用 builder 构建当前版本的 DEB,先不要上传。`
|
||||||
|
5. 核对 `make -f makefile.builder version` 输出 `1.6.0`,再授权上传。
|
||||||
|
|
||||||
|
先构建再打 tag,得到的是测试包,版本号会变成下一 patch 的 `~` 形式,不是正式号。
|
||||||
|
|
||||||
|
## 常见卡住的地方
|
||||||
|
|
||||||
|
- 当前 shell 找不到 `pouch`:`~/.local/bin` 还没进 PATH,重开终端或 `source` 对应 rc。
|
||||||
|
- 在 `~/.cursor/skills/` 里直接改文件:改的是软链目标,应该改 `~/.pouch/skills/`。Claude Code 有时会把软链替换成普通文件,用 `pouch doctor --fix` 重建。
|
||||||
|
- `pouch init builder` 这条路径不存在:对 Agent 说「用 builder 初始化」。
|
||||||
|
- 请求里没写清授权:Agent 会分析或只做到本地,不会擅自 push tag 或上传包。
|
||||||
|
- 工作区有未提交修改:builder 默认拒绝发布;releaser 不会 stash 或覆盖你的文件。
|
||||||
|
- 同名 tag 已在远端指向别的 commit:releaser 会停,不会 force push。
|
||||||
|
- HEAD 不在稳定 tag 上却想打正式包:先打 tag,再构建。
|
||||||
|
|
||||||
|
## 还想看什么
|
||||||
|
|
||||||
|
- 仓库总览:[README.md](../README.md)
|
||||||
|
- CLI 命令:[pouch/README.md](../pouch/README.md)
|
||||||
|
- releaser:[skills/releaser/README.md](../skills/releaser/README.md)
|
||||||
|
- builder:[skills/builder/README.md](../skills/builder/README.md)
|
||||||
|
- 设计原则和编写规范:[AGENTS.md](../AGENTS.md)
|
||||||
|
|
||||||
+10
-5
@@ -3,17 +3,22 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
BIN_DIR="${HOME}/.local/bin"
|
BIN_DIR="${HOME}/.local/bin"
|
||||||
PATH_MARKER="# skiff: ~/.local/bin"
|
PATH_MARKER="# pouch: ~/.local/bin"
|
||||||
|
LEGACY_PATH_MARKER="# skiff: ~/.local/bin"
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
mkdir -p "$BIN_DIR"
|
||||||
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff"
|
ln -sf "$REPO/bin/pouch" "$BIN_DIR/pouch"
|
||||||
echo "已安装 skiff -> $BIN_DIR/skiff"
|
echo "已安装 pouch -> $BIN_DIR/pouch"
|
||||||
|
if [[ ! -e "$BIN_DIR/skiff" || -L "$BIN_DIR/skiff" ]]; then
|
||||||
|
ln -sf "pouch" "$BIN_DIR/skiff"
|
||||||
|
echo "已保留 skiff -> pouch(旧命令名)"
|
||||||
|
fi
|
||||||
|
|
||||||
PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}" python3 -m skiff bootstrap
|
PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}" python3 -m pouch bootstrap
|
||||||
|
|
||||||
path_already_configured() {
|
path_already_configured() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
[[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file"
|
[[ -f "$file" ]] && { grep -qF "$PATH_MARKER" "$file" || grep -qF "$LEGACY_PATH_MARKER" "$file"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
configure_bash() {
|
configure_bash() {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
|
|
||||||
# Project context (optional)
|
|
||||||
# This is shown to AI when creating artifacts.
|
|
||||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
|
||||||
# Example:
|
|
||||||
# context: |
|
|
||||||
# Tech stack: TypeScript, React, Node.js
|
|
||||||
# We use conventional commits
|
|
||||||
# Domain: e-commerce platform
|
|
||||||
|
|
||||||
# Per-artifact rules (optional)
|
|
||||||
# Add custom rules for specific artifacts.
|
|
||||||
# Example:
|
|
||||||
# rules:
|
|
||||||
# proposal:
|
|
||||||
# - Keep proposals under 500 words
|
|
||||||
# - Always include a "Non-goals" section
|
|
||||||
# tasks:
|
|
||||||
# - Break tasks into chunks of max 2 hours
|
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
# pouch
|
||||||
|
|
||||||
|
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/pouch # 本仓库根目录
|
||||||
|
./install.sh # 软链到 ~/.local/bin/pouch
|
||||||
|
```
|
||||||
|
|
||||||
|
确保 `~/.local/bin` 在 `PATH` 中。
|
||||||
|
|
||||||
|
## 命令风格
|
||||||
|
|
||||||
|
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
|
||||||
|
统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 浏览可用自研 skill
|
||||||
|
pouch add --list
|
||||||
|
|
||||||
|
# 装到当前项目 / 全局
|
||||||
|
pouch add discussion-notes -a cursor -y
|
||||||
|
pouch add discussion-notes -a cursor -g -y
|
||||||
|
|
||||||
|
# 卸载
|
||||||
|
pouch remove discussion-notes -a cursor -y
|
||||||
|
pouch rm discussion-notes -g -y
|
||||||
|
|
||||||
|
# 改完 skill 后提交推送(在任意目录执行,操作 ~/.pouch)
|
||||||
|
pouch publish skills/discussion-notes -m "update discussion-notes" --push
|
||||||
|
```
|
||||||
|
|
||||||
|
开发时也可直接运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=/path/to/pouch python3 -m pouch <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首次安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||||
|
~/.pouch/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` 会安装 CLI,并自动执行 `pouch bootstrap`,将本仓库的 `pouch` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令参考
|
||||||
|
|
||||||
|
### 查看
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
||||||
|
| `pouch status [--target all\|cursor\|claude\|codex\|agents]` | 安装状态总览 |
|
||||||
|
|
||||||
|
### 项目初始化
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
|
||||||
|
| `pouch update` | 更新 pouch 自身,以及已拉取的 catalog / custom source |
|
||||||
|
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态(目前用于 ack) |
|
||||||
|
|
||||||
|
### Skill 安装
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
|
||||||
|
| `pouch select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
|
||||||
|
| `pouch remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
|
||||||
|
| `pouch add --list` | 列出可用 builtin skill |
|
||||||
|
| `pouch publish [paths] -m MSG [--push]` | 在 ~/.pouch 内 git add/commit/push |
|
||||||
|
|
||||||
|
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
|
||||||
|
|
||||||
|
全局目标路径:
|
||||||
|
|
||||||
|
| Agent | 路径 |
|
||||||
|
|-------|------|
|
||||||
|
| cursor | `~/.cursor/skills/` |
|
||||||
|
| claude | `~/.claude/skills/` |
|
||||||
|
| codex | `~/.codex/skills/` |
|
||||||
|
| agents | `~/.agents/skills/`(agents 标准目录,覆盖 OMP) |
|
||||||
|
|
||||||
|
### 预置 Catalog Source
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
|
||||||
|
| `pouch fetch <name>` | 克隆或更新 catalog source checkout |
|
||||||
|
| `pouch add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
|
||||||
|
| `pouch add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
|
||||||
|
|
||||||
|
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
|
||||||
|
会显示在 `pouch select` 的候选列表中。`path` 可以直接指向含
|
||||||
|
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
|
||||||
|
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`pouch add <name>`
|
||||||
|
安装全部,`pouch select` 则展开为 `<name>/<skill>` 供分别勾选。同一
|
||||||
|
`repo` 与 `ref` 共享一份 Git checkout。
|
||||||
|
|
||||||
|
预置 collection 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch add waza/think -g
|
||||||
|
pouch add taste-skill/taste-skill -g
|
||||||
|
pouch add taste-skill -g # 安装 Taste Skill 全部子 skill
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动更新
|
||||||
|
|
||||||
|
日常执行 `pouch list`、`pouch add`、`pouch status` 等命令时,pouch 会按间隔
|
||||||
|
顺带更新自身和已经 clone 过的 catalog / custom Git source,不必单独跑
|
||||||
|
`pouch fetch` 或 `pouch update`。默认每 24 小时最多自动更新一次;网络失败或
|
||||||
|
工作区不干净时跳过,不打断当前命令。`pouch add <catalog>` 在安装前会再刷新
|
||||||
|
该来源,以便拿到最新子 skill。
|
||||||
|
|
||||||
|
在 `~/.config/pouch/config.yaml` 里关闭或改间隔:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auto_update:
|
||||||
|
enabled: true
|
||||||
|
interval: 24h # 也可用 1d、60m、3600
|
||||||
|
```
|
||||||
|
|
||||||
|
本次跳过:`pouch --no-auto-update list`,或设置环境变量 `POUCH_NO_AUTO_UPDATE=1`。
|
||||||
|
立刻更新全部:`pouch update`。
|
||||||
|
|
||||||
|
### 交互式批量安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch select # 当前项目,全部 Agent
|
||||||
|
pouch select -a codex # 当前项目,仅 Codex
|
||||||
|
pouch select -g # 全局安装
|
||||||
|
pouch select --project ~/code/app # 指定项目
|
||||||
|
```
|
||||||
|
|
||||||
|
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
|
||||||
|
取消。普通 `pouch select` 只安装到项目,并在每一项旁只读显示各 Agent 的
|
||||||
|
全局安装状态;`pouch select -g` 只安装到全局。已经安装到目标范围的 skill
|
||||||
|
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `pouch remove`。如果全局
|
||||||
|
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
|
||||||
|
项目模式会把成功选择的项目写入 `.pouch.yaml`。非交互环境请使用
|
||||||
|
`pouch add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
|
||||||
|
skill 条目中,后续 `pouch sync` 不会扩散到其他 Agent。
|
||||||
|
|
||||||
|
### Custom source(多-skill 仓库)
|
||||||
|
|
||||||
|
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Git 仓库,默认 clone 到 ~/.local/share/pouch/sources/company
|
||||||
|
pouch source add company \
|
||||||
|
git@git.company.com:platform/agent-skills.git \
|
||||||
|
--ref main \
|
||||||
|
--skills-path internal/skills
|
||||||
|
|
||||||
|
# 或接入已有本地仓库
|
||||||
|
pouch source add company \
|
||||||
|
--local ~/code/company-agent-skills \
|
||||||
|
--skills-path skills
|
||||||
|
|
||||||
|
pouch source list
|
||||||
|
pouch source fetch company
|
||||||
|
pouch list --source company
|
||||||
|
pouch add company/code-review -g -a codex
|
||||||
|
```
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
|
||||||
|
| `pouch source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
|
||||||
|
| `pouch source list` / `show <name>` | 查看 source |
|
||||||
|
| `pouch source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
|
||||||
|
| `pouch source remove <name>` | 移除配置并保留 checkout |
|
||||||
|
|
||||||
|
配置保存在 `~/.config/pouch/config.yaml`。Git/SSH 认证复用本机 Git 配置,
|
||||||
|
pouch 不保存 token。可以使用 `company/code-review`,也可以使用
|
||||||
|
`pouch add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
|
||||||
|
|
||||||
|
### 项目级
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch enable <name> [--target all] [--project <dir>]` | 写入 `.pouch.yaml` 并创建项目软链 |
|
||||||
|
| `pouch disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
|
||||||
|
| `pouch sync [--target all] [--project <dir>]` | 按 `.pouch.yaml` 重建软链 |
|
||||||
|
|
||||||
|
项目目标路径:
|
||||||
|
|
||||||
|
| Agent | 路径 |
|
||||||
|
|-------|------|
|
||||||
|
| cursor | `<project>/.agents/skills/` |
|
||||||
|
| claude | `<project>/.claude/skills/` |
|
||||||
|
| codex | `<project>/.agents/skills/` |
|
||||||
|
| agents | `<project>/.agents/skills/`(与 cursor/codex 共用路径,软链幂等) |
|
||||||
|
|
||||||
|
### 脚手架与健康检查
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pouch create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
|
||||||
|
| `pouch check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
|
||||||
|
| `pouch finalize <name>` | 校验草稿并移动到正式 `skills/` |
|
||||||
|
| `pouch doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
||||||
|
|
||||||
|
## 常用工作流
|
||||||
|
|
||||||
|
### 新建并全局启用自研 skill
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||||
|
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
|
||||||
|
pouch check my-skill
|
||||||
|
pouch finalize my-skill
|
||||||
|
pouch publish skills/my-skill -m "add my-skill" --push
|
||||||
|
pouch add my-skill -a cursor -g -y
|
||||||
|
pouch doctor -a cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在项目中启用 skill
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/code/my-app
|
||||||
|
pouch add discussion-notes -a cursor -y
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加 Catalog Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch catalog add my-ext https://github.com/org/repo --ref main
|
||||||
|
pouch fetch my-ext
|
||||||
|
pouch add my-ext -g
|
||||||
|
```
|
||||||
|
|
||||||
|
## 源码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
pouch/
|
||||||
|
├── __init__.py # 版本号
|
||||||
|
├── __main__.py # python3 -m pouch 入口
|
||||||
|
├── cli.py # 命令定义与调度
|
||||||
|
├── paths.py # 路径常量与 Agent 目标
|
||||||
|
├── skills.py # builtin/catalog/custom 统一解析
|
||||||
|
├── catalog.py # catalog.yaml 读写与 Skill 发现
|
||||||
|
├── config.py # 用户配置与自动更新设定
|
||||||
|
├── auto_update.py # 命令执行时顺带更新 pouch 与已拉取 source
|
||||||
|
├── sources.py # custom source 配置、发现与 Git 管理
|
||||||
|
├── project.py # .pouch.yaml 管理
|
||||||
|
├── symlinks.py # 软链创建/检查/修复
|
||||||
|
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||||
|
```
|
||||||
|
|
||||||
|
入口脚本:[../bin/pouch](../bin/pouch)
|
||||||
|
|
||||||
|
## 路径约定
|
||||||
|
|
||||||
|
| 变量 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `POUCH_HOME` | `~/.pouch` | pouch 仓库(软链;兼容 `~/.skills`) |
|
||||||
|
| `SKILLS_DIR` | `~/.pouch/skills/` | builtin skill 目录 |
|
||||||
|
| `CATALOG_FILE` | `~/.pouch/catalog.yaml` | 预置 Skill 来源目录 |
|
||||||
|
| `CATALOG_CACHE_DIR` | `~/.local/share/pouch/externals/` | catalog checkout 缓存;按 repo/ref 共享 |
|
||||||
|
| `CONFIG_FILE` | `~/.config/pouch/config.yaml` | custom source 与 auto_update 配置 |
|
||||||
|
| `STATE_FILE` | `~/.local/share/pouch/state.yaml` | 自动更新时间戳 |
|
||||||
|
| `SOURCES_DIR` | `~/.local/share/pouch/sources/` | custom Git source 默认 checkout |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
|
||||||
|
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
|
||||||
|
- 若 Agent 将软链替换为普通目录,运行 `pouch doctor --fix` 重建
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [项目 README](../README.md)
|
||||||
|
- [AGENTS.md](../AGENTS.md)
|
||||||
|
- 各 skill 的人类说明:`skills/<name>/README.md`
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""pouch — Agent Skills 安装与管理 CLI。"""
|
||||||
|
|
||||||
|
__version__ = "0.6.0"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from skiff.cli import main
|
from pouch.cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from skiff.paths import ALL_TARGETS
|
from pouch.paths import ALL_TARGETS
|
||||||
|
|
||||||
AGENT_ALIASES: dict[str, str] = {
|
AGENT_ALIASES: dict[str, str] = {
|
||||||
"cursor": "cursor",
|
"cursor": "cursor",
|
||||||
"claude": "claude",
|
"claude": "claude",
|
||||||
"claude-code": "claude",
|
"claude-code": "claude",
|
||||||
"codex": "codex",
|
"codex": "codex",
|
||||||
"opencode": "opencode",
|
"agents": "agents",
|
||||||
"*": "*",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""在日常命令里顺带更新 pouch 自身和已拉取的外部 source。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from pouch import yaml_io
|
||||||
|
from pouch.catalog import catalog_checkout_path, load_catalog
|
||||||
|
from pouch.config import load_auto_update_settings
|
||||||
|
from pouch.paths import POUCH_HOME, STATE_FILE
|
||||||
|
from pouch.sources import load_sources, source_checkout
|
||||||
|
|
||||||
|
GIT_TIMEOUT_SECONDS = 20
|
||||||
|
_SKIP_COMMANDS = {"update", "publish"}
|
||||||
|
_TRUTHY = {"1", "true", "yes", "on"}
|
||||||
|
_refreshed_paths: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UpdateResult:
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def changed(self) -> bool:
|
||||||
|
return self.status == "updated"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_err(msg: str) -> None:
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(
|
||||||
|
repo: Path,
|
||||||
|
*args: str,
|
||||||
|
check: bool = False,
|
||||||
|
timeout: int = GIT_TIMEOUT_SECONDS,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", str(repo), *args],
|
||||||
|
check=check,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_git_repo(path: Path) -> bool:
|
||||||
|
if not path.is_dir():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = _run_git(path, "rev-parse", "--is-inside-work-tree")
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
return result.returncode == 0 and result.stdout.strip() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _working_tree_dirty(path: Path) -> bool:
|
||||||
|
result = _run_git(path, "status", "--porcelain")
|
||||||
|
return result.returncode != 0 or bool(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _head_sha(path: Path) -> str:
|
||||||
|
result = _run_git(path, "rev-parse", "HEAD")
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_git_checkout(
|
||||||
|
path: Path,
|
||||||
|
ref: str | None = None,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
strict: bool = False,
|
||||||
|
allow_dirty: bool = False,
|
||||||
|
) -> UpdateResult:
|
||||||
|
key = str(path.resolve()) if path.exists() else str(path)
|
||||||
|
if key in _refreshed_paths:
|
||||||
|
return UpdateResult(name, "current")
|
||||||
|
if not _is_git_repo(path):
|
||||||
|
_refreshed_paths.add(key)
|
||||||
|
result = UpdateResult(name, "skipped", "不是 git 仓库")
|
||||||
|
if strict:
|
||||||
|
raise SystemExit(f"{name} 不是 git 仓库: {path}")
|
||||||
|
return result
|
||||||
|
if not allow_dirty and _working_tree_dirty(path):
|
||||||
|
_refreshed_paths.add(key)
|
||||||
|
result = UpdateResult(name, "skipped", "工作区不干净")
|
||||||
|
if strict:
|
||||||
|
raise SystemExit(f"{name} 工作区不干净,拒绝更新: {path}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
before = _head_sha(path)
|
||||||
|
commands: list[tuple[str, ...]] = [("fetch", "--all", "--tags")]
|
||||||
|
if ref:
|
||||||
|
commands.append(("checkout", ref))
|
||||||
|
commands.append(("pull", "--ff-only"))
|
||||||
|
try:
|
||||||
|
for command in commands:
|
||||||
|
ran = _run_git(path, *command, check=strict)
|
||||||
|
if ran.returncode != 0:
|
||||||
|
detail = (ran.stderr or ran.stdout or "git 失败").strip()
|
||||||
|
return UpdateResult(name, "failed", detail)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
detail = ((exc.stderr or exc.stdout or str(exc))).strip()
|
||||||
|
if strict:
|
||||||
|
raise SystemExit(f"更新 {name} 失败: {detail}") from exc
|
||||||
|
return UpdateResult(name, "failed", detail)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
result = UpdateResult(name, "failed", "git 超时")
|
||||||
|
if strict:
|
||||||
|
raise SystemExit(f"更新 {name} 超时: {path}") from None
|
||||||
|
return result
|
||||||
|
except OSError as exc:
|
||||||
|
result = UpdateResult(name, "failed", str(exc))
|
||||||
|
if strict:
|
||||||
|
raise SystemExit(f"更新 {name} 失败: {exc}") from exc
|
||||||
|
return result
|
||||||
|
|
||||||
|
after = _head_sha(path)
|
||||||
|
_refreshed_paths.add(key)
|
||||||
|
if before and after and before != after:
|
||||||
|
return UpdateResult(name, "updated", after[:8])
|
||||||
|
return UpdateResult(name, "current")
|
||||||
|
|
||||||
|
|
||||||
|
def update_pouch_home(*, strict: bool = False) -> UpdateResult:
|
||||||
|
return refresh_git_checkout(
|
||||||
|
POUCH_HOME,
|
||||||
|
name="pouch",
|
||||||
|
strict=strict,
|
||||||
|
allow_dirty=strict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_cloned_catalogs(*, strict: bool = False) -> list[UpdateResult]:
|
||||||
|
results: list[UpdateResult] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for name, entry in load_catalog().items():
|
||||||
|
repo = str(entry.get("repo", ""))
|
||||||
|
if repo.startswith("~"):
|
||||||
|
continue
|
||||||
|
dest = catalog_checkout_path(name, entry)
|
||||||
|
resolved = dest.resolve() if dest.exists() else dest
|
||||||
|
if resolved in seen or not dest.is_dir():
|
||||||
|
continue
|
||||||
|
seen.add(resolved)
|
||||||
|
results.append(
|
||||||
|
refresh_git_checkout(
|
||||||
|
dest,
|
||||||
|
str(entry.get("ref", "main") or "main"),
|
||||||
|
name=f"catalog:{name}",
|
||||||
|
strict=strict,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def update_custom_sources(*, strict: bool = False) -> list[UpdateResult]:
|
||||||
|
results: list[UpdateResult] = []
|
||||||
|
for name, entry in load_sources().items():
|
||||||
|
if entry.get("local_path"):
|
||||||
|
continue
|
||||||
|
checkout = source_checkout(name, entry)
|
||||||
|
if not checkout.is_dir():
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
refresh_git_checkout(
|
||||||
|
checkout,
|
||||||
|
str(entry.get("ref", "main") or "main"),
|
||||||
|
name=f"custom:{name}",
|
||||||
|
strict=strict,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_updates(*, strict_pouch: bool = False) -> list[UpdateResult]:
|
||||||
|
results = [update_pouch_home(strict=strict_pouch)]
|
||||||
|
results.extend(update_cloned_catalogs(strict=False))
|
||||||
|
results.extend(update_custom_sources(strict=False))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
path = path or STATE_FILE
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(data: dict[str, Any], path: Path | None = None) -> None:
|
||||||
|
path = path or STATE_FILE
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_auto_update(path: Path | None = None) -> None:
|
||||||
|
state = load_state(path)
|
||||||
|
state["last_auto_update"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
save_state(state, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _interval_elapsed(state: dict[str, Any], interval_seconds: int) -> bool:
|
||||||
|
if interval_seconds <= 0:
|
||||||
|
return True
|
||||||
|
raw = state.get("last_auto_update")
|
||||||
|
if not raw:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
last = datetime.fromisoformat(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
if last.tzinfo is None:
|
||||||
|
last = last.replace(tzinfo=timezone.utc)
|
||||||
|
elapsed = (datetime.now(timezone.utc) - last).total_seconds()
|
||||||
|
return elapsed >= interval_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _env_disabled() -> bool:
|
||||||
|
return os.environ.get("POUCH_NO_AUTO_UPDATE", "").strip().lower() in _TRUTHY
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(path: Path) -> Any | None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
lock_file = path.open("a+")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
lock_file.close()
|
||||||
|
return None
|
||||||
|
return lock_file
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_file: Any | None) -> None:
|
||||||
|
if lock_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
lock_file.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def reset_refresh_cache() -> None:
|
||||||
|
_refreshed_paths.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def report_results(results: Iterable[UpdateResult], *, prefix: str = "[pouch]") -> None:
|
||||||
|
changed = [item.name for item in results if item.changed]
|
||||||
|
failed = [item for item in results if item.status == "failed"]
|
||||||
|
if changed:
|
||||||
|
_print_err(f"{prefix} 已自动更新: {', '.join(changed)}")
|
||||||
|
for item in failed:
|
||||||
|
detail = f": {item.detail}" if item.detail else ""
|
||||||
|
_print_err(f"{prefix} 更新 {item.name} 失败,已跳过{detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_auto_update(*, command: str | None = None, disabled: bool = False) -> list[UpdateResult]:
|
||||||
|
if disabled or command in _SKIP_COMMANDS or _env_disabled():
|
||||||
|
return []
|
||||||
|
if not POUCH_HOME.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
settings = load_auto_update_settings()
|
||||||
|
if not settings.enabled:
|
||||||
|
return []
|
||||||
|
state = load_state()
|
||||||
|
if not _interval_elapsed(state, settings.interval_seconds):
|
||||||
|
return []
|
||||||
|
|
||||||
|
lock_file = _acquire_lock(STATE_FILE.with_name("auto-update.lock"))
|
||||||
|
if lock_file is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
# 拿到锁后再读一次,避免并发重复拉取。
|
||||||
|
state = load_state()
|
||||||
|
if not _interval_elapsed(state, settings.interval_seconds):
|
||||||
|
return []
|
||||||
|
results = run_updates(strict_pouch=False)
|
||||||
|
record_auto_update()
|
||||||
|
report_results(results)
|
||||||
|
return results
|
||||||
|
except Exception as exc: # noqa: BLE001 — 自动更新不得打断用户命令
|
||||||
|
_print_err(f"[pouch] 自动更新失败,已跳过: {exc}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_file)
|
||||||
@@ -6,8 +6,8 @@ import hashlib
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from skiff import yaml_io
|
from pouch import yaml_io
|
||||||
from skiff.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
|
from pouch.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
|
||||||
|
|
||||||
|
|
||||||
def load_catalog(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
def load_catalog(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||||
@@ -36,7 +36,7 @@ def catalog_repo(entry: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
def catalog_repo_path(entry: dict[str, Any]) -> Path:
|
def catalog_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 CATALOG_CACHE_DIR
|
from pouch.paths import CATALOG_CACHE_DIR
|
||||||
|
|
||||||
repo = str(entry.get("repo", ""))
|
repo = str(entry.get("repo", ""))
|
||||||
ref = str(entry.get("ref", "main"))
|
ref = str(entry.get("ref", "main"))
|
||||||
@@ -46,7 +46,7 @@ def catalog_repo_path(entry: dict[str, Any]) -> Path:
|
|||||||
|
|
||||||
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
|
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
|
||||||
"""Use a local repo directly, otherwise return its external checkout."""
|
"""Use a local repo directly, otherwise return its external checkout."""
|
||||||
from skiff.paths import CATALOG_CACHE_DIR
|
from pouch.paths import CATALOG_CACHE_DIR
|
||||||
|
|
||||||
configured_repo = str(entry.get("repo", ""))
|
configured_repo = str(entry.get("repo", ""))
|
||||||
local_repo = Path(catalog_repo(entry))
|
local_repo = Path(catalog_repo(entry))
|
||||||
+210
-113
@@ -1,4 +1,4 @@
|
|||||||
"""skiff CLI 入口。"""
|
"""pouch CLI 入口。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,28 +15,29 @@ import tempfile
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from skiff import __version__
|
from pouch import __version__
|
||||||
from skiff.agents import flatten_agent_args, resolve_agent_args
|
from pouch.agents import flatten_agent_args, resolve_agent_args
|
||||||
from skiff.gitops import publish as git_publish
|
from pouch.gitops import publish as git_publish
|
||||||
from skiff.paths import (
|
from pouch.paths import (
|
||||||
ALL_TARGETS,
|
ALL_TARGETS,
|
||||||
DRAFTS_DIR,
|
DRAFTS_DIR,
|
||||||
CATALOG_CACHE_DIR,
|
CATALOG_CACHE_DIR,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
SKILLS_DIR,
|
SKILLS_DIR,
|
||||||
SKILLS_HOME,
|
POUCH_HOME,
|
||||||
TEMPLATE_DIR,
|
TEMPLATE_DIR,
|
||||||
agent_skill_dir,
|
agent_skill_dir,
|
||||||
ensure_skills_home,
|
ensure_pouch_home,
|
||||||
|
project_manifest,
|
||||||
)
|
)
|
||||||
from skiff.project import (
|
from pouch.project import (
|
||||||
add_skill_to_manifest,
|
add_skill_to_manifest,
|
||||||
iter_manifest_skills,
|
iter_manifest_skills,
|
||||||
load_manifest,
|
load_manifest,
|
||||||
remove_skill_from_manifest,
|
remove_skill_from_manifest,
|
||||||
resolve_manifest_skill,
|
resolve_manifest_skill,
|
||||||
)
|
)
|
||||||
from skiff.catalog import (
|
from pouch.catalog import (
|
||||||
discover_catalog_skills,
|
discover_catalog_skills,
|
||||||
catalog_checkout_path,
|
catalog_checkout_path,
|
||||||
catalog_skill_path,
|
catalog_skill_path,
|
||||||
@@ -44,8 +45,8 @@ from skiff.catalog import (
|
|||||||
catalog_repo,
|
catalog_repo,
|
||||||
save_catalog,
|
save_catalog,
|
||||||
)
|
)
|
||||||
from skiff.selector import SkillChoice, select_skills
|
from pouch.selector import SkillChoice, select_skills
|
||||||
from skiff.skills import (
|
from pouch.skills import (
|
||||||
list_custom_skills,
|
list_custom_skills,
|
||||||
list_builtin_skills,
|
list_builtin_skills,
|
||||||
builtin_skill_path,
|
builtin_skill_path,
|
||||||
@@ -57,7 +58,8 @@ from skiff.skills import (
|
|||||||
validate_skill_dir,
|
validate_skill_dir,
|
||||||
validate_skill_name,
|
validate_skill_name,
|
||||||
)
|
)
|
||||||
from skiff.sources import (
|
from pouch.auto_update import maybe_auto_update, record_auto_update, refresh_git_checkout, run_updates
|
||||||
|
from pouch.sources import (
|
||||||
discover_source_skills,
|
discover_source_skills,
|
||||||
fetch_source,
|
fetch_source,
|
||||||
load_sources,
|
load_sources,
|
||||||
@@ -66,8 +68,8 @@ from skiff.sources import (
|
|||||||
source_skills_root,
|
source_skills_root,
|
||||||
validate_source_name,
|
validate_source_name,
|
||||||
)
|
)
|
||||||
from skiff.yaml_io import safe_dump
|
from pouch.yaml_io import safe_dump
|
||||||
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
from pouch.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||||
|
|
||||||
_RENAME_NOREPLACE = 1
|
_RENAME_NOREPLACE = 1
|
||||||
|
|
||||||
@@ -249,15 +251,33 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
def _ensure_source_fetched(
|
||||||
|
name: str,
|
||||||
|
source: str | None = None,
|
||||||
|
*,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> None:
|
||||||
name, source = split_skill_spec(name, source)
|
name, source = split_skill_spec(name, source)
|
||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
sources = load_sources()
|
sources = load_sources()
|
||||||
if source in sources:
|
if source in sources:
|
||||||
root = source_skills_root(source, sources[source])
|
entry = sources[source]
|
||||||
|
root = source_skills_root(source, entry)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
_print(f"拉取 source: {source}")
|
_print(f"拉取 source: {source}")
|
||||||
fetch_source(source, sources[source])
|
fetch_source(source, entry)
|
||||||
|
elif refresh and not entry.get("local_path"):
|
||||||
|
result = refresh_git_checkout(
|
||||||
|
source_checkout(source, entry),
|
||||||
|
str(entry.get("ref", "main") or "main"),
|
||||||
|
name=f"custom:{source}",
|
||||||
|
)
|
||||||
|
if result.status == "updated":
|
||||||
|
_err(f"[pouch] 已更新 custom:{source}")
|
||||||
|
elif result.status == "failed":
|
||||||
|
_err(
|
||||||
|
f"[pouch] 更新 custom:{source} 失败,使用本地缓存: {result.detail}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
catalog_name = (
|
catalog_name = (
|
||||||
@@ -273,7 +293,11 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
|||||||
source in (None, "catalog", catalog_name)
|
source in (None, "catalog", catalog_name)
|
||||||
or source == f"catalog:{catalog_name}"
|
or source == f"catalog:{catalog_name}"
|
||||||
):
|
):
|
||||||
_ensure_catalog_fetched(catalog_name, catalog[catalog_name])
|
_ensure_catalog_fetched(
|
||||||
|
catalog_name,
|
||||||
|
catalog[catalog_name],
|
||||||
|
refresh=refresh,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if source not in (None, "catalog"):
|
if source not in (None, "catalog"):
|
||||||
@@ -282,17 +306,31 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
|||||||
if name not in catalog:
|
if name not in catalog:
|
||||||
return
|
return
|
||||||
|
|
||||||
_ensure_catalog_fetched(name, catalog[name])
|
_ensure_catalog_fetched(name, catalog[name], refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_catalog_fetched(name: str, entry: dict[str, object]) -> None:
|
def _ensure_catalog_fetched(
|
||||||
|
name: str,
|
||||||
|
entry: dict[str, object],
|
||||||
|
*,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> None:
|
||||||
path = catalog_skill_path(name, entry)
|
path = catalog_skill_path(name, entry)
|
||||||
|
repo = catalog_repo(entry)
|
||||||
|
ref = str(entry.get("ref", "main") or "main")
|
||||||
|
dest = catalog_checkout_path(name, entry)
|
||||||
|
if dest.is_dir() and (dest / ".git").exists() and refresh and not str(entry.get("repo", "")).startswith("~"):
|
||||||
|
result = refresh_git_checkout(dest, ref, name=f"catalog:{name}")
|
||||||
|
if result.status == "updated":
|
||||||
|
_err(f"[pouch] 已更新 catalog:{name}")
|
||||||
|
elif result.status == "failed":
|
||||||
|
_err(f"[pouch] 更新 catalog:{name} 失败,使用本地缓存: {result.detail}")
|
||||||
|
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
|
||||||
|
return
|
||||||
|
|
||||||
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
|
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
|
||||||
return
|
return
|
||||||
|
|
||||||
repo = catalog_repo(entry)
|
|
||||||
ref = entry.get("ref", "main")
|
|
||||||
dest = catalog_checkout_path(name, entry)
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@@ -333,14 +371,17 @@ def _expand_install_request(
|
|||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
sources = load_sources()
|
sources = load_sources()
|
||||||
if source in sources:
|
if source in sources:
|
||||||
|
_ensure_source_fetched(name, source, refresh=True)
|
||||||
return [(name, source)]
|
return [(name, source)]
|
||||||
if source == "catalog" and name in catalog:
|
if source == "catalog" and name in catalog:
|
||||||
|
_ensure_catalog_fetched(name, catalog[name], refresh=True)
|
||||||
available = _catalog_skill_names(name, catalog[name])
|
available = _catalog_skill_names(name, catalog[name])
|
||||||
root = catalog_skill_path(name, catalog[name])
|
root = catalog_skill_path(name, catalog[name])
|
||||||
if (root / "SKILL.md").is_file():
|
if (root / "SKILL.md").is_file():
|
||||||
return [(name, "catalog")]
|
return [(name, "catalog")]
|
||||||
return [(skill_name, f"catalog:{name}") for skill_name in available]
|
return [(skill_name, f"catalog:{name}") for skill_name in available]
|
||||||
if source in catalog:
|
if source in catalog:
|
||||||
|
_ensure_catalog_fetched(source, catalog[source], refresh=True)
|
||||||
available = _catalog_skill_names(source, catalog[source])
|
available = _catalog_skill_names(source, catalog[source])
|
||||||
if name not in available:
|
if name not in available:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@@ -348,13 +389,14 @@ def _expand_install_request(
|
|||||||
)
|
)
|
||||||
return [(name, f"catalog:{source}")]
|
return [(name, f"catalog:{source}")]
|
||||||
if source is None and name in catalog:
|
if source is None and name in catalog:
|
||||||
|
_ensure_catalog_fetched(name, catalog[name], refresh=True)
|
||||||
available = _catalog_skill_names(name, catalog[name])
|
available = _catalog_skill_names(name, catalog[name])
|
||||||
root = catalog_skill_path(name, catalog[name])
|
root = catalog_skill_path(name, catalog[name])
|
||||||
if (root / "SKILL.md").is_file():
|
if (root / "SKILL.md").is_file():
|
||||||
return [(name, f"catalog:{name}")]
|
return [(name, f"catalog:{name}")]
|
||||||
return [(skill_name, f"catalog:{name}") for skill_name in available]
|
return [(skill_name, f"catalog:{name}") for skill_name in available]
|
||||||
if source is None and name in sources:
|
if source is None and name in sources:
|
||||||
_ensure_source_fetched(name, name)
|
_ensure_source_fetched(name, name, refresh=True)
|
||||||
available = discover_source_skills(name, sources[name])
|
available = discover_source_skills(name, sources[name])
|
||||||
if not available:
|
if not available:
|
||||||
raise SystemExit(f"custom source {name!r} 中没有可安装的 skill")
|
raise SystemExit(f"custom source {name!r} 中没有可安装的 skill")
|
||||||
@@ -374,7 +416,7 @@ def _install_skill(
|
|||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
name, source = split_skill_spec(name, source)
|
name, source = split_skill_spec(name, source)
|
||||||
_ensure_source_fetched(name, source)
|
_ensure_source_fetched(name, source, refresh=True)
|
||||||
skill_path, resolved_source = resolve_skill_source(name, source=source)
|
skill_path, resolved_source = resolve_skill_source(name, source=source)
|
||||||
links = [
|
links = [
|
||||||
(target, agent_skill_dir(target, project_root=project_root) / name)
|
(target, agent_skill_dir(target, project_root=project_root) / name)
|
||||||
@@ -485,7 +527,7 @@ def _remove_skill(
|
|||||||
|
|
||||||
|
|
||||||
def cmd_list(args: argparse.Namespace) -> None:
|
def cmd_list(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
source_filter = normalize_source(args.source)
|
source_filter = normalize_source(args.source)
|
||||||
builtin = list_builtin_skills()
|
builtin = list_builtin_skills()
|
||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
@@ -528,18 +570,34 @@ def cmd_list(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def cmd_bootstrap(args: argparse.Namespace) -> None:
|
def cmd_bootstrap(args: argparse.Namespace) -> None:
|
||||||
del args
|
del args
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
project_skill = "skiff"
|
project_skill = "pouch"
|
||||||
builtin_skill_path(project_skill)
|
builtin_skill_path(project_skill)
|
||||||
_install_skill(project_skill, list(ALL_TARGETS), project_root=None)
|
_install_skill(project_skill, list(ALL_TARGETS), project_root=None)
|
||||||
_print("已全局安装 builtin skiff skill 到所有 agent")
|
_print("已全局安装 builtin pouch skill 到所有 agent")
|
||||||
|
|
||||||
|
|
||||||
def cmd_update(args: argparse.Namespace) -> None:
|
def cmd_update(args: argparse.Namespace) -> None:
|
||||||
del args
|
del args
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
_print(f"更新 skiff: {SKILLS_HOME}")
|
_print(f"更新 pouch: {POUCH_HOME}")
|
||||||
subprocess.run(["git", "-C", str(SKILLS_HOME), "pull"], check=True)
|
results = run_updates()
|
||||||
|
record_auto_update()
|
||||||
|
labels = {
|
||||||
|
"updated": "已更新",
|
||||||
|
"current": "已是最新",
|
||||||
|
"skipped": "跳过",
|
||||||
|
"failed": "失败",
|
||||||
|
}
|
||||||
|
for item in results:
|
||||||
|
extra = f"({item.detail})" if item.detail else ""
|
||||||
|
line = f"{labels.get(item.status, item.status)} {item.name}{extra}"
|
||||||
|
if item.status == "failed":
|
||||||
|
_err(line)
|
||||||
|
else:
|
||||||
|
_print(line)
|
||||||
|
if any(item.status == "failed" for item in results):
|
||||||
|
raise SystemExit("部分来源更新失败")
|
||||||
|
|
||||||
|
|
||||||
def _installed_links(
|
def _installed_links(
|
||||||
@@ -558,7 +616,7 @@ def _installed_links(
|
|||||||
|
|
||||||
|
|
||||||
def cmd_status(args: argparse.Namespace) -> None:
|
def cmd_status(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||||
builtin = list_builtin_skills()
|
builtin = list_builtin_skills()
|
||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
@@ -578,7 +636,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|||||||
)
|
)
|
||||||
entries.extend((source, name) for source, names in custom.items() for name in names)
|
entries.extend((source, name) for source, names in custom.items() for name in names)
|
||||||
|
|
||||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
_print(f"pouch 仓库: {POUCH_HOME.resolve()}")
|
||||||
_print(f"agents: {', '.join(targets)}\n")
|
_print(f"agents: {', '.join(targets)}\n")
|
||||||
|
|
||||||
for package in unfetched_catalog:
|
for package in unfetched_catalog:
|
||||||
@@ -604,10 +662,10 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _print_available_skills() -> None:
|
def _print_available_skills() -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
builtin = list_builtin_skills()
|
builtin = list_builtin_skills()
|
||||||
if not builtin:
|
if not builtin:
|
||||||
_print("~/.skills/skills/ 中没有自研 skill")
|
_print("~/.pouch/skills/ 中没有自研 skill")
|
||||||
return
|
return
|
||||||
|
|
||||||
_print(f"来源: {SKILLS_DIR}\n")
|
_print(f"来源: {SKILLS_DIR}\n")
|
||||||
@@ -621,14 +679,14 @@ def _print_available_skills() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_add(args: argparse.Namespace) -> None:
|
def cmd_add(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
|
|
||||||
if args.list_available:
|
if args.list_available:
|
||||||
if args.source:
|
if args.source:
|
||||||
cmd_list(argparse.Namespace(source=args.source))
|
cmd_list(argparse.Namespace(source=args.source))
|
||||||
else:
|
else:
|
||||||
_print_available_skills()
|
_print_available_skills()
|
||||||
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
|
_print("使用 pouch add <name> 安装,或 pouch add <name> -g 全局安装")
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.all:
|
if args.all:
|
||||||
@@ -639,7 +697,7 @@ def cmd_add(args: argparse.Namespace) -> None:
|
|||||||
elif source == "catalog":
|
elif source == "catalog":
|
||||||
names = list(load_catalog())
|
names = list(load_catalog())
|
||||||
else:
|
else:
|
||||||
_ensure_source_fetched("", source)
|
_ensure_source_fetched("", source, refresh=True)
|
||||||
names = list_custom_skills(source)[source]
|
names = list_custom_skills(source)[source]
|
||||||
else:
|
else:
|
||||||
names = list_builtin_skills()
|
names = list_builtin_skills()
|
||||||
@@ -647,7 +705,7 @@ def cmd_add(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
names = _collect_skill_names(args.skills, args.skills_flag)
|
names = _collect_skill_names(args.skills, args.skills_flag)
|
||||||
if not names:
|
if not names:
|
||||||
raise SystemExit("请指定 skill 名称,或使用 skiff add --list 查看可用 skill")
|
raise SystemExit("请指定 skill 名称,或使用 pouch add --list 查看可用 skill")
|
||||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||||
|
|
||||||
project_root = None if args.global_scope else _project_root(args.project)
|
project_root = None if args.global_scope else _project_root(args.project)
|
||||||
@@ -661,7 +719,7 @@ def cmd_add(args: argparse.Namespace) -> None:
|
|||||||
original_links: list[tuple[Path, str | None]] = []
|
original_links: list[tuple[Path, str | None]] = []
|
||||||
for skill_name, source in requests:
|
for skill_name, source in requests:
|
||||||
validate_skill_name(skill_name)
|
validate_skill_name(skill_name)
|
||||||
_ensure_source_fetched(skill_name, source)
|
_ensure_source_fetched(skill_name, source, refresh=True)
|
||||||
resolve_skill_source(skill_name, source=source)
|
resolve_skill_source(skill_name, source=source)
|
||||||
for target in targets:
|
for target in targets:
|
||||||
link = agent_skill_dir(target, project_root=project_root) / skill_name
|
link = agent_skill_dir(target, project_root=project_root) / skill_name
|
||||||
@@ -671,7 +729,7 @@ def cmd_add(args: argparse.Namespace) -> None:
|
|||||||
(link, str(link.readlink()) if link.is_symlink() else None)
|
(link, str(link.readlink()) if link.is_symlink() else None)
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest_path = project_root / ".skills.yaml" if project_root else None
|
manifest_path = project_manifest(project_root) if project_root else None
|
||||||
manifest_before = (
|
manifest_before = (
|
||||||
manifest_path.read_bytes()
|
manifest_path.read_bytes()
|
||||||
if manifest_path and manifest_path.is_file()
|
if manifest_path and manifest_path.is_file()
|
||||||
@@ -709,10 +767,10 @@ def cmd_add(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_select(args: argparse.Namespace) -> None:
|
def cmd_select(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"`skiff select` 需要交互式终端;非交互环境请使用 `skiff add <name>...`"
|
"`pouch select` 需要交互式终端;非交互环境请使用 `pouch add <name>...`"
|
||||||
)
|
)
|
||||||
|
|
||||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||||
@@ -877,7 +935,7 @@ def cmd_select(args: argparse.Namespace) -> None:
|
|||||||
names = sorted(selected - selected_installed_keys)
|
names = sorted(selected - selected_installed_keys)
|
||||||
|
|
||||||
failures: list[tuple[str, str]] = []
|
failures: list[tuple[str, str]] = []
|
||||||
manifest_path = project_root / ".skills.yaml" if project_root else None
|
manifest_path = project_manifest(project_root) if project_root else None
|
||||||
successful = set(selected & selected_installed_keys)
|
successful = set(selected & selected_installed_keys)
|
||||||
for name in names:
|
for name in names:
|
||||||
skill_name, source = choice_requests[name]
|
skill_name, source = choice_requests[name]
|
||||||
@@ -915,7 +973,7 @@ def cmd_select(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_remove(args: argparse.Namespace) -> None:
|
def cmd_remove(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
project_root = None if args.global_scope else _project_root(args.project)
|
project_root = None if args.global_scope else _project_root(args.project)
|
||||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||||
|
|
||||||
@@ -925,7 +983,7 @@ def cmd_remove(args: argparse.Namespace) -> None:
|
|||||||
names = _collect_skill_names(args.skills, args.skills_flag)
|
names = _collect_skill_names(args.skills, args.skills_flag)
|
||||||
|
|
||||||
if not names:
|
if not names:
|
||||||
raise SystemExit("请指定 skill 名称,或使用 skiff remove --all")
|
raise SystemExit("请指定 skill 名称,或使用 pouch remove --all")
|
||||||
|
|
||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
sources = load_sources()
|
sources = load_sources()
|
||||||
@@ -958,14 +1016,14 @@ def cmd_remove(args: argparse.Namespace) -> None:
|
|||||||
for name in dict.fromkeys(expanded):
|
for name in dict.fromkeys(expanded):
|
||||||
total += _remove_skill(name, targets, project_root=project_root)
|
total += _remove_skill(name, targets, project_root=project_root)
|
||||||
if project_root is not None:
|
if project_root is not None:
|
||||||
remove_skill_from_manifest(project_root / ".skills.yaml", name)
|
remove_skill_from_manifest(project_manifest(project_root), name)
|
||||||
|
|
||||||
if total == 0:
|
if total == 0:
|
||||||
_print("没有移除任何 skill")
|
_print("没有移除任何 skill")
|
||||||
|
|
||||||
|
|
||||||
def cmd_publish(args: argparse.Namespace) -> None:
|
def cmd_publish(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
paths = args.paths or ["."]
|
paths = args.paths or ["."]
|
||||||
git_publish(
|
git_publish(
|
||||||
paths=paths,
|
paths=paths,
|
||||||
@@ -976,7 +1034,7 @@ def cmd_publish(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_catalog_add(args: argparse.Namespace) -> None:
|
def cmd_catalog_add(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
validate_skill_name(args.name)
|
validate_skill_name(args.name)
|
||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
if args.name in catalog:
|
if args.name in catalog:
|
||||||
@@ -992,7 +1050,7 @@ def cmd_catalog_add(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_fetch(args: argparse.Namespace) -> None:
|
def cmd_fetch(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
catalog = load_catalog()
|
catalog = load_catalog()
|
||||||
if args.name not in catalog:
|
if args.name not in catalog:
|
||||||
raise SystemExit(f"catalog 中不存在: {args.name}")
|
raise SystemExit(f"catalog 中不存在: {args.name}")
|
||||||
@@ -1106,9 +1164,9 @@ def cmd_source_remove(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_sync(args: argparse.Namespace) -> None:
|
def cmd_sync(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
root = _project_root(args.project)
|
root = _project_root(args.project)
|
||||||
manifest_path = root / ".skills.yaml"
|
manifest_path = project_manifest(root)
|
||||||
if not manifest_path.is_file():
|
if not manifest_path.is_file():
|
||||||
raise SystemExit(f"未找到 {manifest_path}")
|
raise SystemExit(f"未找到 {manifest_path}")
|
||||||
|
|
||||||
@@ -1121,7 +1179,7 @@ def cmd_sync(args: argparse.Namespace) -> None:
|
|||||||
for entry in iter_manifest_skills(data):
|
for entry in iter_manifest_skills(data):
|
||||||
name = entry["name"]
|
name = entry["name"]
|
||||||
source = entry.get("source")
|
source = entry.get("source")
|
||||||
_ensure_source_fetched(name, source)
|
_ensure_source_fetched(name, source, refresh=True)
|
||||||
skill_path, _ = resolve_manifest_skill(entry)
|
skill_path, _ = resolve_manifest_skill(entry)
|
||||||
skill_targets = targets
|
skill_targets = targets
|
||||||
if entry.get("targets"):
|
if entry.get("targets"):
|
||||||
@@ -1133,7 +1191,7 @@ def cmd_sync(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_create(args: argparse.Namespace) -> None:
|
def cmd_create(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
validate_skill_name(args.name)
|
validate_skill_name(args.name)
|
||||||
if not TEMPLATE_DIR.is_dir():
|
if not TEMPLATE_DIR.is_dir():
|
||||||
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
|
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
|
||||||
@@ -1170,8 +1228,8 @@ def cmd_create(args: argparse.Namespace) -> None:
|
|||||||
}
|
}
|
||||||
(dst / "brief.yaml").write_text(safe_dump(brief), encoding="utf-8")
|
(dst / "brief.yaml").write_text(safe_dump(brief), encoding="utf-8")
|
||||||
_print(f"草稿已创建: {dst}")
|
_print(f"草稿已创建: {dst}")
|
||||||
_print(f"下一步: 请完善 skiff 草稿 {args.name}")
|
_print(f"下一步: 请完善 pouch 草稿 {args.name}")
|
||||||
_print(f"完成后运行: skiff check {args.name} && skiff finalize {args.name}")
|
_print(f"完成后运行: pouch check {args.name} && pouch finalize {args.name}")
|
||||||
|
|
||||||
|
|
||||||
def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
|
def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
|
||||||
@@ -1185,7 +1243,7 @@ def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_check(args: argparse.Namespace) -> None:
|
def cmd_check(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
validate_skill_name(args.name)
|
validate_skill_name(args.name)
|
||||||
path, kind = _draft_or_builtin_path(args.name)
|
path, kind = _draft_or_builtin_path(args.name)
|
||||||
issues = validate_skill_dir(path, args.name)
|
issues = validate_skill_dir(path, args.name)
|
||||||
@@ -1199,7 +1257,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_finalize(args: argparse.Namespace) -> None:
|
def cmd_finalize(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
validate_skill_name(args.name)
|
validate_skill_name(args.name)
|
||||||
draft = DRAFTS_DIR / args.name
|
draft = DRAFTS_DIR / args.name
|
||||||
if not draft.is_dir():
|
if not draft.is_dir():
|
||||||
@@ -1221,25 +1279,25 @@ def cmd_finalize(args: argparse.Namespace) -> None:
|
|||||||
if brief.exists():
|
if brief.exists():
|
||||||
brief.unlink()
|
brief.unlink()
|
||||||
_print(f"已完成 skill: {final}")
|
_print(f"已完成 skill: {final}")
|
||||||
_print(f"下一步: skiff publish skills/{args.name} -m \"add {args.name}\" --push")
|
_print(f"下一步: pouch publish skills/{args.name} -m \"add {args.name}\" --push")
|
||||||
|
|
||||||
|
|
||||||
def cmd_doctor(args: argparse.Namespace) -> None:
|
def cmd_doctor(args: argparse.Namespace) -> None:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||||
issues = 0
|
issues = 0
|
||||||
|
|
||||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
_print(f"pouch 仓库: {POUCH_HOME.resolve()}")
|
||||||
if SKILLS_HOME.is_symlink():
|
if POUCH_HOME.is_symlink():
|
||||||
if not SKILLS_HOME.resolve().is_dir():
|
if not POUCH_HOME.resolve().is_dir():
|
||||||
_err(f"✗ ~/.skills 指向无效路径: {SKILLS_HOME.resolve()}")
|
_err(f"✗ ~/.pouch 指向无效路径: {POUCH_HOME.resolve()}")
|
||||||
issues += 1
|
issues += 1
|
||||||
else:
|
else:
|
||||||
_print("✓ ~/.skills 软链正常")
|
_print("✓ ~/.pouch 软链正常")
|
||||||
elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir():
|
elif POUCH_HOME.is_dir() and (POUCH_HOME / "skills").is_dir():
|
||||||
_print("✓ ~/.skills 为本地仓库目录")
|
_print("✓ ~/.pouch 为本地仓库目录")
|
||||||
else:
|
else:
|
||||||
_err("✗ ~/.skills 未正确配置")
|
_err("✗ ~/.pouch 未正确配置")
|
||||||
issues += 1
|
issues += 1
|
||||||
|
|
||||||
for name in list_builtin_skills():
|
for name in list_builtin_skills():
|
||||||
@@ -1278,13 +1336,13 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
_print(f"\n发现 {issues} 个问题")
|
_print(f"\n发现 {issues} 个问题")
|
||||||
if not args.fix:
|
if not args.fix:
|
||||||
_print("提示: 使用 skiff doctor --fix 尝试自动修复软链")
|
_print("提示: 使用 pouch doctor --fix 尝试自动修复软链")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_init(args: argparse.Namespace) -> None:
|
def cmd_init(args: argparse.Namespace) -> None:
|
||||||
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
|
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
validate_skill_name(args.name)
|
validate_skill_name(args.name)
|
||||||
skills_root = SKILLS_DIR.resolve()
|
skills_root = SKILLS_DIR.resolve()
|
||||||
skill_source = (SKILLS_DIR / args.name).resolve()
|
skill_source = (SKILLS_DIR / args.name).resolve()
|
||||||
@@ -1307,14 +1365,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
initial_project_stat.st_ino,
|
initial_project_stat.st_ino,
|
||||||
stat.S_IFMT(initial_project_stat.st_mode),
|
stat.S_IFMT(initial_project_stat.st_mode),
|
||||||
)
|
)
|
||||||
destination = project / "docs" / args.name
|
destination = project / ".pouch" / args.name
|
||||||
project_file = destination / "project.md"
|
project_file = destination / "project.md"
|
||||||
tasks_file = destination / "tasks.yaml"
|
tasks_file = destination / "tasks.yaml"
|
||||||
knowledge_file = destination / "knowledge.yaml"
|
knowledge_file = destination / "knowledge.yaml"
|
||||||
delivery_file = destination / "delivery.yaml"
|
delivery_file = destination / "delivery.yaml"
|
||||||
|
regression_file = destination / "regression.yaml"
|
||||||
managed_targets = [project_file, tasks_file]
|
managed_targets = [project_file, tasks_file]
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
managed_targets.extend((knowledge_file, delivery_file))
|
managed_targets.extend((knowledge_file, delivery_file, regression_file))
|
||||||
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||||
if existing:
|
if existing:
|
||||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||||
@@ -1331,19 +1390,29 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
(
|
(
|
||||||
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
|
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
|
||||||
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
|
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
|
||||||
|
(
|
||||||
|
skill_source / "templates" / "regression.template.yaml",
|
||||||
|
regression_file,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
missing = [path for path, _ in template_targets if not path.is_file()]
|
missing = [path for path, _ in template_targets if not path.is_file()]
|
||||||
if missing:
|
if missing:
|
||||||
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
|
paths = ", ".join(str(path.relative_to(POUCH_HOME)) for path in missing)
|
||||||
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
||||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||||
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
||||||
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
||||||
|
regression_validator = skill_source / "scripts" / "validate_regression.py"
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
missing_validators = [
|
missing_validators = [
|
||||||
path
|
path
|
||||||
for path in (validator, knowledge_validator, delivery_validator)
|
for path in (
|
||||||
|
validator,
|
||||||
|
knowledge_validator,
|
||||||
|
delivery_validator,
|
||||||
|
regression_validator,
|
||||||
|
)
|
||||||
if not path.is_file()
|
if not path.is_file()
|
||||||
]
|
]
|
||||||
if missing_validators:
|
if missing_validators:
|
||||||
@@ -1357,13 +1426,13 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
"<project_name>": project.name,
|
"<project_name>": project.name,
|
||||||
"<repo_path>": str(project),
|
"<repo_path>": str(project),
|
||||||
"<dev_worktree>": str(project),
|
"<dev_worktree>": str(project),
|
||||||
"<overlay_file_path>": f"docs/{args.name}/project.md",
|
"<overlay_file_path>": f".pouch/{args.name}/project.md",
|
||||||
"<ack_version>": ack_version,
|
"<ack_version>": ack_version,
|
||||||
"<接入时的 ack skill 版本>": ack_version,
|
"<接入时的 ack skill 版本>": ack_version,
|
||||||
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
|
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
|
||||||
}
|
}
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix=f"skiff-{args.name}-init-") as temp_dir:
|
with tempfile.TemporaryDirectory(prefix=f"pouch-{args.name}-init-") as temp_dir:
|
||||||
staging = Path(temp_dir)
|
staging = Path(temp_dir)
|
||||||
staged_files: dict[Path, Path] = {}
|
staged_files: dict[Path, Path] = {}
|
||||||
rendered_files: dict[Path, str] = {}
|
rendered_files: dict[Path, str] = {}
|
||||||
@@ -1416,6 +1485,21 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"初始化交付契约校验失败(exit {completed.returncode})"
|
f"初始化交付契约校验失败(exit {completed.returncode})"
|
||||||
)
|
)
|
||||||
|
if args.name == "ack" and regression_validator.is_file():
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(regression_validator),
|
||||||
|
str(staged_files[regression_file]),
|
||||||
|
"--tasks",
|
||||||
|
str(staged_files[tasks_file]),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise SystemExit(
|
||||||
|
f"初始化回归目录校验失败(exit {completed.returncode})"
|
||||||
|
)
|
||||||
|
|
||||||
for target, staged in staged_files.items():
|
for target, staged in staged_files.items():
|
||||||
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
||||||
@@ -1430,10 +1514,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
|
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
|
||||||
|
|
||||||
project_fd: int | None = None
|
project_fd: int | None = None
|
||||||
docs_fd: int | None = None
|
pouch_fd: int | None = None
|
||||||
transaction_fd: int | None = None
|
transaction_fd: int | None = None
|
||||||
staging_fd: int | None = None
|
staging_fd: int | None = None
|
||||||
docs_created = False
|
pouch_created = False
|
||||||
transaction_name: str | None = None
|
transaction_name: str | None = None
|
||||||
staged_names: list[str] = []
|
staged_names: list[str] = []
|
||||||
published = False
|
published = False
|
||||||
@@ -1461,8 +1545,8 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
project,
|
project,
|
||||||
phase="初始化",
|
phase="初始化",
|
||||||
)
|
)
|
||||||
docs_fd, docs_created = _open_or_create_directory_at(project_fd, "docs")
|
pouch_fd, pouch_created = _open_or_create_directory_at(project_fd, ".pouch")
|
||||||
if docs_created:
|
if pouch_created:
|
||||||
os.fsync(project_fd)
|
os.fsync(project_fd)
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
project_fd,
|
project_fd,
|
||||||
@@ -1470,15 +1554,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
phase="初始化",
|
phase="初始化",
|
||||||
)
|
)
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
project / "docs",
|
project / ".pouch",
|
||||||
phase="初始化",
|
phase="初始化",
|
||||||
label="docs 目录",
|
label=".pouch 目录",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
destination_stat = os.stat(
|
destination_stat = os.stat(
|
||||||
args.name,
|
args.name,
|
||||||
dir_fd=docs_fd,
|
dir_fd=pouch_fd,
|
||||||
follow_symlinks=False,
|
follow_symlinks=False,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -1487,14 +1571,14 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
if stat.S_ISLNK(destination_stat.st_mode):
|
if stat.S_ISLNK(destination_stat.st_mode):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"初始化路径必须是普通目录且不能是软链接: "
|
"初始化路径必须是普通目录且不能是软链接: "
|
||||||
f"docs/{args.name}"
|
f".pouch/{args.name}"
|
||||||
)
|
)
|
||||||
raise SystemExit(f"拒绝覆盖已有路径: docs/{args.name}")
|
raise SystemExit(f"拒绝覆盖已有路径: .pouch/{args.name}")
|
||||||
|
|
||||||
for _ in range(32):
|
for _ in range(32):
|
||||||
candidate = f".{args.name}-init-{secrets.token_hex(8)}"
|
candidate = f".{args.name}-init-{secrets.token_hex(8)}"
|
||||||
try:
|
try:
|
||||||
os.mkdir(candidate, mode=0o700, dir_fd=docs_fd)
|
os.mkdir(candidate, mode=0o700, dir_fd=pouch_fd)
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
continue
|
continue
|
||||||
transaction_name = candidate
|
transaction_name = candidate
|
||||||
@@ -1505,7 +1589,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
transaction_fd = os.open(
|
transaction_fd = os.open(
|
||||||
transaction_name,
|
transaction_name,
|
||||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||||
dir_fd=docs_fd,
|
dir_fd=pouch_fd,
|
||||||
)
|
)
|
||||||
os.mkdir("payload", mode=0o755, dir_fd=transaction_fd)
|
os.mkdir("payload", mode=0o755, dir_fd=transaction_fd)
|
||||||
staging_fd = os.open(
|
staging_fd = os.open(
|
||||||
@@ -1534,21 +1618,21 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
phase="发布",
|
phase="发布",
|
||||||
)
|
)
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
project / "docs",
|
project / ".pouch",
|
||||||
phase="发布",
|
phase="发布",
|
||||||
label="docs 目录",
|
label=".pouch 目录",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_rename_directory_noreplace(
|
_rename_directory_noreplace(
|
||||||
transaction_fd,
|
transaction_fd,
|
||||||
"payload",
|
"payload",
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
args.name,
|
args.name,
|
||||||
)
|
)
|
||||||
except FileExistsError as exc:
|
except FileExistsError as exc:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"拒绝覆盖已有路径: docs/{args.name}"
|
f"拒绝覆盖已有路径: .pouch/{args.name}"
|
||||||
) from exc
|
) from exc
|
||||||
published = True
|
published = True
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
@@ -1560,23 +1644,23 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
if (
|
if (
|
||||||
transaction_name is not None
|
transaction_name is not None
|
||||||
and _directory_entry_matches_open_fd(
|
and _directory_entry_matches_open_fd(
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
transaction_name,
|
transaction_name,
|
||||||
transaction_fd,
|
transaction_fd,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
os.rmdir(transaction_name, dir_fd=docs_fd)
|
os.rmdir(transaction_name, dir_fd=pouch_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
transaction_name = None
|
transaction_name = None
|
||||||
try:
|
try:
|
||||||
os.fsync(docs_fd)
|
os.fsync(pouch_fd)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"初始化目录已完整发布,但无法确认目录项持久化;"
|
"初始化目录已完整发布,但无法确认目录项持久化;"
|
||||||
f"请检查 docs/{args.name} 后再重试"
|
f"请检查 .pouch/{args.name} 后再重试"
|
||||||
) from exc
|
) from exc
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
project_fd,
|
project_fd,
|
||||||
@@ -1584,10 +1668,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
phase="完成初始化",
|
phase="完成初始化",
|
||||||
)
|
)
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
project / "docs",
|
project / ".pouch",
|
||||||
phase="完成初始化",
|
phase="完成初始化",
|
||||||
label="docs 目录",
|
label=".pouch 目录",
|
||||||
)
|
)
|
||||||
_assert_open_directory_path(
|
_assert_open_directory_path(
|
||||||
staging_fd,
|
staging_fd,
|
||||||
@@ -1612,15 +1696,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
if (
|
if (
|
||||||
transaction_name is not None
|
transaction_name is not None
|
||||||
and transaction_fd is not None
|
and transaction_fd is not None
|
||||||
and docs_fd is not None
|
and pouch_fd is not None
|
||||||
and _directory_entry_matches_open_fd(
|
and _directory_entry_matches_open_fd(
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
transaction_name,
|
transaction_name,
|
||||||
transaction_fd,
|
transaction_fd,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
os.rmdir(transaction_name, dir_fd=docs_fd)
|
os.rmdir(transaction_name, dir_fd=pouch_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
@@ -1628,7 +1712,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
for directory_fd in (
|
for directory_fd in (
|
||||||
staging_fd,
|
staging_fd,
|
||||||
transaction_fd,
|
transaction_fd,
|
||||||
docs_fd,
|
pouch_fd,
|
||||||
project_fd,
|
project_fd,
|
||||||
):
|
):
|
||||||
if directory_fd is not None:
|
if directory_fd is not None:
|
||||||
@@ -1644,6 +1728,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
_print(f" 知识库: {knowledge_file}")
|
_print(f" 知识库: {knowledge_file}")
|
||||||
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
||||||
|
_print(f" 回归目录: {regression_file}")
|
||||||
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
||||||
|
|
||||||
|
|
||||||
@@ -1655,7 +1740,7 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
|||||||
nargs="+",
|
nargs="+",
|
||||||
action="append",
|
action="append",
|
||||||
metavar="AGENT",
|
metavar="AGENT",
|
||||||
help="目标 agent(cursor、claude、claude-code、codex、opencode、*)",
|
help="目标 agent(cursor、claude、claude-code、codex、agents、*)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-g",
|
"-g",
|
||||||
@@ -1670,20 +1755,28 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
|||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="skiff",
|
prog="pouch",
|
||||||
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills)",
|
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--version", action="version", version=f"skiff {__version__}")
|
parser.add_argument("--version", action="version", version=f"pouch {__version__}")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-auto-update",
|
||||||
|
action="store_true",
|
||||||
|
help="本次跳过自动更新 pouch 与已拉取的 source",
|
||||||
|
)
|
||||||
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
p_bootstrap = sub.add_parser(
|
p_bootstrap = sub.add_parser(
|
||||||
"bootstrap",
|
"bootstrap",
|
||||||
help="将本项目 skiff skill 全局安装到所有 agent",
|
help="将本项目 pouch skill 全局安装到所有 agent",
|
||||||
)
|
)
|
||||||
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 = sub.add_parser(
|
||||||
|
"update",
|
||||||
|
help="更新 pouch 自身以及已拉取的 catalog / custom source",
|
||||||
|
)
|
||||||
p_update.set_defaults(func=cmd_update)
|
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")
|
||||||
@@ -1727,7 +1820,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
p_publish = sub.add_parser(
|
p_publish = sub.add_parser(
|
||||||
"publish",
|
"publish",
|
||||||
help="在 ~/.skills 内 git add / commit / push",
|
help="在 ~/.pouch 内 git add / commit / push",
|
||||||
)
|
)
|
||||||
p_publish.add_argument("paths", nargs="*", help="要提交的路径(默认 .)")
|
p_publish.add_argument("paths", nargs="*", help="要提交的路径(默认 .)")
|
||||||
p_publish.add_argument("-m", "--message", help="commit 说明")
|
p_publish.add_argument("-m", "--message", help="commit 说明")
|
||||||
@@ -1778,11 +1871,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p_source_remove.add_argument(
|
p_source_remove.add_argument(
|
||||||
"--delete-checkout",
|
"--delete-checkout",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="同时永久删除 skiff 管理的 checkout",
|
help="同时永久删除 pouch 管理的 checkout",
|
||||||
)
|
)
|
||||||
p_source_remove.set_defaults(func=cmd_source_remove)
|
p_source_remove.set_defaults(func=cmd_source_remove)
|
||||||
|
|
||||||
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
|
p_sync = sub.add_parser("sync", help="按 .pouch.yaml 重建项目软链")
|
||||||
p_sync.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
p_sync.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
||||||
p_sync.add_argument("--project")
|
p_sync.add_argument("--project")
|
||||||
p_sync.set_defaults(func=cmd_sync)
|
p_sync.set_defaults(func=cmd_sync)
|
||||||
@@ -1817,6 +1910,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
def main(argv: list[str] | None = None) -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
maybe_auto_update(
|
||||||
|
command=getattr(args, "command", None),
|
||||||
|
disabled=bool(getattr(args, "no_auto_update", False)),
|
||||||
|
)
|
||||||
args.func(args)
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""用户级 pouch 配置(~/.config/pouch/config.yaml)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pouch import yaml_io
|
||||||
|
from pouch.paths import CONFIG_FILE
|
||||||
|
|
||||||
|
DEFAULT_AUTO_UPDATE_INTERVAL = "24h"
|
||||||
|
_INTERVAL_UNITS = {
|
||||||
|
"s": 1,
|
||||||
|
"m": 60,
|
||||||
|
"h": 3600,
|
||||||
|
"d": 86400,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AutoUpdateSettings:
|
||||||
|
enabled: bool = True
|
||||||
|
interval_seconds: int = 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def load_user_config(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
path = path or CONFIG_FILE
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"pouch 配置格式错误: {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save_user_config(data: dict[str, Any], path: Path | None = None) -> None:
|
||||||
|
path = path or CONFIG_FILE
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_interval(value: Any) -> int:
|
||||||
|
if value is None or value is True:
|
||||||
|
return AutoUpdateSettings().interval_seconds
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError(f"无效的自动更新间隔: {value!r}")
|
||||||
|
if isinstance(value, int):
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError(f"无效的自动更新间隔: {value!r}")
|
||||||
|
return value
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return AutoUpdateSettings().interval_seconds
|
||||||
|
if text.isdigit():
|
||||||
|
return int(text)
|
||||||
|
unit = text[-1]
|
||||||
|
amount = text[:-1]
|
||||||
|
if unit in _INTERVAL_UNITS and amount.isdigit():
|
||||||
|
return int(amount) * _INTERVAL_UNITS[unit]
|
||||||
|
raise ValueError(f"无效的自动更新间隔: {value!r}(例如 24h、1d、60m、3600)")
|
||||||
|
|
||||||
|
|
||||||
|
def load_auto_update_settings(
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> AutoUpdateSettings:
|
||||||
|
if config is None:
|
||||||
|
config = load_user_config(path)
|
||||||
|
raw = config.get("auto_update", True)
|
||||||
|
if raw is False:
|
||||||
|
return AutoUpdateSettings(enabled=False)
|
||||||
|
if raw is True or raw is None:
|
||||||
|
return AutoUpdateSettings()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return AutoUpdateSettings()
|
||||||
|
enabled = raw.get("enabled", True)
|
||||||
|
if enabled is False:
|
||||||
|
return AutoUpdateSettings(enabled=False)
|
||||||
|
try:
|
||||||
|
interval = parse_interval(raw.get("interval", DEFAULT_AUTO_UPDATE_INTERVAL))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(str(exc)) from exc
|
||||||
|
return AutoUpdateSettings(enabled=bool(enabled), interval_seconds=interval)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""~/.skills 仓库内的 git 操作。"""
|
"""~/.pouch 仓库内的 git 操作。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from skiff.paths import SKILLS_HOME
|
from pouch.paths import POUCH_HOME
|
||||||
|
|
||||||
|
|
||||||
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||||
@@ -35,7 +35,7 @@ def publish(
|
|||||||
push: bool,
|
push: bool,
|
||||||
no_commit: bool,
|
no_commit: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
repo = SKILLS_HOME.resolve()
|
repo = POUCH_HOME.resolve()
|
||||||
ensure_git_repo(repo)
|
ensure_git_repo(repo)
|
||||||
|
|
||||||
_run_git(repo, "add", "--", *paths)
|
_run_git(repo, "add", "--", *paths)
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""路径与 Agent 目标定义。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HOME = Path.home()
|
||||||
|
PREFERRED_POUCH_HOME = HOME / ".pouch"
|
||||||
|
LEGACY_POUCH_HOME = HOME / ".skills"
|
||||||
|
PROJECT_MANIFEST = ".pouch.yaml"
|
||||||
|
LEGACY_PROJECT_MANIFEST = ".skills.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _first_existing_dir(*candidates: Path, default: Path) -> Path:
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_dir():
|
||||||
|
return path
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _first_existing_file(*candidates: Path, default: Path) -> Path:
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
POUCH_HOME = _first_existing_dir(
|
||||||
|
PREFERRED_POUCH_HOME,
|
||||||
|
LEGACY_POUCH_HOME,
|
||||||
|
default=PREFERRED_POUCH_HOME,
|
||||||
|
)
|
||||||
|
SKILLS_DIR = POUCH_HOME / "skills"
|
||||||
|
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||||
|
DRAFTS_DIR = POUCH_HOME / ".drafts"
|
||||||
|
CATALOG_FILE = POUCH_HOME / "catalog.yaml"
|
||||||
|
LEGACY_REGISTRY_FILE = POUCH_HOME / "registry.yaml"
|
||||||
|
CATALOG_CACHE_DIR = _first_existing_dir(
|
||||||
|
HOME / ".local" / "share" / "pouch" / "externals",
|
||||||
|
HOME / ".local" / "share" / "skills" / "externals",
|
||||||
|
default=HOME / ".local" / "share" / "pouch" / "externals",
|
||||||
|
)
|
||||||
|
CONFIG_FILE = _first_existing_file(
|
||||||
|
HOME / ".config" / "pouch" / "config.yaml",
|
||||||
|
HOME / ".config" / "skiff" / "config.yaml",
|
||||||
|
default=HOME / ".config" / "pouch" / "config.yaml",
|
||||||
|
)
|
||||||
|
DATA_DIR = _first_existing_dir(
|
||||||
|
HOME / ".local" / "share" / "pouch",
|
||||||
|
HOME / ".local" / "share" / "skiff",
|
||||||
|
default=HOME / ".local" / "share" / "pouch",
|
||||||
|
)
|
||||||
|
SOURCES_DIR = _first_existing_dir(
|
||||||
|
DATA_DIR / "sources",
|
||||||
|
HOME / ".local" / "share" / "pouch" / "sources",
|
||||||
|
HOME / ".local" / "share" / "skiff" / "sources",
|
||||||
|
default=HOME / ".local" / "share" / "pouch" / "sources",
|
||||||
|
)
|
||||||
|
STATE_FILE = DATA_DIR / "state.yaml"
|
||||||
|
|
||||||
|
AGENT_GLOBAL: dict[str, Path] = {
|
||||||
|
"cursor": HOME / ".cursor" / "skills",
|
||||||
|
"claude": HOME / ".claude" / "skills",
|
||||||
|
"codex": HOME / ".codex" / "skills",
|
||||||
|
# agents 标准目录:OMP 原生 canonical(agents provider),cursor/codex 项目级同路径
|
||||||
|
"agents": HOME / ".agents" / "skills",
|
||||||
|
}
|
||||||
|
|
||||||
|
AGENT_PROJECT: dict[str, str] = {
|
||||||
|
"cursor": ".agents/skills",
|
||||||
|
"claude": ".claude/skills",
|
||||||
|
"codex": ".agents/skills",
|
||||||
|
# 与 cursor/codex 共用 .agents/skills;软链幂等,同路径只写一次
|
||||||
|
"agents": ".agents/skills",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_TARGETS = ("cursor", "claude", "codex", "agents")
|
||||||
|
|
||||||
|
|
||||||
|
def project_manifest(root: Path) -> Path:
|
||||||
|
"""项目清单路径:优先 .pouch.yaml,否则沿用 .skills.yaml。"""
|
||||||
|
preferred = root / PROJECT_MANIFEST
|
||||||
|
legacy = root / LEGACY_PROJECT_MANIFEST
|
||||||
|
if preferred.is_file():
|
||||||
|
return preferred
|
||||||
|
if legacy.is_file():
|
||||||
|
return legacy
|
||||||
|
return preferred
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_targets(target: str | None) -> list[str]:
|
||||||
|
if target is None or target == "all":
|
||||||
|
return list(ALL_TARGETS)
|
||||||
|
if target not in ALL_TARGETS:
|
||||||
|
raise ValueError(f"未知 target: {target!r},可选: {', '.join(ALL_TARGETS)}, all")
|
||||||
|
return [target]
|
||||||
|
|
||||||
|
|
||||||
|
def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
|
||||||
|
if project_root is None:
|
||||||
|
return AGENT_GLOBAL[target]
|
||||||
|
return project_root / AGENT_PROJECT[target]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_pouch_home() -> None:
|
||||||
|
if not POUCH_HOME.is_dir():
|
||||||
|
raise SystemExit(
|
||||||
|
"~/.pouch 不存在。请将本仓库克隆到 ~/.pouch。"
|
||||||
|
"若仍是 ~/.skills,先执行: mv ~/.skills ~/.pouch"
|
||||||
|
)
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
"""项目级 .skills.yaml 管理。"""
|
"""项目级 .pouch.yaml 管理。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from skiff import yaml_io
|
from pouch import yaml_io
|
||||||
from skiff.paths import PROJECT_MANIFEST
|
from pouch.paths import project_manifest
|
||||||
from skiff.skills import normalize_source, resolve_skill_source
|
from pouch.skills import normalize_source, resolve_skill_source
|
||||||
|
|
||||||
|
|
||||||
def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]:
|
def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]:
|
||||||
path = path or Path.cwd() / PROJECT_MANIFEST
|
path = path or project_manifest(Path.cwd())
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return path, {"skills": []}
|
return path, {"skills": []}
|
||||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise SystemExit(f".skills.yaml 格式错误: {path}")
|
raise SystemExit(f".pouch.yaml 格式错误: {path}")
|
||||||
if "skills" not in data:
|
if "skills" not in data:
|
||||||
data["skills"] = []
|
data["skills"] = []
|
||||||
return path, data
|
return path, data
|
||||||
@@ -34,7 +34,7 @@ def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"name": entry, "source": "builtin"}
|
return {"name": entry, "source": "builtin"}
|
||||||
name = entry.get("name")
|
name = entry.get("name")
|
||||||
if not name:
|
if not name:
|
||||||
raise SystemExit(f".skills.yaml 条目缺少 name: {entry}")
|
raise SystemExit(f".pouch.yaml 条目缺少 name: {entry}")
|
||||||
source = normalize_source(str(entry.get("source", "builtin")))
|
source = normalize_source(str(entry.get("source", "builtin")))
|
||||||
if source == "catalog" and entry.get("registry"):
|
if source == "catalog" and entry.get("registry"):
|
||||||
source = f"catalog:{entry['registry']}"
|
source = f"catalog:{entry['registry']}"
|
||||||
@@ -58,7 +58,7 @@ def add_skill_to_manifest(
|
|||||||
) -> None:
|
) -> None:
|
||||||
path = manifest_path
|
path = manifest_path
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
path = path / PROJECT_MANIFEST
|
path = project_manifest(path)
|
||||||
|
|
||||||
file_path, data = load_manifest(path) if path.is_file() else (path, {"skills": []})
|
file_path, data = load_manifest(path) if path.is_file() else (path, {"skills": []})
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
@@ -82,8 +82,8 @@ def add_skill_to_manifest(
|
|||||||
|
|
||||||
def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool:
|
def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool:
|
||||||
path = manifest_path
|
path = manifest_path
|
||||||
if not path.is_file():
|
if path.is_dir() or not path.is_file():
|
||||||
path = path / PROJECT_MANIFEST
|
path = project_manifest(path if path.is_dir() else path.parent)
|
||||||
file_path, data = load_manifest(path)
|
file_path, data = load_manifest(path)
|
||||||
original = data.get("skills", [])
|
original = data.get("skills", [])
|
||||||
kept = [e for e in original if normalize_skill_entry(e)["name"] != name]
|
kept = [e for e in original if normalize_skill_entry(e)["name"] != name]
|
||||||
@@ -165,7 +165,7 @@ def select_skills(
|
|||||||
)
|
)
|
||||||
|
|
||||||
footer = (
|
footer = (
|
||||||
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
|
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
|
||||||
)
|
)
|
||||||
stdscr.addnstr(
|
stdscr.addnstr(
|
||||||
height - 1,
|
height - 1,
|
||||||
@@ -5,13 +5,13 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
from pouch.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_pouch_home
|
||||||
from skiff.catalog import (
|
from pouch.catalog import (
|
||||||
catalog_skill_path,
|
catalog_skill_path,
|
||||||
discover_catalog_skills,
|
discover_catalog_skills,
|
||||||
load_catalog,
|
load_catalog,
|
||||||
)
|
)
|
||||||
from skiff.sources import (
|
from pouch.sources import (
|
||||||
discover_source_skills,
|
discover_source_skills,
|
||||||
list_source_skills,
|
list_source_skills,
|
||||||
load_sources,
|
load_sources,
|
||||||
@@ -19,7 +19,7 @@ from skiff.sources import (
|
|||||||
|
|
||||||
|
|
||||||
def list_builtin_skills() -> list[str]:
|
def list_builtin_skills() -> list[str]:
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
if not SKILLS_DIR.is_dir():
|
if not SKILLS_DIR.is_dir():
|
||||||
return []
|
return []
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
@@ -76,7 +76,7 @@ def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
|
|||||||
|
|
||||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||||
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
||||||
ensure_skills_home()
|
ensure_pouch_home()
|
||||||
name, source = split_skill_spec(name, source)
|
name, source = split_skill_spec(name, source)
|
||||||
|
|
||||||
builtin = SKILLS_DIR / name
|
builtin = SKILLS_DIR / name
|
||||||
@@ -106,7 +106,7 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
|||||||
if not (path / "SKILL.md").is_file():
|
if not (path / "SKILL.md").is_file():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
|
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
|
||||||
f"请运行: skiff fetch {name}"
|
f"请运行: pouch fetch {name}"
|
||||||
)
|
)
|
||||||
return path, f"catalog:{name}"
|
return path, f"catalog:{name}"
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
|||||||
if source not in sources:
|
if source not in sources:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"项目依赖 source {source!r},但本机尚未配置。"
|
f"项目依赖 source {source!r},但本机尚未配置。"
|
||||||
f"请运行: skiff source add {source} <repo>"
|
f"请运行: pouch source add {source} <repo>"
|
||||||
)
|
)
|
||||||
skills = discover_source_skills(source, sources[source])
|
skills = discover_source_skills(source, sources[source])
|
||||||
if name not in skills:
|
if name not in skills:
|
||||||
@@ -156,7 +156,7 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
|||||||
path, resolved_source = candidates[0]
|
path, resolved_source = candidates[0]
|
||||||
if resolved_source.startswith("catalog:") and not path.exists():
|
if resolved_source.startswith("catalog:") and not path.exists():
|
||||||
provider = resolved_source.split(":", 1)[1]
|
provider = resolved_source.split(":", 1)[1]
|
||||||
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: skiff fetch {provider}")
|
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: pouch fetch {provider}")
|
||||||
return path, resolved_source
|
return path, resolved_source
|
||||||
|
|
||||||
raise SystemExit(f"找不到 skill: {name}")
|
raise SystemExit(f"找不到 skill: {name}")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
|
pouch 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
|
||||||
`registry` 外部仓库和用户配置的 custom source。
|
`registry` 外部仓库和用户配置的 custom source。
|
||||||
|
|
||||||
这些名称混合了不同维度:
|
这些名称混合了不同维度:
|
||||||
@@ -17,8 +17,8 @@ skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时
|
|||||||
|
|
||||||
本文建议将用户可见的来源注册方式统一为三类:
|
本文建议将用户可见的来源注册方式统一为三类:
|
||||||
|
|
||||||
- `builtin`:随当前 skiff 仓库提供的 Skill。
|
- `builtin`:随当前 pouch 仓库提供的 Skill。
|
||||||
- `catalog:<name>`:由 skiff 自带目录预先登记的来源。
|
- `catalog:<name>`:由 pouch 自带目录预先登记的来源。
|
||||||
- `custom:<name>`:用户在本机显式配置的命名来源。
|
- `custom:<name>`:用户在本机显式配置的命名来源。
|
||||||
|
|
||||||
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
|
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
|
||||||
@@ -29,20 +29,20 @@ Git 仓库或本地目录。
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
S[skiff 可发现的 Skills]
|
S[pouch 可发现的 Skills]
|
||||||
|
|
||||||
S --> O["owned<br/>~/.skills/skills/*"]
|
S --> O["owned<br/>~/.pouch/skills/*"]
|
||||||
S --> C["custom source<br/>~/.config/skiff/config.yaml"]
|
S --> C["custom source<br/>~/.config/pouch/config.yaml"]
|
||||||
S --> R["registry<br/>registry.yaml"]
|
S --> R["registry<br/>registry.yaml"]
|
||||||
|
|
||||||
O --> O1["本仓库维护<br/>随 skiff 一起分发"]
|
O --> O1["本仓库维护<br/>随 pouch 一起分发"]
|
||||||
|
|
||||||
C --> C1["本地目录<br/>--local PATH"]
|
C --> C1["本地目录<br/>--local PATH"]
|
||||||
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
|
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
|
||||||
|
|
||||||
R --> R1["外部单 Skill 仓库"]
|
R --> R1["外部单 Skill 仓库"]
|
||||||
R --> R2["外部 Skill Collection"]
|
R --> R2["外部 Skill Collection"]
|
||||||
R1 --> E["~/.local/share/skills/externals/"]
|
R1 --> E["~/.local/share/pouch/externals/"]
|
||||||
R2 --> E
|
R2 --> E
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ flowchart TD
|
|||||||
- `list` 和 `status` 支持 owned、registry 和 custom source。
|
- `list` 和 `status` 支持 owned、registry 和 custom source。
|
||||||
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
|
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
|
||||||
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
|
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
|
||||||
- `.skills.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
|
- `.pouch.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
|
||||||
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
|
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
|
||||||
|
|
||||||
## 现行模型
|
## 现行模型
|
||||||
@@ -61,11 +61,11 @@ flowchart TD
|
|||||||
A[Skill Provider]
|
A[Skill Provider]
|
||||||
|
|
||||||
A --> B["builtin<br/>仓库内隐式注册"]
|
A --> B["builtin<br/>仓库内隐式注册"]
|
||||||
A --> C["catalog:waza<br/>skiff 预置目录"]
|
A --> C["catalog:waza<br/>pouch 预置目录"]
|
||||||
A --> D["custom:company<br/>用户本机配置"]
|
A --> D["custom:company<br/>用户本机配置"]
|
||||||
|
|
||||||
B --> B1["skills/ack"]
|
B --> B1["skills/ack"]
|
||||||
B --> B2["skills/skiff"]
|
B --> B2["skills/pouch"]
|
||||||
B --> B3["skills/builder"]
|
B --> B3["skills/builder"]
|
||||||
|
|
||||||
C --> C1["Git 或本地目录"]
|
C --> C1["Git 或本地目录"]
|
||||||
@@ -85,14 +85,14 @@ flowchart TD
|
|||||||
|
|
||||||
| 类型 | 含义 | 配置来源 | 用户界面展示 |
|
| 类型 | 含义 | 配置来源 | 用户界面展示 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `builtin` | 随当前 skiff 仓库提供 | `skills/` | `builtin` |
|
| `builtin` | 随当前 pouch 仓库提供 | `skills/` | `builtin` |
|
||||||
| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
|
| `catalog` | pouch 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
|
||||||
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/skiff/config.yaml` | `custom:<name>` |
|
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/pouch/config.yaml` | `custom:<name>` |
|
||||||
|
|
||||||
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
|
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
|
||||||
方式,而不是仓库的所有权关系。
|
方式,而不是仓库的所有权关系。
|
||||||
|
|
||||||
`catalog` 比 `registry` 更准确:当前文件只是 skiff 随仓库维护的预置来源目录,
|
`catalog` 比 `registry` 更准确:当前文件只是 pouch 随仓库维护的预置来源目录,
|
||||||
并不是远程注册中心,也不是一种 Skill 来源协议。
|
并不是远程注册中心,也不是一种 Skill 来源协议。
|
||||||
|
|
||||||
### 三个正交维度
|
### 三个正交维度
|
||||||
@@ -128,7 +128,7 @@ layout: single | collection
|
|||||||
|
|
||||||
## `select` 展示
|
## `select` 展示
|
||||||
|
|
||||||
`skiff select` 应同时展示 builtin、catalog 和 custom Skill:
|
`pouch select` 应同时展示 builtin、catalog 和 custom Skill:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[ ] ack builtin
|
[ ] ack builtin
|
||||||
@@ -151,7 +151,7 @@ layout: single | collection
|
|||||||
|
|
||||||
## 配置表示
|
## 配置表示
|
||||||
|
|
||||||
新写入的 `.skills.yaml` 使用以下形式:
|
新写入的 `.pouch.yaml` 使用以下形式:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
skills:
|
skills:
|
||||||
@@ -172,7 +172,7 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
|
|||||||
|
|
||||||
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
|
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
|
||||||
`custom:<name>`。
|
`custom:<name>`。
|
||||||
2. 新生成的 `.skills.yaml` 对内置 Skill 写入 `source: builtin`。
|
2. 新生成的 `.pouch.yaml` 对内置 Skill 写入 `source: builtin`。
|
||||||
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
|
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
|
||||||
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
|
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
|
||||||
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
|
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
|
||||||
@@ -188,14 +188,14 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
|
|||||||
|
|
||||||
当前实现覆盖:
|
当前实现覆盖:
|
||||||
|
|
||||||
- `skiff/skills.py`:来源解析、归一化和 builtin 命名。
|
- `pouch/skills.py`:来源解析、归一化和 builtin 命名。
|
||||||
- `skiff/project.py`:manifest 默认值、序列化与旧值兼容。
|
- `pouch/project.py`:manifest 默认值、序列化与旧值兼容。
|
||||||
- `skiff/sources.py`:来源保留字。
|
- `pouch/sources.py`:来源保留字。
|
||||||
- `skiff/catalog.py`:catalog 配置、checkout 与 Skill 发现。
|
- `pouch/catalog.py`:catalog 配置、checkout 与 Skill 发现。
|
||||||
- `skiff/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
|
- `pouch/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
|
||||||
- `skiff/selector.py`:统一 catalog/custom collection 的父子展示。
|
- `pouch/selector.py`:统一 catalog/custom collection 的父子展示。
|
||||||
- CLI 与来源解析测试。
|
- CLI 与来源解析测试。
|
||||||
- 根 README、`skiff/README.md`、`skills/skiff/SKILL.md` 和相关示例。
|
- 根 README、`pouch/README.md`、`skills/pouch/SKILL.md` 和相关示例。
|
||||||
|
|
||||||
## 验证要求
|
## 验证要求
|
||||||
|
|
||||||
@@ -212,6 +212,6 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
|
|||||||
|
|
||||||
## 设计前提
|
## 设计前提
|
||||||
|
|
||||||
迁移前 `registry.yaml` 的真实职责只是维护 skiff 预置的来源目录,而不是提供远程
|
迁移前 `registry.yaml` 的真实职责只是维护 pouch 预置的来源目录,而不是提供远程
|
||||||
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
|
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
|
||||||
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
|
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
|
||||||
@@ -6,14 +6,14 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from skiff import yaml_io
|
from pouch.config import load_user_config, save_user_config
|
||||||
from skiff.paths import CONFIG_FILE, SOURCES_DIR
|
from pouch.paths import CONFIG_FILE, SOURCES_DIR
|
||||||
|
|
||||||
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
|
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
|
||||||
|
|
||||||
|
|
||||||
def validate_source_name(name: str) -> None:
|
def validate_source_name(name: str) -> None:
|
||||||
from skiff.skills import validate_skill_name
|
from pouch.skills import validate_skill_name
|
||||||
|
|
||||||
validate_skill_name(name)
|
validate_skill_name(name)
|
||||||
if name in RESERVED_SOURCES:
|
if name in RESERVED_SOURCES:
|
||||||
@@ -21,27 +21,20 @@ def validate_source_name(name: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||||
path = path or CONFIG_FILE
|
data = load_user_config(path)
|
||||||
if not path.is_file():
|
|
||||||
return {}
|
|
||||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise SystemExit(f"skiff 配置格式错误: {path}")
|
|
||||||
raw = data.get("sources", {})
|
raw = data.get("sources", {})
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return {}
|
return {}
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise SystemExit(f"skiff 配置 sources 格式错误: {path}")
|
raise SystemExit(f"pouch 配置 sources 格式错误: {path or CONFIG_FILE}")
|
||||||
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
|
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
|
||||||
|
|
||||||
|
|
||||||
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||||
path = path or CONFIG_FILE
|
path = path or CONFIG_FILE
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
data = load_user_config(path)
|
||||||
path.write_text(
|
data["sources"] = sources
|
||||||
yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False),
|
save_user_config(data, path)
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
|
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
|
||||||
@@ -66,7 +66,7 @@ def copy_template(src: Path, dst: Path) -> None:
|
|||||||
def find_repo_root(start: Path | None = None) -> Path | None:
|
def find_repo_root(start: Path | None = None) -> Path | None:
|
||||||
start = (start or Path.cwd()).resolve()
|
start = (start or Path.cwd()).resolve()
|
||||||
for directory in [start, *start.parents]:
|
for directory in [start, *start.parents]:
|
||||||
if (directory / ".skills.yaml").is_file():
|
if (directory / ".pouch.yaml").is_file() or (directory / ".skills.yaml").is_file():
|
||||||
return directory
|
return directory
|
||||||
if (directory / "skills").is_dir() and (
|
if (directory / "skills").is_dir() and (
|
||||||
(directory / "catalog.yaml").is_file()
|
(directory / "catalog.yaml").is_file()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""轻量 YAML 读写(覆盖 skiff 使用的子集,无第三方依赖)。"""
|
"""轻量 YAML 读写(覆盖 pouch 使用的子集,无第三方依赖)。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
|
|||||||
return True
|
return True
|
||||||
if lower == "false":
|
if lower == "false":
|
||||||
return False
|
return False
|
||||||
|
if raw == "[]":
|
||||||
|
return []
|
||||||
|
if raw == "{}":
|
||||||
|
return {}
|
||||||
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
|
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
|
||||||
return int(raw)
|
return int(raw)
|
||||||
return raw
|
return raw
|
||||||
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
|
|||||||
return f"{pad}{{}}\n"
|
return f"{pad}{{}}\n"
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, list) and not value:
|
||||||
|
lines.append(f"{pad}{key}: []")
|
||||||
|
elif isinstance(value, dict) and not value:
|
||||||
|
lines.append(f"{pad}{key}: {{}}")
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
|
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
|
||||||
lines.append(f"{pad}{key}:")
|
lines.append(f"{pad}{key}:")
|
||||||
for item in value:
|
for item in value:
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
# skiff 仅使用 Python 标准库,无第三方依赖
|
# pouch 仅使用 Python 标准库,无第三方依赖
|
||||||
|
|||||||
-251
@@ -1,251 +0,0 @@
|
|||||||
# skiff
|
|
||||||
|
|
||||||
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/skills # 本仓库根目录
|
|
||||||
./install.sh # 软链到 ~/.local/bin/skiff
|
|
||||||
```
|
|
||||||
|
|
||||||
确保 `~/.local/bin` 在 `PATH` 中。
|
|
||||||
|
|
||||||
## 命令风格
|
|
||||||
|
|
||||||
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
|
|
||||||
统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 浏览可用自研 skill
|
|
||||||
skiff add --list
|
|
||||||
|
|
||||||
# 装到当前项目 / 全局
|
|
||||||
skiff add discussion-notes -a cursor -y
|
|
||||||
skiff add discussion-notes -a cursor -g -y
|
|
||||||
|
|
||||||
# 卸载
|
|
||||||
skiff remove discussion-notes -a cursor -y
|
|
||||||
skiff rm discussion-notes -g -y
|
|
||||||
|
|
||||||
# 改完 skill 后提交推送(在任意目录执行,操作 ~/.skills)
|
|
||||||
skiff publish skills/discussion-notes -m "update discussion-notes" --push
|
|
||||||
```
|
|
||||||
|
|
||||||
开发时也可直接运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 首次安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
|
||||||
~/.skills/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
`install.sh` 会安装 CLI,并自动执行 `skiff bootstrap`,将本仓库的 `skiff` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
skiff bootstrap
|
|
||||||
```
|
|
||||||
|
|
||||||
## 命令参考
|
|
||||||
|
|
||||||
### 查看
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
|
||||||
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
|
||||||
|
|
||||||
### 项目初始化
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
|
|
||||||
| `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
|
|
||||||
| `skiff init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
|
|
||||||
|
|
||||||
### Skill 安装
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
|
|
||||||
| `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
|
|
||||||
| `skiff remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
|
|
||||||
| `skiff add --list` | 列出可用 builtin skill |
|
|
||||||
| `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push |
|
|
||||||
|
|
||||||
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
|
|
||||||
|
|
||||||
全局目标路径:
|
|
||||||
|
|
||||||
| Agent | 路径 |
|
|
||||||
|-------|------|
|
|
||||||
| cursor | `~/.cursor/skills/` |
|
|
||||||
| claude | `~/.claude/skills/` |
|
|
||||||
| codex | `~/.codex/skills/` |
|
|
||||||
|
|
||||||
### 预置 Catalog Source
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
|
|
||||||
| `skiff fetch <name>` | 克隆或更新 catalog source checkout |
|
|
||||||
| `skiff add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
|
|
||||||
| `skiff add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
|
|
||||||
|
|
||||||
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
|
|
||||||
会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含
|
|
||||||
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
|
|
||||||
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add <name>`
|
|
||||||
安装全部,`skiff select` 则展开为 `<name>/<skill>` 供分别勾选。同一
|
|
||||||
`repo` 与 `ref` 共享一份 Git checkout。
|
|
||||||
|
|
||||||
### 交互式批量安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
skiff select # 当前项目,全部 Agent
|
|
||||||
skiff select -a codex # 当前项目,仅 Codex
|
|
||||||
skiff select -g # 全局安装
|
|
||||||
skiff select --project ~/code/app # 指定项目
|
|
||||||
```
|
|
||||||
|
|
||||||
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
|
|
||||||
取消。普通 `skiff select` 只安装到项目,并在每一项旁只读显示各 Agent 的
|
|
||||||
全局安装状态;`skiff select -g` 只安装到全局。已经安装到目标范围的 skill
|
|
||||||
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `skiff remove`。如果全局
|
|
||||||
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
|
|
||||||
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
|
|
||||||
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
|
|
||||||
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
|
|
||||||
|
|
||||||
### Custom source(多-skill 仓库)
|
|
||||||
|
|
||||||
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Git 仓库,默认 clone 到 ~/.local/share/skiff/sources/company
|
|
||||||
skiff source add company \
|
|
||||||
git@git.company.com:platform/agent-skills.git \
|
|
||||||
--ref main \
|
|
||||||
--skills-path internal/skills
|
|
||||||
|
|
||||||
# 或接入已有本地仓库
|
|
||||||
skiff source add company \
|
|
||||||
--local ~/code/company-agent-skills \
|
|
||||||
--skills-path skills
|
|
||||||
|
|
||||||
skiff source list
|
|
||||||
skiff source fetch company
|
|
||||||
skiff list --source company
|
|
||||||
skiff add company/code-review -g -a codex
|
|
||||||
```
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
|
|
||||||
| `skiff source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
|
|
||||||
| `skiff source list` / `show <name>` | 查看 source |
|
|
||||||
| `skiff source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
|
|
||||||
| `skiff source remove <name>` | 移除配置并保留 checkout |
|
|
||||||
|
|
||||||
配置保存在 `~/.config/skiff/config.yaml`。Git/SSH 认证复用本机 Git 配置,
|
|
||||||
skiff 不保存 token。可以使用 `company/code-review`,也可以使用
|
|
||||||
`skiff add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
|
|
||||||
|
|
||||||
### 项目级
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff enable <name> [--target all] [--project <dir>]` | 写入 `.skills.yaml` 并创建项目软链 |
|
|
||||||
| `skiff disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
|
|
||||||
| `skiff sync [--target all] [--project <dir>]` | 按 `.skills.yaml` 重建软链 |
|
|
||||||
|
|
||||||
项目目标路径:
|
|
||||||
|
|
||||||
| Agent | 路径 |
|
|
||||||
|-------|------|
|
|
||||||
| cursor | `<project>/.agents/skills/` |
|
|
||||||
| claude | `<project>/.claude/skills/` |
|
|
||||||
| codex | `<project>/.agents/skills/` |
|
|
||||||
|
|
||||||
### 脚手架与健康检查
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
|
|
||||||
| `skiff check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
|
|
||||||
| `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` |
|
|
||||||
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
|
||||||
|
|
||||||
## 常用工作流
|
|
||||||
|
|
||||||
### 新建并全局启用自研 skill
|
|
||||||
|
|
||||||
```bash
|
|
||||||
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
|
|
||||||
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
|
|
||||||
skiff check my-skill
|
|
||||||
skiff finalize my-skill
|
|
||||||
skiff publish skills/my-skill -m "add my-skill" --push
|
|
||||||
skiff add my-skill -a cursor -g -y
|
|
||||||
skiff doctor -a cursor
|
|
||||||
```
|
|
||||||
|
|
||||||
### 在项目中启用 skill
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/code/my-app
|
|
||||||
skiff add declarative-openspec-loop -a cursor -y
|
|
||||||
```
|
|
||||||
|
|
||||||
### 添加 Catalog Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
skiff catalog add my-ext https://github.com/org/repo --ref main
|
|
||||||
skiff fetch my-ext
|
|
||||||
skiff add my-ext -g
|
|
||||||
```
|
|
||||||
|
|
||||||
## 源码结构
|
|
||||||
|
|
||||||
```
|
|
||||||
skiff/
|
|
||||||
├── __init__.py # 版本号
|
|
||||||
├── __main__.py # python3 -m skiff 入口
|
|
||||||
├── cli.py # 命令定义与调度
|
|
||||||
├── paths.py # 路径常量与 Agent 目标
|
|
||||||
├── skills.py # builtin/catalog/custom 统一解析
|
|
||||||
├── catalog.py # catalog.yaml 读写与 Skill 发现
|
|
||||||
├── sources.py # custom source 配置、发现与 Git 管理
|
|
||||||
├── project.py # .skills.yaml 管理
|
|
||||||
├── symlinks.py # 软链创建/检查/修复
|
|
||||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
|
||||||
```
|
|
||||||
|
|
||||||
入口脚本:[../bin/skiff](../bin/skiff)
|
|
||||||
|
|
||||||
## 路径约定
|
|
||||||
|
|
||||||
| 变量 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
|
||||||
| `SKILLS_DIR` | `~/.skills/skills/` | builtin skill 目录 |
|
|
||||||
| `CATALOG_FILE` | `~/.skills/catalog.yaml` | 预置 Skill 来源目录 |
|
|
||||||
| `CATALOG_CACHE_DIR` | `~/.local/share/skills/externals/` | catalog checkout 兼容缓存;按 repo/ref 共享 |
|
|
||||||
| `CONFIG_FILE` | `~/.config/skiff/config.yaml` | custom source 配置 |
|
|
||||||
| `SOURCES_DIR` | `~/.local/share/skiff/sources/` | custom Git source 默认 checkout |
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
|
|
||||||
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
|
|
||||||
- 若 Agent 将软链替换为普通目录,运行 `skiff doctor --fix` 重建
|
|
||||||
|
|
||||||
## 相关文档
|
|
||||||
|
|
||||||
- [项目 README](../README.md)
|
|
||||||
- [AGENTS.md](../AGENTS.md)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""路径与 Agent 目标定义。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
HOME = Path.home()
|
|
||||||
SKILLS_HOME = HOME / ".skills"
|
|
||||||
SKILLS_DIR = SKILLS_HOME / "skills"
|
|
||||||
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
|
||||||
DRAFTS_DIR = SKILLS_HOME / ".drafts"
|
|
||||||
CATALOG_FILE = SKILLS_HOME / "catalog.yaml"
|
|
||||||
LEGACY_REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
|
||||||
CATALOG_CACHE_DIR = HOME / ".local" / "share" / "skills" / "externals"
|
|
||||||
CONFIG_FILE = HOME / ".config" / "skiff" / "config.yaml"
|
|
||||||
SOURCES_DIR = HOME / ".local" / "share" / "skiff" / "sources"
|
|
||||||
PROJECT_MANIFEST = ".skills.yaml"
|
|
||||||
|
|
||||||
AGENT_GLOBAL: dict[str, Path] = {
|
|
||||||
"cursor": HOME / ".cursor" / "skills",
|
|
||||||
"claude": HOME / ".claude" / "skills",
|
|
||||||
"codex": HOME / ".codex" / "skills",
|
|
||||||
"opencode": HOME / ".config" / "opencode" / "skills",
|
|
||||||
}
|
|
||||||
|
|
||||||
AGENT_PROJECT: dict[str, str] = {
|
|
||||||
"cursor": ".agents/skills",
|
|
||||||
"claude": ".claude/skills",
|
|
||||||
"codex": ".agents/skills",
|
|
||||||
"opencode": ".opencode/skills",
|
|
||||||
}
|
|
||||||
|
|
||||||
ALL_TARGETS = ("cursor", "claude", "codex", "opencode")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_targets(target: str | None) -> list[str]:
|
|
||||||
if target is None or target == "all":
|
|
||||||
return list(ALL_TARGETS)
|
|
||||||
if target not in ALL_TARGETS:
|
|
||||||
raise ValueError(f"未知 target: {target!r},可选: {', '.join(ALL_TARGETS)}, all")
|
|
||||||
return [target]
|
|
||||||
|
|
||||||
|
|
||||||
def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
|
|
||||||
if project_root is None:
|
|
||||||
return AGENT_GLOBAL[target]
|
|
||||||
return project_root / AGENT_PROJECT[target]
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_skills_home() -> None:
|
|
||||||
if not SKILLS_HOME.is_dir():
|
|
||||||
raise SystemExit(
|
|
||||||
"~/.skills 不存在。请将 skills 仓库克隆到 ~/.skills"
|
|
||||||
)
|
|
||||||
@@ -27,11 +27,38 @@ description: >-
|
|||||||
2. 第二步
|
2. 第二步
|
||||||
3. 第三步
|
3. 第三步
|
||||||
|
|
||||||
|
## 运营类 skill 可选:初始化 / 检查 / 工作
|
||||||
|
|
||||||
|
需要项目配置才能工作的 skill(例如构建、部署、协作闭环)增加这三个模式:
|
||||||
|
|
||||||
|
- **初始化**(用户点名才跑):探项目 → 只建本 skill 拥有的安全空结构 → 用仓库
|
||||||
|
证据填能填的 → 列出待配置项。不覆盖已有文件,不猜密钥、主机、仓库地址。
|
||||||
|
- **检查**(只读):同一套诊断,不写文件。
|
||||||
|
- **工作**:配置不齐就停,告诉用户先初始化。禁止静默初始化。
|
||||||
|
|
||||||
|
报告格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## <skill> 初始化:完成 | 部分完成 | 阻塞
|
||||||
|
|
||||||
|
已具备: …
|
||||||
|
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||||
|
工具链: …
|
||||||
|
下一步: 一句话
|
||||||
|
```
|
||||||
|
|
||||||
|
项目状态放在 skill 真正消费的位置(覆盖层、`makefile.builder`、compose),不要为了对齐
|
||||||
|
而新建一层没人读的 `.pouch/<skill>/`。`pouch init` 只用于确实有 templates/
|
||||||
|
覆盖层的 skill。需要 make 目标或发布凭据的 skill 用自己的文件名(如 `makefile.builder`、
|
||||||
|
`.env.builder`),不要占用用户的 `Makefile` 或 `.env`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 约束或边界条件
|
- 约束或边界条件
|
||||||
|
- `SKILL.md` 只留模式路由、全模式安全边界,以及「若 X 则读 `references/Y.md`」。
|
||||||
|
单模式步骤不要写进正文。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
+92
-219
@@ -1,268 +1,141 @@
|
|||||||
# ACK
|
# ACK
|
||||||
|
|
||||||
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
|
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
|
||||||
|
|
||||||
- Coordinator 拆解需求、派发任务并终检。
|
- Coordinator 拆解需求、派发任务并终检
|
||||||
- Developer 实现并执行白盒验证。
|
- Developer 实现并做白盒验证
|
||||||
- Test 独立执行黑盒复测。
|
- Test 独立做黑盒复测
|
||||||
|
|
||||||
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
|
||||||
`leftover`,然后继续处理其它任务。
|
|
||||||
|
|
||||||
项目还可以在同一份 `docs/ack/delivery.yaml` 里声明测试环境部署和版本发布。
|
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
|
||||||
用户告诉 ACK 这两件事怎么做之后,再说「重新布测试环境」或「发布一个版本」,
|
|
||||||
ACK 按对应 intent 执行。任务全部验证后仍可按 profile 做常规交付。配置默认关闭,
|
只在你显式说 `/ack` 或 `$ack` 时运行。
|
||||||
稳定发布与生产部署始终保留人工批准点。
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
| 场景 | 怎么说 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
|
||||||
|
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
|
||||||
|
| 做需求 | `/ack 处理这个需求:…` | 先建 ship 记录;复杂需求走 specifier / long-ship,小改动走 short-ship |
|
||||||
|
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 short-ship |
|
||||||
|
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
|
||||||
|
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
|
||||||
|
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
|
||||||
|
| 回归 | `/ack 回归` | 先布测试环境,再按 `regression.yaml` 用浏览器或 API 跑 |
|
||||||
|
|
||||||
|
做需求和修 bug 在 `verified` 之后,都要更新回归目录。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
全局安装:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff add ack -g
|
pouch add ack # 当前项目
|
||||||
|
pouch add ack -g # 全局
|
||||||
```
|
```
|
||||||
|
|
||||||
或只安装到当前项目:
|
## 使用前准备
|
||||||
|
|
||||||
```bash
|
- 项目是 Git 仓库。
|
||||||
skiff add ack
|
- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok、OMP 或 Pi。
|
||||||
```
|
- 使用 Pi 时需安装 `pi`;默认模型用 `agents.yaml` 中 `cli: pi`、`model: null` 表示。
|
||||||
|
- 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
|
||||||
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
|
||||||
|
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
|
||||||
|
|
||||||
## 初始化项目
|
## 初始化项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff init ack
|
pouch init ack
|
||||||
skiff init ack --project ~/code/my-app
|
pouch init ack --project ~/code/my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
初始化后,项目只保存自己的 ACK 状态:
|
也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/ack/
|
.pouch/ack/
|
||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml # 项目配置与 taskStore 指针
|
||||||
|
├── task-records/ # 追加式任务/执行记录
|
||||||
├── knowledge.yaml
|
├── knowledge.yaml
|
||||||
└── delivery.yaml # 默认 enabled: false
|
├── delivery.yaml # 默认 enabled: false
|
||||||
|
├── regression.yaml # 默认 cases: []
|
||||||
|
└── agents.yaml # 可选:临时覆盖 Developer/Test 的 CLI、模型和权限
|
||||||
```
|
```
|
||||||
|
|
||||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
|
||||||
目录读取。
|
|
||||||
|
|
||||||
ACK 从当前命令指定的 `--project-root/docs/ack/` 定位项目状态,不要求在
|
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件;需要消除单文件冲突时,先运行 `scripts/migrate_tasks.py` 的 dry-run。
|
||||||
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
|
|
||||||
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
|
|
||||||
|
|
||||||
## Skill 结构
|
任务状态追加示例:
|
||||||
|
|
||||||
```text
|
|
||||||
skills/ack/
|
|
||||||
├── SKILL.md
|
|
||||||
├── README.md
|
|
||||||
├── VERSION
|
|
||||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
|
||||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
|
|
||||||
├── examples/ # 完整示例
|
|
||||||
└── scripts/ # 状态校验、任务/知识选择、安全验证执行与结构化 worker launcher
|
|
||||||
```
|
|
||||||
|
|
||||||
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
|
|
||||||
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
|
||||||
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
|
||||||
已经独立验证的项目知识护栏。
|
|
||||||
`docs/ack/delivery.yaml` 是测试环境部署和版本发布的唯一契约,也声明常规构建、
|
|
||||||
发布和部署能力;每次执行结果另记在 `tasks.yaml.deliveryRuns`。
|
|
||||||
|
|
||||||
## 检查项目状态
|
|
||||||
|
|
||||||
Agent 会从当前 ACK Skill 目录解析校验脚本:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
--task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial
|
||||||
--tasks docs/ack/tasks.yaml
|
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
## 使用示例
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
|
|
||||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
|
||||||
--task-id BUG-001
|
|
||||||
|
|
||||||
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
|
|
||||||
--component web --path web/app.py --tag long-running-service --limit 10
|
|
||||||
```
|
|
||||||
|
|
||||||
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
|
|
||||||
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
|
|
||||||
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
|
|
||||||
上下文。
|
|
||||||
|
|
||||||
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
|
|
||||||
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
|
|
||||||
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
|
|
||||||
|
|
||||||
需要执行知识项引用的检查时,只传 registry ID 给 ACK 的安全执行入口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <ack-skill-dir>/scripts/run_verification.py \
|
|
||||||
docs/ack/knowledge.yaml check-api-contract --project-root <project-root>
|
|
||||||
```
|
|
||||||
|
|
||||||
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
|
|
||||||
`O_NOFOLLOW` 打开知识库和检查文件;检查内容复制到匿名、尽可能 sealed 的稳定
|
|
||||||
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
|
|
||||||
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
|
|
||||||
项目内无 symlink 的权威
|
|
||||||
`docs/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
|
|
||||||
和 `ACK_PROJECT_ROOT` 都固定到该根 fd;后者是只在检查进程存活期间有效的
|
|
||||||
`/proc/self/fd/...` 或 `/dev/fd/...` 路径。原始可读路径另放在
|
|
||||||
`ACK_PROJECT_ROOT_DISPLAY`,只能用于日志,不能用于资源访问。Runner 还提供
|
|
||||||
`ACK_VERIFICATION_REF` 和 `ACK_VERIFICATION_PATH`;检查脚本必须据此定位资源,
|
|
||||||
不能依赖 `$0` 或 `__file__` 所在目录,因为实际执行的是匿名快照。
|
|
||||||
|
|
||||||
知识先由 Developer 或 Test 作为 `candidate` 提名,经独立验证和 Coordinator gate
|
|
||||||
后才能成为 `active`。Coordinator 按路径、组件、依赖、版本和标签推荐相关知识,
|
|
||||||
确认后将固定 revision 的 `knowledgeRefs` 写入任务上下文;每轮只派发命中的少量
|
|
||||||
条目,不全量注入知识库。
|
|
||||||
|
|
||||||
旧项目只有 `project.md` 和 `tasks.yaml` 时,不要重跑初始化。由 `/ack` 检查现有
|
|
||||||
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
|
|
||||||
同时只补 `project.knowledgeFile: docs/ack/knowledge.yaml`,再运行跨文件校验。
|
|
||||||
|
|
||||||
只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`。知识正文不能作为自由 shell
|
|
||||||
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
|
||||||
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
|
||||||
|
|
||||||
## 配置与运行交付
|
|
||||||
|
|
||||||
用户可以直接向 `/ack` 说明两件独立操作,并写进同一份契约:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ack 测试时先 go build -o garden ./cmd/garden,再启动这个二进制;
|
|
||||||
发版方式以后再告诉你。
|
|
||||||
```
|
|
||||||
|
|
||||||
ACK 把它维护成 `docs/ack/delivery.yaml` 的 `intents.testEnvironment` /
|
|
||||||
`intents.release`、entrypoint、artifact、environment 和 profile。首次配置保持
|
|
||||||
关闭,确认后才启用。之后用户可以说:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
/ack 初始化
|
||||||
|
/ack 检查配置
|
||||||
|
/ack 处理这个需求:给后台加导出 CSV
|
||||||
|
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
|
||||||
|
/ack 处理飞书里待整理的 bug
|
||||||
|
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
|
||||||
/ack 重新布一下测试环境,我要测试
|
/ack 重新布一下测试环境,我要测试
|
||||||
/ack 发布一个版本
|
/ack 发布一个版本
|
||||||
|
/ack 回归
|
||||||
|
/ack 跑 full 回归
|
||||||
|
/ack 回归 REG-login-001
|
||||||
|
/ack 修这些回归失败
|
||||||
```
|
```
|
||||||
|
|
||||||
对应 intent 未配置时先问清楚并写回同一文件,不猜测。intent 运行不要求当前有
|
首次配发版也可以一次说清:
|
||||||
`verified` 任务;`deliveryRuns.intent` 记录是测试环境还是发版。
|
|
||||||
|
|
||||||
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
|
|
||||||
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
|
|
||||||
profile 只能停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须经过
|
|
||||||
对应 approval 步骤。具体契约见 `references/delivery.md`。
|
|
||||||
|
|
||||||
## 启动 Worker
|
|
||||||
|
|
||||||
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
|
||||||
allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md`
|
|
||||||
或任务里保存完整启动命令、额外 argv 或环境变量。
|
|
||||||
|
|
||||||
先审阅计划,不产生终端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
|
||||||
--project-root <project-root> \
|
|
||||||
--task-id <task-id> \
|
|
||||||
--attempt-id <task-id>-A<round> \
|
|
||||||
--role developer \
|
|
||||||
--profile-id codex-dev-standard \
|
|
||||||
--worktree <allowed-worktree>
|
|
||||||
```
|
|
||||||
|
|
||||||
确认后执行 `launch`,并显式绑定刚审阅的 fingerprint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|
||||||
--project-root <project-root> \
|
|
||||||
--task-id <task-id> \
|
|
||||||
--attempt-id <task-id>-A<round> \
|
|
||||||
--role developer \
|
|
||||||
--profile-id codex-dev-standard \
|
|
||||||
--worktree <allowed-worktree> \
|
|
||||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
|
||||||
```
|
|
||||||
|
|
||||||
launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定
|
|
||||||
argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后
|
|
||||||
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
|
|
||||||
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
|
|
||||||
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
|
|
||||||
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
|
|
||||||
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
|
|
||||||
不能跨任务或跨轮次改挂。
|
|
||||||
|
|
||||||
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
|
|
||||||
Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭
|
|
||||||
sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件
|
|
||||||
伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用
|
|
||||||
停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数,workspace-write 默认 yolo。旧任务板没有结构化
|
|
||||||
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
|
|
||||||
|
|
||||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
|
|
||||||
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
|
|
||||||
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
|
|
||||||
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
|
|
||||||
启动 fresh worker。
|
|
||||||
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
|
|
||||||
重复启动,结果不确定时必须先 reconcile。
|
|
||||||
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
|
|
||||||
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
|
|
||||||
直接重试。
|
|
||||||
|
|
||||||
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
|
|
||||||
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
|
|
||||||
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
|
|
||||||
|
|
||||||
## 开始一个需求
|
|
||||||
|
|
||||||
初始化完成后可以直接说:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ack 处理这个需求:<一句话需求>
|
|
||||||
```
|
|
||||||
|
|
||||||
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
|
||||||
可观测验收信号;用户确认后才派发实现和复测。
|
|
||||||
|
|
||||||
首次配置交付可以说:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
|
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
|
||||||
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
|
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
|
||||||
```
|
```
|
||||||
|
|
||||||
之后处理需求时只需在确认计划中选择 profile:
|
之后做需求时只要在确认计划里选 profile:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。
|
/ack 处理这个需求:…。任务验证通过后执行 review profile。
|
||||||
```
|
```
|
||||||
|
|
||||||
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或
|
临时调整角色启动方式时直接说明意图,例如:
|
||||||
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
|
|
||||||
|
```text
|
||||||
|
/ack 让这个项目的 dev agent 用 pi 默认模型,test agent 用 cursor-agent auto 模型
|
||||||
|
```
|
||||||
|
|
||||||
|
ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时优先于
|
||||||
|
`tasks.yaml.project.orchestration`;删除后自动回退。配置 hash 会绑定启动计划和 receipt,
|
||||||
|
修改后必须重新审阅并生成 plan。
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
|
||||||
|
2. 做需求:规格和任务写到 `.pouch/ship/<id>/`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。
|
||||||
|
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
|
||||||
|
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
|
||||||
|
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
|
||||||
|
6. 回归失败只报告,不会自动开修。
|
||||||
|
7. 未获授权不提交、不推送、不创建终端、不部署,也不改项目的 `AGENTS.md` / `CLAUDE.md`。
|
||||||
|
|
||||||
|
飞书收件时,你维护标题、详细描述和附件。Coordinator 整理问题说明、期望和验收并写回飞书。你针对当前 revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- 初始化:五个状态文件齐,报告为完成,或列出待配置项和下一步。
|
||||||
|
- 需求 / bug:任务变为 `verified`,或三轮后记为 `leftover`;回归目录有本轮用例,或明确「无回归」。
|
||||||
|
- 测试环境:目标环境容器 Up,健康检查通过。
|
||||||
|
- 发版:停在约定档位;stable / 生产有单独批准记录。
|
||||||
|
- 回归:列出通过和失败的用例;失败不会被自动拿去修。
|
||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
|
|
||||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
|
||||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
|
||||||
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
|
||||||
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
|
|
||||||
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型;从 `0.16.0` 起,
|
|
||||||
`delivery.yaml` 可用 `intents.testEnvironment` 与 `intents.release` 把测试环境部署和
|
|
||||||
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
|
|
||||||
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
|
|
||||||
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
|
|
||||||
`--always-approve`,sandbox 仍必开。旧项目可以不迁移而继续使用原闭环。旧项目的
|
|
||||||
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
|
||||||
|
|||||||
+140
-262
@@ -1,276 +1,154 @@
|
|||||||
---
|
---
|
||||||
name: ack
|
name: ack
|
||||||
description: >-
|
description: >-
|
||||||
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
初始化、检查并运行 ACK 三角色闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||||
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
初始化 ACK、检查 .pouch/ack、按 ACK 做需求或修 bug、指挥
|
||||||
工作,配置测试环境与发版方式,重新部署测试环境,或发布版本时使用。
|
Coordinator/Developer/Test、配置或运行测试环境与发版、或跑回归时使用。
|
||||||
---
|
---
|
||||||
|
|
||||||
# ACK 项目协作入口
|
# ACK 项目协作入口
|
||||||
|
|
||||||
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
|
||||||
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
`delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。
|
||||||
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
|
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
|
||||||
内容。
|
`CLAUDE.md`。
|
||||||
|
|
||||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
执行位置和 profile 选择可以统一写在项目根 `.pouch/agent.yaml` 的 `ack.<name>` binding:
|
||||||
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
`location` 提供 Orca environment/host/project/path,`profile` 提供 cli/model/
|
||||||
|
`reasoningEffort`/permission。ACK 的 `agents.yaml` 或 tasks orchestration 仍保留其
|
||||||
|
`profileVersion`、`modelAllowlist`、fingerprint/receipt 字段,并由 ACK adapter 做兼容
|
||||||
|
映射;不要为了接入 common runtime 删除旧 receipt 或绕过 launcher。远程 ACK worker
|
||||||
|
必须先按 binding 精确定位 worktree,不能把本机目录当远程目录。
|
||||||
|
|
||||||
|
固定配置文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`;任务执行记录从
|
||||||
|
`.pouch/ack/task-records/` 追加读取。旧项目没有 `taskStore` 时仍兼容直接读取
|
||||||
|
`tasks.yaml`。固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
|
||||||
|
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和
|
||||||
|
`.pouch/ack/regression.yaml`。
|
||||||
|
|
||||||
|
开始时解析当前 `SKILL.md` 所在目录为 `<ack-skill-dir>`。优先
|
||||||
|
`git rev-parse --show-toplevel` 作为项目根。
|
||||||
|
|
||||||
## 选择模式
|
## 选择模式
|
||||||
|
|
||||||
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
- 初始化、接入 ACK:执行「初始化」。
|
||||||
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
- 检查配置是否完整:执行「检查」。
|
||||||
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
|
||||||
- 用户用自然语言说明怎么部署测试环境、怎么发布版本,或要求增加、修改、关闭交付
|
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
|
||||||
流程:执行“交付配置维护”。测试环境和发版必须写进同一份
|
`.pouch/ship/`,不要写进缺陷任务板。
|
||||||
`docs/ack/delivery.yaml`。
|
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
|
||||||
- 用户要求部署、重新部署测试环境,或按已配置方式开始测试:执行“运行测试环境”。
|
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
|
||||||
- 用户要求发布版本:执行“运行版本发布”。
|
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
|
||||||
|
- 任务板使用 Orca 时,编排命令见 [orca-adapter.md](references/orca-adapter.md)。
|
||||||
始终先解析真实项目根目录。优先使用 `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` 已存在,不重复初始化、不覆盖文件;转入“检查”。旧项目只有
|
|
||||||
`project.md` 与 `tasks.yaml` 时,先报告缺少 `knowledge.yaml`。用户授权后,
|
|
||||||
从 `templates/knowledge.template.yaml` 生成这个缺失文件并替换项目名和时间;若
|
|
||||||
`tasks.yaml` 尚无 `project.knowledgeFile`,同时只补
|
|
||||||
`docs/ack/knowledge.yaml` 这一项。不要重跑 `skiff init`,也不要改写其它已有
|
|
||||||
项目状态。
|
|
||||||
4. 读取项目的公开配置和文档,例如 README、语言清单、包管理清单、测试配置与
|
|
||||||
CI,确定项目名、技术栈、源码/规格/测试路径及真实可执行命令。
|
|
||||||
5. 完善 `docs/ack/project.md`:
|
|
||||||
- 用实际项目值替换全部占位符。
|
|
||||||
- 无服务地址时把 Base URL 写为 `n/a`,不要虚构端口。
|
|
||||||
- 无法从项目证据确定的命令写为 `n/a`,并在结果中列为待配置项。
|
|
||||||
- 只写项目差异,不复制 `references/` 中的通用规范。
|
|
||||||
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
|
|
||||||
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
|
|
||||||
项目状态固定从当前项目根的 `docs/ack/` 推导,不写入 `repoPath` 或 `devWorktree`;
|
|
||||||
worker 默认在 `--project-root`(权威状态目录)工作,不再配置
|
|
||||||
`allowedWorktrees` 白名单(v0.19 起废弃);需要隔离 worktree 时由 Coordinator 在
|
|
||||||
派发时显式指定。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。
|
|
||||||
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
|
||||||
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
|
||||||
猜测并激活知识。
|
|
||||||
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
|
||||||
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
|
|
||||||
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
|
|
||||||
9. 更新 `updatedAt`,并运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
|
||||||
--tasks docs/ack/tasks.yaml
|
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
|
||||||
```
|
|
||||||
|
|
||||||
10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有
|
|
||||||
`<...>` 占位符。
|
|
||||||
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
|
||||||
缺失值。
|
|
||||||
11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
|
||||||
不推送。
|
|
||||||
|
|
||||||
## 检查
|
|
||||||
|
|
||||||
1. 检查以下路径:
|
|
||||||
- `docs/ack/project.md`
|
|
||||||
- `docs/ack/tasks.yaml`
|
|
||||||
- `docs/ack/knowledge.yaml`
|
|
||||||
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
|
||||||
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
|
|
||||||
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
|
|
||||||
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
|
|
||||||
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
|
||||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
|
||||||
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
|
||||||
存在。
|
|
||||||
3. 查找未替换占位符,并核对项目根、覆盖层路径、Developer 白盒命令、Test
|
|
||||||
黑盒命令和 Base URL。
|
|
||||||
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
|
||||||
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
|
|
||||||
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
|
|
||||||
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
|
|
||||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
|
||||||
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
|
||||||
缺少可选交付配置而宣称失败。
|
|
||||||
5. 若存在 `project.bugIntake`,运行
|
|
||||||
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。
|
|
||||||
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
|
|
||||||
`references/feishu-bug-intake.md`。
|
|
||||||
6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
|
||||||
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
|
||||||
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
|
||||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
|
||||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
|
||||||
必须同时为空或同时填写。
|
|
||||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
|
||||||
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
|
||||||
|
|
||||||
## 工作
|
|
||||||
|
|
||||||
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
|
|
||||||
2. 依次读取:
|
|
||||||
- `docs/ack/project.md`
|
|
||||||
- 运行 `python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml`,只读取
|
|
||||||
`project`、`summary` 和默认可工作状态的任务;已知当前任务时传
|
|
||||||
`--task-id <ack-task-id>`。选择器会解析并校验完整任务板,并只附带选中任务引用的
|
|
||||||
receipt 与 delivery run。命中超过默认预算时用 `--task-id` / `--status` 缩小,
|
|
||||||
不直接回退为输出完整 `tasks.yaml`。
|
|
||||||
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
|
|
||||||
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
|
||||||
- `<ack-skill-dir>/references/kickoff.md`
|
|
||||||
- kickoff 指定且与当前任务相关的 references 文件
|
|
||||||
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
|
|
||||||
`<ack-skill-dir>/references/delivery.md`
|
|
||||||
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
|
||||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
|
||||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
|
||||||
不全量注入知识库。
|
|
||||||
`project.bugIntake.workflow` 为 `clarified-writeback-v1`(推荐)或
|
|
||||||
`reviewed-writeback-v1`(兼容旧项目)时,按
|
|
||||||
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
|
|
||||||
读取用户填写的 Bug。新工作流中,用户只维护标题、详细描述和附件;Coordinator 根据
|
|
||||||
来源事实与项目上下文整理问题说明、期望效果和可观测验收标准,不在收件箱写修复逻辑,
|
|
||||||
只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
|
|
||||||
用户针对当前 `draftRevision` 明确审核通过并亲自在飞书把状态改为 `已确认` 前,不创建
|
|
||||||
或刷新 `tasks.yaml` 任务、不启动 worker、不派发 Developer/Test,也不修改应用代码。
|
|
||||||
Coordinator 不得自行写入 `已确认`。审核通过后重新读取,要求 revision 与批准值完全
|
|
||||||
一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
|
|
||||||
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
|
|
||||||
payload hash 通过后,再用 `mark-imported` 把最终任务 ID 与同一 revision 写回飞书,
|
|
||||||
才进入三角色闭环。未声明 workflow 的旧八字段配置只按
|
|
||||||
`read-only-v1` 兼容,不得写回;
|
|
||||||
标题、详细描述和附件是来源事实,不得把 Coordinator 推断伪装成用户原文;整行空白
|
|
||||||
记录按批次 warning 跳过。
|
|
||||||
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
|
||||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
|
||||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
|
||||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
|
||||||
然后交给用户确认;若启用了交付,必须默认把 `defaultProfile`、目标、停止点和需要
|
|
||||||
审批的步骤放入同一份计划,不能静默省略。用户可明确取消本轮交付;确认前不派发
|
|
||||||
实现,也不执行交付。
|
|
||||||
5. 创建或更换 worker 时,只使用
|
|
||||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
|
||||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
|
||||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
|
||||||
executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为
|
|
||||||
`launch --expected-launch-fingerprint` 传入。派发前先寻找同一 ACK 运行内的空闲
|
|
||||||
worker;只有角色、profile、worktree 和启动身份仍完全匹配,且后端能清理历史消息、
|
|
||||||
返回可核对的新会话身份时才复用。不得复用正在工作、等待回报或状态不明的 worker;
|
|
||||||
任一条件不符、清理能力不存在或无法确认清理成功时创建 fresh worker。持久化
|
|
||||||
receipt 只作审计与 dispatch 关联,不能单独授权复用。当前 Orca 终端接口不能提供
|
|
||||||
可验证的历史消息清理,因此使用 Orca 时仍走 fresh worker。
|
|
||||||
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证;若
|
|
||||||
`intents.testEnvironment` 已启用,Coordinator 先按「运行测试环境」拉起服务,再
|
|
||||||
派 Test 独立黑盒复测。派发后先确认 worker 真正开始执行(terminal read 确认任务
|
|
||||||
注入;卡在审批提示、未回车或额度限制时按环境失败处理并报告),等待期间用
|
|
||||||
`scripts/worker_probe.py` 滚动检查活性,不盲等 `worker_done`。Coordinator 读取
|
|
||||||
证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和
|
|
||||||
`knowledgeCandidates`,Test 回报 `knowledgeChecks`;`candidate` 只有在独立验证和
|
|
||||||
gate 后才能由 Coordinator 写入或激活。
|
|
||||||
7. 执行知识项的 `verification.ref` 时,只调用
|
|
||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
|
||||||
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
|
||||||
也不要给 runner 注入额外命令或参数。
|
|
||||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。三轮预算只计算 Test 已对齐正确
|
|
||||||
服务、数据和工具后实际执行验收所得的产品失败;环境失败不占复验轮次,不写
|
|
||||||
`failed_retest`,而写入 `dispatch.environmentIncidents`。Coordinator 先做一次有界、
|
|
||||||
安全的恢复;事件未解决、需要用户动作或会阻断本轮时,立即向用户报告原因、影响、
|
|
||||||
已尝试动作、下一恢复动作和明确的 `userAction`;即使已自动恢复,也要在最终报告汇总。
|
|
||||||
每项最多三轮有效产品复验,仍失败才记录 `leftover` 并继续其它任务。细则见
|
|
||||||
`references/optimization-method.md` §4。
|
|
||||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
|
||||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
|
||||||
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
|
|
||||||
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
|
|
||||||
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
|
|
||||||
run,不回写成任务失败。开发或测试环境完成构建、部署和健康检查后写
|
|
||||||
`validation_ready`,并把访问地址、验证范围和用户下一步交给用户;不能停在
|
|
||||||
`verified` 却声称整轮 ACK 已结束。默认 profile 最多到 `validation_ready` 或
|
|
||||||
`review_ready`,稳定发布和生产部署必须在对应步骤再次取得明确批准。
|
|
||||||
11. Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先
|
|
||||||
dry-run 审阅决策,再 `--apply` 回收所有只属于 `verified` 任务的 worker
|
|
||||||
终端,并核对关闭回执;历史 receipt 和任务证据继续保留。任何还被 `open`、`dispatched`、`fixed_by_dev`、
|
|
||||||
`retesting`、`blocked`、`failed_retest`、`leftover` 或未解决环境事件引用的终端
|
|
||||||
都保留,不设置 TTL,也不能因为同一终端还关联过 `verified` 任务而误关。若关闭
|
|
||||||
结果不确定,记录并报告,不重复关闭或伪报已回收。
|
|
||||||
|
|
||||||
## 交付配置维护
|
|
||||||
|
|
||||||
1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布
|
|
||||||
入口和 CI;把用户自然语言描述转换为结构化 `intents`、entrypoint、artifact、
|
|
||||||
destination、environment 与 profile。测试环境部署和版本发布都写进这一份
|
|
||||||
`delivery.yaml`,不要拆成第二份文档。配置只引用仓库内脚本或声明式工具 target,
|
|
||||||
不保存 shell。
|
|
||||||
2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
|
|
||||||
`deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
|
|
||||||
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
|
|
||||||
3. 运行 delivery、tasks 和跨文件校验;需要的脚本不存在、不可执行、引用不完整或
|
|
||||||
涉及凭据正文时 fail closed。凭据只写 secret 名称,值由外部环境提供。
|
|
||||||
4. 用户确认后才把配置设为启用。配置修改只影响下一次 delivery run;已确认或正在
|
|
||||||
执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改
|
|
||||||
扩大权限。
|
|
||||||
|
|
||||||
## 运行测试环境
|
|
||||||
|
|
||||||
1. 读取 `docs/ack/delivery.yaml` 与 `references/delivery.md`。
|
|
||||||
2. `enabled` 不为 true,或 `intents.testEnvironment` 为 null:停止,请用户说明如何
|
|
||||||
部署测试环境,转入交付配置维护。不猜测编译或启动命令。
|
|
||||||
3. 不要求任务已 `verified`。按该 profile 执行 build → deploy → health-check。
|
|
||||||
4. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`,
|
|
||||||
`intent: testEnvironment`,`taskIds` 可为空。
|
|
||||||
5. 派发 Test 前若该 intent 已启用,必须先完成本步骤。
|
|
||||||
|
|
||||||
## 运行版本发布
|
|
||||||
|
|
||||||
1. 读取同一份 `docs/ack/delivery.yaml` 与 `references/delivery.md`。
|
|
||||||
2. `enabled` 不为 true,或 `intents.release` 为 null:停止,请用户说明如何发版,
|
|
||||||
写入同一文件后再执行。
|
|
||||||
3. 按该 profile 顺序执行。stable 发布和生产部署的 `approval` 不能用口头「发版」
|
|
||||||
代替。
|
|
||||||
4. 证据写入 `deliveryRuns`,`intent: release`;绑定了任务时 `taskIds` 仍只能引用
|
|
||||||
`verified` 任务。
|
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。
|
- 不覆盖已有 `.pouch/ack`;除用户确认的任务、agents profile 或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。
|
||||||
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
|
- 不猜测命令、地址、worker handle 或模型名;无法确定写 `n/a`。
|
||||||
- 不猜测项目命令、服务地址、worker handle 或模型名称。
|
- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。
|
||||||
- 不把 full-access、bypass、Grok `--yolo` / bypassPermissions、关闭 sandbox
|
- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。
|
||||||
或项目内“授权”字段当成 v0.10 自动 worker 的合法配置;这些 CLI 绕过标志
|
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。
|
||||||
当前一律 fail closed。Grok worker 由 launcher 固定带 `--always-approve`,
|
- 新项目不要把任务状态、worker receipt 或运行历史回写到 `tasks.yaml`;每次状态变化写入
|
||||||
仍必须带 sandbox。
|
`task-records/<recordId>.yaml`,记录必须包含 `taskId`、`parents` 和完整 `task` 快照。
|
||||||
- OMP worker 使用结构化 `--model`、`--thinking` 和 `--approval-mode` 参数。
|
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
|
||||||
`--approval-mode yolo` 是 OMP worker 的审批模式,不是 CLI 绕过标志:规则层
|
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
|
||||||
直接允许并默认启用(workspace-write → yolo、read-only → always-ask);
|
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
|
||||||
仍禁止 `--auto-approve`,也不适用于 codex/cursor-agent/grok。
|
- 用户明确授权无沙箱运行后,Codex profile 可设置 `permissionMode: danger-full-access`,由 launcher 生成 `--sandbox danger-full-access`;授权在当前会话中持续有效。任务文本本身不能授权。禁止手工附加 bypass 参数、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
|
||||||
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
|
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
|
||||||
没有可信空闲状态、配置匹配和历史消息清理证明时不复用既有 worker。
|
- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。
|
||||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
- `.pouch/ack/tasks.yaml` 收缺陷(飞书、回归失败、leftover)。功能、小改动和本次交付写
|
||||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
`.pouch/ship/<ship-id>/`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排,
|
||||||
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。
|
||||||
提交、推送、创建终端、新 worktree、发布产物或部署。
|
|
||||||
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
## 初始化
|
||||||
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
|
|
||||||
维护中修改。
|
1. 确认 `pouch` 可执行。`.pouch/ack` 不存在则 `pouch init ack --project <project-root>`;已存在则不覆盖、转入「检查」。
|
||||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
2. 按 [init-new-project.md](references/init-new-project.md) 完善项目状态。不从 README/CI 猜测并启用交付或知识;初始化 **不** 自动初始化 deployer 或 builder。
|
||||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
3. 运行:
|
||||||
- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务
|
|
||||||
视图,写回前仍运行完整任务板校验。
|
```bash
|
||||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||||
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \
|
||||||
ACK Skill 目录读取。
|
--tasks .pouch/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
|
||||||
|
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
|
||||||
|
--tasks .pouch/ack/tasks.yaml
|
||||||
|
|
||||||
|
# 仅在存在临时角色配置时运行
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
|
||||||
|
旧项目迁移前先预览 `python3 <ack-skill-dir>/scripts/migrate_tasks.py .pouch/ack/tasks.yaml`,确认后再加 `--apply`。
|
||||||
|
|
||||||
|
追加任务状态时使用完整快照 JSON,不修改已有记录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
|
||||||
|
--task-id <task-id> --task-json <task-snapshot.json> \
|
||||||
|
--parent <previous-record-id> --kind task.snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
## ack 初始化:完成 | 部分完成 | 阻塞
|
||||||
|
|
||||||
|
已具备: …
|
||||||
|
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||||
|
工具链: pouch …
|
||||||
|
下一步: 一句话
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查
|
||||||
|
|
||||||
|
只读,不自动修复。核对清单见 [adoption-checklist.md](references/adoption-checklist.md)。用 `select_tasks.py` 看配置;跑与「初始化」相同的四个校验器;存在 `agents.yaml` 时再跑 `validate_agents.py`。对比 `VERSION` 与 `ackVersion`(旧 `kitVersion` 仍可读);从 `0.10.0` 起 `project.orchestration` 与 `workerReceipts` 必须同时存在。旧项目可无 delivery/regression,存在或被引用时必须校验。若有 `bugIntake` 再跑 `feishu_bug_intake.py check`。若测试环境走 deployer,只读跑其 `check.py`;未通过列入待配置,不要复制 compose 或静默初始化 deployer。用同一报告格式,标题改为 `## ack 检查:…`。
|
||||||
|
|
||||||
|
## 工作
|
||||||
|
|
||||||
|
1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。
|
||||||
|
2. 读 `project.md`;若存在 `agents.yaml`,先用 `validate_agents.py` 校验并读取其 `defaults`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。
|
||||||
|
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。范围清楚的小改动转
|
||||||
|
`short-ship`;复杂需求转 `specifier` / `long-ship`。修 bug 写短问题说明、复现和可观测
|
||||||
|
验收,不写大 PRD;要交付时链接 `short-ship`。
|
||||||
|
4. 用户已确认后,先读取并按 [closed-loop.md](references/closed-loop.md) 执行;使用 Orca 时同时读取 [orca-adapter.md](references/orca-adapter.md)。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。
|
||||||
|
这不是 fire-and-forget:Coordinator 不得在 `launch` / `dispatch` 后只回复“已派发”并结束当前会话。每个任务都必须在同一轮持续执行默认 60 秒窗口的 `check --wait`,并按 60–120 秒节奏运行 `worker_probe.py`,直到 Developer 回报 `worker_done` 或 `escalation`;随后写回 `fixed_by_dev`,主动派发 Test,并继续等待 `retest_result`。只有完成 Test 终检、重试/`leftover` 决策、回归决定和 worker 回收后,才可以结束本轮 ACK。
|
||||||
|
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
|
||||||
|
6. 知识 `verification.ref` 只经 `run_verification.py`。不把 `worker_done` 或 Test 自报成功当作完成。环境失败先有界恢复并报告 `userAction`;三轮产品失败记 `leftover`。
|
||||||
|
7. 任务 `verified` 且改了可见行为或 API 后,转入「运行回归」收获用例。若本次计划含交付,再走对应交付模式;默认最多到 `validation_ready` 或 `review_ready`。
|
||||||
|
8. 整轮任务完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,回收所有只属于 `verified` 任务的 worker 终端,并核对关闭回执;仍关联未完成任务的 worker 不设置 TTL。关闭结果不确定则记录,不伪报。
|
||||||
|
|
||||||
|
不能停在“任务已 `verified` 却声称整轮 ACK 已结束”:必须完成回归决定和 worker 回收。
|
||||||
|
|
||||||
|
## 角色启动配置维护
|
||||||
|
|
||||||
|
用户明确说「让这个项目的 dev agent 用 pi 默认模型、test agent 用 cursor-agent auto」等临时调整时,创建或更新 `.pouch/ack/agents.yaml`。配置文件形状见 [agents.template.yaml](templates/agents.template.yaml) 和 [agents.example.yaml](examples/agents.example.yaml),只写结构化 profile,不写自由命令。
|
||||||
|
|
||||||
|
存在 `agents.yaml` 时,ACK 的 `profile-hash`、`plan`、`launch` 和 receipt 全部绑定该文件的 hash;修改配置后必须重新 plan,旧 fingerprint 不能继续启动。删除该文件即可回退到 `tasks.yaml.project.orchestration`。配置本身不代表提交、推送、发布或部署授权。
|
||||||
|
|
||||||
|
## 飞书收件
|
||||||
|
|
||||||
|
若存在 `project.bugIntake`,读 [feishu-bug-intake.md](references/feishu-bug-intake.md)。用户针对当前 `draftRevision` 明确审核通过并亲自把飞书状态改为 `已确认` 前:不创建或刷新任务、不启动 worker、不派发、不改应用代码。Coordinator 不得自行写入 `已确认`。未声明 workflow 的旧配置只按 `read-only-v1`,不得写回。来源消失或读取失败时不删除已有任务。
|
||||||
|
|
||||||
|
## 启动 worker
|
||||||
|
|
||||||
|
只使用 `scripts/launch_worker.py plan|launch`。不得直接 `orca terminal create --command`,不得拼接自由 command、argv、executable、env 或 cwd。读 [model-routing.md](references/model-routing.md)。先审阅 `plan.launchFingerprint`,再作为 `launch --expected-launch-fingerprint` 传入。派发文案用 [prompt-templates.md](references/prompt-templates.md)。派发后 terminal read 确认已开始;卡在审批、未回车或额度限制按环境失败处理。等待期间用 `scripts/worker_probe.py`,不盲等 `worker_done`。收到无错误的 `worker_done` / Test 复测结果后,按 [orca-adapter.md](references/orca-adapter.md) 用 `reclaim_workers.py --completed-task-id ... --completed-role ... --apply` 关闭对应 Orca terminal;失败、escalation 或环境异常不关闭。
|
||||||
|
|
||||||
|
## 交付配置维护
|
||||||
|
|
||||||
|
读 [delivery.md](references/delivery.md)。测试环境写成 `{via: deployer, env: <env>}`;`.pouch/deployer/<env>` 未就绪则停止并加载 deployer「初始化」,不在 ACK 里复制 compose。发版写在同一份 `delivery.yaml`,不保存 shell。本地 `npm run dev` / `go run` 不算测试环境。旧项目首次启用只补文件指针与 `deliveryRuns: []`,保持 `enabled: false`,用户确认后才启用。进行中的 run 使用开始时的 commit/config 快照。
|
||||||
|
|
||||||
|
## 运行测试环境
|
||||||
|
|
||||||
|
读 `delivery.yaml` 与 [delivery.md](references/delivery.md),并加载 deployer。`enabled` 非 true 或 intent 为 null:停止,转入交付配置维护。intent 必须是 `{via: deployer, env: <env>}`;旧 profile ID 字符串要先迁移。不要用 ACK delivery profile 布环境,不猜测启动命令。不要求任务已 `verified`。对 `.pouch/deployer/<env>` 按 deployer:list → 按服务 sync+up → 健康检查。证据写入 `deliveryRuns`(`intent: testEnvironment`,`profile: deployer-<env>`)。未安装、缺目录、`check.py` 失败或健康检查失败:fail closed,报告 `userAction`,不记产品失败。
|
||||||
|
|
||||||
|
## 运行回归
|
||||||
|
|
||||||
|
读 [regression.md](references/regression.md)。缺目录则停止;用户授权后从模板生成空文件并只补指针与 `regressionRuns: []`。用 `select_regression.py` 读 active 用例(默认 `--suite smoke`);无命中则停止。先执行「运行测试环境」,再派独立 Test worker。Coordinator 不亲自点浏览器或打 API;`browser` 不得改成只打 API。终检写入 `regressionRuns`。失败不自动派 Developer,不占三轮预算。任务 `verified` 后给出新增/更新/退役/无回归四选一,用户确认后写入;Test 只提名。
|
||||||
|
|
||||||
|
## 运行版本发布
|
||||||
|
|
||||||
|
1. 读同一份 `delivery.yaml` 与 [delivery.md](references/delivery.md)。
|
||||||
|
2. `enabled` 不为 true,或 `intents.release` 为 null:停止,先做交付配置维护。
|
||||||
|
3. 按该 profile 顺序执行。stable 发布和生产部署的 `approval` 不能用口头「发版」代替。
|
||||||
|
4. 证据写入 `deliveryRuns`,`intent: release`;绑定了任务时 `taskIds` 仍只能引用 `verified` 任务。
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.18.0
|
0.20.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "ACK"
|
display_name: "ACK"
|
||||||
short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环"
|
short_description: "初始化、检查并运行 ACK 开发、验证、测试环境、发版与回归闭环"
|
||||||
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, record how to deploy the test environment and publish a release in delivery.yaml, redeploy the test environment, or publish a version."
|
default_prompt: "Use $ack to initialize or check ACK, coordinate feature or bug work, bind the test environment to deployer, redeploy it, publish a release, or run regression."
|
||||||
policy:
|
policy:
|
||||||
allow_implicit_invocation: false
|
allow_implicit_invocation: false
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Example .pouch/ack/agents.yaml for a temporary routing choice.
|
||||||
|
profileVersion: 1
|
||||||
|
mode: "orca"
|
||||||
|
modelAllowlist:
|
||||||
|
pi:
|
||||||
|
developer:
|
||||||
|
standard: [null]
|
||||||
|
strong: [null]
|
||||||
|
cursor-agent:
|
||||||
|
test:
|
||||||
|
standard: ["auto"]
|
||||||
|
profiles:
|
||||||
|
pi-dev-default:
|
||||||
|
role: "developer"
|
||||||
|
cli: "pi"
|
||||||
|
tier: "standard"
|
||||||
|
model: null
|
||||||
|
reasoningEffort: "medium"
|
||||||
|
permissionMode: "workspace-write"
|
||||||
|
pi-dev-strong:
|
||||||
|
role: "developer"
|
||||||
|
cli: "pi"
|
||||||
|
tier: "strong"
|
||||||
|
model: null
|
||||||
|
reasoningEffort: "high"
|
||||||
|
permissionMode: "workspace-write"
|
||||||
|
cursor-test-auto:
|
||||||
|
role: "test"
|
||||||
|
cli: "cursor-agent"
|
||||||
|
tier: "standard"
|
||||||
|
model: "auto"
|
||||||
|
reasoningEffort: null
|
||||||
|
permissionMode: "workspace-write"
|
||||||
|
defaults:
|
||||||
|
developer: "pi-dev-default"
|
||||||
|
test: "cursor-test-auto"
|
||||||
|
developerUpgraded: "pi-dev-strong"
|
||||||
@@ -7,7 +7,9 @@ enabled: true
|
|||||||
defaultProfile: "review"
|
defaultProfile: "review"
|
||||||
|
|
||||||
intents:
|
intents:
|
||||||
testEnvironment: test-env
|
testEnvironment:
|
||||||
|
via: deployer
|
||||||
|
env: test
|
||||||
release: null
|
release: null
|
||||||
|
|
||||||
entrypoints:
|
entrypoints:
|
||||||
@@ -104,19 +106,6 @@ environments:
|
|||||||
mutex: "notes-test-deploy"
|
mutex: "notes-test-deploy"
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
test-env:
|
|
||||||
stopAt: validation_ready
|
|
||||||
steps:
|
|
||||||
- id: build-deb
|
|
||||||
action: build
|
|
||||||
artifact: service-deb
|
|
||||||
- id: deploy-test
|
|
||||||
action: deploy
|
|
||||||
artifact: service-deb
|
|
||||||
environment: test-server
|
|
||||||
- id: smoke-test
|
|
||||||
action: health-check
|
|
||||||
environment: test-server
|
|
||||||
review:
|
review:
|
||||||
stopAt: review_ready
|
stopAt: review_ready
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ack v0.18.0。
|
> 本项目基于 ack v0.20.0。
|
||||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
|
||||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。
|
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、`delivery.yaml`
|
||||||
|
> 与 `regression.yaml`。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
@@ -12,10 +13,12 @@
|
|||||||
- 技术栈:`TypeScript + React (Vite) + Go`
|
- 技术栈:`TypeScript + React (Vite) + Go`
|
||||||
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
|
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
|
||||||
- Base URL:`http://localhost:5173`
|
- Base URL:`http://localhost:5173`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板配置:`.pouch/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`
|
||||||
- 交付契约:`docs/ack/delivery.yaml`
|
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||||
- 覆盖层文件:`docs/ack/project.md`
|
- 交付契约:`.pouch/ack/delivery.yaml`
|
||||||
|
- 回归目录:`.pouch/ack/regression.yaml`
|
||||||
|
- 覆盖层文件:`.pouch/ack/project.md`
|
||||||
|
|
||||||
## 稳定规范(引用,不重复)
|
## 稳定规范(引用,不重复)
|
||||||
|
|
||||||
@@ -27,11 +30,13 @@
|
|||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令:`references/orca-adapter.md`
|
- Orca 编排命令:`references/orca-adapter.md`
|
||||||
- 验证后交付:`references/delivery.md`
|
- 验证后交付:`references/delivery.md`
|
||||||
|
- 回归目录与运行:`references/regression.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
结构化配置位于 `docs/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层
|
结构化配置默认位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`;存在
|
||||||
`workerReceipts`。本项目默认使用:
|
`.pouch/ack/agents.yaml` 时以后者为准。启动记录位于顶层 `workerReceipts`。
|
||||||
|
本项目默认使用:
|
||||||
|
|
||||||
| 角色 | profile ID | 档位 |
|
| 角色 | profile ID | 档位 |
|
||||||
|------|------------|------|
|
|------|------------|------|
|
||||||
@@ -39,8 +44,8 @@
|
|||||||
| Test | `codex-test-standard` | standard |
|
| Test | `codex-test-standard` | standard |
|
||||||
| Developer 升级 | `codex-dev-strong` | strong |
|
| Developer 升级 | `codex-dev-strong` | strong |
|
||||||
|
|
||||||
项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行
|
项目如临时改用 Pi、Cursor 或 Grok,应修改 `agents.yaml` 的结构化 profile、allowlist
|
||||||
任务板校验。本文件不保存或拼接 worker shell 命令。
|
和 defaults,再运行对应校验。本文件不保存或拼接 worker shell 命令。
|
||||||
|
|
||||||
## 路径权限
|
## 路径权限
|
||||||
|
|
||||||
@@ -53,8 +58,10 @@
|
|||||||
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
|
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
|
||||||
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
|
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||||
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
|
||||||
|
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
|
||||||
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
@@ -80,8 +87,10 @@ ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动
|
|||||||
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
||||||
path/args。
|
path/args。
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||||
交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。
|
`scripts/validate_regression.py` 执行。
|
||||||
|
测试环境由 ACK 内部调用 deployer;发版机器入口以 `delivery.yaml` 为准,本覆盖层
|
||||||
|
不维护第二套发布或部署命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
@@ -94,7 +103,7 @@ path/args。
|
|||||||
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
|
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
|
||||||
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
|
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
|
||||||
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
- 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
||||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||||
`candidate` 不派发,知识库不全量注入。
|
`candidate` 不派发,知识库不全量注入。
|
||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
version: 1
|
||||||
|
updatedAt: "2026-08-25T10:00:00+08:00"
|
||||||
|
project:
|
||||||
|
name: "notes-web"
|
||||||
|
cases:
|
||||||
|
- id: REG-login-001
|
||||||
|
title: 登录后进入工作台
|
||||||
|
status: active
|
||||||
|
source:
|
||||||
|
taskId: BUG-001
|
||||||
|
kind: bug
|
||||||
|
suite: smoke
|
||||||
|
surface: browser
|
||||||
|
setup: 使用测试账号 A,未登录
|
||||||
|
steps:
|
||||||
|
- 打开 /login
|
||||||
|
- 输入账号 A 并提交
|
||||||
|
expected:
|
||||||
|
- kind: visible-text
|
||||||
|
value: 工作台
|
||||||
|
- kind: url
|
||||||
|
value: /dashboard
|
||||||
|
- id: REG-notes-create-001
|
||||||
|
title: 创建笔记后列表出现标题
|
||||||
|
status: active
|
||||||
|
source:
|
||||||
|
taskId: FEAT-012
|
||||||
|
kind: feature
|
||||||
|
suite: full
|
||||||
|
surface: api
|
||||||
|
setup: 已登录账号 A,笔记列表为空
|
||||||
|
steps:
|
||||||
|
- POST /api/notes 创建标题为回归笔记的条目
|
||||||
|
- GET /api/notes
|
||||||
|
expected:
|
||||||
|
- kind: api-status
|
||||||
|
value: "200"
|
||||||
|
- kind: api-field
|
||||||
|
value: "items[].title=回归笔记"
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
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"
|
||||||
ackVersion: "0.18.0"
|
ackVersion: "0.20.0"
|
||||||
project:
|
project:
|
||||||
name: "notes-web"
|
name: "notes-web"
|
||||||
baseUrl: "http://localhost:5173"
|
baseUrl: "http://localhost:5173"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: ".pouch/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: ".pouch/ack/knowledge.yaml"
|
||||||
deliveryFile: "docs/ack/delivery.yaml"
|
deliveryFile: ".pouch/ack/delivery.yaml"
|
||||||
|
regressionFile: ".pouch/ack/regression.yaml"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "manual"
|
mode: "manual"
|
||||||
@@ -96,6 +97,7 @@ project:
|
|||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
deliveryRuns: []
|
deliveryRuns: []
|
||||||
|
regressionRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: ["BUG-002"]
|
verified: ["BUG-002"]
|
||||||
|
|||||||
@@ -3,30 +3,36 @@
|
|||||||
## 安装与初始化
|
## 安装与初始化
|
||||||
|
|
||||||
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
||||||
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
- [ ] 已运行 `pouch init ack --project <project-root>`。
|
||||||
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与
|
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
|
||||||
默认关闭的 `delivery.yaml`。
|
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
|
||||||
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
||||||
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||||
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
||||||
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
|
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
|
||||||
|
- [ ] 若存在 `.pouch/ack/agents.yaml`,已运行 `validate_agents.py`;它优先于任务板
|
||||||
|
`project.orchestration`,且只包含结构化 profile,不包含自由 command/argv/env。
|
||||||
|
|
||||||
## 项目覆盖层
|
## 项目覆盖层
|
||||||
|
|
||||||
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
|
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
|
||||||
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
||||||
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
||||||
`docs/ack/knowledge.yaml`。
|
`.pouch/ack/knowledge.yaml`。
|
||||||
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
|
- [ ] 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,顶层有
|
||||||
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
||||||
|
- [ ] 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,顶层有
|
||||||
|
`regressionRuns: []`;旧项目未采用回归能力时可无这两项。
|
||||||
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
||||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||||
allowlist,默认 profile 与角色/档位一致。
|
allowlist,默认 profile 与角色/档位一致。
|
||||||
|
- [ ] `agents.yaml`(若存在)使用受支持的 profileVersion,Pi 默认模型使用
|
||||||
|
`cli: pi` + `model: null`,配置 hash 会绑定启动 plan/fingerprint/receipt。
|
||||||
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
|
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
|
||||||
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
|
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
|
||||||
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
||||||
`--project-root/docs/ack/` 推导,worker 路径由 `--worktree` 显式指定。
|
`--project-root/.pouch/ack/` 推导,worker 路径由 `--worktree` 显式指定。
|
||||||
|
|
||||||
## 路径权限
|
## 路径权限
|
||||||
|
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
- [ ] 私有配置只读且不提交。
|
- [ ] 私有配置只读且不提交。
|
||||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||||
|
- [ ] `regression.yaml` 只有 Coordinator 写;Test 只通过 `regressionCandidates` 提名。
|
||||||
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||||
|
|
||||||
## 任务板
|
## 任务板
|
||||||
@@ -48,8 +55,9 @@
|
|||||||
## 可选交付
|
## 可选交付
|
||||||
|
|
||||||
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
|
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
|
||||||
- [ ] 测试环境部署和版本发布都写在同一份 `delivery.yaml` 的 `intents` 中,没有第二份
|
- [ ] 测试环境绑定 deployer(`intents.testEnvironment.via: deployer`),发版写在
|
||||||
操作文档。未说明的 intent 保持 `null`。
|
同一份 `delivery.yaml` 的 `intents.release`。没有第二份操作文档。未说明的
|
||||||
|
intent 保持 `null`。旧 profile ID 字符串已迁移。
|
||||||
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
|
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
|
||||||
shell、自由 command、凭据值或环境变量值。
|
shell、自由 command、凭据值或环境变量值。
|
||||||
- [ ] artifact、destination、environment 和 profile 引用均通过
|
- [ ] artifact、destination、environment 和 profile 引用均通过
|
||||||
@@ -81,6 +89,13 @@
|
|||||||
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
|
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
|
||||||
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
|
|
||||||
|
## 回归
|
||||||
|
|
||||||
|
- [ ] 新项目 `regression.yaml` 为 `cases: []`,没有虚构用例。
|
||||||
|
- [ ] 已运行 `validate_regression.py --tasks ...` 并通过。
|
||||||
|
- [ ] 任务 `verified` 后给出新增/更新/退役/无回归四选一,确认后才写入目录。
|
||||||
|
- [ ] 运行回归先走 deployer 测试环境,再派独立 Test;失败不自动开修。
|
||||||
|
|
||||||
## 编排
|
## 编排
|
||||||
|
|
||||||
- [ ] 已选择 Orca 或手动模式。
|
- [ ] 已选择 Orca 或手动模式。
|
||||||
@@ -110,6 +125,6 @@
|
|||||||
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
|
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
|
||||||
|
|
||||||
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
||||||
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
`.pouch/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
||||||
或 `scripts/`,并更新 `VERSION`。项目特有、跨任务复用且已经验证的经验才写入
|
或 `scripts/`,并更新 `VERSION`。项目特有、跨任务复用且已经验证的经验才写入
|
||||||
`docs/ack/knowledge.yaml`。
|
`.pouch/ack/knowledge.yaml`。
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
|
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
|
||||||
|
|
||||||
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨
|
原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层
|
||||||
|
materialize 成任务视图);已经验证且可跨
|
||||||
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
|
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
|
||||||
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
|
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
|
||||||
Developer 只实现。
|
Developer 只实现。
|
||||||
@@ -15,12 +16,12 @@ Developer 只实现。
|
|||||||
|
|
||||||
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|
||||||
|----------|------|-----------|----------|
|
|----------|------|-----------|----------|
|
||||||
| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
|
| `prepare(task)` | Coordinator 把任务和验收信号写进 `task-records/`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
|
||||||
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
|
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
|
||||||
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
|
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
|
||||||
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
|
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
|
||||||
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
|
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
|
||||||
| `writeback(task, result)` | Coordinator 把任务结果写回 `tasks.yaml`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
|
| `writeback(task, result)` | Coordinator 把任务结果追加到 `task-records/`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
|
||||||
|
|
||||||
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
|
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
|
||||||
|
|
||||||
@@ -30,21 +31,21 @@ Developer 只实现。
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Coordinator 发现或读取 open 任务
|
Coordinator 发现或读取 open 任务
|
||||||
-> prepare:写/补全 tasks.yaml 验收信号
|
-> prepare:写/补全 task-records/ 中的任务验收信号
|
||||||
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
|
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
|
||||||
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
|
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
|
||||||
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||||
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
|
-> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration
|
||||||
-> 运行 launcher plan 并审阅 launchFingerprint
|
-> 运行 launcher plan 并审阅 launchFingerprint
|
||||||
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
|
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
|
||||||
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md)
|
-> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md)
|
||||||
-> dispatch 给 Developer(--to <worker handle>)
|
-> dispatch 给 Developer(--to <worker handle>)
|
||||||
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
||||||
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
||||||
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||||
-> writeback fixed_by_dev
|
-> writeback fixed_by_dev
|
||||||
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先执行该 profile
|
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先按 deployer
|
||||||
拉起待测服务,再派 Test;Test 不发明编译或启动命令
|
绑定拉起 `.pouch/deployer/<env>`,再派 Test;Test 不发明编译或启动命令
|
||||||
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
||||||
-> dispatch 给 Test(retesting)
|
-> dispatch 给 Test(retesting)
|
||||||
-> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
-> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
||||||
@@ -133,7 +134,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
|
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
|
||||||
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
|
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
|
||||||
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
|
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
|
||||||
- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把
|
- 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把
|
||||||
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
|
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
|
||||||
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
|
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
|
||||||
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
|
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
|
||||||
@@ -173,10 +174,11 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
||||||
| 小改动、追求快 | 当前 worktree |
|
| 小改动、追求快 | 当前 worktree |
|
||||||
|
|
||||||
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
|
**项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、
|
||||||
`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在
|
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
|
||||||
worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护
|
副本(通常在基线/协调所在 worktree)。任务执行状态追加到
|
||||||
时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
`.pouch/ack/task-records/<recordId>.yaml`;Coordinator 单写任务记录、知识、回归目录、
|
||||||
|
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||||
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
||||||
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||||
|
|
||||||
@@ -190,20 +192,20 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
|
|||||||
|
|
||||||
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
|
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
|
||||||
|
|
||||||
1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。
|
1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。
|
||||||
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
|
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
|
||||||
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
|
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
|
||||||
手动发给 Developer(另一个会话/终端/人)。
|
手动发给 Developer(另一个会话/终端/人)。
|
||||||
3. Developer 完成后按 worker_done 模板回报。
|
3. Developer 完成后按 worker_done 模板回报。
|
||||||
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
|
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
|
||||||
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
|
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
|
||||||
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
|
6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。
|
||||||
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
|
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
|
||||||
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
|
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
|
||||||
不派 Developer、不消耗产品轮次。
|
不派 Developer、不消耗产品轮次。
|
||||||
|
|
||||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||||
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
|
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
|
||||||
|
|
||||||
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
||||||
@@ -233,7 +235,7 @@ actual:
|
|||||||
snapshot evidence:
|
snapshot evidence:
|
||||||
```
|
```
|
||||||
|
|
||||||
Test 只回传证据,不写 `tasks.yaml` 或 `knowledge.yaml`;由 Coordinator 落盘。
|
Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。
|
||||||
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
|
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
|
||||||
`not_applicable` 及证据。
|
`not_applicable` 及证据。
|
||||||
|
|
||||||
@@ -271,13 +273,14 @@ frontendDir:
|
|||||||
worktreePath:
|
worktreePath:
|
||||||
```
|
```
|
||||||
|
|
||||||
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以该 profile 为准,不另写一套启动命令。
|
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以 deployer 绑定为准,不另写一套启动命令。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 结果回写
|
## 结果回写
|
||||||
|
|
||||||
Coordinator 回写 `tasks.yaml` 时按状态填写(字段结构见 `templates/tasks.schema.json`):
|
Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见
|
||||||
|
`templates/tasks.schema.json`):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# 通过(Test 复测 + Coordinator 终检)
|
# 通过(Test 复测 + Coordinator 终检)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
|
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
|
||||||
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
||||||
交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入
|
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
|
||||||
`docs/ack/tasks.yaml.deliveryRuns`。
|
任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。
|
||||||
|
|
||||||
## 1. 配置与授权不是一回事
|
## 1. 配置与授权不是一回事
|
||||||
|
|
||||||
@@ -54,28 +54,34 @@ channel、environment 或 source revision 漂移时重新确认。
|
|||||||
|
|
||||||
## 3.1 测试环境与发版写在同一份契约
|
## 3.1 测试环境与发版写在同一份契约
|
||||||
|
|
||||||
`docs/ack/delivery.yaml` 是测试环境部署和版本发布的唯一文档。不要另写操作手册,
|
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一文档。不要另写操作手册,
|
||||||
也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或
|
也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或
|
||||||
「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`、entrypoint、
|
「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`。
|
||||||
artifact、environment 和 profile。
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
intents:
|
intents:
|
||||||
testEnvironment: local-binary # profile ID,或 null
|
testEnvironment:
|
||||||
release: null # profile ID,或 null
|
via: deployer
|
||||||
|
env: test # 项目 .pouch/deployer/test;尚未说明时为 null
|
||||||
|
release: null # profile ID,或 null
|
||||||
```
|
```
|
||||||
|
|
||||||
- `testEnvironment` 指向 `stopAt: validation_ready` 的 profile:build 产物、部署到
|
- `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」
|
||||||
development/staging、健康检查。用户说「重新布测试环境」「我要测试」时执行它;
|
「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.pouch/deployer/<env>`
|
||||||
派发 Test 复测前,若该 intent 已配置且 `enabled: true`,Coordinator 也先执行它。
|
按服务执行 sync + up 和健康检查。环境目录不存在或 deployer `check.py` 未通过
|
||||||
不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动命令。
|
时,加载 deployer skill 的「初始化」,不要在 ACK 里复制 compose 命令。派发
|
||||||
|
Test 复测或跑回归前,若该 intent 已配置且 `enabled: true`,Coordinator 也先
|
||||||
|
执行它。不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动
|
||||||
|
命令。旧的 profile ID 字符串不再执行,必须迁到 `{via: deployer, env: <env>}`。
|
||||||
|
本地进程启动写在 `project.md`,不算这个 intent。
|
||||||
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
|
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
|
||||||
口头「发版」不能代替 stable/production 的 `approval` 步骤。
|
口头「发版」不能代替 stable/production 的 `approval` 步骤。
|
||||||
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
|
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
|
||||||
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
|
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
|
||||||
- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填
|
- 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填
|
||||||
`testEnvironment` 或 `release`,`taskIds` 可为空。绑定任务的常规交付 run 不填
|
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
|
||||||
`intent`,仍只能引用 `verified` 任务。
|
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
|
||||||
|
`verified` 任务。
|
||||||
|
|
||||||
## 4. 运行前检查
|
## 4. 运行前检查
|
||||||
|
|
||||||
@@ -85,7 +91,7 @@ intents:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py \
|
python3 <ack-skill-dir>/scripts/validate_delivery.py \
|
||||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml \
|
.pouch/ack/delivery.yaml --tasks .pouch/ack/tasks.yaml \
|
||||||
--project-root <project-root>
|
--project-root <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,14 +112,17 @@ intents:
|
|||||||
- `verify`:运行指定 entrypoint,失败即停止。
|
- `verify`:运行指定 entrypoint,失败即停止。
|
||||||
- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft
|
- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft
|
||||||
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
|
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
|
||||||
明确要求时才调用独立的 `manage-release`。没有对应能力或认证时标记 blocked,不用
|
明确要求时才调用独立的 `releaser`。没有对应能力或认证时标记 blocked,不用
|
||||||
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
|
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
|
||||||
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
|
- `build`:对当前 source revision 调用已安装的 `builder` `obtain.sh --sha <revision>`,
|
||||||
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
|
不要假定本机 `make`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线;没有流水线或 job 失败
|
||||||
revision,不能在目标机器重新拉源码构建。
|
则本步 `failed`。DEB 必须记录包名、版本、架构和 SHA-256;OCI image 必须记录完整
|
||||||
|
引用、platform 和 digest。产物必须绑定当前 source revision,不能在目标机器重新拉
|
||||||
|
源码构建。
|
||||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
|
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
|
||||||
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
|
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
|
||||||
时才加载,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
时才加载,否则必须走契约中已审查的 upload entrypoint。`BUILD_VENUE=ci` 且 obtain
|
||||||
|
已带回 registry digest 时,本步只复核,不再本机 push。项目入口只接受
|
||||||
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
||||||
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
||||||
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
||||||
@@ -147,7 +156,9 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
|||||||
## 7. 与低层 Skill 的边界
|
## 7. 与低层 Skill 的边界
|
||||||
|
|
||||||
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
||||||
上传、镜像或 Git 发布实现。`builder` 和
|
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`releaser` 和
|
||||||
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载
|
||||||
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失
|
||||||
调用时,ACK 不能绕过它的触发与授权边界。
|
builder 或 releaser 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用
|
||||||
|
时把对应步骤标为 `blocked`。低层 skill 自身要求显式调用时,ACK 不能绕过它的
|
||||||
|
触发与授权边界;ACK 内部调用 deployer 布测试环境是该 skill 的合法调用路径。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## 项目配置与 Base 结构
|
## 项目配置与 Base 结构
|
||||||
|
|
||||||
`docs/ack/tasks.yaml` 的 `project.bugIntake` 必须声明 `provider: feishu-base`、
|
`.pouch/ack/tasks.yaml` 的 `project.bugIntake` 必须声明 `provider: feishu-base`、
|
||||||
`workflow: clarified-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、`viewId` 和字段映射:
|
`workflow: clarified-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、`viewId` 和字段映射:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -34,8 +34,8 @@ fields:
|
|||||||
调整当前 view 的可见字段,不删除旧列:
|
调整当前 view 的可见字段,不删除旧列:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-plan docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-plan .pouch/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-apply docs/ack/tasks.yaml \
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-apply .pouch/ack/tasks.yaml \
|
||||||
--expected-schema-fingerprint <schemaFingerprint>
|
--expected-schema-fingerprint <schemaFingerprint>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -68,11 +68,11 @@ locale;调用者环境中的凭据和运行时注入变量不会传入。不
|
|||||||
## 读取与整理
|
## 读取与整理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check .pouch/ack/tasks.yaml
|
||||||
tmpdir=$(mktemp -d)
|
tmpdir=$(mktemp -d)
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch docs/ack/tasks.yaml \
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch .pouch/ack/tasks.yaml \
|
||||||
--output-dir "$tmpdir"
|
--output-dir "$tmpdir"
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan .pouch/ack/tasks.yaml \
|
||||||
--output-dir "$tmpdir"
|
--output-dir "$tmpdir"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ Coordinator 对每条 Bug:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
||||||
docs/ack/tasks.yaml --record-id <record-id> \
|
.pouch/ack/tasks.yaml --record-id <record-id> \
|
||||||
--expected-source-ref <sourceRef> \
|
--expected-source-ref <sourceRef> \
|
||||||
--expected-draft-revision <draftRevision> --input <draft.json>
|
--expected-draft-revision <draftRevision> --input <draft.json>
|
||||||
```
|
```
|
||||||
@@ -120,7 +120,7 @@ python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
||||||
docs/ack/tasks.yaml --record-id <record-id> \
|
.pouch/ack/tasks.yaml --record-id <record-id> \
|
||||||
--expected-source-ref <approved-sourceRef> \
|
--expected-source-ref <approved-sourceRef> \
|
||||||
--expected-draft-revision <approvedDraftRevision>
|
--expected-draft-revision <approvedDraftRevision>
|
||||||
```
|
```
|
||||||
@@ -135,7 +135,7 @@ python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py mark-imported \
|
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py mark-imported \
|
||||||
docs/ack/tasks.yaml --record-id <record-id> --task-id <ack-task-id> \
|
.pouch/ack/tasks.yaml --record-id <record-id> --task-id <ack-task-id> \
|
||||||
--expected-source-ref <approved-sourceRef> \
|
--expected-source-ref <approved-sourceRef> \
|
||||||
--expected-draft-revision <approvedDraftRevision>
|
--expected-draft-revision <approvedDraftRevision>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
|
|
||||||
1. 目标项目根目录。
|
1. 目标项目根目录。
|
||||||
2. ACK Skill 已全局安装或安装到当前项目。
|
2. ACK Skill 已全局安装或安装到当前项目。
|
||||||
3. `skiff` 命令可用。
|
3. `pouch` 命令可用。
|
||||||
|
|
||||||
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
不要覆盖已有的 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent
|
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml`、`.pouch/ack/regression.yaml`、
|
||||||
|
`AGENTS.md` 或其它 Agent
|
||||||
指令文件。ACK 不会自动
|
指令文件。ACK 不会自动
|
||||||
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
||||||
内容或其它私有配置写入 ACK 项目状态。
|
内容或其它私有配置写入 ACK 项目状态。
|
||||||
@@ -22,23 +23,24 @@
|
|||||||
在目标项目执行:
|
在目标项目执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff init ack
|
pouch init ack
|
||||||
```
|
```
|
||||||
|
|
||||||
或显式指定项目:
|
或显式指定项目:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff init ack --project <project-root>
|
pouch init ack --project <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
命令从 ACK Skill 自带的 `templates/` 生成:
|
命令从 ACK Skill 自带的 `templates/` 生成:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/ack/
|
.pouch/ack/
|
||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
├── knowledge.yaml
|
├── knowledge.yaml
|
||||||
└── delivery.yaml # 默认 enabled: false
|
├── delivery.yaml # 默认 enabled: false
|
||||||
|
└── regression.yaml # 默认 cases: []
|
||||||
```
|
```
|
||||||
|
|
||||||
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
||||||
@@ -47,24 +49,31 @@ docs/ack/
|
|||||||
### 旧项目补充知识库
|
### 旧项目补充知识库
|
||||||
|
|
||||||
旧项目已经有 `project.md` 和 `tasks.yaml`、但没有 `knowledge.yaml` 时,不要重跑
|
旧项目已经有 `project.md` 和 `tasks.yaml`、但没有 `knowledge.yaml` 时,不要重跑
|
||||||
`skiff init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
|
`pouch init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
|
||||||
`templates/knowledge.template.yaml` 生成 `docs/ack/knowledge.yaml`,替换项目名和
|
`templates/knowledge.template.yaml` 生成 `.pouch/ack/knowledge.yaml`,替换项目名和
|
||||||
当前时间,保留 `entries: []`。如果现有任务板缺少
|
当前时间,保留 `entries: []`。如果现有任务板缺少
|
||||||
`project.knowledgeFile`,同一次授权只补
|
`project.knowledgeFile`,同一次授权只补
|
||||||
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
`knowledgeFile: .pouch/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
||||||
知识库和跨文件引用校验。
|
知识库和跨文件引用校验。
|
||||||
|
|
||||||
### 旧项目补充交付配置
|
### 旧项目补充交付配置
|
||||||
|
|
||||||
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
|
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
|
||||||
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
|
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
|
||||||
任务板补 `project.deliveryFile: docs/ack/delivery.yaml` 与顶层
|
任务板补 `project.deliveryFile: .pouch/ack/delivery.yaml` 与顶层
|
||||||
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
||||||
解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。
|
解析结果后才启用。不要重跑 `pouch init ack`,也不要改写已有任务或知识。
|
||||||
|
|
||||||
|
### 旧项目补充回归目录
|
||||||
|
|
||||||
|
`regression.yaml` 对旧项目是可选能力。用户明确要求回归或授权补齐时,从
|
||||||
|
`templates/regression.template.yaml` 生成 `.pouch/ack/regression.yaml`,并只补
|
||||||
|
`project.regressionFile: .pouch/ack/regression.yaml` 与顶层 `regressionRuns: []`。
|
||||||
|
保持 `cases: []`,不要从聊天虚构用例。
|
||||||
|
|
||||||
## 完善项目覆盖层
|
## 完善项目覆盖层
|
||||||
|
|
||||||
编辑 `docs/ack/project.md`,填入:
|
编辑 `.pouch/ack/project.md`,填入:
|
||||||
|
|
||||||
- 项目名、技术栈、运行命令和 Base URL。
|
- 项目名、技术栈、运行命令和 Base URL。
|
||||||
- Coordinator、Developer、Test 的实际模型档位。
|
- Coordinator、Developer、Test 的实际模型档位。
|
||||||
@@ -76,7 +85,7 @@ docs/ack/
|
|||||||
|
|
||||||
## 完善任务板
|
## 完善任务板
|
||||||
|
|
||||||
编辑 `docs/ack/tasks.yaml`:
|
编辑 `.pouch/ack/tasks.yaml`:
|
||||||
|
|
||||||
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
||||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||||
@@ -84,14 +93,19 @@ docs/ack/
|
|||||||
自动补交付配置。
|
自动补交付配置。
|
||||||
- `updatedAt` 使用当前带时区时间。
|
- `updatedAt` 使用当前带时区时间。
|
||||||
- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。
|
- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。
|
||||||
ACK 从命令行 `--project-root` 下固定的 `docs/ack/` 布局解析项目状态,不把
|
ACK 从命令行 `--project-root` 下固定的 `.pouch/ack/` 布局解析项目状态,不把
|
||||||
`repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取,
|
`repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取,
|
||||||
不再参与路径绑定。
|
不再参与路径绑定。
|
||||||
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
- 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,并保留顶层
|
||||||
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||||
|
- 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,并保留顶层
|
||||||
|
`regressionRuns: []`。旧项目只有在采用回归能力时才补这两个字段。
|
||||||
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
||||||
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
||||||
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||||
|
- `.pouch/ack/agents.yaml` 是可选的临时 worker 路由覆盖;存在时优先于任务板中的
|
||||||
|
`project.orchestration`。它必须使用结构化 profile;Pi 默认模型写成 `cli: pi`、
|
||||||
|
`model: null`,不能写自由命令。
|
||||||
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
||||||
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
||||||
|
|
||||||
@@ -100,7 +114,7 @@ docs/ack/
|
|||||||
|
|
||||||
## 初始化项目知识
|
## 初始化项目知识
|
||||||
|
|
||||||
新项目的 `docs/ack/knowledge.yaml` 保持 `verificationRegistry: {}` 与
|
新项目的 `.pouch/ack/knowledge.yaml` 保持 `verificationRegistry: {}` 与
|
||||||
`entries: []`。不要从聊天、README、issue 或单次失败中猜测并激活知识。
|
`entries: []`。不要从聊天、README、issue 或单次失败中猜测并激活知识。
|
||||||
|
|
||||||
项目运行 ACK 后,Developer 和 Test 可以通过回报提名 `knowledgeCandidates`;
|
项目运行 ACK 后,Developer 和 Test 可以通过回报提名 `knowledgeCandidates`;
|
||||||
@@ -116,34 +130,47 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
|
|||||||
|
|
||||||
## 初始化项目交付
|
## 初始化项目交付
|
||||||
|
|
||||||
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
新项目的 `.pouch/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
||||||
`intents.testEnvironment: null` 与 `intents.release: null`。
|
`intents.testEnvironment: null` 与 `intents.release: null`。
|
||||||
不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言说明测试环境或发版
|
不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator
|
||||||
方式后,Coordinator 按 `delivery.md` 把两者都写入这一份契约:`intents` 指向对应
|
把 `intents.testEnvironment` 写成 `{via: deployer, env: <env>}`,若
|
||||||
profile,工具 target 与仓库脚本分开引用。配置中不保存 shell、环境变量值或凭据
|
`.pouch/deployer/<env>` 尚未就绪则加载 deployer skill 的「初始化」;发版仍指向
|
||||||
正文;稳定发布和生产部署必须有显式 approval 步骤。
|
profile。ACK 初始化不自动跑 deployer 初始化。
|
||||||
|
配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式
|
||||||
|
approval 步骤。
|
||||||
|
|
||||||
|
## 初始化回归目录
|
||||||
|
|
||||||
|
新项目的 `.pouch/ack/regression.yaml` 保持 `cases: []`。不要从 README 或聊天猜测
|
||||||
|
用例。任务 `verified` 后按 `regression.md` 收获。
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
||||||
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
|
||||||
|
--tasks .pouch/ack/tasks.yaml
|
||||||
|
# 存在临时角色配置时
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
同时确认:
|
同时确认:
|
||||||
|
|
||||||
- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的
|
- `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 和
|
||||||
`<...>` 占位符。
|
`regression.yaml` 没有未替换的 `<...>` 占位符。
|
||||||
- `project.overlayFile` 指向真实文件。
|
- `project.overlayFile` 指向真实文件。
|
||||||
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
- `project.knowledgeFile` 指向 `.pouch/ack/knowledge.yaml`。
|
||||||
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
|
- 新项目的 `project.deliveryFile` 指向 `.pouch/ack/delivery.yaml`;交付默认关闭。
|
||||||
- Developer 与 Test 的验证命令可执行。
|
- Developer 与 Test 的验证命令可执行。
|
||||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||||
|
- 若存在 `agents.yaml`,其 profile/allowlist/defaults 也通过校验,且启动配置 hash
|
||||||
|
会绑定 plan、fingerprint 和 receipt。
|
||||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
||||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||||
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
|
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
|
||||||
@@ -152,12 +179,8 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
|||||||
|
|
||||||
## 初始化报告
|
## 初始化报告
|
||||||
|
|
||||||
完成后报告:
|
按 `SKILL.md`「初始化」最后一步的格式报告(`## ack 初始化:完成 | 部分完成 | 阻塞`),
|
||||||
|
列出已具备项、待配置项(路径 + 字段 + 示例)、工具链和下一步。
|
||||||
|
|
||||||
- 创建或确认的四个项目文件。
|
只有结构校验通过且必填项目事实完整时才称「完成」;否则称「部分完成」或「阻塞」。
|
||||||
- 检测到的技术栈和验证命令。
|
除非用户明确要求,不提交、不推送。
|
||||||
- 任务板、项目知识和交付契约校验结果。
|
|
||||||
- 仍需用户补充的值。
|
|
||||||
|
|
||||||
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
|
||||||
具体阻塞项。除非用户明确要求,不提交、不推送。
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# 如何开始一个需求(Kickoff)
|
# 如何开始一个需求(Kickoff)
|
||||||
|
|
||||||
从零开一个需求的启动手册。角色/权限见 `roles-and-permissions.md`,闭环见 `closed-loop.md`,模型见 `model-routing.md`。
|
从零开一个需求的启动手册。本文件只覆盖确认前的产品文档、任务拆分与验收信号。
|
||||||
|
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
|
||||||
|
不要在本文件开头预读其它规范。
|
||||||
|
|
||||||
|
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + long-ship)。ACK 任务板
|
||||||
|
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,8 +13,9 @@
|
|||||||
|
|
||||||
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
|
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
|
||||||
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
|
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
|
||||||
具体 CLI、模型、reasoning effort 和执行模式的机器事实源是
|
具体 CLI、模型、reasoning effort 和执行模式的机器事实源优先是
|
||||||
`docs/ack/tasks.yaml` 的 `project.orchestration`。`docs/ack/project.md` 只解释项目
|
`.pouch/ack/agents.yaml`,不存在时回退到 `.pouch/ack/tasks.yaml` 的
|
||||||
|
`project.orchestration`。`.pouch/ack/project.md` 只解释项目
|
||||||
差异,不能提供另一套启动命令。
|
差异,不能提供另一套启动命令。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -18,30 +24,31 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
我要做一个新需求:<一句话需求>。
|
我要做一个新需求:<一句话需求>。
|
||||||
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
|
你作为 ack 的 Coordinator(PM),按 ACK Skill 执行:
|
||||||
|
|
||||||
1. 先读 docs/ack/project.md,并用 `scripts/select_tasks.py docs/ack/tasks.yaml`
|
1. 先读 .pouch/ack/project.md,并用 `scripts/select_tasks.py .pouch/ack/tasks.yaml`
|
||||||
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
|
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
|
||||||
完整 tasks.yaml 注入上下文。校验 docs/ack/knowledge.yaml 并用
|
完整 tasks.yaml 注入上下文。校验 .pouch/ack/knowledge.yaml 并用
|
||||||
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目。
|
||||||
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
本 kickoff 只做确认前的产品文档与任务拆分;用户确认后的闭环、飞书、启动
|
||||||
如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与
|
worker、交付和回归按 ACK Skill 的模式路由按条件加载,不要在本步预读其它
|
||||||
references/delivery.md,但不要把配置本身当作执行授权。
|
references。配置本身不是执行授权。
|
||||||
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||||
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
||||||
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
||||||
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。
|
||||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||||
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||||
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
||||||
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
||||||
fingerprint 创建 fresh worker;
|
fingerprint 创建 fresh worker;
|
||||||
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先拉起测试环境 →
|
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
|
||||||
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
dispatch 测试独立复测 → 你读证据终检 → 追加 task-records;
|
||||||
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
||||||
记录、恢复并告诉我下一步,不占产品复验轮次。
|
记录、恢复并告诉我下一步,不占产品复验轮次。
|
||||||
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
|
||||||
|
.pouch/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写
|
||||||
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
||||||
或 review_ready,stable/production 步骤再次向我确认。
|
或 review_ready,stable/production 步骤再次向我确认。
|
||||||
```
|
```
|
||||||
@@ -51,25 +58,25 @@
|
|||||||
## 第 1 步:Coordinator 产出(确认前)
|
## 第 1 步:Coordinator 产出(确认前)
|
||||||
|
|
||||||
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
|
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
|
||||||
2. 任务板 → `docs/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(见 `optimization-method.md` §1)。
|
2. 任务板 → `.pouch/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||||
3. 项目知识 → 从 `docs/ack/knowledge.yaml` 按 component、path、dependency、version
|
3. 项目知识 → 从 `.pouch/ack/knowledge.yaml` 按 component、path、dependency、version
|
||||||
和 tag 推荐 `active` 条目,Coordinator 确认后写入固定 revision 的显式
|
和 tag 推荐 `active` 条目,Coordinator 确认后写入固定 revision 的显式
|
||||||
`knowledgeRefs`。candidate 不参与选择。
|
`knowledgeRefs`。candidate 不参与选择。
|
||||||
4. 校验结构与引用:
|
4. 校验结构与引用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
`project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、
|
有效 worker routing(`agents.yaml` 优先,否则 `project.orchestration`)是 profile 的机器
|
||||||
不匹配的角色/档位和非安全权限必须在派发前 fail closed。
|
SSOT;未知字段、非 allowlist 模型、不匹配的角色/档位和非安全权限必须在派发前 fail closed。
|
||||||
|
|
||||||
读取任务上下文使用:
|
读取任务上下文使用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml \
|
||||||
--task-id <ack-task-id>
|
--task-id <ack-task-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -85,20 +92,19 @@ python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
|||||||
|
|
||||||
## 第 2 步:决定 worktree
|
## 第 2 步:决定 worktree
|
||||||
|
|
||||||
见 `closed-loop.md` §「子任务放哪」:
|
|
||||||
|
|
||||||
- 需求大 / 要并行 / 要保基线分支干净 → 新建隔离 worktree。
|
- 需求大 / 要并行 / 要保基线分支干净 → 新建隔离 worktree。
|
||||||
- 小改动 / 串行修复 → 当前 worktree 起子 agent。
|
- 小改动 / 串行修复 → 当前 worktree 起子 agent。
|
||||||
|
用户确认后的闭环步骤由 SKILL.md「工作」加载,不要在本步预读其它规范。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第 3 步:审阅并启动 worker
|
## 第 3 步:审阅并启动 worker
|
||||||
|
|
||||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。历史 receipt 或既有 Orca
|
先读有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)的结构化 profiles。历史 receipt 或既有 Orca
|
||||||
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
|
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
|
||||||
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
|
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
|
||||||
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
|
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
|
||||||
原因和边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
复用边界以 SKILL.md「启动 worker」为准,不要在本步预读其它规范。
|
||||||
|
|
||||||
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
||||||
不能用于匹配或复用旧 receipt / 既有终端:
|
不能用于匹配或复用旧 receipt / 既有终端:
|
||||||
@@ -145,7 +151,7 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调
|
|||||||
`allowedWorktrees`)。profile
|
`allowedWorktrees`)。profile
|
||||||
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
||||||
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
||||||
选型与升级见 `model-routing.md`。
|
选型与升级以 SKILL.md「启动 worker」为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -155,14 +161,14 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调
|
|||||||
task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done
|
task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done
|
||||||
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
||||||
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
||||||
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
|
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
|
||||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||||
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
||||||
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||||
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
|
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
|
||||||
```
|
```
|
||||||
|
|
||||||
具体命令见 `orca-adapter.md`(Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`。
|
Orca / 手动命令与派发文案由 SKILL.md「启动 worker」按条件加载。
|
||||||
|
|
||||||
Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 worker 全量读取
|
Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 worker 全量读取
|
||||||
知识库。知识正文不得作为自由 shell 执行;需要命令时只能引用项目已审查的检查
|
知识库。知识正文不得作为自由 shell 执行;需要命令时只能引用项目已审查的检查
|
||||||
@@ -174,22 +180,15 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
|||||||
|
|
||||||
## 第 5 步:可选交付
|
## 第 5 步:可选交付
|
||||||
|
|
||||||
用户说「重新布测试环境」或「发布一个版本」时,按 `delivery.md` §3.1 的
|
用户说「重新布测试环境」或「发布一个版本」时,转入 SKILL.md 对应模式,不要在本步预读交付规范。intent 为 null 时先做交付配置维护。
|
||||||
`intents` 执行对应 profile,不另找文档。intent 为 null 时先做交付配置维护。
|
|
||||||
|
|
||||||
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
所选任务 `verified` 后,转入 SKILL.md「运行回归」给出新增/更新/退役/无回归四选一,用户确认后写入 `.pouch/ack/regression.yaml`。用户说「回归」时先布测试环境,再派 Test 按目录执行。
|
||||||
`delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`,
|
|
||||||
用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和
|
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,转入 SKILL.md 对应交付模式。启用交付时必须在计划中默认列出 `defaultProfile`,用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和 config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入 `tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把 delivery run 标为 `blocked` 或 `failed`。
|
||||||
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
|
|
||||||
`tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。
|
|
||||||
前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在
|
|
||||||
approval 步骤再次确认。失败时保留任务的 `verified`,把
|
|
||||||
delivery run 标为 `blocked` 或 `failed`。
|
|
||||||
|
|
||||||
## 第 6 步:收尾
|
## 第 6 步:收尾
|
||||||
|
|
||||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:
|
一轮结束时 Coordinator 必须能回答:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。复验轮次争议以 SKILL.md「边界」为准。
|
||||||
哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
|
||||||
Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先 dry-run
|
Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先 dry-run
|
||||||
审阅决策、再 `--apply` 关闭所有只关联 `verified` 任务的 Developer/Test 终端并核对
|
审阅决策、再 `--apply` 关闭所有只关联 `verified` 任务的 Developer/Test 终端并核对
|
||||||
回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、`leftover`
|
回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、`leftover`
|
||||||
@@ -201,8 +200,8 @@ Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py`
|
|||||||
## 一句话
|
## 一句话
|
||||||
|
|
||||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)解析安全 profile → 审阅 plan 并用 expected
|
||||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||||
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile
|
||||||
跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收仅属于 verified
|
到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收
|
||||||
任务的 worker,保留 blocked/failed/leftover worker。
|
仅属于 verified 任务的 worker,保留 blocked/failed/leftover worker。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 模型路由(稳定核心)
|
# 模型路由(稳定核心)
|
||||||
|
|
||||||
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
|
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的规范源。
|
||||||
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
||||||
工作,把机械执行交给较弱模型。
|
工作,把机械执行交给较弱模型。
|
||||||
|
|
||||||
@@ -64,23 +64,32 @@ profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强
|
|||||||
|
|
||||||
## 结构化 worker profile
|
## 结构化 worker profile
|
||||||
|
|
||||||
### 机器事实源
|
### 机器事实源与项目临时覆盖
|
||||||
|
|
||||||
worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的
|
||||||
`project.orchestration`。`project.md` 可以解释项目为何选某个档位,但不能另写一份
|
`project.orchestration`。项目需要临时调整角色 CLI 或模型时,可以在同一目录增加
|
||||||
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 为准。
|
`.pouch/ack/agents.yaml`;它使用与 `project.orchestration` 相同的结构,且优先级更高。
|
||||||
|
删除该文件即回退到任务板配置。`project.md` 只能解释项目为何选某个档位,不能另写一份
|
||||||
|
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 和
|
||||||
|
`templates/agents.schema.json` 为准。
|
||||||
|
|
||||||
|
`agents.yaml` 的配置 hash 会进入 profile-hash、launch plan、fingerprint 和 receipt。
|
||||||
|
因此修改临时配置后必须重新审阅 plan;旧 fingerprint 不会被接受。
|
||||||
|
|
||||||
每个 profile 明确声明:
|
每个 profile 明确声明:
|
||||||
|
|
||||||
- `role`:`developer` 或 `test`;
|
- `role`:`developer` 或 `test`;
|
||||||
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`);
|
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`、`pi`);
|
||||||
- `tier`:角色模型档位;
|
- `tier`:角色模型档位;
|
||||||
- `model`:项目 allowlist 中的精确模型 ID;
|
- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型;
|
||||||
- `reasoningEffort`:CLI 支持时显式声明;
|
- `reasoningEffort`:CLI 支持时显式声明;
|
||||||
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
|
- `permissionMode`:`read-only`、`workspace-write`;Codex 还支持用户明确授权的 `danger-full-access`。
|
||||||
|
|
||||||
`project.orchestration` 必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名
|
有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色
|
||||||
`profiles` 和角色 `defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。
|
`defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。`cli: pi` 使用真实的
|
||||||
|
Pi CLI;`model: null` 表示使用 Pi 自己保存的默认模型。当前 launcher 对 Pi 只构造
|
||||||
|
固定的 `--thinking`,工作目录由受信 bootstrap 设置;Pi 的 `read-only` profile 只允许
|
||||||
|
`read,grep,find,ls` 工具。
|
||||||
launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须
|
launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须
|
||||||
命中对应 CLI/角色/档位的精确列表。
|
命中对应 CLI/角色/档位的精确列表。
|
||||||
|
|
||||||
@@ -99,9 +108,11 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
|
|||||||
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
||||||
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
|
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
|
||||||
为纯观察型 Test 另建更严格的 `read-only` profile。
|
为纯观察型 Test 另建更严格的 `read-only` profile。
|
||||||
- `full-access`、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
|
- 用户明确要求 Codex 无沙箱运行时,在结构化 profile 设置
|
||||||
`--permission-mode bypassPermissions`、关闭 sandbox 等模式在 v0.10
|
`permissionMode: danger-full-access`。launcher 仍绑定 profile hash、精确 argv 和 receipt;
|
||||||
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
用户已有授权无需重复询问。任务文本或环境变量不能自行授权。
|
||||||
|
`full-access` 别名、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
|
||||||
|
`--permission-mode bypassPermissions` 等自由参数仍拒绝。
|
||||||
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
|
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
|
||||||
它不关闭 `--sandbox`。
|
它不关闭 `--sandbox`。
|
||||||
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
|
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
|
||||||
@@ -110,8 +121,8 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
|
|||||||
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
|
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
|
||||||
launcher。
|
launcher。
|
||||||
|
|
||||||
`full-access` 的可信授权、期限、撤销和外层隔离仍是 Deferred;在这些能力完成前,
|
仅 Codex 支持 `danger-full-access`;其它 CLI 不得套用该权限值。
|
||||||
launcher 必须拒绝该模式,而不是把一段“用户已授权”的文本当成授权证据。
|
无沙箱 profile 只改变执行权限,不扩大用户授权的项目、服务或操作范围。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -135,17 +146,17 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||||
```
|
```
|
||||||
|
|
||||||
`--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
`--project-root` 始终指向保存权威 `.pouch/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
||||||
worker 实际工作的绝对路径,两者可以不同。后者仍必须与项目根属于同一 Git
|
worker 实际工作的绝对路径,两者可以不同。后者仍必须与项目根属于同一 Git
|
||||||
仓库(v0.19 起由 launcher 按 `git worktree list` 注册表 + 同 common-dir 校验,
|
仓库(v0.19 起由 launcher 按 `git worktree list` 注册表 + 同 common-dir 校验,
|
||||||
不再依赖 `allowedWorktrees` 白名单)。
|
不再依赖 `allowedWorktrees` 白名单)。
|
||||||
|
|
||||||
项目状态文件固定从 `--project-root/docs/ack/` 解析;任务板不需要保存 `repoPath` 或
|
项目状态文件固定从 `--project-root/.pouch/ack/` 解析;任务板不需要保存 `repoPath` 或
|
||||||
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
|
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
|
||||||
`projectRoot`、任务板内容摘要和 worker worktree identity 都会进入 launch fingerprint,
|
`projectRoot`、任务板内容摘要、有效 agents 配置 hash 和 worker worktree identity 都会进入 launch fingerprint,
|
||||||
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
|
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
|
||||||
|
|
||||||
- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。
|
- `profile-hash` 读取有效路由配置中的 profile,并输出配置来源与确定性 hash。
|
||||||
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
|
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
|
||||||
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
|
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
|
||||||
`plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv
|
`plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv
|
||||||
|
|||||||
@@ -176,14 +176,18 @@ one dispatch = one bug = one acceptance path
|
|||||||
|
|
||||||
## 8. 优先让测试可执行化
|
## 8. 优先让测试可执行化
|
||||||
|
|
||||||
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。这类可执行测试由 Test 拥有并维护(见 `roles-and-permissions.md` 权限表的 `<integration_test_paths>`)。优先级:
|
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。黑盒回归目录是
|
||||||
|
`.pouch/ack/regression.yaml`(见 `regression.md`);可执行脚本仍由 Test 维护在
|
||||||
|
`<integration_test_paths>`。优先级:
|
||||||
|
|
||||||
1. API smoke。
|
1. 把本轮验收信号收获进 `regression.yaml`。
|
||||||
2. 浏览器脚本或 case 文档。
|
2. API smoke。
|
||||||
3. 单元测试。
|
3. 浏览器脚本或 `automationRef`。
|
||||||
4. 人工检查清单。
|
4. 单元测试。
|
||||||
|
5. 人工检查清单。
|
||||||
|
|
||||||
目标不是全部自动化,而是把最容易反复误判的路径自动化。
|
目标不是全部自动化,而是把最容易反复误判的路径变成可重复跑的回归用例。任务
|
||||||
|
`verified` 后必须给出新增/更新/退役/无回归四选一。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
|
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
|
||||||
|
|
||||||
所有结论仍以 `tasks.yaml` 为准,Orca 消息不是最终记录。
|
所有结论仍以读取层 materialize 的任务视图为准,底层最终记录追加在
|
||||||
|
`task-records/`;Orca 消息不是最终记录。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,8 +64,9 @@ reset/attestation 后才能启用上述复用路径,不能用项目文档声
|
|||||||
## 通过 launcher 创建 worker
|
## 通过 launcher 创建 worker
|
||||||
|
|
||||||
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
|
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
|
||||||
模型、reasoning effort 和权限由 `tasks.yaml.project.orchestration` 的结构化 profile
|
模型、reasoning effort 和权限由 common `agent.yaml` 的 `ack.<name>` binding 映射到
|
||||||
决定,并且只能由 ACK launcher 构造。
|
`.pouch/ack/agents.yaml`(存在时)或
|
||||||
|
`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。
|
||||||
|
|
||||||
先只读生成计划:
|
先只读生成计划:
|
||||||
|
|
||||||
@@ -96,17 +98,17 @@ Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的
|
|||||||
标准输出是单一 JSON。成功后:
|
标准输出是单一 JSON。成功后:
|
||||||
|
|
||||||
1. 校验返回 receipt 的 `receiptHash`;
|
1. 校验返回 receipt 的 `receiptHash`;
|
||||||
2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`;
|
2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`;
|
||||||
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
|
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
|
||||||
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
|
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
|
||||||
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
|
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
|
||||||
与当前 ACK 任务完全一致;
|
与当前 ACK 任务完全一致;
|
||||||
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
|
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
|
||||||
|
|
||||||
profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 full-access 授权
|
profile 支持 `read-only`、`workspace-write`,以及用户明确授权的 Codex
|
||||||
通道;bypass、YOLO/force、关闭 sandbox 及其它 full-access 请求必须由 schema 和
|
`danger-full-access`。无沙箱权限必须写入结构化 profile,由同一 launcher 生成
|
||||||
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
|
`--sandbox danger-full-access` 并绑定 fingerprint/receipt,不手写 bypass 命令。
|
||||||
`model-routing.md`。
|
模型档位与升级规则见 `model-routing.md`。
|
||||||
|
|
||||||
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
|
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
|
||||||
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
|
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
|
||||||
@@ -129,7 +131,7 @@ Policy:
|
|||||||
- Each issue can have at most 3 valid product-retest rounds.
|
- Each issue can have at most 3 valid product-retest rounds.
|
||||||
- Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round.
|
- Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round.
|
||||||
- If still failing after 3 valid product rounds, record as leftover and continue next issue.
|
- If still failing after 3 valid product rounds, record as leftover and continue next issue.
|
||||||
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml and knowledge.yaml.
|
- worker_done and retest reports are not final completion; only Coordinator writes task-records/ and knowledge.yaml.
|
||||||
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
|
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
|
||||||
EOF
|
EOF
|
||||||
)" --json
|
)" --json
|
||||||
@@ -215,7 +217,7 @@ EOF
|
|||||||
且终端进入工作指示(Working / Running)。
|
且终端进入工作指示(Working / Running)。
|
||||||
- 手动投递路径:`orca terminal send` 必须带 `--enter`;投递后同样 read 确认。
|
- 手动投递路径:`orca terminal send` 必须带 `--enter`;投递后同样 read 确认。
|
||||||
- 确认失败或终端仍停在欢迎提示:按「消息未投递」记录环境失败,不消耗产品轮次。
|
- 确认失败或终端仍停在欢迎提示:按「消息未投递」记录环境失败,不消耗产品轮次。
|
||||||
2. 等待期间滚动 probe(每 60–120 秒一次):
|
2. 等待期间滚动 probe(每 60–120 秒一次;不要在 30 秒窗口后每次都 probe):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/worker_probe.py \
|
python3 <ack-skill-dir>/scripts/worker_probe.py \
|
||||||
@@ -229,15 +231,17 @@ EOF
|
|||||||
- `not-started`:检查是否漏投递或未回车;重新投递或记录环境失败,不占轮次。
|
- `not-started`:检查是否漏投递或未回车;重新投递或记录环境失败,不占轮次。
|
||||||
- `running` / `progress`:继续滚动 wait。
|
- `running` / `progress`:继续滚动 wait。
|
||||||
- `unknown`:按 `dispatch-show` 与 Orca live state 人工核对,不自动重试。
|
- `unknown`:按 `dispatch-show` 与 Orca live state 人工核对,不自动重试。
|
||||||
4. `check --wait` 使用短窗口(60–90 秒)而不是 15 分钟:窗口超时是检查点,先 probe
|
4. `check --wait` 默认使用 60 秒窗口而不是 30 秒或 15 分钟:窗口超时是检查点,
|
||||||
再决定继续等待、恢复或上报。
|
仅在达到 probe 间隔后再探测,然后决定继续等待、恢复或上报。新鲜 heartbeat 才能
|
||||||
|
说明 progress;按 worker 每 5 分钟一次的约定,连续三个周期(超过 15 分钟)未更新、
|
||||||
|
格式非法或时间在未来的 heartbeat 只能报告 `unknown`,不得当作持续进展。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
orca orchestration check \
|
orca orchestration check \
|
||||||
--terminal <coordinator_handle> \
|
--terminal <coordinator_handle> \
|
||||||
--wait \
|
--wait \
|
||||||
--types worker_done,retest_result,escalation,decision_gate \
|
--types worker_done,retest_result,escalation,decision_gate \
|
||||||
--timeout-ms 90000 \
|
--timeout-ms 60000 \
|
||||||
--json
|
--json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -282,7 +286,16 @@ orca orchestration send \
|
|||||||
--json
|
--json
|
||||||
```
|
```
|
||||||
|
|
||||||
收到 worker_done 后,Coordinator 写回 `fixed_by_dev`,再按上文派发给 Test 复测。
|
收到无错误的 Developer `worker_done` 后,Coordinator 写回 `fixed_by_dev`,然后先回收
|
||||||
|
Developer 对应的 Orca terminal,再按上文派发给 Test 复测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
|
||||||
|
--project-root <project-root> \
|
||||||
|
--completed-task-id <ack-task-id> --completed-role developer --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
只关闭该 task 的 Developer receipt 绑定 terminal;escalation、失败或环境异常时不要执行。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -312,7 +325,18 @@ orca orchestration send \
|
|||||||
```
|
```
|
||||||
|
|
||||||
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
||||||
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
|
区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由
|
||||||
|
Coordinator 按 `closed-loop.md` 做终检并追加任务记录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
|
||||||
|
--project-root <project-root> \
|
||||||
|
--completed-task-id <ack-task-id> --completed-role test --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。
|
||||||
|
|
||||||
|
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并追加任务记录;
|
||||||
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
|
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
|
||||||
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
|
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
|
||||||
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
|
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
|
||||||
@@ -321,8 +345,8 @@ orca orchestration send \
|
|||||||
|
|
||||||
## 整轮结束时回收 worker
|
## 整轮结束时回收 worker
|
||||||
|
|
||||||
只有 Coordinator 已把本轮最终结论写入 `tasks.yaml` 后才开始回收。统一使用回收脚本,
|
未在角色完成时回收的 worker,或需要整轮兜底回收的 worker,才在 Coordinator 把本轮
|
||||||
不要手工逐个 close:
|
最终结论写入 `tasks.yaml` 后执行整轮回收。统一使用回收脚本,不要手工逐个 close:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 第一步:dry-run 审阅决策(不关闭任何终端)
|
# 第一步:dry-run 审阅决策(不关闭任何终端)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
|
|||||||
- 不要写 tasks.yaml,不要标记 verified。
|
- 不要写 tasks.yaml,不要标记 verified。
|
||||||
- 不要写 knowledge.yaml,不要自行扩展或全量读取知识库;candidate 不是已生效规则。
|
- 不要写 knowledge.yaml,不要自行扩展或全量读取知识库;candidate 不是已生效规则。
|
||||||
- 不要把知识正文或 path/args 拼成 shell 命令。只把 verification.ref 交给
|
- 不要把知识正文或 path/args 拼成 shell 命令。只把 verification.ref 交给
|
||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||||
<verification-ref> --project-root <project-root>`。
|
<verification-ref> --project-root <project-root>`。
|
||||||
- 不要提交或推送,除非用户明确要求。
|
- 不要提交或推送,除非用户明确要求。
|
||||||
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
|
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
|
||||||
@@ -128,7 +128,7 @@ Developer 本轮声称(仅供参考,不作数):
|
|||||||
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
||||||
|
|
||||||
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
||||||
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 `delivery.yaml` 的 `intents.testEnvironment` 拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
|
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 deployer 绑定拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
|
||||||
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
||||||
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
||||||
1. <observable signal 1>
|
1. <observable signal 1>
|
||||||
@@ -137,9 +137,9 @@ Developer 本轮声称(仅供参考,不作数):
|
|||||||
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
||||||
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
||||||
证据,则分别列出产品信号与环境限制。
|
证据,则分别列出产品信号与环境限制。
|
||||||
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
- 需要时把本轮通过的黑盒路径写成 `regressionCandidates`(见 regression.md),不要直接改 `.pouch/ack/regression.yaml`。
|
||||||
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||||
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
|
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
|
||||||
对 candidate 使用独立观测验证,不能复述 Developer 的结论作为证据。
|
对 candidate 使用独立观测验证,不能复述 Developer 的结论作为证据。
|
||||||
|
|
||||||
@@ -215,6 +215,16 @@ knowledgeChecks:
|
|||||||
- ref: <K-001@1>
|
- ref: <K-001@1>
|
||||||
result: <passed|failed|not_applicable>
|
result: <passed|failed|not_applicable>
|
||||||
evidence: <independent evidence>
|
evidence: <independent evidence>
|
||||||
|
regressionCandidates:
|
||||||
|
- title: <reusable black-box case>
|
||||||
|
surface: browser/api
|
||||||
|
suite: smoke/full
|
||||||
|
setup: <preconditions>
|
||||||
|
steps: [<step>]
|
||||||
|
expected:
|
||||||
|
- kind: visible-text/api-status/api-field/url/interaction
|
||||||
|
value: <observable signal>
|
||||||
|
sourceKind: feature/bug
|
||||||
knowledgeCandidates:
|
knowledgeCandidates:
|
||||||
- kind: guardrail/pitfall/verification
|
- kind: guardrail/pitfall/verification
|
||||||
title: <new lesson found by Test>
|
title: <new lesson found by Test>
|
||||||
@@ -263,6 +273,10 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
||||||
- 待验证 candidate:<remaining candidates or none>
|
- 待验证 candidate:<remaining candidates or none>
|
||||||
|
|
||||||
|
回归:
|
||||||
|
- 收获:新增/更新/退役/无回归 <case ids or none>
|
||||||
|
- 最近一次回归运行:<RR-id / passed|failed|n/a>
|
||||||
|
|
||||||
交付(未启用时写 n/a):
|
交付(未启用时写 n/a):
|
||||||
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
||||||
- PR/MR:<URL and head/base>
|
- PR/MR:<URL and head/base>
|
||||||
@@ -274,3 +288,28 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- <repo_path>: <git status summary>
|
- <repo_path>: <git status summary>
|
||||||
- <dev_worktree>: <git status summary>
|
- <dev_worktree>: <git status summary>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 派发给 Test 跑回归
|
||||||
|
|
||||||
|
```text
|
||||||
|
请按回归目录对当前测试环境做独立黑盒回归。
|
||||||
|
suite: <smoke|full|custom>
|
||||||
|
baseUrl: <deployer 给出的地址>
|
||||||
|
|
||||||
|
用例(来自 select_regression.py,不要发明步骤):
|
||||||
|
- <REG-id>: surface=<browser|api>
|
||||||
|
setup: <setup>
|
||||||
|
steps: <steps>
|
||||||
|
expected: <expected signals>
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 先确认 Base URL 可访问;不可访问时回报 environmentFailure,不要编造产品失败。
|
||||||
|
- surface=browser 必须走真实页面交互,不能改成只打 API。
|
||||||
|
- 逐条对照 expected 回报 pass/fail 与证据。
|
||||||
|
- 不要修改源码,不要写 tasks.yaml / knowledge.yaml / regression.yaml。
|
||||||
|
|
||||||
|
完成后按 §5 的复测报告格式回报,signals 使用 REG-id。
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# ACK 回归目录与运行
|
||||||
|
|
||||||
|
本文件是回归用例目录、收获时机和运行模式的 SSOT。三角色闭环仍见
|
||||||
|
`closed-loop.md`;测试环境部署见 `delivery.md` 与 deployer skill。
|
||||||
|
|
||||||
|
回归不是新 skill,也不写入 `knowledge.yaml`。知识是护栏;回归是可观测的黑盒
|
||||||
|
用例清单。
|
||||||
|
|
||||||
|
## 1. 目录
|
||||||
|
|
||||||
|
项目状态是 `.pouch/ack/regression.yaml`。任务板用
|
||||||
|
`project.regressionFile: .pouch/ack/regression.yaml` 声明,运行证据写在
|
||||||
|
任务记录的 `regressionRuns`(读取时 materialize 到任务视图)。新项目初始化会生成空目录;旧项目没有该文件时,
|
||||||
|
「运行回归」先停下来,用户授权后再从模板补齐。
|
||||||
|
|
||||||
|
只有 Coordinator 写 `regression.yaml` 和任务记录。Test 在复测报告里提名
|
||||||
|
`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
|
||||||
|
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
|
||||||
|
`.pouch/ack/`。
|
||||||
|
|
||||||
|
`tests/browser/**` 仍可由 Test 维护可执行脚本。用例可用可选 `automationRef`
|
||||||
|
指向那些脚本;没有脚本时 Test worker 按目录里的步骤和验收信号执行。
|
||||||
|
|
||||||
|
## 2. 用例
|
||||||
|
|
||||||
|
每条用例是给独立 Test worker 的说明书,复用
|
||||||
|
`optimization-method.md` §1 的可观测信号。ACK 不提供 YAML-DSL 解释器,也不强制
|
||||||
|
Playwright。
|
||||||
|
|
||||||
|
读取时用 `scripts/select_regression.py`,不要把完整目录注入上下文:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
|
||||||
|
--suite full
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
|
||||||
|
--case-id REG-login-001
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `--suite smoke` 只返回 `status: active` 且 `suite: smoke` 的用例。
|
||||||
|
`--suite full` 返回全部 active 用例。写回前仍运行
|
||||||
|
`scripts/validate_regression.py`。
|
||||||
|
|
||||||
|
`surface: browser` 的用例必须走真实页面交互,不能改成只打 API。
|
||||||
|
`surface: api` 以 HTTP 状态和字段为证据。
|
||||||
|
|
||||||
|
## 3. 收获
|
||||||
|
|
||||||
|
任务进入 `verified` 且改了用户可见行为或 API 后,闭环结束前 Coordinator 必须给出
|
||||||
|
四选一,不能跳过:
|
||||||
|
|
||||||
|
- 新增
|
||||||
|
- 更新已有用例
|
||||||
|
- 退役(功能已删除)
|
||||||
|
- 无回归(纯内部重构、leftover、无用户可见变化)
|
||||||
|
|
||||||
|
草案来自任务验收信号和 Test 的独立证据,按 `path + expected` 去重。用户确认后
|
||||||
|
才写入目录。P0 / 主路径默认 `suite: smoke`,其余 `full`。
|
||||||
|
|
||||||
|
## 4. 运行
|
||||||
|
|
||||||
|
用户说「回归」「跑回归测试」时执行本模式,不要求当前有 `verified` 任务。
|
||||||
|
|
||||||
|
1. 目录存在且有选中的 active 用例;否则停止。
|
||||||
|
2. 先按「运行测试环境」调用 deployer,拿到 Base URL。
|
||||||
|
3. Coordinator 派独立 Test worker,只带选中用例、Base URL 和 surface。
|
||||||
|
4. Test 逐条对照 `expected` 回报证据。Coordinator 终检后写入 `regressionRuns`。
|
||||||
|
5. 失败只报告,不自动派 Developer,不占任务三轮预算。用户明确要求修复时,按
|
||||||
|
「修 bug」为每条失败开一个任务。
|
||||||
|
|
||||||
|
环境失败(没有浏览器、打不开部署地址、deployer 失败)记环境事件,不记产品失败。
|
||||||
|
|
||||||
|
默认跑 smoke。用户点名 full 或具体 case id 时按点名范围执行。
|
||||||
@@ -12,12 +12,12 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
||||||
|------|----------|----------|------------|
|
|------|----------|----------|------------|
|
||||||
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
| Coordinator (PM) | 需求拆解、定验收信号、单写 `task-records/` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
||||||
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
||||||
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
||||||
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||||
|
|
||||||
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。
|
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有追加到 `task-records/` 才算数。
|
||||||
|
|
||||||
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
上面的表定义了**边界**(谁能碰什么),这一节定义**能力**(每个角色到底该怎么做好自己的事)。每个角色用同一骨架描述:`Outcome`(产出什么)/ `Must Do`(必须做)/ `Must Not`(不能做)/ `Evidence`(拿什么证明)/ `Output`(交付格式)。派发 prompt 会引用这里,见 `prompt-templates.md`。
|
上面的表定义了**边界**(谁能碰什么),这一节定义**能力**(每个角色到底该怎么做好自己的事)。每个角色用同一骨架描述:`Outcome`(产出什么)/ `Must Do`(必须做)/ `Must Not`(不能做)/ `Evidence`(拿什么证明)/ `Output`(交付格式)。派发 prompt 会引用这里,见 `prompt-templates.md`。
|
||||||
|
|
||||||
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `docs/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
|
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `.pouch/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
|
||||||
|
|
||||||
### Coordinator (PM):拆解与终检
|
### Coordinator (PM):拆解与终检
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
## 路径权限模板
|
## 路径权限模板
|
||||||
|
|
||||||
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `docs/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
|
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `.pouch/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
|
||||||
|
|
||||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||||
|------|:-----------:|:----:|:---------:|------|
|
|------|:-----------:|:----:|:---------:|------|
|
||||||
@@ -108,8 +108,10 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
||||||
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||||
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只保存稳定项目配置;任务状态见 `task-records/` |
|
||||||
|
| `task-records/` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 |
|
||||||
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -142,7 +144,7 @@ launch / service / data / browser / tooling environment failure
|
|||||||
-> 环境恢复后 retesting
|
-> 环境恢复后 retesting
|
||||||
```
|
```
|
||||||
|
|
||||||
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
|
状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同):
|
||||||
|
|
||||||
| 状态 | 依据来源 | 含义 |
|
| 状态 | 依据来源 | 含义 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
@@ -178,11 +180,12 @@ planned -> skipped
|
|||||||
|
|
||||||
## 项目状态写入约定(并发安全)
|
## 项目状态写入约定(并发安全)
|
||||||
|
|
||||||
`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源,
|
`task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置,
|
||||||
|
`knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源,
|
||||||
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
||||||
|
|
||||||
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
- **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。
|
||||||
对它们都是只读的。
|
Test 与 Developer 对它们都是只读的。
|
||||||
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
||||||
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
|
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
|
||||||
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
|
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from approval_payload import approval_payload_hash, review_items
|
from approval_payload import approval_payload_hash, review_items
|
||||||
|
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||||
from validate_tasks import validate_builtin as validate_task_board
|
from validate_tasks import validate_builtin as validate_task_board
|
||||||
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
|
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
|
||||||
|
|
||||||
@@ -180,21 +181,11 @@ def cli_environment() -> dict[str, str]:
|
|||||||
|
|
||||||
def load_board(path: Path) -> dict[str, Any]:
|
def load_board(path: Path) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
text = path.read_text(encoding="utf-8")
|
value = parse_document(path)
|
||||||
if path.suffix.lower() == ".json":
|
if is_task_store_manifest(value):
|
||||||
value = load_json_unique(text)
|
value = materialize_board(value, path)
|
||||||
else:
|
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError, TaskStoreError) as exc:
|
||||||
try:
|
|
||||||
import yaml # type: ignore
|
|
||||||
value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml))
|
|
||||||
except ImportError:
|
|
||||||
value = load_yaml_subset(text)
|
|
||||||
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
|
|
||||||
raise IntakeError(f"cannot read task board: {exc}") from exc
|
raise IntakeError(f"cannot read task board: {exc}") from exc
|
||||||
except Exception as exc: # PyYAML errors are intentionally not exposed verbatim.
|
|
||||||
raise IntakeError("cannot parse task board") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise IntakeError("task board must be an object")
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ from worker_profiles import ( # noqa: E402
|
|||||||
executable_basename_matches_cli,
|
executable_basename_matches_cli,
|
||||||
profile_hash,
|
profile_hash,
|
||||||
render_worker_argv,
|
render_worker_argv,
|
||||||
validate_routing_document,
|
validate_orchestration,
|
||||||
)
|
)
|
||||||
|
|
||||||
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
||||||
RECEIPT_VERSION = 1
|
RECEIPT_VERSION = 1
|
||||||
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
||||||
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
|
TASKS_RELATIVE_PATH = Path(".pouch/ack/tasks.yaml")
|
||||||
|
AGENTS_RELATIVE_PATH = Path(".pouch/ack/agents.yaml")
|
||||||
MAX_CONTROL_OUTPUT = 1024 * 1024
|
MAX_CONTROL_OUTPUT = 1024 * 1024
|
||||||
MAX_RECORD_SIZE = 256 * 1024
|
MAX_RECORD_SIZE = 256 * 1024
|
||||||
LAUNCH_TTL_SECONDS = 120
|
LAUNCH_TTL_SECONDS = 120
|
||||||
@@ -85,12 +86,49 @@ WORKER_CREDENTIAL_NAMES = {
|
|||||||
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
||||||
"grok": frozenset({"XAI_API_KEY"}),
|
"grok": frozenset({"XAI_API_KEY"}),
|
||||||
"omp": frozenset({"OPENCODE_API_KEY"}),
|
"omp": frozenset({"OPENCODE_API_KEY"}),
|
||||||
|
"pi": frozenset(
|
||||||
|
{
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"ANT_LING_API_KEY",
|
||||||
|
"AZURE_OPENAI_API_KEY",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"DEEPSEEK_API_KEY",
|
||||||
|
"NVIDIA_API_KEY",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"MISTRAL_API_KEY",
|
||||||
|
"GROQ_API_KEY",
|
||||||
|
"CEREBRAS_API_KEY",
|
||||||
|
"CLOUDFLARE_API_KEY",
|
||||||
|
"CLOUDFLARE_ACCOUNT_ID",
|
||||||
|
"CLOUDFLARE_GATEWAY_ID",
|
||||||
|
"XAI_API_KEY",
|
||||||
|
"OPENROUTER_API_KEY",
|
||||||
|
"AI_GATEWAY_API_KEY",
|
||||||
|
"ZAI_API_KEY",
|
||||||
|
"ZAI_CODING_CN_API_KEY",
|
||||||
|
"OPENCODE_API_KEY",
|
||||||
|
"RADIUS_API_KEY",
|
||||||
|
"FIREWORKS_API_KEY",
|
||||||
|
"TOGETHER_API_KEY",
|
||||||
|
"BASETEN_API_KEY",
|
||||||
|
"KIMI_API_KEY",
|
||||||
|
"MINIMAX_API_KEY",
|
||||||
|
"MINIMAX_CN_API_KEY",
|
||||||
|
"QWEN_TOKEN_PLAN_API_KEY",
|
||||||
|
"QWEN_TOKEN_PLAN_CN_API_KEY",
|
||||||
|
"XIAOMI_API_KEY",
|
||||||
|
"XIAOMI_TOKEN_PLAN_CN_API_KEY",
|
||||||
|
"XIAOMI_TOKEN_PLAN_AMS_API_KEY",
|
||||||
|
"XIAOMI_TOKEN_PLAN_SGP_API_KEY",
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
CLI_TITLE_LABELS = {
|
CLI_TITLE_LABELS = {
|
||||||
"codex": "CODEX",
|
"codex": "CODEX",
|
||||||
"cursor-agent": "CURSOR",
|
"cursor-agent": "CURSOR",
|
||||||
"grok": "GROK",
|
"grok": "GROK",
|
||||||
"omp": "OMP",
|
"omp": "OMP",
|
||||||
|
"pi": "PI",
|
||||||
}
|
}
|
||||||
INHERITED_ENVIRONMENT_PREFIXES = (
|
INHERITED_ENVIRONMENT_PREFIXES = (
|
||||||
"LC_",
|
"LC_",
|
||||||
@@ -262,12 +300,55 @@ def authoritative_tasks_path(project_root: Path) -> Path:
|
|||||||
return tasks_path
|
return tasks_path
|
||||||
|
|
||||||
|
|
||||||
|
def optional_agents_path(project_root: Path) -> Path | None:
|
||||||
|
agents_path = project_root / AGENTS_RELATIVE_PATH
|
||||||
|
try:
|
||||||
|
metadata = os.lstat(agents_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
raise LaunchError(f"agents.yaml 不可访问: {agents_path}") from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise LaunchError(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}")
|
||||||
|
_assert_no_symlink_components(agents_path, "agents.yaml 路径")
|
||||||
|
if metadata.st_size > MAX_RECORD_SIZE:
|
||||||
|
raise LaunchError(f"agents.yaml 超过 {MAX_RECORD_SIZE} 字节上限")
|
||||||
|
return agents_path
|
||||||
|
|
||||||
|
|
||||||
|
def load_effective_orchestration(
|
||||||
|
project_root: Path,
|
||||||
|
board: dict,
|
||||||
|
) -> tuple[dict, str, str]:
|
||||||
|
"""Load the optional project override and return routing, hash, source."""
|
||||||
|
|
||||||
|
agents_path = optional_agents_path(project_root)
|
||||||
|
if agents_path is not None:
|
||||||
|
agents = load_document(agents_path)
|
||||||
|
errors = validate_orchestration(agents, where="agents.yaml")
|
||||||
|
if errors:
|
||||||
|
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
|
||||||
|
raise LaunchError(f"agents.yaml 未通过 worker 路由校验:\n{formatted}")
|
||||||
|
return agents, canonical_sha256(agents), str(agents_path)
|
||||||
|
|
||||||
|
project = board.get("project")
|
||||||
|
orchestration = project.get("orchestration") if isinstance(project, dict) else None
|
||||||
|
if not isinstance(orchestration, dict):
|
||||||
|
raise LaunchError("任务板缺少 project.orchestration,且没有 agents.yaml")
|
||||||
|
return orchestration, canonical_sha256(orchestration), "tasks.yaml:project.orchestration"
|
||||||
|
|
||||||
|
|
||||||
def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]:
|
def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]:
|
||||||
project_root = canonical_directory(project_root_value, "项目根目录")
|
project_root = canonical_directory(project_root_value, "项目根目录")
|
||||||
tasks_path = authoritative_tasks_path(project_root)
|
tasks_path = authoritative_tasks_path(project_root)
|
||||||
board = load_document(tasks_path)
|
board = load_document(tasks_path)
|
||||||
errors = validate_builtin(board)
|
effective, _, _ = load_effective_orchestration(project_root, board)
|
||||||
errors.extend(validate_routing_document(board))
|
errors = validate_builtin(board, orchestration_override=effective)
|
||||||
|
if optional_agents_path(project_root) is not None:
|
||||||
|
project = board.get("project")
|
||||||
|
board_orchestration = project.get("orchestration") if isinstance(project, dict) else None
|
||||||
|
if board_orchestration is not None:
|
||||||
|
errors.extend(validate_orchestration(board_orchestration))
|
||||||
if errors:
|
if errors:
|
||||||
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
|
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
|
||||||
raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}")
|
raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}")
|
||||||
@@ -307,8 +388,25 @@ def _is_trusted_grok_executable(resolved: Path, metadata: os.stat_result) -> boo
|
|||||||
return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid()
|
return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_pi_shim(candidate: Path, resolved: Path, metadata: os.stat_result) -> bool:
|
||||||
|
"""Accept the mise-managed ``pi`` shim while retaining target identity checks."""
|
||||||
|
|
||||||
|
home, _ = account_identity()
|
||||||
|
expected_shim = home / ".local" / "share" / "mise" / "shims" / "pi"
|
||||||
|
if candidate != expected_shim or candidate.name != "pi":
|
||||||
|
return False
|
||||||
|
if resolved.name != "mise":
|
||||||
|
return False
|
||||||
|
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return candidate.is_symlink() and candidate.resolve(strict=True) == resolved
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def resolve_executable(name: str) -> Path:
|
def resolve_executable(name: str) -> Path:
|
||||||
supported = {"codex", "cursor-agent", "grok", "omp", "git", "orca"}
|
supported = {"codex", "cursor-agent", "grok", "omp", "pi", "git", "orca"}
|
||||||
if name not in supported:
|
if name not in supported:
|
||||||
raise LaunchError(f"不支持的可执行文件: {name}")
|
raise LaunchError(f"不支持的可执行文件: {name}")
|
||||||
search_paths = trusted_path_entries()
|
search_paths = trusted_path_entries()
|
||||||
@@ -337,6 +435,10 @@ def resolve_executable(name: str) -> Path:
|
|||||||
if _is_trusted_grok_executable(resolved, metadata):
|
if _is_trusted_grok_executable(resolved, metadata):
|
||||||
return resolved
|
return resolved
|
||||||
continue
|
continue
|
||||||
|
if name == "pi":
|
||||||
|
if _is_trusted_pi_shim(candidate, resolved, metadata):
|
||||||
|
return candidate
|
||||||
|
continue
|
||||||
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||||
continue
|
continue
|
||||||
if resolved.name != name:
|
if resolved.name != name:
|
||||||
@@ -672,14 +774,13 @@ def build_plan(
|
|||||||
raise LaunchError("slot 必须是 1..99 的整数")
|
raise LaunchError("slot 必须是 1..99 的整数")
|
||||||
|
|
||||||
project_root, board = load_authoritative_board(project_root_value)
|
project_root, board = load_authoritative_board(project_root_value)
|
||||||
|
orchestration, routing_hash, routing_source = load_effective_orchestration(
|
||||||
|
project_root, board
|
||||||
|
)
|
||||||
board_hash = canonical_sha256(board)
|
board_hash = canonical_sha256(board)
|
||||||
find_task(board, task_id)
|
find_task(board, task_id)
|
||||||
project = board["project"]
|
|
||||||
orchestration = project.get("orchestration")
|
|
||||||
if not isinstance(orchestration, dict):
|
|
||||||
raise LaunchError("旧任务板缺少 project.orchestration,只能使用手动模式")
|
|
||||||
if orchestration.get("mode") != "orca":
|
if orchestration.get("mode") != "orca":
|
||||||
raise LaunchError("project.orchestration.mode 不是 orca,拒绝自动创建 worker")
|
raise LaunchError("有效 worker 配置的 mode 不是 orca,拒绝自动创建 worker")
|
||||||
profiles = orchestration.get("profiles")
|
profiles = orchestration.get("profiles")
|
||||||
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
|
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
|
||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
@@ -718,6 +819,11 @@ def build_plan(
|
|||||||
profile,
|
profile,
|
||||||
profile_version=orchestration["profileVersion"],
|
profile_version=orchestration["profileVersion"],
|
||||||
)
|
)
|
||||||
|
routing_facts = (
|
||||||
|
{}
|
||||||
|
if routing_source == "tasks.yaml:project.orchestration"
|
||||||
|
else {"routingSource": routing_source, "routingHash": routing_hash}
|
||||||
|
)
|
||||||
created_for = {
|
created_for = {
|
||||||
"taskId": task_id,
|
"taskId": task_id,
|
||||||
"attemptId": attempt_id,
|
"attemptId": attempt_id,
|
||||||
@@ -735,6 +841,7 @@ def build_plan(
|
|||||||
"worktree": worktree,
|
"worktree": worktree,
|
||||||
"requested": requested,
|
"requested": requested,
|
||||||
"slot": slot,
|
"slot": slot,
|
||||||
|
**routing_facts,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -762,6 +869,7 @@ def build_plan(
|
|||||||
"requested": requested,
|
"requested": requested,
|
||||||
"slot": slot,
|
"slot": slot,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
**routing_facts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1136,6 +1244,9 @@ def build_receipt(
|
|||||||
},
|
},
|
||||||
"createdAt": created_at,
|
"createdAt": created_at,
|
||||||
}
|
}
|
||||||
|
if "routingSource" in plan and "routingHash" in plan:
|
||||||
|
receipt["routingSource"] = plan["routingSource"]
|
||||||
|
receipt["routingHash"] = plan["routingHash"]
|
||||||
receipt["receiptHash"] = canonical_sha256(receipt)
|
receipt["receiptHash"] = canonical_sha256(receipt)
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
@@ -1579,8 +1690,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
|
def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
|
||||||
if not PROFILE_ID_RE.fullmatch(profile_id):
|
if not PROFILE_ID_RE.fullmatch(profile_id):
|
||||||
raise LaunchError("profile-id 格式非法")
|
raise LaunchError("profile-id 格式非法")
|
||||||
_, board = load_authoritative_board(project_root_value)
|
project_root, board = load_authoritative_board(project_root_value)
|
||||||
orchestration = board["project"].get("orchestration")
|
orchestration, routing_hash, routing_source = load_effective_orchestration(
|
||||||
|
project_root, board
|
||||||
|
)
|
||||||
profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None
|
profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None
|
||||||
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
|
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
|
||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
@@ -1588,6 +1701,8 @@ def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
|
|||||||
return {
|
return {
|
||||||
"profileVersion": orchestration["profileVersion"],
|
"profileVersion": orchestration["profileVersion"],
|
||||||
"profileId": profile_id,
|
"profileId": profile_id,
|
||||||
|
"routingSource": routing_source,
|
||||||
|
"routingHash": routing_hash,
|
||||||
"profileHash": profile_hash(
|
"profileHash": profile_hash(
|
||||||
profile,
|
profile,
|
||||||
profile_version=orchestration["profileVersion"],
|
profile_version=orchestration["profileVersion"],
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Migrate a legacy ACK tasks.yaml into immutable task records.
|
||||||
|
|
||||||
|
The default is a read-only dry run. ``--apply`` writes a sibling
|
||||||
|
``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces
|
||||||
|
the task board with a small manifest that points at ``task-records/``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, content: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
||||||
|
try:
|
||||||
|
temporary.write_text(content, encoding="utf-8")
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_id(task_id: str) -> str:
|
||||||
|
return f"EV-{task_id}-initial"
|
||||||
|
|
||||||
|
|
||||||
|
def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
value: dict[str, Any] = {
|
||||||
|
"recordVersion": STORE_VERSION,
|
||||||
|
"recordId": _record_id(task_id),
|
||||||
|
"taskId": task_id,
|
||||||
|
"parents": [],
|
||||||
|
"kind": "task.snapshot",
|
||||||
|
}
|
||||||
|
if task is not None:
|
||||||
|
value["task"] = task
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def build_records(board: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
tasks = board.get("tasks")
|
||||||
|
if not isinstance(tasks, list):
|
||||||
|
raise TaskStoreError("legacy tasks 必须是列表")
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
task_ids: set[str] = set()
|
||||||
|
for task in tasks:
|
||||||
|
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
|
||||||
|
raise TaskStoreError("每条 legacy task 必须包含字符串 id")
|
||||||
|
task_id = task["id"]
|
||||||
|
if task_id in task_ids:
|
||||||
|
raise TaskStoreError(f"task id 重复: {task_id}")
|
||||||
|
task_ids.add(task_id)
|
||||||
|
records.append(_record(task_id, dict(task)))
|
||||||
|
|
||||||
|
receipts = board.get("workerReceipts") or []
|
||||||
|
if not isinstance(receipts, list):
|
||||||
|
raise TaskStoreError("workerReceipts 必须是列表")
|
||||||
|
by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids}
|
||||||
|
global_receipts: list[dict[str, Any]] = []
|
||||||
|
for receipt in receipts:
|
||||||
|
if not isinstance(receipt, dict):
|
||||||
|
raise TaskStoreError("workerReceipts 每项必须是对象")
|
||||||
|
created_for = receipt.get("createdFor")
|
||||||
|
task_id = created_for.get("taskId") if isinstance(created_for, dict) else None
|
||||||
|
if isinstance(task_id, str) and task_id in by_task:
|
||||||
|
by_task[task_id].append(receipt)
|
||||||
|
else:
|
||||||
|
global_receipts.append(receipt)
|
||||||
|
for record in records:
|
||||||
|
task_id = record["taskId"]
|
||||||
|
if by_task[task_id]:
|
||||||
|
record["workerReceipts"] = by_task[task_id]
|
||||||
|
|
||||||
|
global_record = _record(GLOBAL_TASK_ID)
|
||||||
|
if global_receipts:
|
||||||
|
global_record["workerReceipts"] = global_receipts
|
||||||
|
for field in ("deliveryRuns", "regressionRuns"):
|
||||||
|
value = board.get(field)
|
||||||
|
if value is not None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise TaskStoreError(f"{field} 必须是列表")
|
||||||
|
global_record[field] = value
|
||||||
|
if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")):
|
||||||
|
records.append(global_record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]:
|
||||||
|
board = parse_document(path)
|
||||||
|
if "taskStore" in board:
|
||||||
|
raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移")
|
||||||
|
records = build_records(board)
|
||||||
|
record_directory = directory or "task-records"
|
||||||
|
relative = Path(record_directory)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts:
|
||||||
|
raise TaskStoreError("records directory 必须是任务板目录下的相对路径")
|
||||||
|
output_dir = path.parent / relative
|
||||||
|
backup = path.with_name(f"{path.stem}.legacy{path.suffix}")
|
||||||
|
if apply and backup.exists():
|
||||||
|
raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}")
|
||||||
|
|
||||||
|
manifest = dict(board)
|
||||||
|
for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"):
|
||||||
|
manifest.pop(field, None)
|
||||||
|
manifest["tasks"] = []
|
||||||
|
manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory}
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, backup)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for record in records:
|
||||||
|
record_path = output_dir / f"{record['recordId']}.yaml"
|
||||||
|
if record_path.exists():
|
||||||
|
raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}")
|
||||||
|
_atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
_atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "apply" if apply else "dry-run",
|
||||||
|
"tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]),
|
||||||
|
"records": len(records),
|
||||||
|
"recordsDirectory": str(output_dir),
|
||||||
|
"backup": str(backup),
|
||||||
|
"taskStore": manifest["taskStore"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="写入记录并替换任务板")
|
||||||
|
parser.add_argument("--records-dir", help="任务记录目录,默认 task-records")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir)
|
||||||
|
except (OSError, TaskStoreError) as exc:
|
||||||
|
print(f"迁移失败: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -17,7 +17,9 @@ Default mode is dry-run: print decisions only. Pass ``--apply`` to actually
|
|||||||
show-verify and close. Closing is conservative: identity must match the
|
show-verify and close. Closing is conservative: identity must match the
|
||||||
receipt, the close receipt must be ok, and the handle must disappear from the
|
receipt, the close receipt must be ok, and the handle must disappear from the
|
||||||
live terminal list; anything uncertain stays retained and is reported instead
|
live terminal list; anything uncertain stays retained and is reported instead
|
||||||
of being retried.
|
of being retried. A completed role can be reclaimed before the whole task is
|
||||||
|
verified with ``--completed-task-id`` and ``--completed-role``; this is only
|
||||||
|
valid after that role has reported success.
|
||||||
|
|
||||||
Output is a single JSON document:
|
Output is a single JSON document:
|
||||||
{"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]}
|
{"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]}
|
||||||
@@ -43,6 +45,10 @@ from launch_worker import ( # noqa: E402
|
|||||||
KEEP_STATUSES = frozenset(
|
KEEP_STATUSES = frozenset(
|
||||||
{"open", "dispatched", "fixed_by_dev", "retesting", "blocked", "failed_retest", "leftover"}
|
{"open", "dispatched", "fixed_by_dev", "retesting", "blocked", "failed_retest", "leftover"}
|
||||||
)
|
)
|
||||||
|
COMPLETED_ROLE_STATUSES = {
|
||||||
|
"developer": frozenset({"fixed_by_dev", "retesting", "verified"}),
|
||||||
|
"test": frozenset({"retesting", "verified"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def collect_handle_tasks(board: dict) -> dict[str, dict[str, object]]:
|
def collect_handle_tasks(board: dict) -> dict[str, dict[str, object]]:
|
||||||
@@ -115,10 +121,25 @@ def decide(handle: str, entry: dict[str, object]) -> tuple[str, str, list[str]]:
|
|||||||
|
|
||||||
def close_terminal(orca: Path, handle: str) -> None:
|
def close_terminal(orca: Path, handle: str) -> None:
|
||||||
"""Show-verify identity, close the tab, then confirm it left the live list."""
|
"""Show-verify identity, close the tab, then confirm it left the live list."""
|
||||||
show = run_json(
|
try:
|
||||||
[str(orca), "terminal", "show", "--terminal", handle, "--json"],
|
show = run_json(
|
||||||
"terminal show",
|
[str(orca), "terminal", "show", "--terminal", handle, "--json"],
|
||||||
)
|
"terminal show",
|
||||||
|
)
|
||||||
|
except LaunchError:
|
||||||
|
# A role may already have been reclaimed before the final verified-task
|
||||||
|
# sweep. Treat a handle absent from the live list as idempotent success;
|
||||||
|
# retain other show failures for manual reconciliation.
|
||||||
|
listing = run_json(
|
||||||
|
[str(orca), "terminal", "list", "--json"],
|
||||||
|
"terminal list",
|
||||||
|
)
|
||||||
|
terminals = listing.get("result", {}).get("terminals")
|
||||||
|
if isinstance(terminals, list) and not any(
|
||||||
|
isinstance(item, dict) and item.get("handle") == handle for item in terminals
|
||||||
|
):
|
||||||
|
return
|
||||||
|
raise
|
||||||
terminal = show.get("result", {}).get("terminal")
|
terminal = show.get("result", {}).get("terminal")
|
||||||
if not isinstance(terminal, dict) or terminal.get("handle") != handle:
|
if not isinstance(terminal, dict) or terminal.get("handle") != handle:
|
||||||
raise LaunchError(f"terminal show 未返回匹配的 handle: {handle}")
|
raise LaunchError(f"terminal show 未返回匹配的 handle: {handle}")
|
||||||
@@ -144,11 +165,70 @@ def close_terminal(orca: Path, handle: str) -> None:
|
|||||||
raise LaunchError(f"terminal close 后 handle 仍在 live list: {handle}")
|
raise LaunchError(f"terminal close 后 handle 仍在 live list: {handle}")
|
||||||
|
|
||||||
|
|
||||||
|
def completed_role_handle(board: dict, task_id: str, role: str) -> str:
|
||||||
|
"""Resolve the receipt-bound terminal for a role that just succeeded."""
|
||||||
|
|
||||||
|
if role not in COMPLETED_ROLE_STATUSES:
|
||||||
|
raise LaunchError(f"不支持的完成角色: {role}")
|
||||||
|
tasks = board.get("tasks")
|
||||||
|
tasks_by_id = (
|
||||||
|
{task.get("id"): task for task in tasks if isinstance(task, dict)}
|
||||||
|
if isinstance(tasks, list)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
task = tasks_by_id.get(task_id)
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
raise LaunchError(f"找不到 ACK task: {task_id}")
|
||||||
|
status = task.get("status")
|
||||||
|
if status not in COMPLETED_ROLE_STATUSES[role]:
|
||||||
|
raise LaunchError(
|
||||||
|
f"{role} 完成回收要求任务处于 {sorted(COMPLETED_ROLE_STATUSES[role])},当前是 {status!r}"
|
||||||
|
)
|
||||||
|
if unresolved_incidents(task):
|
||||||
|
raise LaunchError(f"task {task_id} 存在未解决环境事件,不能回收 {role} terminal")
|
||||||
|
|
||||||
|
dispatch = task.get("dispatch")
|
||||||
|
role_dispatch = dispatch.get(role) if isinstance(dispatch, dict) else None
|
||||||
|
receipt_id = role_dispatch.get("receiptId") if isinstance(role_dispatch, dict) else None
|
||||||
|
if not isinstance(receipt_id, str) or not receipt_id:
|
||||||
|
raise LaunchError(f"task {task_id} 缺少 {role} receiptId")
|
||||||
|
|
||||||
|
receipts = board.get("workerReceipts")
|
||||||
|
receipt = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in receipts
|
||||||
|
if isinstance(item, dict) and item.get("id") == receipt_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
) if isinstance(receipts, list) else None
|
||||||
|
if not isinstance(receipt, dict):
|
||||||
|
raise LaunchError(f"找不到 {role} receipt: {receipt_id}")
|
||||||
|
created_for = receipt.get("createdFor")
|
||||||
|
binding = receipt.get("binding")
|
||||||
|
if (
|
||||||
|
not isinstance(created_for, dict)
|
||||||
|
or created_for.get("taskId") != task_id
|
||||||
|
or created_for.get("role") != role
|
||||||
|
or not isinstance(binding, dict)
|
||||||
|
or not isinstance(binding.get("handle"), str)
|
||||||
|
or not binding.get("handle")
|
||||||
|
):
|
||||||
|
raise LaunchError(f"{role} receipt 与 task/role/handle 不匹配: {receipt_id}")
|
||||||
|
return binding["handle"]
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根")
|
parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根")
|
||||||
parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策")
|
parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策")
|
||||||
parser.add_argument("--handle", help="只处理指定 handle(默认全部)")
|
parser.add_argument("--handle", help="只处理指定 handle(默认全部)")
|
||||||
|
parser.add_argument("--completed-task-id", help="刚完成的 ACK task ID;与 --completed-role 一起使用")
|
||||||
|
parser.add_argument(
|
||||||
|
"--completed-role",
|
||||||
|
choices=("developer", "test"),
|
||||||
|
help="刚成功完成的角色;与 --completed-task-id 一起使用",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -157,6 +237,68 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False))
|
print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False))
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
if bool(args.completed_task_id) != bool(args.completed_role):
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"mode": "apply" if args.apply else "dry-run",
|
||||||
|
"error": "--completed-task-id 与 --completed-role 必须同时提供",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if args.completed_task_id:
|
||||||
|
if args.handle:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"mode": "apply" if args.apply else "dry-run",
|
||||||
|
"error": "完成角色回收不能同时使用 --handle",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
handle = completed_role_handle(board, args.completed_task_id, args.completed_role)
|
||||||
|
closed: bool | None = None
|
||||||
|
decision = "close-completed-role"
|
||||||
|
reason = f"{args.completed_role} reported success for task {args.completed_task_id}"
|
||||||
|
if args.apply:
|
||||||
|
close_terminal(resolve_executable("orca"), handle)
|
||||||
|
closed = True
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"mode": "apply" if args.apply else "dry-run",
|
||||||
|
"handles": [
|
||||||
|
{
|
||||||
|
"handle": handle,
|
||||||
|
"decision": decision,
|
||||||
|
"reason": reason,
|
||||||
|
"tasks": [args.completed_task_id],
|
||||||
|
"role": args.completed_role,
|
||||||
|
"closed": closed,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
except LaunchError as exc:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"mode": "apply" if args.apply else "dry-run",
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
handles = collect_handle_tasks(board)
|
handles = collect_handle_tasks(board)
|
||||||
results: list[dict[str, object]] = []
|
results: list[dict[str, object]] = []
|
||||||
for handle in sorted(handles):
|
for handle in sorted(handles):
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Append one immutable ACK task record.
|
||||||
|
|
||||||
|
The input task is a complete snapshot. The command never edits an existing
|
||||||
|
record; it creates a new file with exclusive creation and lets the materializer
|
||||||
|
report multiple heads when two workspaces branched from the same parent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from task_store import (
|
||||||
|
RECORD_ID_RE,
|
||||||
|
STORE_VERSION,
|
||||||
|
TASK_ID_RE,
|
||||||
|
TaskStoreError,
|
||||||
|
materialize_board,
|
||||||
|
parse_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_mapping(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise TaskStoreError(f"{label}读取失败: {path}") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TaskStoreError(f"{label}必须是对象")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_list(path: Path, label: str) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise TaskStoreError(f"{label}读取失败: {path}") from exc
|
||||||
|
if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
|
||||||
|
raise TaskStoreError(f"{label}必须是对象列表")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_record_id(task_id: str) -> str:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
return f"EV-{task_id}-{stamp}-{secrets.token_hex(4)}"
|
||||||
|
|
||||||
|
|
||||||
|
def append_record(
|
||||||
|
board_path: Path,
|
||||||
|
task: dict[str, Any],
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
record_id: str | None,
|
||||||
|
parents: list[str],
|
||||||
|
kind: str,
|
||||||
|
worker_receipts: list[dict[str, Any]] | None = None,
|
||||||
|
delivery_runs: list[dict[str, Any]] | None = None,
|
||||||
|
regression_runs: list[dict[str, Any]] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
manifest = parse_document(board_path)
|
||||||
|
if not isinstance(manifest.get("taskStore"), dict):
|
||||||
|
raise TaskStoreError("任务板没有启用 taskStore;先运行 migrate_tasks.py --apply")
|
||||||
|
if TASK_ID_RE.fullmatch(task_id) is None:
|
||||||
|
raise TaskStoreError("taskId 格式无效")
|
||||||
|
if task.get("id") != task_id:
|
||||||
|
raise TaskStoreError("输入 task.id 必须与 --task-id 相同")
|
||||||
|
if not isinstance(kind, str) or not kind.strip():
|
||||||
|
raise TaskStoreError("kind 必须是非空字符串")
|
||||||
|
chosen_id = record_id or _auto_record_id(task_id)
|
||||||
|
if RECORD_ID_RE.fullmatch(chosen_id) is None:
|
||||||
|
raise TaskStoreError("recordId 格式无效")
|
||||||
|
if any(not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None for parent in parents):
|
||||||
|
raise TaskStoreError("parents 必须是 recordId 字符串列表")
|
||||||
|
|
||||||
|
# Materialize first so malformed existing records or unresolved heads stop
|
||||||
|
# the next write instead of compounding an already ambiguous store.
|
||||||
|
materialized = materialize_board(manifest, board_path)
|
||||||
|
existing_errors = materialized.get("taskStoreErrors")
|
||||||
|
if isinstance(existing_errors, list) and existing_errors:
|
||||||
|
raise TaskStoreError("现有 taskStore 已有冲突,先解决: " + "; ".join(map(str, existing_errors)))
|
||||||
|
|
||||||
|
directory = board_path.parent / str(manifest["taskStore"].get("directory", "task-records"))
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
output = directory / f"{chosen_id}.yaml"
|
||||||
|
record: dict[str, Any] = {
|
||||||
|
"recordVersion": STORE_VERSION,
|
||||||
|
"recordId": chosen_id,
|
||||||
|
"taskId": task_id,
|
||||||
|
"parents": parents,
|
||||||
|
"kind": kind,
|
||||||
|
"task": task,
|
||||||
|
}
|
||||||
|
for field, value in (
|
||||||
|
("workerReceipts", worker_receipts),
|
||||||
|
("deliveryRuns", delivery_runs),
|
||||||
|
("regressionRuns", regression_runs),
|
||||||
|
):
|
||||||
|
if value:
|
||||||
|
record[field] = value
|
||||||
|
content = json.dumps(record, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
descriptor: int | None = None
|
||||||
|
try:
|
||||||
|
descriptor = os.open(output, flags, 0o644)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||||
|
descriptor = None
|
||||||
|
handle.write(content)
|
||||||
|
except FileExistsError as exc:
|
||||||
|
raise TaskStoreError(f"记录已存在,拒绝覆盖: {output}") from exc
|
||||||
|
finally:
|
||||||
|
if descriptor is not None:
|
||||||
|
os.close(descriptor)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
|
||||||
|
parser.add_argument("--task-id", required=True)
|
||||||
|
parser.add_argument("--task-json", type=Path, required=True, help="完整 task 快照 JSON")
|
||||||
|
parser.add_argument("--record-id")
|
||||||
|
parser.add_argument("--parent", action="append", default=[])
|
||||||
|
parser.add_argument("--kind", default="task.snapshot")
|
||||||
|
parser.add_argument("--worker-receipts-json", type=Path)
|
||||||
|
parser.add_argument("--delivery-runs-json", type=Path)
|
||||||
|
parser.add_argument("--regression-runs-json", type=Path)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
task = _read_json_mapping(args.task_json, "task")
|
||||||
|
output = append_record(
|
||||||
|
Path(args.tasks), task, task_id=args.task_id,
|
||||||
|
record_id=args.record_id, parents=args.parent, kind=args.kind,
|
||||||
|
worker_receipts=(
|
||||||
|
_read_json_list(args.worker_receipts_json, "worker receipt")
|
||||||
|
if args.worker_receipts_json else None
|
||||||
|
),
|
||||||
|
delivery_runs=(
|
||||||
|
_read_json_list(args.delivery_runs_json, "delivery run")
|
||||||
|
if args.delivery_runs_json else None
|
||||||
|
),
|
||||||
|
regression_runs=(
|
||||||
|
_read_json_list(args.regression_runs_json, "regression run")
|
||||||
|
if args.regression_runs_json else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (OSError, TaskStoreError) as exc:
|
||||||
|
print(f"追加任务记录失败: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -57,7 +57,7 @@ def _validate_knowledge_location(
|
|||||||
knowledge_path: Path,
|
knowledge_path: Path,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
expected = project_root / "docs" / "ack" / "knowledge.yaml"
|
expected = project_root / ".pouch" / "ack" / "knowledge.yaml"
|
||||||
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
|
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
|
||||||
if lexical != expected:
|
if lexical != expected:
|
||||||
return (
|
return (
|
||||||
@@ -171,7 +171,7 @@ def load_authoritative_knowledge(
|
|||||||
) -> tuple[dict[str, Any] | None, str | None]:
|
) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
source_fd, open_error = _open_regular_beneath(
|
source_fd, open_error = _open_regular_beneath(
|
||||||
project_root,
|
project_root,
|
||||||
"docs/ack/knowledge.yaml",
|
".pouch/ack/knowledge.yaml",
|
||||||
require_executable=False,
|
require_executable=False,
|
||||||
)
|
)
|
||||||
if open_error is not None or source_fd is None:
|
if open_error is not None or source_fd is None:
|
||||||
@@ -329,7 +329,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
|
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--project-root",
|
"--project-root",
|
||||||
help="项目根目录;默认从 knowledge.yaml 的 docs/ack 布局或 Git 推断",
|
help="项目根目录;默认从 knowledge.yaml 的 .pouch/ack 布局或 Git 推断",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""选择 ACK 回归目录中的 active 用例。
|
||||||
|
|
||||||
|
默认返回 smoke 套件。本脚本只输出数据,不执行 steps 或 automationRef。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 select_regression.py .pouch/ack/regression.yaml
|
||||||
|
python3 select_regression.py .pouch/ack/regression.yaml --suite full
|
||||||
|
python3 select_regression.py .pouch/ack/regression.yaml --case-id REG-login-001
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from validate_regression import ID_RE, load_yaml, validate_builtin
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 50
|
||||||
|
MAX_LIMIT = 200
|
||||||
|
|
||||||
|
|
||||||
|
def select_cases(
|
||||||
|
data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
suite: str,
|
||||||
|
case_ids: list[str],
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cases = data.get("cases")
|
||||||
|
if not isinstance(cases, list):
|
||||||
|
raise ValueError("cases 必须是列表")
|
||||||
|
|
||||||
|
wanted = set(case_ids)
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
for case in cases:
|
||||||
|
if not isinstance(case, dict):
|
||||||
|
continue
|
||||||
|
if case.get("status") != "active":
|
||||||
|
continue
|
||||||
|
case_id = case.get("id")
|
||||||
|
if wanted:
|
||||||
|
if case_id not in wanted:
|
||||||
|
continue
|
||||||
|
elif suite == "smoke" and case.get("suite") != "smoke":
|
||||||
|
continue
|
||||||
|
selected.append(case)
|
||||||
|
|
||||||
|
missing = wanted - {
|
||||||
|
case.get("id") for case in selected if isinstance(case.get("id"), str)
|
||||||
|
}
|
||||||
|
if missing:
|
||||||
|
raise ValueError("找不到 active 用例: " + ", ".join(sorted(missing)))
|
||||||
|
if len(selected) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"命中 {len(selected)} 条,超过 --limit {limit};"
|
||||||
|
"请用 --suite / --case-id 缩小范围"
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="选择 ACK 回归用例")
|
||||||
|
parser.add_argument(
|
||||||
|
"regression", nargs="?", default=".pouch/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--suite",
|
||||||
|
choices=("smoke", "full"),
|
||||||
|
default="smoke",
|
||||||
|
help="smoke 只返回 suite=smoke;full 返回全部 active 用例",
|
||||||
|
)
|
||||||
|
parser.add_argument("--case-id", action="append", default=[], dest="case_ids")
|
||||||
|
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||||
|
parser.add_argument(
|
||||||
|
"--format", choices=("json", "ids"), default="json", dest="output_format"
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
regression_path = Path(args.regression)
|
||||||
|
if not regression_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
|
||||||
|
return 2
|
||||||
|
if not 1 <= args.limit <= MAX_LIMIT:
|
||||||
|
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
|
||||||
|
return 2
|
||||||
|
for case_id in args.case_ids:
|
||||||
|
if ID_RE.fullmatch(case_id) is None:
|
||||||
|
sys.stderr.write(f"--case-id 必须使用 REG-<id> 格式: {case_id}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data = load_yaml(regression_path, "回归目录")
|
||||||
|
errors = validate_builtin(data)
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write(f"回归目录无效,拒绝选择,共 {len(errors)} 项:\n")
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f" - {error}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = select_cases(
|
||||||
|
data,
|
||||||
|
suite=args.suite,
|
||||||
|
case_ids=args.case_ids,
|
||||||
|
limit=args.limit,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
sys.stderr.write(f"回归选择失败: {exc}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ids = [case.get("id") for case in selected]
|
||||||
|
if args.output_format == "ids":
|
||||||
|
if ids:
|
||||||
|
sys.stdout.write("\n".join(str(item) for item in ids) + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"count": len(selected),
|
||||||
|
"limit": args.limit,
|
||||||
|
"suite": args.suite,
|
||||||
|
"ids": ids,
|
||||||
|
"cases": selected,
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -197,7 +197,7 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"tasks",
|
"tasks",
|
||||||
nargs="?",
|
nargs="?",
|
||||||
default="docs/ack/tasks.yaml",
|
default=".pouch/ack/tasks.yaml",
|
||||||
help="任务板路径",
|
help="任务板路径",
|
||||||
)
|
)
|
||||||
parser.add_argument("--task-id", action="append", default=[])
|
parser.add_argument("--task-id", action="append", default=[])
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Merge-friendly ACK task records.
|
||||||
|
|
||||||
|
``tasks.yaml`` remains the small project manifest. When it contains a
|
||||||
|
``taskStore`` mapping, task state is materialized from immutable record files
|
||||||
|
under the configured directory. Legacy boards without that mapping keep
|
||||||
|
their original single-file behavior.
|
||||||
|
|
||||||
|
Records are complete task snapshots rather than patches. A later record
|
||||||
|
names its parent record(s), so a concurrent edit produces multiple heads and
|
||||||
|
is reported as a semantic merge conflict instead of being silently resolved
|
||||||
|
by file order or timestamps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from yaml_subset import (
|
||||||
|
DuplicateKeyError,
|
||||||
|
YamlSubsetError,
|
||||||
|
load_json_unique,
|
||||||
|
load_yaml_subset,
|
||||||
|
make_unique_pyyaml_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
STORE_VERSION = 1
|
||||||
|
GLOBAL_TASK_ID = "__project__"
|
||||||
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||||
|
RECORD_SUFFIXES = {".yaml", ".yml", ".json"}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStoreError(ValueError):
|
||||||
|
"""A malformed task store or record."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_document(path: Path) -> dict[str, Any]:
|
||||||
|
"""Parse one YAML/JSON mapping without importing the validator."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise TaskStoreError(f"读取失败: {path}: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
value = load_json_unique(content)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
value = load_yaml_subset(content)
|
||||||
|
else:
|
||||||
|
value = yaml.load(
|
||||||
|
content,
|
||||||
|
Loader=make_unique_pyyaml_loader(yaml),
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
|
||||||
|
raise TaskStoreError(f"解析失败: {path}: {exc}") from exc
|
||||||
|
except Exception as exc: # PyYAML errors are intentionally bounded.
|
||||||
|
raise TaskStoreError(f"解析失败: {path}") from exc
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TaskStoreError(f"{path}: 顶层必须是对象(mapping)")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _record_directory(manifest: dict[str, Any], manifest_path: Path) -> Path:
|
||||||
|
config = manifest.get("taskStore")
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise TaskStoreError("taskStore 必须是对象")
|
||||||
|
version = config.get("version", STORE_VERSION)
|
||||||
|
if version != STORE_VERSION:
|
||||||
|
raise TaskStoreError(f"不支持 taskStore.version={version!r}")
|
||||||
|
directory = config.get("directory", "task-records")
|
||||||
|
if not isinstance(directory, str) or not directory.strip():
|
||||||
|
raise TaskStoreError("taskStore.directory 必须是非空相对路径")
|
||||||
|
relative = Path(directory)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts:
|
||||||
|
raise TaskStoreError("taskStore.directory 必须位于任务板目录下")
|
||||||
|
return manifest_path.parent / relative
|
||||||
|
|
||||||
|
|
||||||
|
def _record_paths(directory: Path) -> list[Path]:
|
||||||
|
if not directory.exists():
|
||||||
|
return []
|
||||||
|
if directory.is_symlink() or not directory.is_dir():
|
||||||
|
raise TaskStoreError(f"taskStore.directory 不是普通目录: {directory}")
|
||||||
|
paths: list[Path] = []
|
||||||
|
for path in sorted(directory.iterdir(), key=lambda item: item.name):
|
||||||
|
if path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise TaskStoreError(f"任务记录必须是普通文件: {path}")
|
||||||
|
if path.suffix.lower() in RECORD_SUFFIXES:
|
||||||
|
paths.append(path)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_collection(
|
||||||
|
records: list[dict[str, Any]],
|
||||||
|
field: str,
|
||||||
|
errors: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
by_id: dict[str, dict[str, Any]] = {}
|
||||||
|
for record in records:
|
||||||
|
values = record.get(field)
|
||||||
|
if values is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(values, list):
|
||||||
|
errors.append(f"{field} 必须是列表")
|
||||||
|
continue
|
||||||
|
for index, item in enumerate(values):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors.append(f"{field}[{index}] 必须是对象")
|
||||||
|
continue
|
||||||
|
item_id = item.get("id")
|
||||||
|
if not isinstance(item_id, str) or not item_id:
|
||||||
|
errors.append(f"{field}[{index}].id 必须是非空字符串")
|
||||||
|
continue
|
||||||
|
previous = by_id.get(item_id)
|
||||||
|
if previous is not None and previous != item:
|
||||||
|
errors.append(f"{field} 出现相同 id 的不同记录: {item_id}")
|
||||||
|
continue
|
||||||
|
if previous is None:
|
||||||
|
by_id[item_id] = item
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_board(manifest: dict[str, Any], manifest_path: Path) -> dict[str, Any]:
|
||||||
|
"""Return a board-shaped view from a manifest and immutable records."""
|
||||||
|
|
||||||
|
directory = _record_directory(manifest, manifest_path)
|
||||||
|
paths = _record_paths(directory)
|
||||||
|
# This permits a staged rollout: a freshly initialized board has an empty
|
||||||
|
# store and the legacy empty ``tasks: []`` field until its first record.
|
||||||
|
if not paths:
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
by_record_id: dict[str, dict[str, Any]] = {}
|
||||||
|
for path in paths:
|
||||||
|
record = parse_document(path)
|
||||||
|
where = str(path.relative_to(manifest_path.parent))
|
||||||
|
version = record.get("recordVersion", STORE_VERSION)
|
||||||
|
record_id = record.get("recordId")
|
||||||
|
task_id = record.get("taskId")
|
||||||
|
parents = record.get("parents", [])
|
||||||
|
if version != STORE_VERSION:
|
||||||
|
errors.append(f"{where}: 不支持 recordVersion={version!r}")
|
||||||
|
if not isinstance(record_id, str) or RECORD_ID_RE.fullmatch(record_id) is None:
|
||||||
|
errors.append(f"{where}: recordId 格式无效")
|
||||||
|
elif record_id in by_record_id:
|
||||||
|
errors.append(f"{where}: recordId 重复: {record_id}")
|
||||||
|
if not isinstance(task_id, str) or (
|
||||||
|
task_id != GLOBAL_TASK_ID and TASK_ID_RE.fullmatch(task_id) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: taskId 格式无效")
|
||||||
|
if not isinstance(parents, list) or any(
|
||||||
|
not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None
|
||||||
|
for parent in parents
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: parents 必须是 recordId 字符串列表")
|
||||||
|
task = record.get("task")
|
||||||
|
if task_id != GLOBAL_TASK_ID and not isinstance(task, dict):
|
||||||
|
errors.append(f"{where}: 非全局记录必须包含 task 对象")
|
||||||
|
if isinstance(record_id, str) and record_id not in by_record_id:
|
||||||
|
by_record_id[record_id] = record
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
record_id = record.get("recordId")
|
||||||
|
task_id = record.get("taskId")
|
||||||
|
if not isinstance(record_id, str) or not isinstance(task_id, str):
|
||||||
|
continue
|
||||||
|
for parent in record.get("parents", []):
|
||||||
|
if parent not in by_record_id:
|
||||||
|
errors.append(f"{record_id}: 引用了不存在的 parent: {parent}")
|
||||||
|
elif by_record_id[parent].get("taskId") != task_id:
|
||||||
|
errors.append(f"{record_id}: parent 必须属于同一个 taskId")
|
||||||
|
|
||||||
|
task_records: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for record in records:
|
||||||
|
task_id = record.get("taskId")
|
||||||
|
if isinstance(task_id, str) and task_id != GLOBAL_TASK_ID:
|
||||||
|
task_records.setdefault(task_id, []).append(record)
|
||||||
|
|
||||||
|
tasks: list[dict[str, Any]] = []
|
||||||
|
for task_id in sorted(task_records):
|
||||||
|
items = task_records[task_id]
|
||||||
|
referenced = {
|
||||||
|
parent
|
||||||
|
for item in items
|
||||||
|
for parent in item.get("parents", [])
|
||||||
|
if isinstance(parent, str)
|
||||||
|
}
|
||||||
|
heads = [
|
||||||
|
item for item in items
|
||||||
|
if isinstance(item.get("recordId"), str)
|
||||||
|
and item["recordId"] not in referenced
|
||||||
|
]
|
||||||
|
if len(heads) != 1:
|
||||||
|
errors.append(
|
||||||
|
f"task {task_id}: 发现 {len(heads)} 个未合并 head,必须创建同时引用全部 head 的合并记录"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
task = heads[0].get("task")
|
||||||
|
if isinstance(task, dict):
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
result = dict(manifest)
|
||||||
|
result["tasks"] = tasks
|
||||||
|
result["workerReceipts"] = _unique_collection(records, "workerReceipts", errors)
|
||||||
|
for field in ("deliveryRuns", "regressionRuns"):
|
||||||
|
if field in manifest or any(field in record for record in records):
|
||||||
|
result[field] = _unique_collection(records, field, errors)
|
||||||
|
result["summary"] = {
|
||||||
|
"open": [task["id"] for task in tasks if task.get("status") == "open"],
|
||||||
|
"verified": [task["id"] for task in tasks if task.get("status") == "verified"],
|
||||||
|
"failedRetest": [task["id"] for task in tasks if task.get("status") == "failed_retest"],
|
||||||
|
"leftovers": [task["id"] for task in tasks if task.get("status") == "leftover"],
|
||||||
|
}
|
||||||
|
if errors:
|
||||||
|
result["taskStoreErrors"] = errors
|
||||||
|
else:
|
||||||
|
result.pop("taskStoreErrors", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def is_task_store_manifest(value: object) -> bool:
|
||||||
|
return isinstance(value, dict) and "taskStore" in value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GLOBAL_TASK_ID",
|
||||||
|
"STORE_VERSION",
|
||||||
|
"TaskStoreError",
|
||||||
|
"is_task_store_manifest",
|
||||||
|
"materialize_board",
|
||||||
|
"parse_document",
|
||||||
|
]
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate an optional ACK .pouch/ack/agents.yaml routing override."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from validate_tasks import load_document, validate_with_schema
|
||||||
|
from worker_profiles import validate_orchestration
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 ACK agents.yaml 结构")
|
||||||
|
parser.add_argument("agents", nargs="?", default="agents.yaml", help="agents.yaml 路径")
|
||||||
|
parser.add_argument("--schema", help="agents.schema.json 路径(默认自动探测)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
agents_path = Path(args.agents)
|
||||||
|
if not agents_path.is_file() or agents_path.is_symlink():
|
||||||
|
sys.stderr.write(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data = load_document(agents_path)
|
||||||
|
schema_path = Path(args.schema) if args.schema else (
|
||||||
|
Path(__file__).resolve().parent.parent / "templates" / "agents.schema.json"
|
||||||
|
)
|
||||||
|
errors = validate_orchestration(data, where="agents.yaml")
|
||||||
|
mode = "内置语义规则"
|
||||||
|
try:
|
||||||
|
import jsonschema # type: ignore # noqa: F401
|
||||||
|
|
||||||
|
if schema_path.is_file():
|
||||||
|
errors = validate_with_schema(data, schema_path) + errors
|
||||||
|
mode = f"schema ({schema_path.name}) + 内置语义规则"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write(f"agents.yaml 校验失败({mode}),共 {len(errors)} 项:\n")
|
||||||
|
for error in dict.fromkeys(errors):
|
||||||
|
sys.stderr.write(f" - {error}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write(f"agents.yaml 校验通过({mode}):{agents_path}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
|
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python3 validate_delivery.py docs/ack/delivery.yaml
|
python3 validate_delivery.py .pouch/ack/delivery.yaml
|
||||||
python3 validate_delivery.py docs/ack/delivery.yaml \
|
python3 validate_delivery.py .pouch/ack/delivery.yaml \
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
|
||||||
|
|
||||||
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
||||||
"""
|
"""
|
||||||
@@ -22,15 +22,7 @@ import sys
|
|||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from yaml_subset import (
|
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||||
DuplicateKeyError,
|
|
||||||
YamlSubsetError,
|
|
||||||
load_json_unique,
|
|
||||||
load_yaml_subset,
|
|
||||||
make_unique_pyyaml_loader,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||||
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
|
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
|
||||||
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
|
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
|
||||||
@@ -102,9 +94,9 @@ CLASSIFICATIONS = {"development", "staging", "production"}
|
|||||||
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||||
INTENT_FIELDS = {"testEnvironment", "release"}
|
INTENT_FIELDS = {"testEnvironment", "release"}
|
||||||
INTENT_STOP_AT = {
|
INTENT_STOP_AT = {
|
||||||
"testEnvironment": "validation_ready",
|
|
||||||
"release": "released",
|
"release": "released",
|
||||||
}
|
}
|
||||||
|
TEST_ENVIRONMENT_FIELDS = {"via", "env"}
|
||||||
ACTIONS = {
|
ACTIONS = {
|
||||||
"verify",
|
"verify",
|
||||||
"pull-request",
|
"pull-request",
|
||||||
@@ -162,36 +154,12 @@ def _reject_unknown(
|
|||||||
|
|
||||||
def _load_document(path: Path, label: str) -> dict[str, Any]:
|
def _load_document(path: Path, label: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
content = path.read_text(encoding="utf-8")
|
data = parse_document(path)
|
||||||
except OSError as exc:
|
if is_task_store_manifest(data):
|
||||||
|
data = materialize_board(data, path)
|
||||||
|
except TaskStoreError as exc:
|
||||||
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
if path.suffix.lower() == ".json":
|
|
||||||
try:
|
|
||||||
data = load_json_unique(content)
|
|
||||||
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
|
||||||
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
import yaml # type: ignore
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
data = load_yaml_subset(content)
|
|
||||||
except YamlSubsetError as exc:
|
|
||||||
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
|
|
||||||
except yaml.YAMLError as exc: # type: ignore
|
|
||||||
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -681,10 +649,47 @@ def _validate_profiles(
|
|||||||
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
|
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_test_environment_intent(
|
||||||
|
value: Any,
|
||||||
|
errors: list[str],
|
||||||
|
project_root: Path | None,
|
||||||
|
) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
if isinstance(value, str):
|
||||||
|
errors.append(
|
||||||
|
"intents.testEnvironment: 已改为 deployer 绑定 "
|
||||||
|
"{via: deployer, env: <env>},不能再使用 profile ID "
|
||||||
|
f"{value!r}。请迁移后由 ACK 内部调用 deployer skill"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append("intents.testEnvironment: 必须是 null 或 {via, env} 对象")
|
||||||
|
return
|
||||||
|
_reject_unknown(value, TEST_ENVIRONMENT_FIELDS, "intents.testEnvironment", errors)
|
||||||
|
if value.get("via") != "deployer":
|
||||||
|
errors.append("intents.testEnvironment.via 必须是 'deployer'")
|
||||||
|
env = value.get("env")
|
||||||
|
if not isinstance(env, str) or ID_RE.fullmatch(env) is None:
|
||||||
|
errors.append("intents.testEnvironment.env 必须是小写连字符环境名")
|
||||||
|
return
|
||||||
|
if project_root is None:
|
||||||
|
return
|
||||||
|
env_dir = project_root / ".pouch" / "deployer" / env
|
||||||
|
if not env_dir.is_dir():
|
||||||
|
env_dir = project_root / ".skiff" / "deployer" / env
|
||||||
|
if not env_dir.is_dir():
|
||||||
|
errors.append(
|
||||||
|
"intents.testEnvironment.env: 找不到 "
|
||||||
|
f".pouch/deployer/{env};先按 deployer skill 配置项目测试环境"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_intents(
|
def _validate_intents(
|
||||||
values: Any,
|
values: Any,
|
||||||
profiles: dict[str, Any],
|
profiles: dict[str, Any],
|
||||||
errors: list[str],
|
errors: list[str],
|
||||||
|
project_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if values is None:
|
if values is None:
|
||||||
return
|
return
|
||||||
@@ -692,25 +697,29 @@ def _validate_intents(
|
|||||||
errors.append("intents: 必须是对象")
|
errors.append("intents: 必须是对象")
|
||||||
return
|
return
|
||||||
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
|
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
|
||||||
for field in sorted(INTENT_FIELDS):
|
if "testEnvironment" not in values:
|
||||||
if field not in values:
|
errors.append("intents.testEnvironment: 必填")
|
||||||
errors.append(f"intents.{field}: 必填")
|
else:
|
||||||
continue
|
_validate_test_environment_intent(
|
||||||
profile_id = values[field]
|
values.get("testEnvironment"), errors, project_root
|
||||||
if profile_id is None:
|
)
|
||||||
continue
|
if "release" not in values:
|
||||||
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
errors.append("intents.release: 必填")
|
||||||
errors.append(f"intents.{field}: 必须是 null 或小写连字符 profile ID")
|
return
|
||||||
continue
|
profile_id = values["release"]
|
||||||
profile = profiles.get(profile_id)
|
if profile_id is None:
|
||||||
if profile is None:
|
return
|
||||||
errors.append(f"intents.{field}: 未定义 profile {profile_id!r}")
|
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
||||||
continue
|
errors.append("intents.release: 必须是 null 或小写连字符 profile ID")
|
||||||
expected_stop = INTENT_STOP_AT[field]
|
return
|
||||||
if _mapping(profile) and profile.get("stopAt") != expected_stop:
|
profile = profiles.get(profile_id)
|
||||||
errors.append(
|
if profile is None:
|
||||||
f"intents.{field}: profile {profile_id!r} 必须 stopAt {expected_stop}"
|
errors.append(f"intents.release: 未定义 profile {profile_id!r}")
|
||||||
)
|
return
|
||||||
|
if _mapping(profile) and profile.get("stopAt") != INTENT_STOP_AT["release"]:
|
||||||
|
errors.append(
|
||||||
|
f"intents.release: profile {profile_id!r} 必须 stopAt released"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
|
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
|
||||||
@@ -758,7 +767,7 @@ def validate_builtin(data: dict[str, Any], project_root: Path | None = None) ->
|
|||||||
environments=environments,
|
environments=environments,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
_validate_intents(data.get("intents"), profiles, errors)
|
_validate_intents(data.get("intents"), profiles, errors, project_root)
|
||||||
|
|
||||||
if enabled:
|
if enabled:
|
||||||
if default_profile not in profiles:
|
if default_profile not in profiles:
|
||||||
@@ -777,8 +786,8 @@ def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list
|
|||||||
project = tasks.get("project")
|
project = tasks.get("project")
|
||||||
if not isinstance(project, dict):
|
if not isinstance(project, dict):
|
||||||
return ["tasks.project 必须是对象"]
|
return ["tasks.project 必须是对象"]
|
||||||
if project.get("deliveryFile") != "docs/ack/delivery.yaml":
|
if project.get("deliveryFile") != ".pouch/ack/delivery.yaml":
|
||||||
errors.append("tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml")
|
errors.append("tasks.project.deliveryFile 必须固定为 .pouch/ack/delivery.yaml")
|
||||||
delivery_project = delivery.get("project")
|
delivery_project = delivery.get("project")
|
||||||
if (
|
if (
|
||||||
isinstance(delivery_project, dict)
|
isinstance(delivery_project, dict)
|
||||||
@@ -806,8 +815,8 @@ def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
|
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
|
||||||
parser.add_argument("delivery", nargs="?", default="docs/ack/delivery.yaml")
|
parser.add_argument("delivery", nargs="?", default=".pouch/ack/delivery.yaml")
|
||||||
parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml")
|
parser.add_argument("--tasks", help="关联的 .pouch/ack/tasks.yaml")
|
||||||
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
|
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
|
||||||
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
|
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@@ -189,10 +189,10 @@ def _timestamp(value: Any) -> datetime | None:
|
|||||||
|
|
||||||
|
|
||||||
def infer_project_root(document_path: Path) -> Path | None:
|
def infer_project_root(document_path: Path) -> Path | None:
|
||||||
"""从标准 docs/ack 布局或 Git marker 推断项目根,不解析文档 symlink。"""
|
"""从标准 .pouch/ack 布局或 Git marker 推断项目根,不解析文档 symlink。"""
|
||||||
lexical = document_path.expanduser().absolute()
|
lexical = document_path.expanduser().absolute()
|
||||||
parent = lexical.parent
|
parent = lexical.parent
|
||||||
if parent.name == "ack" and parent.parent.name == "docs":
|
if parent.name == "ack" and parent.parent.name in {".pouch", "docs"}:
|
||||||
return parent.parent.parent.resolve()
|
return parent.parent.parent.resolve()
|
||||||
for candidate in (parent, *parent.parents):
|
for candidate in (parent, *parent.parents):
|
||||||
if (candidate / ".git").exists():
|
if (candidate / ".git").exists():
|
||||||
@@ -245,7 +245,7 @@ def _tasks_project_root(
|
|||||||
return inferred
|
return inferred
|
||||||
|
|
||||||
# Legacy task boards may still declare repoPath. It is only a fallback for
|
# Legacy task boards may still declare repoPath. It is only a fallback for
|
||||||
# non-standard layouts; docs/ack location is authoritative when available.
|
# non-standard layouts; .pouch/ack location is authoritative when available.
|
||||||
project = tasks_data.get("project")
|
project = tasks_data.get("project")
|
||||||
repo_path = project.get("repoPath") if isinstance(project, dict) else None
|
repo_path = project.get("repoPath") if isinstance(project, dict) else None
|
||||||
if _nonempty(repo_path):
|
if _nonempty(repo_path):
|
||||||
@@ -779,10 +779,10 @@ def _validate_knowledge_file_binding(
|
|||||||
knowledge_file = project.get("knowledgeFile")
|
knowledge_file = project.get("knowledgeFile")
|
||||||
if not _nonempty(knowledge_file):
|
if not _nonempty(knowledge_file):
|
||||||
return ["[tasks] project.knowledgeFile 必填"]
|
return ["[tasks] project.knowledgeFile 必填"]
|
||||||
if knowledge_file != "docs/ack/knowledge.yaml":
|
if knowledge_file != ".pouch/ack/knowledge.yaml":
|
||||||
errors.append(
|
errors.append(
|
||||||
"[tasks] project.knowledgeFile 必须固定为 "
|
"[tasks] project.knowledgeFile 必须固定为 "
|
||||||
"'docs/ack/knowledge.yaml'"
|
"'.pouch/ack/knowledge.yaml'"
|
||||||
)
|
)
|
||||||
relative = Path(knowledge_file)
|
relative = Path(knowledge_file)
|
||||||
segments = knowledge_file.replace("\\", "/").split("/")
|
segments = knowledge_file.replace("\\", "/").split("/")
|
||||||
@@ -812,7 +812,7 @@ def _validate_knowledge_file_binding(
|
|||||||
binding_root = project_root or declared_root
|
binding_root = project_root or declared_root
|
||||||
if binding_root is None or not binding_root.is_dir():
|
if binding_root is None or not binding_root.is_dir():
|
||||||
errors.append(
|
errors.append(
|
||||||
"[tasks] 无法从 docs/ack 布局确定现有项目根目录;"
|
"[tasks] 无法从 .pouch/ack 布局确定现有项目根目录;"
|
||||||
"请传入 --project-root"
|
"请传入 --project-root"
|
||||||
)
|
)
|
||||||
return errors
|
return errors
|
||||||
@@ -1071,7 +1071,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验")
|
parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--project-root",
|
"--project-root",
|
||||||
help="可选项目根目录;默认从 tasks.yaml 的 docs/ack 布局推断",
|
help="可选项目根目录;默认从 tasks.yaml 的 .pouch/ack 布局推断",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""校验 ACK 项目回归目录及其任务引用。
|
||||||
|
|
||||||
|
权威结构位于 templates/regression.schema.json。jsonschema 是可选依赖;内置规则
|
||||||
|
始终检查用例 ID、验收信号和跨文件引用。本脚本只解析数据,不执行 steps。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 validate_regression.py .pouch/ack/regression.yaml
|
||||||
|
python3 validate_regression.py .pouch/ack/regression.yaml \
|
||||||
|
--tasks .pouch/ack/tasks.yaml
|
||||||
|
|
||||||
|
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||||
|
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||||
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||||
|
STATUSES = {"active", "retired"}
|
||||||
|
SUITES = {"smoke", "full"}
|
||||||
|
SURFACES = {"browser", "api"}
|
||||||
|
SOURCE_KINDS = {"feature", "bug"}
|
||||||
|
EXPECTED_KINDS = {"visible-text", "api-status", "api-field", "url", "interaction"}
|
||||||
|
CASE_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"source",
|
||||||
|
"suite",
|
||||||
|
"surface",
|
||||||
|
"setup",
|
||||||
|
"steps",
|
||||||
|
"expected",
|
||||||
|
"automationRef",
|
||||||
|
}
|
||||||
|
SOURCE_FIELDS = {"taskId", "kind"}
|
||||||
|
EXPECTED_FIELDS = {"kind", "value"}
|
||||||
|
TOP_LEVEL_FIELDS = {"version", "updatedAt", "project", "cases"}
|
||||||
|
|
||||||
|
|
||||||
|
def _nonempty(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> bool:
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unknown(
|
||||||
|
value: dict[str, Any],
|
||||||
|
allowed: set[str],
|
||||||
|
where: str,
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for field in sorted(set(value) - allowed):
|
||||||
|
errors.append(f"{where}: 未知字段 {field!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = parse_document(path)
|
||||||
|
if is_task_store_manifest(data):
|
||||||
|
data = materialize_board(data, path)
|
||||||
|
except TaskStoreError as exc:
|
||||||
|
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def infer_project_root(document_path: Path) -> Path | None:
|
||||||
|
lexical = document_path.expanduser().absolute()
|
||||||
|
parent = lexical.parent
|
||||||
|
if parent.name == "ack" and parent.parent.name == "docs":
|
||||||
|
return parent.parent.parent.resolve()
|
||||||
|
for candidate in (parent, *parent.parents):
|
||||||
|
if (candidate / ".git").exists():
|
||||||
|
return candidate.resolve()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_expected(value: Any, where: str, errors: list[str]) -> None:
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
errors.append(f"{where}: 必须是非空列表")
|
||||||
|
return
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
item_where = f"{where}[{index}]"
|
||||||
|
if not _mapping(item):
|
||||||
|
errors.append(f"{item_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(item, EXPECTED_FIELDS, item_where, errors)
|
||||||
|
if item.get("kind") not in EXPECTED_KINDS:
|
||||||
|
errors.append(f"{item_where}.kind: 必须是 {sorted(EXPECTED_KINDS)}")
|
||||||
|
if not _nonempty(item.get("value")):
|
||||||
|
errors.append(f"{item_where}.value: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_builtin(data: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
|
||||||
|
if data.get("version") != 1 or isinstance(data.get("version"), bool):
|
||||||
|
errors.append("version 必须是整数 1")
|
||||||
|
if not _nonempty(data.get("updatedAt")):
|
||||||
|
errors.append("updatedAt 必须是非空字符串")
|
||||||
|
|
||||||
|
project = data.get("project")
|
||||||
|
if not _mapping(project):
|
||||||
|
errors.append("project 必须是对象")
|
||||||
|
else:
|
||||||
|
_reject_unknown(project, {"name"}, "project", errors)
|
||||||
|
if not _nonempty(project.get("name")):
|
||||||
|
errors.append("project.name 必须是非空字符串")
|
||||||
|
|
||||||
|
cases = data.get("cases")
|
||||||
|
if not isinstance(cases, list):
|
||||||
|
errors.append("cases 必须是列表")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, case in enumerate(cases):
|
||||||
|
where = f"cases[{index}]"
|
||||||
|
if not _mapping(case):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(case, CASE_FIELDS, where, errors)
|
||||||
|
missing = sorted(CASE_FIELDS - {"automationRef"} - set(case))
|
||||||
|
for field in missing:
|
||||||
|
errors.append(f"{where}.{field}: 必填")
|
||||||
|
|
||||||
|
case_id = case.get("id")
|
||||||
|
if not isinstance(case_id, str) or ID_RE.fullmatch(case_id) is None:
|
||||||
|
errors.append(f"{where}.id: 必须使用 REG-<id> 格式")
|
||||||
|
elif case_id in seen:
|
||||||
|
errors.append(f"{where}.id: 不能重复 {case_id!r}")
|
||||||
|
else:
|
||||||
|
seen.add(case_id)
|
||||||
|
|
||||||
|
if not _nonempty(case.get("title")):
|
||||||
|
errors.append(f"{where}.title: 必须是非空字符串")
|
||||||
|
if case.get("status") not in STATUSES:
|
||||||
|
errors.append(f"{where}.status: 必须是 {sorted(STATUSES)}")
|
||||||
|
if case.get("suite") not in SUITES:
|
||||||
|
errors.append(f"{where}.suite: 必须是 {sorted(SUITES)}")
|
||||||
|
if case.get("surface") not in SURFACES:
|
||||||
|
errors.append(f"{where}.surface: 必须是 {sorted(SURFACES)}")
|
||||||
|
if not _nonempty(case.get("setup")):
|
||||||
|
errors.append(f"{where}.setup: 必须是非空字符串")
|
||||||
|
|
||||||
|
source = case.get("source")
|
||||||
|
if not _mapping(source):
|
||||||
|
errors.append(f"{where}.source: 必须是对象")
|
||||||
|
else:
|
||||||
|
_reject_unknown(source, SOURCE_FIELDS, f"{where}.source", errors)
|
||||||
|
task_id = source.get("taskId")
|
||||||
|
if not isinstance(task_id, str) or TASK_ID_RE.fullmatch(task_id) is None:
|
||||||
|
errors.append(f"{where}.source.taskId: 必须是任务 ID")
|
||||||
|
if source.get("kind") not in SOURCE_KINDS:
|
||||||
|
errors.append(f"{where}.source.kind: 必须是 {sorted(SOURCE_KINDS)}")
|
||||||
|
|
||||||
|
steps = case.get("steps")
|
||||||
|
if (
|
||||||
|
not isinstance(steps, list)
|
||||||
|
or not steps
|
||||||
|
or any(not _nonempty(step) for step in steps)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.steps: 必须是非空字符串列表")
|
||||||
|
|
||||||
|
_validate_expected(case.get("expected"), f"{where}.expected", errors)
|
||||||
|
|
||||||
|
automation_ref = case.get("automationRef")
|
||||||
|
if automation_ref is not None:
|
||||||
|
if (
|
||||||
|
not isinstance(automation_ref, str)
|
||||||
|
or automation_ref.startswith("/")
|
||||||
|
or "\\" in automation_ref
|
||||||
|
or ".." in automation_ref.split("/")
|
||||||
|
or RELATIVE_PATH_RE.fullmatch(automation_ref) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.automationRef: 必须是项目内相对路径")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
|
||||||
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
errors = []
|
||||||
|
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
|
||||||
|
location = "/".join(str(part) for part in error.path) or "<root>"
|
||||||
|
errors.append(f"[schema] {location}: {error.message}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tasks_link(
|
||||||
|
regression: dict[str, Any],
|
||||||
|
tasks: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
project = tasks.get("project")
|
||||||
|
if not isinstance(project, dict):
|
||||||
|
return ["[tasks] project 必须是对象"]
|
||||||
|
regression_file = project.get("regressionFile")
|
||||||
|
if regression_file != ".pouch/ack/regression.yaml":
|
||||||
|
errors.append(
|
||||||
|
"[tasks] project.regressionFile 必须固定为 .pouch/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
if not isinstance(tasks.get("regressionRuns"), list):
|
||||||
|
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
|
||||||
|
|
||||||
|
regression_project = regression.get("project")
|
||||||
|
if (
|
||||||
|
isinstance(regression_project, dict)
|
||||||
|
and _nonempty(regression_project.get("name"))
|
||||||
|
and _nonempty(project.get("name"))
|
||||||
|
and regression_project["name"] != project["name"]
|
||||||
|
):
|
||||||
|
errors.append("regression.project.name 必须与 tasks.project.name 一致")
|
||||||
|
|
||||||
|
case_ids = {
|
||||||
|
case.get("id")
|
||||||
|
for case in regression.get("cases") or []
|
||||||
|
if isinstance(case, dict) and isinstance(case.get("id"), str)
|
||||||
|
}
|
||||||
|
task_ids = {
|
||||||
|
task.get("id")
|
||||||
|
for task in tasks.get("tasks") or []
|
||||||
|
if isinstance(task, dict) and isinstance(task.get("id"), str)
|
||||||
|
}
|
||||||
|
for index, task in enumerate(tasks.get("tasks") or []):
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
continue
|
||||||
|
where = f"[tasks] tasks[{index}]"
|
||||||
|
refs = task.get("regressionRefs")
|
||||||
|
if refs is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(refs, list):
|
||||||
|
errors.append(f"{where}.regressionRefs: 必须是列表")
|
||||||
|
continue
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ref_index, ref in enumerate(refs):
|
||||||
|
ref_where = f"{where}.regressionRefs[{ref_index}]"
|
||||||
|
if not isinstance(ref, str) or ID_RE.fullmatch(ref) is None:
|
||||||
|
errors.append(f"{ref_where}: 必须使用 REG-<id> 格式")
|
||||||
|
continue
|
||||||
|
if ref in seen:
|
||||||
|
errors.append(f"{ref_where}: 不能重复 {ref!r}")
|
||||||
|
seen.add(ref)
|
||||||
|
if ref not in case_ids:
|
||||||
|
errors.append(f"{ref_where}: 未知用例 {ref!r}")
|
||||||
|
|
||||||
|
for index, run in enumerate(tasks.get("regressionRuns") or []):
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
continue
|
||||||
|
where = f"[tasks] regressionRuns[{index}]"
|
||||||
|
for case_id in run.get("caseIds") or []:
|
||||||
|
if isinstance(case_id, str) and case_id not in case_ids:
|
||||||
|
errors.append(f"{where}.caseIds: 未知用例 {case_id!r}")
|
||||||
|
source_task_ids = run.get("taskIds") or []
|
||||||
|
if isinstance(source_task_ids, list):
|
||||||
|
for task_id in source_task_ids:
|
||||||
|
if isinstance(task_id, str) and task_id not in task_ids:
|
||||||
|
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_all(
|
||||||
|
data: dict[str, Any],
|
||||||
|
schema_path: Path,
|
||||||
|
*,
|
||||||
|
use_schema: bool | None = None,
|
||||||
|
) -> tuple[list[str], str]:
|
||||||
|
if use_schema is None:
|
||||||
|
try:
|
||||||
|
import jsonschema # type: ignore # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
use_schema = False
|
||||||
|
else:
|
||||||
|
use_schema = schema_path.is_file()
|
||||||
|
|
||||||
|
errors = validate_builtin(data)
|
||||||
|
if use_schema and schema_path.is_file():
|
||||||
|
errors = validate_with_schema(data, schema_path) + errors
|
||||||
|
mode = f"schema ({schema_path.name}) + 内置结构"
|
||||||
|
else:
|
||||||
|
mode = "内置结构"
|
||||||
|
return list(dict.fromkeys(errors)), mode
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 ACK 项目回归目录")
|
||||||
|
parser.add_argument(
|
||||||
|
"regression", nargs="?", default=".pouch/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
parser.add_argument("--tasks", help="关联的 .pouch/ack/tasks.yaml")
|
||||||
|
parser.add_argument("--schema", help="regression.schema.json 路径(默认自动探测)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
regression_path = Path(args.regression)
|
||||||
|
if not regression_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
schema_path = (
|
||||||
|
Path(args.schema)
|
||||||
|
if args.schema
|
||||||
|
else Path(__file__).resolve().parent.parent
|
||||||
|
/ "templates"
|
||||||
|
/ "regression.schema.json"
|
||||||
|
)
|
||||||
|
if args.schema and not schema_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到 schema: {schema_path}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data = load_yaml(regression_path, "回归目录")
|
||||||
|
errors, mode = validate_all(data, schema_path)
|
||||||
|
|
||||||
|
if args.tasks:
|
||||||
|
tasks_path = Path(args.tasks)
|
||||||
|
if not tasks_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
|
||||||
|
return 2
|
||||||
|
tasks = load_yaml(tasks_path, "任务板")
|
||||||
|
errors.extend(validate_tasks_link(data, tasks))
|
||||||
|
mode += " + tasks 引用"
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write(f"回归目录校验失败({mode}),共 {len(errors)} 项:\n")
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f"- {error}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write(f"回归目录校验通过({mode})\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -25,13 +25,8 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from approval_payload import approval_payload_hash
|
from approval_payload import approval_payload_hash
|
||||||
from yaml_subset import (
|
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||||
DuplicateKeyError,
|
from worker_profiles import validate_orchestration
|
||||||
YamlSubsetError,
|
|
||||||
load_json_unique,
|
|
||||||
load_yaml_subset,
|
|
||||||
make_unique_pyyaml_loader,
|
|
||||||
)
|
|
||||||
from worker_profiles import validate_routing_document
|
from worker_profiles import validate_routing_document
|
||||||
|
|
||||||
STATUS_ENUM = {
|
STATUS_ENUM = {
|
||||||
@@ -51,6 +46,8 @@ KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
|||||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||||
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
REGRESSION_RUN_ID_RE = re.compile(r"^RR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
REGRESSION_CASE_ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||||
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||||
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
||||||
SEMVER_RE = re.compile(
|
SEMVER_RE = re.compile(
|
||||||
@@ -117,6 +114,51 @@ DELIVERY_STATUSES = {
|
|||||||
}
|
}
|
||||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||||
|
REGRESSION_RUN_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"suite",
|
||||||
|
"caseIds",
|
||||||
|
"status",
|
||||||
|
"sourceRevision",
|
||||||
|
"configRevision",
|
||||||
|
"baseUrl",
|
||||||
|
"results",
|
||||||
|
"evidence",
|
||||||
|
"updatedAt",
|
||||||
|
}
|
||||||
|
REGRESSION_RUN_OPTIONAL_FIELDS = {"deliveryRunId", "taskIds"}
|
||||||
|
REGRESSION_RUN_SUITES = {"smoke", "full", "custom"}
|
||||||
|
REGRESSION_RUN_STATUSES = {
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"passed",
|
||||||
|
"failed",
|
||||||
|
"blocked",
|
||||||
|
"skipped",
|
||||||
|
}
|
||||||
|
REGRESSION_RESULT_FIELDS = {"caseId", "result", "evidence"}
|
||||||
|
REGRESSION_RESULTS = {"pass", "fail", "skipped"}
|
||||||
|
REGRESSION_CANDIDATE_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"surface",
|
||||||
|
"suite",
|
||||||
|
"setup",
|
||||||
|
"steps",
|
||||||
|
"expected",
|
||||||
|
"sourceKind",
|
||||||
|
}
|
||||||
|
REGRESSION_CANDIDATE_REQUIRED_FIELDS = {"title", "surface", "steps", "expected"}
|
||||||
|
REGRESSION_SURFACES = {"browser", "api"}
|
||||||
|
REGRESSION_SUITES = {"smoke", "full"}
|
||||||
|
REGRESSION_SOURCE_KINDS = {"feature", "bug"}
|
||||||
|
REGRESSION_EXPECTED_KINDS = {
|
||||||
|
"visible-text",
|
||||||
|
"api-status",
|
||||||
|
"api-field",
|
||||||
|
"url",
|
||||||
|
"interaction",
|
||||||
|
}
|
||||||
FEISHU_REQUIRED_FIELDS = {
|
FEISHU_REQUIRED_FIELDS = {
|
||||||
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||||
"attachments", "updatedAt",
|
"attachments", "updatedAt",
|
||||||
@@ -195,38 +237,12 @@ def _nonempty_string(value: object) -> bool:
|
|||||||
|
|
||||||
def load_document(path: Path) -> dict:
|
def load_document(path: Path) -> dict:
|
||||||
try:
|
try:
|
||||||
content = path.read_text(encoding="utf-8")
|
data = parse_document(path)
|
||||||
except OSError as exc:
|
if is_task_store_manifest(data):
|
||||||
|
data = materialize_board(data, path)
|
||||||
|
except TaskStoreError as exc:
|
||||||
sys.stderr.write(f"任务板读取失败: {exc}\n")
|
sys.stderr.write(f"任务板读取失败: {exc}\n")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
if path.suffix.lower() == ".json":
|
|
||||||
try:
|
|
||||||
data = load_json_unique(content)
|
|
||||||
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
|
||||||
sys.stderr.write(f"JSON 解析失败: {exc}\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
import yaml # type: ignore
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
data = load_yaml_subset(content)
|
|
||||||
except YamlSubsetError as exc:
|
|
||||||
sys.stderr.write(f"YAML 子集解析失败: {exc}\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
data = yaml.load(
|
|
||||||
content,
|
|
||||||
Loader=make_unique_pyyaml_loader(yaml),
|
|
||||||
)
|
|
||||||
except yaml.YAMLError as exc: # type: ignore
|
|
||||||
sys.stderr.write(f"YAML 解析失败: {exc}\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
sys.stderr.write("任务板顶层必须是对象(mapping)\n")
|
|
||||||
raise SystemExit(1)
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -636,6 +652,205 @@ def validate_delivery_runs(
|
|||||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_regression_fields(task: dict, where: str, errors: list[str]) -> None:
|
||||||
|
if "regressionRefs" in task:
|
||||||
|
refs = task["regressionRefs"]
|
||||||
|
if not isinstance(refs, list):
|
||||||
|
errors.append(f"{where}.regressionRefs: 必须是列表")
|
||||||
|
else:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, ref in enumerate(refs):
|
||||||
|
item_where = f"{where}.regressionRefs[{index}]"
|
||||||
|
if not isinstance(ref, str) or REGRESSION_CASE_ID_RE.fullmatch(ref) is None:
|
||||||
|
errors.append(f"{item_where}: 必须使用 REG-<id> 格式")
|
||||||
|
continue
|
||||||
|
if ref in seen:
|
||||||
|
errors.append(f"{item_where}: 不能重复 {ref!r}")
|
||||||
|
seen.add(ref)
|
||||||
|
|
||||||
|
if "regressionCandidates" not in task:
|
||||||
|
return
|
||||||
|
candidates = task["regressionCandidates"]
|
||||||
|
if not isinstance(candidates, list):
|
||||||
|
errors.append(f"{where}.regressionCandidates: 必须是列表")
|
||||||
|
return
|
||||||
|
for index, candidate in enumerate(candidates):
|
||||||
|
item_where = f"{where}.regressionCandidates[{index}]"
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
errors.append(f"{item_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(
|
||||||
|
candidate, REGRESSION_CANDIDATE_FIELDS, item_where, errors
|
||||||
|
)
|
||||||
|
missing = sorted(REGRESSION_CANDIDATE_REQUIRED_FIELDS - set(candidate))
|
||||||
|
for field in missing:
|
||||||
|
errors.append(f"{item_where}.{field}: 必填")
|
||||||
|
if "id" in candidate and (
|
||||||
|
not isinstance(candidate.get("id"), str)
|
||||||
|
or REGRESSION_CASE_ID_RE.fullmatch(candidate["id"]) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{item_where}.id: 必须使用 REG-<id> 格式")
|
||||||
|
if not _nonempty_string(candidate.get("title")):
|
||||||
|
errors.append(f"{item_where}.title: 必须是非空字符串")
|
||||||
|
if candidate.get("surface") not in REGRESSION_SURFACES:
|
||||||
|
errors.append(f"{item_where}.surface: 必须是 {sorted(REGRESSION_SURFACES)}")
|
||||||
|
if "suite" in candidate and candidate.get("suite") not in REGRESSION_SUITES:
|
||||||
|
errors.append(f"{item_where}.suite: 必须是 {sorted(REGRESSION_SUITES)}")
|
||||||
|
if "sourceKind" in candidate and (
|
||||||
|
candidate.get("sourceKind") not in REGRESSION_SOURCE_KINDS
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{item_where}.sourceKind: 必须是 {sorted(REGRESSION_SOURCE_KINDS)}"
|
||||||
|
)
|
||||||
|
steps = candidate.get("steps")
|
||||||
|
if (
|
||||||
|
not isinstance(steps, list)
|
||||||
|
or not steps
|
||||||
|
or any(not _nonempty_string(step) for step in steps)
|
||||||
|
):
|
||||||
|
errors.append(f"{item_where}.steps: 必须是非空字符串列表")
|
||||||
|
expected = candidate.get("expected")
|
||||||
|
if not isinstance(expected, list) or not expected:
|
||||||
|
errors.append(f"{item_where}.expected: 必须是非空列表")
|
||||||
|
continue
|
||||||
|
for expected_index, item in enumerate(expected):
|
||||||
|
expected_where = f"{item_where}.expected[{expected_index}]"
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors.append(f"{expected_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
if item.get("kind") not in REGRESSION_EXPECTED_KINDS:
|
||||||
|
errors.append(
|
||||||
|
f"{expected_where}.kind: 必须是 {sorted(REGRESSION_EXPECTED_KINDS)}"
|
||||||
|
)
|
||||||
|
if not _nonempty_string(item.get("value")):
|
||||||
|
errors.append(f"{expected_where}.value: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_regression_runs(value: object, errors: list[str]) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
errors.append("regressionRuns 必须是列表")
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_run_ids: set[str] = set()
|
||||||
|
for index, run in enumerate(value):
|
||||||
|
where = f"regressionRuns[{index}]"
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(
|
||||||
|
run,
|
||||||
|
REGRESSION_RUN_FIELDS | REGRESSION_RUN_OPTIONAL_FIELDS,
|
||||||
|
where,
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
missing = sorted(REGRESSION_RUN_FIELDS - set(run))
|
||||||
|
for field in missing:
|
||||||
|
errors.append(f"{where}.{field}: 必填")
|
||||||
|
|
||||||
|
run_id = run.get("id")
|
||||||
|
if not isinstance(run_id, str) or REGRESSION_RUN_ID_RE.fullmatch(run_id) is None:
|
||||||
|
errors.append(f"{where}.id: 必须使用 RR-<id> 格式")
|
||||||
|
elif run_id in seen_run_ids:
|
||||||
|
errors.append(f"{where}.id: 不能重复 {run_id!r}")
|
||||||
|
else:
|
||||||
|
seen_run_ids.add(run_id)
|
||||||
|
|
||||||
|
if run.get("suite") not in REGRESSION_RUN_SUITES:
|
||||||
|
errors.append(f"{where}.suite: 必须是 {sorted(REGRESSION_RUN_SUITES)}")
|
||||||
|
status = run.get("status")
|
||||||
|
if status not in REGRESSION_RUN_STATUSES:
|
||||||
|
errors.append(f"{where}.status: 必须是 {sorted(REGRESSION_RUN_STATUSES)}")
|
||||||
|
|
||||||
|
case_ids = run.get("caseIds")
|
||||||
|
if (
|
||||||
|
not isinstance(case_ids, list)
|
||||||
|
or not case_ids
|
||||||
|
or any(
|
||||||
|
not isinstance(case_id, str)
|
||||||
|
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||||
|
for case_id in case_ids
|
||||||
|
)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.caseIds: 必须是非空 REG-<id> 列表")
|
||||||
|
case_ids = []
|
||||||
|
elif len(case_ids) != len(set(case_ids)):
|
||||||
|
errors.append(f"{where}.caseIds: 不能包含重复值")
|
||||||
|
|
||||||
|
task_ids = run.get("taskIds")
|
||||||
|
if task_ids is not None and (
|
||||||
|
not isinstance(task_ids, list)
|
||||||
|
or any(not _nonempty_string(task_id) for task_id in task_ids)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.taskIds: 必须是任务 ID 列表")
|
||||||
|
delivery_run_id = run.get("deliveryRunId")
|
||||||
|
if delivery_run_id is not None and (
|
||||||
|
not isinstance(delivery_run_id, str)
|
||||||
|
or DELIVERY_RUN_ID_RE.fullmatch(delivery_run_id) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.deliveryRunId: 必须使用 DR-<id> 格式")
|
||||||
|
|
||||||
|
for field in ("sourceRevision", "configRevision"):
|
||||||
|
revision = run.get(field)
|
||||||
|
if revision is not None and (
|
||||||
|
not isinstance(revision, str) or GIT_REVISION_RE.fullmatch(revision) is None
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{where}.{field}: 必须是 null 或 7..64 位小写十六进制 revision"
|
||||||
|
)
|
||||||
|
if status != "skipped":
|
||||||
|
for field in ("sourceRevision", "configRevision", "baseUrl"):
|
||||||
|
if not _nonempty_string(run.get(field)):
|
||||||
|
errors.append(f"{where}.{field}: status={status!r} 时必须填写")
|
||||||
|
|
||||||
|
results = run.get("results")
|
||||||
|
if not isinstance(results, list):
|
||||||
|
errors.append(f"{where}.results: 必须是列表")
|
||||||
|
results = []
|
||||||
|
elif status in {"passed", "failed"} and not results:
|
||||||
|
errors.append(f"{where}.results: status={status!r} 时不能为空")
|
||||||
|
seen_results: set[str] = set()
|
||||||
|
for result_index, result in enumerate(results):
|
||||||
|
result_where = f"{where}.results[{result_index}]"
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
errors.append(f"{result_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(result, REGRESSION_RESULT_FIELDS, result_where, errors)
|
||||||
|
case_id = result.get("caseId")
|
||||||
|
if (
|
||||||
|
not isinstance(case_id, str)
|
||||||
|
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{result_where}.caseId: 必须使用 REG-<id> 格式")
|
||||||
|
elif case_id in seen_results:
|
||||||
|
errors.append(f"{result_where}.caseId: 不能重复 {case_id!r}")
|
||||||
|
else:
|
||||||
|
seen_results.add(case_id)
|
||||||
|
if case_id not in case_ids:
|
||||||
|
errors.append(f"{result_where}.caseId: 不在 caseIds 中")
|
||||||
|
if result.get("result") not in REGRESSION_RESULTS:
|
||||||
|
errors.append(
|
||||||
|
f"{result_where}.result: 必须是 {sorted(REGRESSION_RESULTS)}"
|
||||||
|
)
|
||||||
|
if not _nonempty_string(result.get("evidence")):
|
||||||
|
errors.append(f"{result_where}.evidence: 必须是非空字符串")
|
||||||
|
if status == "passed" and any(
|
||||||
|
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: passed 运行不能包含 fail 结果")
|
||||||
|
if status == "failed" and not any(
|
||||||
|
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: failed 运行必须包含至少一条 fail 结果")
|
||||||
|
|
||||||
|
evidence = run.get("evidence")
|
||||||
|
if not isinstance(evidence, list) or any(
|
||||||
|
not _nonempty_string(item) for item in evidence
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||||
|
if not _nonempty_string(run.get("updatedAt")):
|
||||||
|
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||||
import jsonschema # type: ignore
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
@@ -648,8 +863,17 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def validate_builtin(data: dict) -> list[str]:
|
def validate_builtin(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
orchestration_override: dict | None = None,
|
||||||
|
) -> list[str]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
store_errors = data.get("taskStoreErrors")
|
||||||
|
if isinstance(store_errors, list):
|
||||||
|
for error in store_errors:
|
||||||
|
if isinstance(error, str) and error.strip():
|
||||||
|
errors.append(f"taskStore: {error}")
|
||||||
project_intake_workflow = "read-only-v1"
|
project_intake_workflow = "read-only-v1"
|
||||||
|
|
||||||
def validate_string_fields(
|
def validate_string_fields(
|
||||||
@@ -763,28 +987,43 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
"knowledgeFile" in project
|
"knowledgeFile" in project
|
||||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
and project.get("knowledgeFile") != ".pouch/ack/knowledge.yaml"
|
||||||
):
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
"project.knowledgeFile 必须固定为 .pouch/ack/knowledge.yaml"
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
"deliveryFile" in project
|
"deliveryFile" in project
|
||||||
and project.get("deliveryFile") != "docs/ack/delivery.yaml"
|
and project.get("deliveryFile") != ".pouch/ack/delivery.yaml"
|
||||||
):
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
"project.deliveryFile 必须固定为 docs/ack/delivery.yaml"
|
"project.deliveryFile 必须固定为 .pouch/ack/delivery.yaml"
|
||||||
)
|
)
|
||||||
if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list):
|
if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list):
|
||||||
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||||
if "deliveryRuns" in data and "deliveryFile" not in project:
|
if "deliveryRuns" in data and "deliveryFile" not in project:
|
||||||
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
||||||
|
if (
|
||||||
|
"regressionFile" in project
|
||||||
|
and project.get("regressionFile") != ".pouch/ack/regression.yaml"
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"project.regressionFile 必须固定为 .pouch/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
if "regressionFile" in project and not isinstance(
|
||||||
|
data.get("regressionRuns"), list
|
||||||
|
):
|
||||||
|
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
|
||||||
|
if "regressionRuns" in data and "regressionFile" not in project:
|
||||||
|
errors.append("regressionRuns 存在时 project.regressionFile 必须存在")
|
||||||
|
|
||||||
ack_version = data.get("ackVersion")
|
ack_version = data.get("ackVersion")
|
||||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||||
if "ackVersion" in data and version_match is None:
|
if "ackVersion" in data and version_match is None:
|
||||||
errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)")
|
errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)")
|
||||||
routing_required = (
|
routing_required = (
|
||||||
|
orchestration_override is not None
|
||||||
|
or
|
||||||
(isinstance(project, dict) and "orchestration" in project)
|
(isinstance(project, dict) and "orchestration" in project)
|
||||||
or "workerReceipts" in data
|
or "workerReceipts" in data
|
||||||
or (
|
or (
|
||||||
@@ -794,7 +1033,12 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if routing_required:
|
if routing_required:
|
||||||
errors.extend(validate_routing_document(data))
|
errors.extend(
|
||||||
|
validate_routing_document(
|
||||||
|
data,
|
||||||
|
orchestration_override=orchestration_override,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if "summary" in data:
|
if "summary" in data:
|
||||||
summary = data["summary"]
|
summary = data["summary"]
|
||||||
@@ -941,6 +1185,7 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
||||||
|
|
||||||
validate_knowledge_fields(task, where, status, errors)
|
validate_knowledge_fields(task, where, status, errors)
|
||||||
|
validate_regression_fields(task, where, errors)
|
||||||
|
|
||||||
if "dispatch" not in task:
|
if "dispatch" not in task:
|
||||||
dispatch = {}
|
dispatch = {}
|
||||||
@@ -1129,6 +1374,8 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||||
}
|
}
|
||||||
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
||||||
|
if "regressionRuns" in data:
|
||||||
|
validate_regression_runs(data["regressionRuns"], errors)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
@@ -1153,7 +1400,27 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
sys.stderr.write(f"找不到指定的 schema 文件: {schema_path}\n")
|
sys.stderr.write(f"找不到指定的 schema 文件: {schema_path}\n")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
errors = validate_builtin(data)
|
agents_path = tasks_path.with_name("agents.yaml")
|
||||||
|
agents = None
|
||||||
|
if agents_path.is_symlink():
|
||||||
|
errors = ["agents.yaml 不能是 symlink"]
|
||||||
|
agents = None
|
||||||
|
elif agents_path.is_file():
|
||||||
|
agents = load_document(agents_path)
|
||||||
|
if not isinstance(agents, dict):
|
||||||
|
errors = ["agents.yaml 顶层必须是对象(mapping)"]
|
||||||
|
agents = None
|
||||||
|
else:
|
||||||
|
errors = validate_orchestration(agents, where="agents.yaml")
|
||||||
|
else:
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
errors.extend(validate_builtin(data, orchestration_override=agents))
|
||||||
|
if agents is not None:
|
||||||
|
project = data.get("project")
|
||||||
|
board_orchestration = project.get("orchestration") if isinstance(project, dict) else None
|
||||||
|
if board_orchestration is not None:
|
||||||
|
errors.extend(validate_orchestration(board_orchestration))
|
||||||
mode = "内置语义规则"
|
mode = "内置语义规则"
|
||||||
try:
|
try:
|
||||||
import jsonschema # type: ignore # noqa: F401
|
import jsonschema # type: ignore # noqa: F401
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
|
|
||||||
MIGRATION_MESSAGE = (
|
MIGRATION_MESSAGE = (
|
||||||
"ACK v0.10 已停用自由 worker command 校验器;请在 "
|
"ACK v0.10 已停用自由 worker command 校验器;请在 "
|
||||||
"docs/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
|
".pouch/ack/agents.yaml(优先)或 .pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
|
||||||
"scripts/launch_worker.py profile-hash|plan|launch。"
|
"scripts/launch_worker.py profile-hash|plan|launch。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Output (single JSON document on stdout):
|
|||||||
"status": "running | progress | stall | not-started | unknown",
|
"status": "running | progress | stall | not-started | unknown",
|
||||||
"stallReason": "<label> | null",
|
"stallReason": "<label> | null",
|
||||||
"heartbeatAt": "<value> | null",
|
"heartbeatAt": "<value> | null",
|
||||||
|
"heartbeatAgeSeconds": "<number> | null",
|
||||||
"evidence": "<bounded terminal tail>"
|
"evidence": "<bounded terminal tail>"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|||||||
|
|
||||||
from launch_worker import ( # noqa: E402
|
from launch_worker import ( # noqa: E402
|
||||||
LaunchError,
|
LaunchError,
|
||||||
|
parse_timestamp,
|
||||||
resolve_executable,
|
resolve_executable,
|
||||||
run_json,
|
run_json,
|
||||||
utc_now,
|
utc_now,
|
||||||
@@ -53,48 +56,113 @@ WORKING_PATTERN = re.compile(r"(?i)working|•working|running|执行中|正在")
|
|||||||
IDLE_TAIL_PATTERN = re.compile(r"(?i)welcome to|type help|fish, the friendly|>\\s*$")
|
IDLE_TAIL_PATTERN = re.compile(r"(?i)welcome to|type help|fish, the friendly|>\\s*$")
|
||||||
|
|
||||||
MAX_EVIDENCE_CHARS = 500
|
MAX_EVIDENCE_CHARS = 500
|
||||||
|
HEARTBEAT_INTERVAL_SECONDS = 5 * 60
|
||||||
|
HEARTBEAT_MAX_AGE_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 3
|
||||||
|
|
||||||
|
|
||||||
def classify(tail: str | list[str], heartbeat: object) -> dict[str, object]:
|
def heartbeat_age_seconds(
|
||||||
|
heartbeat: object, now: datetime | None = None
|
||||||
|
) -> tuple[float | None, str | None]:
|
||||||
|
"""Return heartbeat age and a diagnostic for malformed/stale timestamps."""
|
||||||
|
|
||||||
|
if heartbeat is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
heartbeat_at = parse_timestamp(heartbeat, "last_heartbeat_at")
|
||||||
|
except LaunchError:
|
||||||
|
return None, "invalid-heartbeat"
|
||||||
|
|
||||||
|
current = now if now is not None else utc_now()
|
||||||
|
age = (current - heartbeat_at).total_seconds()
|
||||||
|
if age < -60:
|
||||||
|
return None, "future-heartbeat"
|
||||||
|
return max(age, 0.0), None
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
stall_reason: str | None,
|
||||||
|
heartbeat: object,
|
||||||
|
heartbeat_age: float | None,
|
||||||
|
evidence: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"stallReason": stall_reason,
|
||||||
|
"heartbeatAt": heartbeat,
|
||||||
|
"heartbeatAgeSeconds": heartbeat_age,
|
||||||
|
"evidence": evidence[:MAX_EVIDENCE_CHARS],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
tail: str | list[str],
|
||||||
|
heartbeat: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
if isinstance(tail, list):
|
if isinstance(tail, list):
|
||||||
tail = "\n".join(tail)
|
tail = "\n".join(tail)
|
||||||
|
heartbeat_age, heartbeat_error = heartbeat_age_seconds(heartbeat, now)
|
||||||
for label, pattern in STALL_PATTERNS:
|
for label, pattern in STALL_PATTERNS:
|
||||||
if pattern.search(tail):
|
if pattern.search(tail):
|
||||||
return {
|
return _result(
|
||||||
"status": "stall",
|
status="stall",
|
||||||
"stallReason": label,
|
stall_reason=label,
|
||||||
"heartbeatAt": heartbeat,
|
heartbeat=heartbeat,
|
||||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
heartbeat_age=heartbeat_age,
|
||||||
}
|
evidence=tail,
|
||||||
if heartbeat:
|
)
|
||||||
return {
|
if heartbeat_error:
|
||||||
"status": "progress",
|
return _result(
|
||||||
"stallReason": None,
|
status="unknown",
|
||||||
"heartbeatAt": heartbeat,
|
stall_reason=heartbeat_error,
|
||||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
heartbeat=heartbeat,
|
||||||
}
|
heartbeat_age=heartbeat_age,
|
||||||
|
evidence=tail,
|
||||||
|
)
|
||||||
|
if heartbeat_age is not None and heartbeat_age <= HEARTBEAT_MAX_AGE_SECONDS:
|
||||||
|
return _result(
|
||||||
|
status="progress",
|
||||||
|
stall_reason=None,
|
||||||
|
heartbeat=heartbeat,
|
||||||
|
heartbeat_age=heartbeat_age,
|
||||||
|
evidence=tail,
|
||||||
|
)
|
||||||
|
if heartbeat_age is not None:
|
||||||
|
return _result(
|
||||||
|
status="unknown",
|
||||||
|
stall_reason="stale-heartbeat",
|
||||||
|
heartbeat=heartbeat,
|
||||||
|
heartbeat_age=heartbeat_age,
|
||||||
|
evidence=tail,
|
||||||
|
)
|
||||||
if WORKING_PATTERN.search(tail):
|
if WORKING_PATTERN.search(tail):
|
||||||
return {
|
return _result(
|
||||||
"status": "running",
|
status="running",
|
||||||
"stallReason": None,
|
stall_reason=None,
|
||||||
"heartbeatAt": None,
|
heartbeat=heartbeat,
|
||||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
heartbeat_age=heartbeat_age,
|
||||||
}
|
evidence=tail,
|
||||||
|
)
|
||||||
# No heartbeat and no working marker: the terminal may still be sitting at
|
# No heartbeat and no working marker: the terminal may still be sitting at
|
||||||
# a welcome/idle prompt (task never started) or have unclassified output.
|
# a welcome/idle prompt (task never started) or have unclassified output.
|
||||||
if IDLE_TAIL_PATTERN.search(tail) or not tail.strip():
|
if IDLE_TAIL_PATTERN.search(tail) or not tail.strip():
|
||||||
return {
|
return _result(
|
||||||
"status": "not-started",
|
status="not-started",
|
||||||
"stallReason": None,
|
stall_reason=None,
|
||||||
"heartbeatAt": None,
|
heartbeat=heartbeat,
|
||||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
heartbeat_age=heartbeat_age,
|
||||||
}
|
evidence=tail,
|
||||||
return {
|
)
|
||||||
"status": "unknown",
|
return _result(
|
||||||
"stallReason": None,
|
status="unknown",
|
||||||
"heartbeatAt": None,
|
stall_reason=None,
|
||||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
heartbeat=heartbeat,
|
||||||
}
|
heartbeat_age=heartbeat_age,
|
||||||
|
evidence=tail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
@@ -126,6 +194,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"stallReason": None,
|
"stallReason": None,
|
||||||
"heartbeatAt": None,
|
"heartbeatAt": None,
|
||||||
|
"heartbeatAgeSeconds": None,
|
||||||
"evidence": f"probe failed: {type(exc).__name__}: {exc}",
|
"evidence": f"probe failed: {type(exc).__name__}: {exc}",
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ LAUNCH_PROTOCOL_VERSION = 1
|
|||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3
|
||||||
|
|
||||||
ROLES = frozenset({"developer", "test"})
|
ROLES = frozenset({"developer", "test"})
|
||||||
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp"})
|
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp", "pi"})
|
||||||
TIERS = frozenset({"standard", "strong"})
|
TIERS = frozenset({"standard", "strong"})
|
||||||
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||||
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
PERMISSION_MODES = frozenset({"read-only", "workspace-write", "danger-full-access"})
|
||||||
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
||||||
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
|
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
|
||||||
CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"})
|
CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"})
|
||||||
CLI_REQUIRES_NULL_REASONING_EFFORT = frozenset({"cursor-agent"})
|
CLI_REQUIRES_NULL_REASONING_EFFORT = frozenset({"cursor-agent"})
|
||||||
|
CLI_ALLOWS_DEFAULT_MODEL = frozenset({"pi"})
|
||||||
|
|
||||||
|
|
||||||
def _cli_choice_text() -> str:
|
def _cli_choice_text() -> str:
|
||||||
@@ -95,7 +96,9 @@ RECEIPT_FIELDS = frozenset(
|
|||||||
"receiptHash",
|
"receiptHash",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"})
|
RECEIPT_CONTEXT_FIELDS = frozenset(
|
||||||
|
{"projectRoot", "boardHash", "routingSource", "routingHash"}
|
||||||
|
)
|
||||||
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
||||||
WORKTREE_FIELDS = frozenset(
|
WORKTREE_FIELDS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -247,12 +250,16 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
errors.append(f"{where}.cli: must be {_cli_choice_text()}")
|
errors.append(f"{where}.cli: must be {_cli_choice_text()}")
|
||||||
if not isinstance(tier, str) or tier not in TIERS:
|
if not isinstance(tier, str) or tier not in TIERS:
|
||||||
errors.append(f"{where}.tier: must be standard/strong")
|
errors.append(f"{where}.tier: must be standard/strong")
|
||||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
if not (isinstance(cli, str) and cli in CLI_ALLOWS_DEFAULT_MODEL and model is None) and (
|
||||||
errors.append(f"{where}.model: must be a safe model ID")
|
not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.model: must be a safe model ID or null for Pi default")
|
||||||
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{where}.permissionMode: must be read-only/workspace-write"
|
f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access"
|
||||||
)
|
)
|
||||||
|
elif permission == "danger-full-access" and cli != "codex":
|
||||||
|
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
|
||||||
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT:
|
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT:
|
||||||
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
||||||
label = "Codex" if cli == "codex" else "Grok"
|
label = "Codex" if cli == "codex" else "Grok"
|
||||||
@@ -265,6 +272,10 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
and effort is not None
|
and effort is not None
|
||||||
):
|
):
|
||||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||||
|
elif isinstance(cli, str) and cli == "pi" and (
|
||||||
|
not isinstance(effort, str) or effort not in REASONING_EFFORTS
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh")
|
||||||
|
|
||||||
if role == "test" and tier != "standard":
|
if role == "test" and tier != "standard":
|
||||||
errors.append(f"{where}.tier: Test may only use standard")
|
errors.append(f"{where}.tier: Test may only use standard")
|
||||||
@@ -312,15 +323,22 @@ def _validate_model_allowlist(value: Any, where: str) -> list[str]:
|
|||||||
if not isinstance(models, list) or not models:
|
if not isinstance(models, list) or not models:
|
||||||
errors.append(f"{tier_where}: must be a non-empty model list")
|
errors.append(f"{tier_where}: must be a non-empty model list")
|
||||||
continue
|
continue
|
||||||
seen: set[str] = set()
|
seen: set[object] = set()
|
||||||
for index, model in enumerate(models):
|
for index, model in enumerate(models):
|
||||||
item_where = f"{tier_where}[{index}]"
|
item_where = f"{tier_where}[{index}]"
|
||||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
if model is None and cli in CLI_ALLOWS_DEFAULT_MODEL:
|
||||||
errors.append(f"{item_where}: must be a safe model ID")
|
model_key: object = ("default",)
|
||||||
elif model in seen:
|
elif not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
||||||
|
errors.append(
|
||||||
|
f"{item_where}: must be a safe model ID or null for Pi default"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
model_key = model
|
||||||
|
if model_key in seen:
|
||||||
errors.append(f"{item_where}: duplicate model {model!r}")
|
errors.append(f"{item_where}: duplicate model {model!r}")
|
||||||
else:
|
else:
|
||||||
seen.add(model)
|
seen.add(model_key)
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -329,7 +347,7 @@ def _allowed_models(
|
|||||||
cli: Any,
|
cli: Any,
|
||||||
role: Any,
|
role: Any,
|
||||||
tier: Any,
|
tier: Any,
|
||||||
) -> list[str] | None:
|
) -> list[Any] | None:
|
||||||
if not isinstance(allowlist, dict):
|
if not isinstance(allowlist, dict):
|
||||||
return None
|
return None
|
||||||
roles = allowlist.get(cli)
|
roles = allowlist.get(cli)
|
||||||
@@ -519,6 +537,19 @@ def render_worker_argv(
|
|||||||
"--no-session",
|
"--no-session",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if cli == "pi":
|
||||||
|
# Pi's omitted --model selects its configured startup default. Its
|
||||||
|
# cwd is supplied by the trusted bootstrap process, because Pi has no
|
||||||
|
# --cwd flag. Read-only profiles are restricted to observation tools;
|
||||||
|
# workspace-write profiles use Pi's normal editing tool set.
|
||||||
|
argv = [executable]
|
||||||
|
if model is not None:
|
||||||
|
argv.extend(["--model", model])
|
||||||
|
argv.extend(["--thinking", profile["reasoningEffort"]])
|
||||||
|
if permission == "read-only":
|
||||||
|
argv.extend(["--tools", "read,grep,find,ls"])
|
||||||
|
return argv
|
||||||
|
|
||||||
if cli != "grok":
|
if cli != "grok":
|
||||||
raise ValueError(f"unsupported cli: {cli}")
|
raise ValueError(f"unsupported cli: {cli}")
|
||||||
grok_permission = "plan" if permission == "read-only" else "acceptEdits"
|
grok_permission = "plan" if permission == "read-only" else "acceptEdits"
|
||||||
@@ -597,8 +628,10 @@ def _validate_requested(value: Any, where: str) -> list[str]:
|
|||||||
if not isinstance(tier, str) or tier not in TIERS:
|
if not isinstance(tier, str) or tier not in TIERS:
|
||||||
errors.append(f"{where}.tier: must be standard/strong")
|
errors.append(f"{where}.tier: must be standard/strong")
|
||||||
model = value.get("model")
|
model = value.get("model")
|
||||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
if not (isinstance(cli, str) and cli in CLI_ALLOWS_DEFAULT_MODEL and model is None) and (
|
||||||
errors.append(f"{where}.model: must be a safe model ID")
|
not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.model: must be a safe model ID or null for Pi default")
|
||||||
effort = value.get("reasoningEffort")
|
effort = value.get("reasoningEffort")
|
||||||
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT and (
|
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT and (
|
||||||
not isinstance(effort, str) or effort not in REASONING_EFFORTS
|
not isinstance(effort, str) or effort not in REASONING_EFFORTS
|
||||||
@@ -611,8 +644,14 @@ def _validate_requested(value: Any, where: str) -> list[str]:
|
|||||||
and effort is not None
|
and effort is not None
|
||||||
):
|
):
|
||||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||||
|
if isinstance(cli, str) and cli == "pi" and (
|
||||||
|
not isinstance(effort, str) or effort not in REASONING_EFFORTS
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh")
|
||||||
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
||||||
errors.append(f"{where}.permissionMode: must be read-only/workspace-write")
|
errors.append(f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access")
|
||||||
|
elif permission == "danger-full-access" and cli != "codex":
|
||||||
|
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
|
||||||
executable = value.get("executable")
|
executable = value.get("executable")
|
||||||
if not _is_absolute_safe_path(executable):
|
if not _is_absolute_safe_path(executable):
|
||||||
errors.append(f"{where}.executable: must be a safe absolute path")
|
errors.append(f"{where}.executable: must be a safe absolute path")
|
||||||
@@ -697,6 +736,16 @@ def validate_worker_receipt(
|
|||||||
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
|
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
|
||||||
):
|
):
|
||||||
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
|
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
|
||||||
|
routing_source = receipt.get("routingSource")
|
||||||
|
routing_hash = receipt.get("routingHash")
|
||||||
|
if (routing_source is None) != (routing_hash is None):
|
||||||
|
errors.append(f"{where}: routingSource and routingHash must be present together")
|
||||||
|
if routing_source is not None and not _is_nonempty_text(routing_source, max_length=512):
|
||||||
|
errors.append(f"{where}.routingSource: must be non-empty single-line text")
|
||||||
|
if routing_hash is not None and (
|
||||||
|
not isinstance(routing_hash, str) or SHA256_RE.fullmatch(routing_hash) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.routingHash: must be a canonical sha256 hex digest")
|
||||||
|
|
||||||
version = receipt.get("receiptVersion")
|
version = receipt.get("receiptVersion")
|
||||||
if version != RECEIPT_VERSION or isinstance(version, bool):
|
if version != RECEIPT_VERSION or isinstance(version, bool):
|
||||||
@@ -818,6 +867,9 @@ def validate_worker_receipt(
|
|||||||
if project_root is not None and board_hash is not None:
|
if project_root is not None and board_hash is not None:
|
||||||
facts["projectRoot"] = project_root
|
facts["projectRoot"] = project_root
|
||||||
facts["boardHash"] = board_hash
|
facts["boardHash"] = board_hash
|
||||||
|
if routing_source is not None and routing_hash is not None:
|
||||||
|
facts["routingSource"] = routing_source
|
||||||
|
facts["routingHash"] = routing_hash
|
||||||
expected_fingerprint = canonical_sha256(facts)
|
expected_fingerprint = canonical_sha256(facts)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
||||||
@@ -1006,17 +1058,30 @@ def _validate_dispatch_links(
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def validate_routing_document(data: Any) -> list[str]:
|
def validate_routing_document(
|
||||||
"""Validate routing and receipts embedded in an ACK tasks document."""
|
data: Any,
|
||||||
|
*,
|
||||||
|
orchestration_override: dict[str, Any] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Validate routing and receipts in an ACK tasks document.
|
||||||
|
|
||||||
|
``orchestration_override`` is the optional project-local agents.yaml
|
||||||
|
routing document. The task board remains the state source, while the
|
||||||
|
override becomes the effective profile source for receipt checks.
|
||||||
|
"""
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return ["<root>: must be an object"]
|
return ["<root>: must be an object"]
|
||||||
project = data.get("project")
|
project = data.get("project")
|
||||||
if not isinstance(project, dict):
|
if not isinstance(project, dict):
|
||||||
return ["project: must be an object"]
|
return ["project: must be an object"]
|
||||||
if "orchestration" not in project:
|
if "orchestration" not in project and orchestration_override is None:
|
||||||
return ["project.orchestration: is required"]
|
return ["project.orchestration: is required"]
|
||||||
orchestration = project.get("orchestration")
|
orchestration = (
|
||||||
|
orchestration_override
|
||||||
|
if orchestration_override is not None
|
||||||
|
else project.get("orchestration")
|
||||||
|
)
|
||||||
errors = validate_orchestration(orchestration)
|
errors = validate_orchestration(orchestration)
|
||||||
|
|
||||||
task_ids: set[str] = set()
|
task_ids: set[str] = set()
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/agents.schema.json",
|
||||||
|
"title": "ACK project agent routing override",
|
||||||
|
"description": "Optional .pouch/ack/agents.yaml. It has the same routing shape as project.orchestration.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["profileVersion", "mode", "modelAllowlist", "profiles", "defaults"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"profileVersion": {"type": "integer", "const": 1},
|
||||||
|
"mode": {"type": "string", "enum": ["orca", "manual"]},
|
||||||
|
"modelAllowlist": {"$ref": "#/definitions/modelAllowlist"},
|
||||||
|
"profiles": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": {"$ref": "#/definitions/profileId"},
|
||||||
|
"additionalProperties": {"$ref": "#/definitions/workerProfile"}
|
||||||
|
},
|
||||||
|
"defaults": {"$ref": "#/definitions/defaults"}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"profileId": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"},
|
||||||
|
"model": {
|
||||||
|
"oneOf": [
|
||||||
|
{"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"},
|
||||||
|
{"type": "null", "description": "仅 pi 使用 null 表示默认模型"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modelList": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/definitions/model"}},
|
||||||
|
"modelTierAllowlist": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"standard": {"$ref": "#/definitions/modelList"},
|
||||||
|
"strong": {"$ref": "#/definitions/modelList"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modelRoleAllowlist": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"developer": {"$ref": "#/definitions/modelTierAllowlist"},
|
||||||
|
"test": {"$ref": "#/definitions/modelTierAllowlist"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modelAllowlist": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"codex": {"$ref": "#/definitions/modelRoleAllowlist"},
|
||||||
|
"cursor-agent": {"$ref": "#/definitions/modelRoleAllowlist"},
|
||||||
|
"grok": {"$ref": "#/definitions/modelRoleAllowlist"},
|
||||||
|
"omp": {"$ref": "#/definitions/modelRoleAllowlist"},
|
||||||
|
"pi": {"$ref": "#/definitions/modelRoleAllowlist"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workerProfile": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["role", "cli", "tier", "model", "reasoningEffort", "permissionMode"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
|
||||||
|
"properties": {
|
||||||
|
"role": {"type": "string", "enum": ["developer", "test"]},
|
||||||
|
"cli": {"type": "string", "enum": ["codex", "cursor-agent", "grok", "omp", "pi"]},
|
||||||
|
"tier": {"type": "string", "enum": ["standard", "strong"]},
|
||||||
|
"model": {"$ref": "#/definitions/model"},
|
||||||
|
"reasoningEffort": {"type": ["string", "null"], "enum": ["low", "medium", "high", "xhigh", null]},
|
||||||
|
"permissionMode": {"type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["developer", "test"],
|
||||||
|
"properties": {
|
||||||
|
"developer": {"$ref": "#/definitions/profileId"},
|
||||||
|
"test": {"$ref": "#/definitions/profileId"},
|
||||||
|
"developerUpgraded": {"$ref": "#/definitions/profileId"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Optional project-local worker routing override.
|
||||||
|
# Save as .pouch/ack/agents.yaml when a temporary role/CLI/model selection is needed.
|
||||||
|
# This file intentionally has the same shape as tasks.yaml.project.orchestration.
|
||||||
|
profileVersion: 1
|
||||||
|
mode: "orca"
|
||||||
|
modelAllowlist:
|
||||||
|
pi:
|
||||||
|
developer:
|
||||||
|
standard: [null]
|
||||||
|
strong: [null]
|
||||||
|
cursor-agent:
|
||||||
|
test:
|
||||||
|
standard: ["auto"]
|
||||||
|
profiles:
|
||||||
|
pi-dev-standard:
|
||||||
|
role: "developer"
|
||||||
|
cli: "pi"
|
||||||
|
tier: "standard"
|
||||||
|
model: null
|
||||||
|
reasoningEffort: "medium"
|
||||||
|
permissionMode: "workspace-write"
|
||||||
|
pi-dev-strong:
|
||||||
|
role: "developer"
|
||||||
|
cli: "pi"
|
||||||
|
tier: "strong"
|
||||||
|
model: null
|
||||||
|
reasoningEffort: "high"
|
||||||
|
permissionMode: "workspace-write"
|
||||||
|
cursor-test-standard:
|
||||||
|
role: "test"
|
||||||
|
cli: "cursor-agent"
|
||||||
|
tier: "standard"
|
||||||
|
model: "auto"
|
||||||
|
reasoningEffort: null
|
||||||
|
permissionMode: "workspace-write"
|
||||||
|
defaults:
|
||||||
|
developer: "pi-dev-standard"
|
||||||
|
test: "cursor-test-standard"
|
||||||
|
developerUpgraded: "pi-dev-strong"
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/delivery.schema.json",
|
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/delivery.schema.json",
|
||||||
"title": "ACK project delivery contract",
|
"title": "ACK project delivery contract",
|
||||||
"description": "docs/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
|
"description": ".pouch/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
@@ -70,8 +70,18 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"testEnvironment": {
|
"testEnvironment": {
|
||||||
"type": ["string", "null"],
|
"oneOf": [
|
||||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
{ "type": "null" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["via", "env"],
|
||||||
|
"properties": {
|
||||||
|
"via": { "const": "deployer" },
|
||||||
|
"env": { "$ref": "#/definitions/id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 复制为 docs/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
|
# 复制为 .pouch/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
|
||||||
version: 1
|
version: 1
|
||||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||||
project:
|
project:
|
||||||
@@ -7,7 +7,8 @@ project:
|
|||||||
enabled: false
|
enabled: false
|
||||||
defaultProfile: null
|
defaultProfile: null
|
||||||
|
|
||||||
# 测试环境部署和版本发布都写在本文件。null 表示用户尚未说明该操作。
|
# 测试环境由 ACK 内部调用 deployer;发版仍指向本文件的 profile。
|
||||||
|
# null 表示用户尚未说明该操作。
|
||||||
intents:
|
intents:
|
||||||
testEnvironment: null
|
testEnvironment: null
|
||||||
release: null
|
release: null
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/knowledge.schema.json",
|
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/knowledge.schema.json",
|
||||||
"title": "ACK project knowledge guardrails",
|
"title": "ACK project knowledge guardrails",
|
||||||
"description": "docs/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
|
"description": ".pouch/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["version", "updatedAt", "project", "verificationRegistry", "entries"],
|
"required": ["version", "updatedAt", "project", "verificationRegistry", "entries"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||||
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
||||||
version: 1
|
version: 1
|
||||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
|
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
|
||||||
> `references/` 读取;本文件只保存项目差异。
|
> `references/` 读取;本文件只保存项目差异。
|
||||||
>
|
>
|
||||||
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`,
|
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `.pouch/ack/project.md`,
|
||||||
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
|
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
|
||||||
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
||||||
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||||
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的
|
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、默认关闭的
|
||||||
> `delivery.yaml`,
|
> `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖,
|
||||||
> 不复制或链接 Skill。
|
> 不复制或链接 Skill。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
@@ -18,10 +18,13 @@
|
|||||||
- 技术栈:`<tech_stack>`
|
- 技术栈:`<tech_stack>`
|
||||||
- 运行命令:`<run_command>`
|
- 运行命令:`<run_command>`
|
||||||
- Base URL:`<base_url>`
|
- Base URL:`<base_url>`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板配置:`.pouch/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`(追加式)
|
||||||
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
|
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||||
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
|
||||||
|
- 回归目录:`.pouch/ack/regression.yaml`
|
||||||
|
- 角色启动覆盖(可选):`.pouch/ack/agents.yaml`
|
||||||
|
- 覆盖层文件:`<overlay_file_path>`(默认 `.pouch/ack/project.md`)
|
||||||
|
|
||||||
## 通用规范(由 ACK Skill 按需读取)
|
## 通用规范(由 ACK Skill 按需读取)
|
||||||
|
|
||||||
@@ -33,12 +36,13 @@
|
|||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||||
- 验证后交付与配置维护(可选):`references/delivery.md`
|
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||||
|
- 回归目录与运行(可选):`references/regression.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
|
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
|
||||||
receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml` 的
|
||||||
`workerReceipts` 为准。本文件不保存可执行 worker 命令。
|
`project.orchestration` 与顶层 `workerReceipts` 为准。本文件不保存可执行 worker 命令。
|
||||||
|
|
||||||
默认 profile:
|
默认 profile:
|
||||||
|
|
||||||
@@ -65,8 +69,10 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
||||||
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||||
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
|
||||||
|
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
|
||||||
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令(项目覆盖层)
|
## 命令(项目覆盖层)
|
||||||
@@ -79,7 +85,7 @@ Developer 白盒验证:
|
|||||||
<local_run_command>
|
<local_run_command>
|
||||||
```
|
```
|
||||||
|
|
||||||
Test 黑盒复测(服务启动以 `delivery.yaml` 的 `intents.testEnvironment` 为准):
|
Test 黑盒复测(服务启动以 deployer 绑定的 `intents.testEnvironment` 为准):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
<preflight_command>
|
<preflight_command>
|
||||||
@@ -96,9 +102,10 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
`ACK_PROJECT_ROOT_DISPLAY`。
|
`ACK_PROJECT_ROOT_DISPLAY`。
|
||||||
|
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||||
|
`scripts/validate_regression.py` 执行。
|
||||||
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
|
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
|
||||||
本文件不维护第二套交付命令。
|
测试环境由 ACK 内部调用 deployer。本文件不维护第二套交付命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
@@ -112,16 +119,17 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
||||||
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
||||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
- 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`;
|
||||||
通过消息回报。
|
Developer 与 Test 都只读,通过消息回报。
|
||||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||||
`candidate` 不派发,知识库不全量注入。
|
`candidate` 不派发,知识库不全量注入。
|
||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
||||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境
|
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境
|
||||||
与发版都写在这份契约的 `intents` 里;用户明确要求重新部署测试环境或发布版本时
|
绑定 deployer,发版写在 `intents.release`;用户明确要求重新部署测试环境或发布
|
||||||
才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后运行。
|
版本时才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后
|
||||||
|
运行。回归目录在 `.pouch/ack/regression.yaml`,由用户明确要求「回归」时运行。
|
||||||
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
||||||
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/regression.schema.json",
|
||||||
|
"title": "ACK project regression catalog",
|
||||||
|
"description": ".pouch/ack/regression.yaml 的权威结构。用例是给 Test worker 的可观测信号说明书,不是可执行 DSL。",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "updatedAt", "project", "cases"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "integer", "const": 1 },
|
||||||
|
"updatedAt": { "type": "string" },
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/case" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"caseId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"stringList": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
},
|
||||||
|
"relativePath": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
|
||||||
|
},
|
||||||
|
"expectedSignal": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "value"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["visible-text", "api-status", "api-field", "url", "interaction"]
|
||||||
|
},
|
||||||
|
"value": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"case": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"source",
|
||||||
|
"suite",
|
||||||
|
"surface",
|
||||||
|
"setup",
|
||||||
|
"steps",
|
||||||
|
"expected"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/definitions/caseId" },
|
||||||
|
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"status": { "type": "string", "enum": ["active", "retired"] },
|
||||||
|
"source": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["taskId", "kind"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"taskId": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"kind": { "type": "string", "enum": ["feature", "bug"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suite": { "type": "string", "enum": ["smoke", "full"] },
|
||||||
|
"surface": { "type": "string", "enum": ["browser", "api"] },
|
||||||
|
"setup": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"steps": { "$ref": "#/definitions/stringList" },
|
||||||
|
"expected": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/definitions/expectedSignal" }
|
||||||
|
},
|
||||||
|
"automationRef": { "$ref": "#/definitions/relativePath" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 复制为 .pouch/ack/regression.yaml。只有 Coordinator 写入;Test 通过回报提名。
|
||||||
|
# 结构见 templates/regression.schema.json。
|
||||||
|
version: 1
|
||||||
|
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||||
|
project:
|
||||||
|
name: "<project_name>"
|
||||||
|
cases: []
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/tasks.schema.json",
|
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/tasks.schema.json",
|
||||||
"title": "ACK task board",
|
"title": "ACK task board",
|
||||||
"description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。",
|
"description": "tasks.yaml 的权威结构。跨语言可用;参考校验实现见 scripts/validate_tasks.py。",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"repoPath": {
|
"repoPath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"description": "Legacy informational field; ACK derives project files from --project-root/docs/ack"
|
"description": "Legacy informational field; ACK derives project files from --project-root/.pouch/ack"
|
||||||
},
|
},
|
||||||
"baseUrl": {
|
"baseUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -57,18 +57,23 @@
|
|||||||
},
|
},
|
||||||
"overlayFile": {
|
"overlayFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "项目覆盖层文件路径,默认 docs/ack/project.md,可自定义"
|
"description": "项目覆盖层文件路径,默认 .pouch/ack/project.md,可自定义"
|
||||||
},
|
},
|
||||||
"knowledgeFile": {
|
"knowledgeFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "docs/ack/knowledge.yaml",
|
"const": ".pouch/ack/knowledge.yaml",
|
||||||
"description": "项目知识护栏库的唯一权威路径"
|
"description": "项目知识护栏库的唯一权威路径"
|
||||||
},
|
},
|
||||||
"deliveryFile": {
|
"deliveryFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "docs/ack/delivery.yaml",
|
"const": ".pouch/ack/delivery.yaml",
|
||||||
"description": "可选项目交付契约的唯一权威路径"
|
"description": "可选项目交付契约的唯一权威路径"
|
||||||
},
|
},
|
||||||
|
"regressionFile": {
|
||||||
|
"type": "string",
|
||||||
|
"const": ".pouch/ack/regression.yaml",
|
||||||
|
"description": "可选项目回归目录的唯一权威路径"
|
||||||
|
},
|
||||||
"bugIntake": {
|
"bugIntake": {
|
||||||
"$ref": "#/definitions/feishuBugIntake"
|
"$ref": "#/definitions/feishuBugIntake"
|
||||||
},
|
},
|
||||||
@@ -110,6 +115,25 @@
|
|||||||
"statusReference": {
|
"statusReference": {
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"taskStore": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"directory"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"directory": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "相对 tasks.yaml 的不可变任务记录目录"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"workerReceipts": {
|
"workerReceipts": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -122,6 +146,12 @@
|
|||||||
"$ref": "#/definitions/deliveryRun"
|
"$ref": "#/definitions/deliveryRun"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"regressionRuns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/regressionRun"
|
||||||
|
}
|
||||||
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -243,6 +273,32 @@
|
|||||||
"deliveryRuns": {}
|
"deliveryRuns": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"regressionFile"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"regressionFile": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"regressionRuns"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"regressionRuns": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -554,8 +610,16 @@
|
|||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"oneOf": [
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null",
|
||||||
|
"description": "仅 Pi 使用 null 表示其配置的默认模型"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTierAllowlist": {
|
"modelTierAllowlist": {
|
||||||
@@ -596,6 +660,12 @@
|
|||||||
},
|
},
|
||||||
"grok": {
|
"grok": {
|
||||||
"$ref": "#/definitions/modelRoleAllowlist"
|
"$ref": "#/definitions/modelRoleAllowlist"
|
||||||
|
},
|
||||||
|
"omp": {
|
||||||
|
"$ref": "#/definitions/modelRoleAllowlist"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"$ref": "#/definitions/modelRoleAllowlist"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -610,6 +680,7 @@
|
|||||||
"permissionMode"
|
"permissionMode"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
|
||||||
"properties": {
|
"properties": {
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -624,7 +695,8 @@
|
|||||||
"codex",
|
"codex",
|
||||||
"cursor-agent",
|
"cursor-agent",
|
||||||
"grok",
|
"grok",
|
||||||
"omp"
|
"omp",
|
||||||
|
"pi"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tier": {
|
"tier": {
|
||||||
@@ -635,8 +707,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"oneOf": [
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null",
|
||||||
|
"description": "仅 Pi 使用 null 表示其配置的默认模型"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"reasoningEffort": {
|
"reasoningEffort": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -655,7 +735,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"read-only",
|
"read-only",
|
||||||
"workspace-write"
|
"workspace-write",
|
||||||
|
"danger-full-access"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -816,6 +897,7 @@
|
|||||||
"environmentPolicy"
|
"environmentPolicy"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
|
||||||
"properties": {
|
"properties": {
|
||||||
"cli": {
|
"cli": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -823,7 +905,8 @@
|
|||||||
"codex",
|
"codex",
|
||||||
"cursor-agent",
|
"cursor-agent",
|
||||||
"grok",
|
"grok",
|
||||||
"omp"
|
"omp",
|
||||||
|
"pi"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"tier": {
|
"tier": {
|
||||||
@@ -834,8 +917,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"oneOf": [
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null",
|
||||||
|
"description": "仅 Pi 使用 null 表示其配置的默认模型"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"reasoningEffort": {
|
"reasoningEffort": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -854,7 +945,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"read-only",
|
"read-only",
|
||||||
"workspace-write"
|
"workspace-write",
|
||||||
|
"danger-full-access"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"executable": {
|
"executable": {
|
||||||
@@ -981,6 +1073,13 @@
|
|||||||
"boardHash": {
|
"boardHash": {
|
||||||
"$ref": "#/definitions/sha256"
|
"$ref": "#/definitions/sha256"
|
||||||
},
|
},
|
||||||
|
"routingSource": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"routingHash": {
|
||||||
|
"$ref": "#/definitions/sha256"
|
||||||
|
},
|
||||||
"slot": {
|
"slot": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -1750,6 +1849,20 @@
|
|||||||
"$ref": "#/definitions/knowledgeCheck"
|
"$ref": "#/definitions/knowledgeCheck"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"regressionRefs": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regressionCandidates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/regressionCandidate"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"if": {
|
"if": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1908,6 +2021,131 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"regressionCandidate": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "surface", "steps", "expected"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"surface": { "type": "string", "enum": ["browser", "api"] },
|
||||||
|
"suite": { "type": "string", "enum": ["smoke", "full"] },
|
||||||
|
"setup": { "type": "string" },
|
||||||
|
"steps": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "value"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"visible-text",
|
||||||
|
"api-status",
|
||||||
|
"api-field",
|
||||||
|
"url",
|
||||||
|
"interaction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"value": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceKind": { "type": "string", "enum": ["feature", "bug"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regressionRun": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"suite",
|
||||||
|
"caseIds",
|
||||||
|
"status",
|
||||||
|
"sourceRevision",
|
||||||
|
"configRevision",
|
||||||
|
"baseUrl",
|
||||||
|
"results",
|
||||||
|
"evidence",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^RR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||||
|
},
|
||||||
|
"suite": { "type": "string", "enum": ["smoke", "full", "custom"] },
|
||||||
|
"caseIds": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskIds": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"deliveryRunId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"passed",
|
||||||
|
"failed",
|
||||||
|
"blocked",
|
||||||
|
"skipped"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceRevision": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{7,64}$"
|
||||||
|
},
|
||||||
|
"configRevision": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{7,64}$"
|
||||||
|
},
|
||||||
|
"baseUrl": { "type": ["string", "null"] },
|
||||||
|
"results": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["caseId", "result", "evidence"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"caseId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"result": { "type": "string", "enum": ["pass", "fail", "skipped"] },
|
||||||
|
"evidence": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"updatedAt": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
# 复制为 docs/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
|
# 复制为 .pouch/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
|
||||||
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"
|
||||||
ackVersion: "<接入时的 ack skill 版本>"
|
ackVersion: "<接入时的 ack skill 版本>"
|
||||||
|
# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。
|
||||||
|
taskStore:
|
||||||
|
version: 1
|
||||||
|
directory: "task-records"
|
||||||
project:
|
project:
|
||||||
name: "<project_name>"
|
name: "<project_name>"
|
||||||
baseUrl: "<base_url>"
|
baseUrl: "<base_url>"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: ".pouch/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: ".pouch/ack/knowledge.yaml"
|
||||||
deliveryFile: "docs/ack/delivery.yaml"
|
deliveryFile: ".pouch/ack/delivery.yaml"
|
||||||
|
regressionFile: ".pouch/ack/regression.yaml"
|
||||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||||
# bugIntake:
|
# bugIntake:
|
||||||
# provider: "feishu-base"
|
# provider: "feishu-base"
|
||||||
@@ -28,6 +33,7 @@ project:
|
|||||||
# attachments: "<field_id>"
|
# attachments: "<field_id>"
|
||||||
# updatedAt: "<field_id>"
|
# updatedAt: "<field_id>"
|
||||||
orchestration:
|
orchestration:
|
||||||
|
# 可选:临时角色路由写到 .pouch/ack/agents.yaml;存在时由 launcher 优先读取。
|
||||||
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
|
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
|
||||||
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
|
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
@@ -114,6 +120,7 @@ project:
|
|||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
deliveryRuns: []
|
deliveryRuns: []
|
||||||
|
regressionRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: []
|
verified: []
|
||||||
@@ -137,6 +144,8 @@ tasks:
|
|||||||
knowledgeApplied: []
|
knowledgeApplied: []
|
||||||
knowledgeCandidates: []
|
knowledgeCandidates: []
|
||||||
knowledgeChecks: []
|
knowledgeChecks: []
|
||||||
|
regressionRefs: []
|
||||||
|
regressionCandidates: []
|
||||||
|
|
||||||
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||||
# source:
|
# source:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# agent-runtime
|
||||||
|
|
||||||
|
统一维护项目的 Agent profile、Orca 执行环境、worktree 和跨 session terminal。它让
|
||||||
|
`builder.android`、`builder.ios` 等 binding 成为可配置路由,而不是写死在 skill 代码里的
|
||||||
|
枚举。
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- 给不同 builder/tester/ACK 绑定不同机器、项目目录、Agent CLI 或模型。
|
||||||
|
- 创建远程 Mac/本地 worktree,并需要后续 session 继续查看构建。
|
||||||
|
- 检查 `.pouch/agent.yaml` 是否能被所有 adapter 安全读取。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- 项目根已有 `.pouch/agent.yaml`。
|
||||||
|
- 远程 environment 已通过 Orca 配对,且 project/worktree 已存在或被明确创建。
|
||||||
|
- 机器路径、project id/name 和模型信息由使用者确认;配置不包含凭据。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
把 builder.android 绑定到本地 app,把 builder.ios 绑定到 macmini,创建 iOS worktree,
|
||||||
|
然后让 builder 继续使用同一个 session 构建。
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 校验并解析通用配置。
|
||||||
|
2. 按 environment、host、project、绝对 path 精确定位 Orca worktree。
|
||||||
|
3. 按需要创建持久 terminal,并记录可显式恢复的 run receipt。
|
||||||
|
|
||||||
|
不会自动复用旧 terminal、执行配置中的自由 shell、读取 secrets 或上传产物。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- `agent.py validate` 通过。
|
||||||
|
- 每个 binding 能唯一解析到目标 worktree,或明确报告需创建/登记的环境。
|
||||||
|
- session 可用 run id 查询、attach 和 stop。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: agent-runtime
|
||||||
|
description: >-
|
||||||
|
维护项目通用 Agent 运行配置、环境绑定、Orca worktree 与跨 session terminal;
|
||||||
|
适用于 builder.android、builder.ios、tester.test、ack.dev 等可扩展绑定。
|
||||||
|
不负责具体构建、测试或 ACK 闭环。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Runtime:统一环境、Agent 与工作区
|
||||||
|
|
||||||
|
本 skill 是 builder、tester、ACK 和 iOS tester 共用的配置层。每个 Git 项目在自己的
|
||||||
|
根目录维护 `.pouch/agent.yaml`;父仓库与 submodule 各自拥有配置,不跨仓库继承。机器
|
||||||
|
专属覆盖放 `.pouch/agent.local.yaml`,运行记录放 `.pouch/agent/runs/`,两者都不提交。
|
||||||
|
|
||||||
|
## 配置模型
|
||||||
|
|
||||||
|
- `locations`:执行环境、Orca project、目标机器上的绝对路径;不放模型和命令。
|
||||||
|
- `profiles`:CLI、模型、reasoning effort、权限和档位;不放路径和 secrets。
|
||||||
|
- `bindings`:任意安全的 `namespace.name`,绑定一个 adapter、location、profile 和
|
||||||
|
adapter 目标。`builder.android`、`builder.ios`、`builder.harmony` 不需要修改代码即可并存。
|
||||||
|
- `adapter` 目前允许 `builder`、`tester`、`ack`。adapter 的具体执行规则仍由对应 skill
|
||||||
|
负责;本层只提供解析、worktree 和 session 生命周期。
|
||||||
|
- `reasoningEffort` 是规范字段;不要新增 `effect`、自由 `command`、`argv`、`shell`、
|
||||||
|
`env` 或 token 字段。
|
||||||
|
|
||||||
|
完整字段、合并和安全规则见 [references/config.md](references/config.md)。需要实际解析或
|
||||||
|
创建工作区时使用 `scripts/agent.py`,不要手工拼接 Orca JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/agent.py validate --root <project-root>
|
||||||
|
python3 <skill-dir>/scripts/agent.py show --root <project-root> --binding builder.android
|
||||||
|
python3 <skill-dir>/scripts/agent.py worktree resolve --root <project-root> --binding builder.ios
|
||||||
|
python3 <skill-dir>/scripts/agent.py worktree create --root <project-root> \
|
||||||
|
--binding builder.ios --name writes-ios --base main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
`builder` 绑定可通过统一入口创建持久 terminal;返回的 run 记录包含 binding、profile
|
||||||
|
hash、环境、worktree、terminal handle 和 source commit。跨 session 只能显式 `status`、
|
||||||
|
`attach`、`stop` 或 `resume`,不能自动复用不明 terminal。tester 的每轮 terminal 仍然
|
||||||
|
是临时的,ACK 仍然必须走其带 fingerprint/receipt 的 launcher。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/agent.py run start --root <project-root> --binding builder.android
|
||||||
|
python3 <skill-dir>/scripts/agent.py run status --root <project-root> --run <run-id>
|
||||||
|
python3 <skill-dir>/scripts/agent.py run attach --root <project-root> --run <run-id>
|
||||||
|
python3 <skill-dir>/scripts/agent.py run resume --root <project-root> --run <run-id>
|
||||||
|
python3 <skill-dir>/scripts/agent.py run stop --root <project-root> --run <run-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill
|
||||||
|
适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。
|
||||||
|
- remote binding 必须能按 `environment + host + path + project` 精确找到 Orca worktree。
|
||||||
|
- 新 binding 只增加配置和对应 adapter 支持,不复制一套 skill 或修改固定枚举。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Agent Runtime"
|
||||||
|
short_description: "统一 Agent 环境、worktree、profile 与跨 session 运行配置"
|
||||||
|
default_prompt: "Use $agent-runtime to validate shared agent config, resolve or create a worktree, or inspect a persistent session."
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Agent Runtime 配置
|
||||||
|
|
||||||
|
## 文件与合并
|
||||||
|
|
||||||
|
提交 `.pouch/agent.yaml`。若存在 `.pouch/agent.local.yaml`,按 mapping 深度合并;local
|
||||||
|
文件只能覆盖同名字段,不能增加未声明的顶层结构。合并后的配置必须再次完整校验。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schemaVersion: 1
|
||||||
|
locations:
|
||||||
|
local-app:
|
||||||
|
environment: local
|
||||||
|
host: local
|
||||||
|
project: git:git.yumee.top/bloggarden/app
|
||||||
|
path: /home/ace/code/draft2/blogger/app
|
||||||
|
remote-ios:
|
||||||
|
environment: macmini
|
||||||
|
host: runtime:14b6d8ce-4528-49f4-8d46-56b266a5ad69
|
||||||
|
project: git:git.yumee.top/bloggarden/app
|
||||||
|
path: /Users/laily/workrepo/bloggarden-app
|
||||||
|
profiles:
|
||||||
|
android-builder:
|
||||||
|
role: build
|
||||||
|
cli: codex
|
||||||
|
tier: standard
|
||||||
|
model: gpt-5
|
||||||
|
reasoningEffort: medium
|
||||||
|
permissionMode: workspace-write
|
||||||
|
ios-builder:
|
||||||
|
role: build
|
||||||
|
cli: codex
|
||||||
|
tier: strong
|
||||||
|
model: gpt-5
|
||||||
|
reasoningEffort: high
|
||||||
|
permissionMode: workspace-write
|
||||||
|
bindings:
|
||||||
|
builder.android:
|
||||||
|
adapter: builder
|
||||||
|
location: local-app
|
||||||
|
profile: android-builder
|
||||||
|
makefile: makefile.builder
|
||||||
|
target: android
|
||||||
|
session:
|
||||||
|
lifecycle: persistent
|
||||||
|
builder.ios:
|
||||||
|
adapter: builder
|
||||||
|
location: remote-ios
|
||||||
|
profile: ios-builder
|
||||||
|
makefile: makefile.builder
|
||||||
|
target: ios
|
||||||
|
session:
|
||||||
|
lifecycle: persistent
|
||||||
|
```
|
||||||
|
|
||||||
|
## 字段原则
|
||||||
|
|
||||||
|
- binding、location、profile ID 只允许小写字母、数字和 `-`,binding 用一个或多个点
|
||||||
|
分隔;ID 是路由名,不是任意 shell 文本。
|
||||||
|
- `environment: local` 使用当前 Orca runtime;远程环境使用已保存的名称。`host` 必须
|
||||||
|
是 `local` 或 `runtime:<environment-id>`,由 Orca 查询结果证明,不能凭主机名猜测。
|
||||||
|
- `project` 必须是 Orca project id 或精确 display name;`path` 必须是目标机器上的绝对
|
||||||
|
路径。解析 worktree 时四者必须同时匹配,标题、分支和 basename 不能消歧。
|
||||||
|
- `profile` 的 CLI 只能来自 ACK/tester 共同 allowlist:`codex`、`cursor-agent`、`grok`、
|
||||||
|
`omp`、`pi`。`model` 为安全模型 ID 或 null;`reasoningEffort` 为 `low|medium|high|xhigh`
|
||||||
|
或 null;权限只允许 `read-only|workspace-write`。
|
||||||
|
- builder 的 `target` 只能是 make target 名;`makefile` 只能是项目内相对文件名。脚本、
|
||||||
|
远端命令、环境变量和凭据由 builder/Orca adapter 提供,不能写入此配置。
|
||||||
|
- `session.lifecycle` 为 `persistent` 或 `ephemeral`。persistent 只表示允许显式跨
|
||||||
|
session attach/resume,不表示自动复用旧 terminal。
|
||||||
|
|
||||||
|
旧版 `.pouch/tester/local.yaml` 和 `.pouch/ack/agents.yaml` 继续兼容。迁移时应让它们
|
||||||
|
由 adapter 从 common profile/location 生成或覆盖,不能删除旧 receipt、fingerprint 或
|
||||||
|
tester 的每轮 terminal 清理语义。
|
||||||
Executable
+490
@@ -0,0 +1,490 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate and execute the shared project Agent runtime contract.
|
||||||
|
|
||||||
|
The command surface is deliberately small. It never evaluates project text as
|
||||||
|
shell, and it only creates builder sessions from a fixed make invocation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError as exc: # pragma: no cover - dependency is checked at runtime
|
||||||
|
raise SystemExit("需要 PyYAML: python3 -m pip install pyyaml") from exc
|
||||||
|
|
||||||
|
|
||||||
|
CLI_NAMES = {"codex", "cursor-agent", "grok", "omp", "pi"}
|
||||||
|
ADAPTERS = {"builder", "tester", "ack"}
|
||||||
|
EFFORTS = {None, "low", "medium", "high", "xhigh"}
|
||||||
|
PERMISSIONS = {"read-only", "workspace-write"}
|
||||||
|
TIERS = {"standard", "strong"}
|
||||||
|
LIFECYCLES = {"persistent", "ephemeral"}
|
||||||
|
ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
|
||||||
|
BINDING_RE = re.compile(r"^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$")
|
||||||
|
TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]{1,96}$")
|
||||||
|
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$")
|
||||||
|
MAKEFILE_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$")
|
||||||
|
FORBIDDEN_KEYS = {"command", "argv", "shell", "env", "environmentVariables", "token", "secret", "password"}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueLoader(yaml.SafeLoader):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _construct_mapping(loader: UniqueLoader, node: yaml.MappingNode, deep: bool = False) -> dict[str, Any]:
|
||||||
|
mapping: dict[str, Any] = {}
|
||||||
|
for key_node, value_node in node.value:
|
||||||
|
key = loader.construct_object(key_node, deep=deep)
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise ConfigError("YAML mapping key 必须是字符串")
|
||||||
|
if key in mapping:
|
||||||
|
raise ConfigError(f"YAML 存在重复键: {key}")
|
||||||
|
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueLoader)
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise ConfigError(f"无法读取 {path}: {exc}") from exc
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigError(f"{path} 顶层必须是 mapping")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(base: dict[str, Any], override: dict[str, Any], where: str = "root") -> dict[str, Any]:
|
||||||
|
result = copy.deepcopy(base)
|
||||||
|
for key, value in override.items():
|
||||||
|
if key not in result:
|
||||||
|
raise ConfigError(f"{where}: local 配置不能增加未声明字段 {key!r}")
|
||||||
|
if isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
result[key] = deep_merge(result[key], value, f"{where}.{key}")
|
||||||
|
else:
|
||||||
|
result[key] = copy.deepcopy(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def text(value: Any, where: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
|
||||||
|
raise ConfigError(f"{where}: 必须是非空文本")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def safe_id(value: Any, where: str) -> str:
|
||||||
|
value = text(value, where)
|
||||||
|
if not ID_RE.fullmatch(value):
|
||||||
|
raise ConfigError(f"{where}: ID 只能使用小写字母、数字和连字符")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def safe_binding_id(value: Any, where: str) -> str:
|
||||||
|
value = text(value, where)
|
||||||
|
if not BINDING_RE.fullmatch(value):
|
||||||
|
raise ConfigError(f"{where}: binding 必须是 namespace.name 形式")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def safe_path(value: Any, where: str) -> str:
|
||||||
|
value = text(value, where)
|
||||||
|
path = Path(value)
|
||||||
|
if not path.is_absolute() or value == os.path.sep or ".." in path.parts or os.path.normpath(value) != value:
|
||||||
|
raise ConfigError(f"{where}: 必须是规范绝对路径")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate(config: dict[str, Any]) -> None:
|
||||||
|
if config.get("schemaVersion") != 1:
|
||||||
|
raise ConfigError("schemaVersion 必须为 1")
|
||||||
|
for key in ("locations", "profiles", "bindings"):
|
||||||
|
if not isinstance(config.get(key), dict) or not config[key]:
|
||||||
|
raise ConfigError(f"{key}: 必须是非空 mapping")
|
||||||
|
locations = config["locations"]
|
||||||
|
for location_id, location in locations.items():
|
||||||
|
safe_id(location_id, f"locations.{location_id}")
|
||||||
|
if not isinstance(location, dict):
|
||||||
|
raise ConfigError(f"locations.{location_id}: 必须是 mapping")
|
||||||
|
for required in ("environment", "project", "path"):
|
||||||
|
if required not in location:
|
||||||
|
raise ConfigError(f"locations.{location_id}: 缺少 {required}")
|
||||||
|
environment = text(location["environment"], f"locations.{location_id}.environment")
|
||||||
|
if environment != "local" and not ID_RE.fullmatch(environment):
|
||||||
|
raise ConfigError(f"locations.{location_id}.environment: 必须是 local 或安全环境名")
|
||||||
|
text(location["project"], f"locations.{location_id}.project")
|
||||||
|
safe_path(location["path"], f"locations.{location_id}.path")
|
||||||
|
host = location.get("host")
|
||||||
|
if host is not None:
|
||||||
|
host = text(host, f"locations.{location_id}.host")
|
||||||
|
if host != "local" and not re.fullmatch(r"runtime:[0-9a-f-]{16,}", host):
|
||||||
|
raise ConfigError(f"locations.{location_id}.host: 必须是 local 或 runtime:<environment-id>")
|
||||||
|
profiles = config["profiles"]
|
||||||
|
for profile_id, profile in profiles.items():
|
||||||
|
safe_id(profile_id, f"profiles.{profile_id}")
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
raise ConfigError(f"profiles.{profile_id}: 必须是 mapping")
|
||||||
|
for required in ("cli", "model", "reasoningEffort", "permissionMode"):
|
||||||
|
if required not in profile:
|
||||||
|
raise ConfigError(f"profiles.{profile_id}: 缺少 {required}")
|
||||||
|
if profile["cli"] not in CLI_NAMES:
|
||||||
|
raise ConfigError(f"profiles.{profile_id}.cli: 不支持 {profile['cli']!r}")
|
||||||
|
if profile["model"] is not None and MODEL_RE.fullmatch(str(profile["model"])) is None:
|
||||||
|
raise ConfigError(f"profiles.{profile_id}.model: 不是安全模型 ID")
|
||||||
|
if profile["reasoningEffort"] not in EFFORTS:
|
||||||
|
raise ConfigError(f"profiles.{profile_id}.reasoningEffort: 不支持该值")
|
||||||
|
if profile["permissionMode"] not in PERMISSIONS:
|
||||||
|
raise ConfigError(f"profiles.{profile_id}.permissionMode: 不支持该值")
|
||||||
|
if profile.get("tier", "standard") not in TIERS:
|
||||||
|
raise ConfigError(f"profiles.{profile_id}.tier: 必须是 standard/strong")
|
||||||
|
for binding_id, binding in config["bindings"].items():
|
||||||
|
safe_binding_id(binding_id, f"bindings.{binding_id}")
|
||||||
|
if not isinstance(binding, dict):
|
||||||
|
raise ConfigError(f"bindings.{binding_id}: 必须是 mapping")
|
||||||
|
for required in ("adapter", "location", "profile"):
|
||||||
|
if required not in binding:
|
||||||
|
raise ConfigError(f"bindings.{binding_id}: 缺少 {required}")
|
||||||
|
if binding["adapter"] not in ADAPTERS:
|
||||||
|
raise ConfigError(f"bindings.{binding_id}.adapter: 不支持 {binding['adapter']!r}")
|
||||||
|
location_id = safe_id(binding["location"], f"bindings.{binding_id}.location")
|
||||||
|
profile_id = safe_id(binding["profile"], f"bindings.{binding_id}.profile")
|
||||||
|
if location_id not in locations or profile_id not in profiles:
|
||||||
|
raise ConfigError(f"bindings.{binding_id}: location/profile 引用不存在")
|
||||||
|
if binding["adapter"] == "builder":
|
||||||
|
makefile = text(binding.get("makefile", "makefile.builder"), f"bindings.{binding_id}.makefile")
|
||||||
|
if Path(makefile).is_absolute() or ".." in Path(makefile).parts or MAKEFILE_RE.fullmatch(makefile) is None:
|
||||||
|
raise ConfigError(f"bindings.{binding_id}.makefile: 必须是项目内相对路径")
|
||||||
|
target = text(binding.get("target"), f"bindings.{binding_id}.target")
|
||||||
|
if TARGET_RE.fullmatch(target) is None:
|
||||||
|
raise ConfigError(f"bindings.{binding_id}.target: 不是安全 make target")
|
||||||
|
session = binding.get("session", {})
|
||||||
|
if not isinstance(session, dict) or session.get("lifecycle", "ephemeral") not in LIFECYCLES:
|
||||||
|
raise ConfigError(f"bindings.{binding_id}.session.lifecycle: 必须是 persistent/ephemeral")
|
||||||
|
scan_forbidden(config)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_forbidden(value: Any, where: str = "root") -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
if key in FORBIDDEN_KEYS:
|
||||||
|
raise ConfigError(f"{where}: 禁止配置字段 {key!r}")
|
||||||
|
scan_forbidden(child, f"{where}.{key}")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, child in enumerate(value):
|
||||||
|
scan_forbidden(child, f"{where}[{index}]")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(root: Path) -> dict[str, Any]:
|
||||||
|
path = root / ".pouch" / "agent.yaml"
|
||||||
|
if not path.is_file():
|
||||||
|
raise ConfigError(f"缺少 {path},请先创建项目通用 Agent 配置")
|
||||||
|
config = load_yaml(path)
|
||||||
|
local_path = root / ".pouch" / "agent.local.yaml"
|
||||||
|
if local_path.is_file():
|
||||||
|
config = deep_merge(config, load_yaml(local_path))
|
||||||
|
validate(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def binding(config: dict[str, Any], binding_id: str) -> dict[str, Any]:
|
||||||
|
if binding_id not in config["bindings"]:
|
||||||
|
raise ConfigError(f"不存在 binding: {binding_id}")
|
||||||
|
value = copy.deepcopy(config["bindings"][binding_id])
|
||||||
|
value["id"] = binding_id
|
||||||
|
value["locationId"] = value["location"]
|
||||||
|
value["location"] = copy.deepcopy(config["locations"][value["locationId"]])
|
||||||
|
value["profileId"] = value["profile"]
|
||||||
|
value["profile"] = copy.deepcopy(config["profiles"][value["profileId"]])
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def orca_json(args: list[str], environment: str | None = None) -> dict[str, Any]:
|
||||||
|
command = ["orca", *args]
|
||||||
|
if environment and environment != "local":
|
||||||
|
command.extend(["--environment", environment])
|
||||||
|
command.append("--json")
|
||||||
|
proc = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||||
|
raw = (proc.stdout or "").strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
detail = (proc.stderr or raw or str(exc)).strip()
|
||||||
|
raise ConfigError(f"Orca 没有返回 JSON: {' '.join(command)}\n{detail}") from exc
|
||||||
|
if not data.get("ok"):
|
||||||
|
error = data.get("error") or {}
|
||||||
|
raise ConfigError(f"{' '.join(command)}: {error.get('message') or error.get('code') or 'unknown error'}")
|
||||||
|
result = data.get("result")
|
||||||
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def environment_id(name: str) -> str:
|
||||||
|
if name == "local":
|
||||||
|
return "local"
|
||||||
|
result = orca_json(["environment", "list"])
|
||||||
|
for item in result.get("environments", []):
|
||||||
|
if isinstance(item, dict) and item.get("name") == name:
|
||||||
|
return str(item.get("id"))
|
||||||
|
raise ConfigError(f"找不到已保存 Orca environment: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def location_host(location: dict[str, Any]) -> str:
|
||||||
|
host = location.get("host")
|
||||||
|
if host:
|
||||||
|
return str(host)
|
||||||
|
if location["environment"] == "local":
|
||||||
|
return "local"
|
||||||
|
return f"runtime:{environment_id(str(location['environment']))}"
|
||||||
|
|
||||||
|
|
||||||
|
def worktree_for(binding_value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
location = binding_value["location"]
|
||||||
|
environment = str(location["environment"])
|
||||||
|
project_ids = {str(location["project"])}
|
||||||
|
projects = orca_json(["project", "list"], environment).get("projects", [])
|
||||||
|
for project in projects:
|
||||||
|
if not isinstance(project, dict):
|
||||||
|
continue
|
||||||
|
if str(project.get("id")) == str(location["project"]) or str(project.get("displayName")) == str(location["project"]):
|
||||||
|
project_ids.update(str(repo_id) for repo_id in project.get("sourceRepoIds", []) if repo_id)
|
||||||
|
result = orca_json(["worktree", "list"], environment)
|
||||||
|
candidates = []
|
||||||
|
for item in result.get("worktrees", []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_project_ids = {str(item.get("projectId")), str(item.get("repoId"))}
|
||||||
|
if not project_ids.intersection(item_project_ids):
|
||||||
|
continue
|
||||||
|
if str(item.get("path")) != str(location["path"]):
|
||||||
|
continue
|
||||||
|
expected_host = location_host(location)
|
||||||
|
# A routed remote runtime stamps its own worktrees with hostId=local;
|
||||||
|
# the environment name/ID is the remote-machine discriminator.
|
||||||
|
if location["environment"] == "local" and expected_host == "local" and str(item.get("hostId")) != "local":
|
||||||
|
continue
|
||||||
|
candidates.append(item)
|
||||||
|
if len(candidates) != 1:
|
||||||
|
if not candidates:
|
||||||
|
raise ConfigError(
|
||||||
|
f"找不到精确 worktree: environment={environment}, host={location_host(location)}, "
|
||||||
|
f"project={location['project']}, path={location['path']};先创建或登记该 worktree"
|
||||||
|
)
|
||||||
|
raise ConfigError(f"binding 匹配到多个 worktree: {len(candidates)}")
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def project_root_commit(root: Path) -> str | None:
|
||||||
|
proc = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, check=False)
|
||||||
|
return proc.stdout.strip() if proc.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_dir(root: Path) -> Path:
|
||||||
|
path = root / ".pouch" / "agent" / "runs"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def run_path(root: Path, run_id: str) -> Path:
|
||||||
|
if not re.fullmatch(r"run_[0-9a-f]{16,64}", run_id):
|
||||||
|
raise ConfigError("run id 格式无效")
|
||||||
|
return run_dir(root) / f"{run_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(args: argparse.Namespace) -> int:
|
||||||
|
config = load_config(Path(args.root).resolve())
|
||||||
|
print(json.dumps({"ok": True, "schemaVersion": config["schemaVersion"], "bindings": sorted(config["bindings"])}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show(args: argparse.Namespace) -> int:
|
||||||
|
config = load_config(Path(args.root).resolve())
|
||||||
|
value = binding(config, args.binding)
|
||||||
|
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_worktree_resolve(args: argparse.Namespace) -> int:
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
value = binding(load_config(root), args.binding)
|
||||||
|
worktree = worktree_for(value)
|
||||||
|
print(json.dumps({"binding": args.binding, "worktree": worktree}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_worktree_create(args: argparse.Namespace) -> int:
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
value = binding(load_config(root), args.binding)
|
||||||
|
location = value["location"]
|
||||||
|
try:
|
||||||
|
existing = worktree_for(value)
|
||||||
|
except ConfigError as exc:
|
||||||
|
if "找不到精确 worktree" not in str(exc):
|
||||||
|
raise
|
||||||
|
existing = None
|
||||||
|
if existing:
|
||||||
|
print(json.dumps({"created": False, "binding": args.binding, "worktree": existing}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
host = location_host(location)
|
||||||
|
command = ["worktree", "create", "--project", str(location["project"]), "--host", host, "--name", args.name, "--no-parent"]
|
||||||
|
if args.base:
|
||||||
|
command.extend(["--base-branch", args.base])
|
||||||
|
result = orca_json(command, str(location["environment"]))
|
||||||
|
print(json.dumps({"created": True, "binding": args.binding, "result": result, "next": "将返回 worktree 的 path 写回 location.path 后再运行 resolve"}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def make_command(value: dict[str, Any]) -> str:
|
||||||
|
# Replace the shell so terminal wait observes the make exit code instead
|
||||||
|
# of waiting forever on the interactive shell after make returns.
|
||||||
|
return f"exec make -f {value.get('makefile', 'makefile.builder')} {value['target']}"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run_start(args: argparse.Namespace) -> int:
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
value = binding(load_config(root), args.binding)
|
||||||
|
if value["adapter"] != "builder":
|
||||||
|
raise ConfigError(f"run start 目前只为 builder 提供受控 make session;{value['adapter']} 请走对应 skill adapter")
|
||||||
|
if value.get("session", {}).get("lifecycle", "ephemeral") != "persistent":
|
||||||
|
raise ConfigError(f"binding {args.binding} 不是 persistent session")
|
||||||
|
worktree = worktree_for(value)
|
||||||
|
environment = str(value["location"]["environment"])
|
||||||
|
result = orca_json(["terminal", "create", "--worktree", f"id:{worktree['id']}", "--command", make_command(value), "--title", args.binding], environment)
|
||||||
|
terminal = result.get("terminal") or result
|
||||||
|
handle = terminal.get("handle") or result.get("handle")
|
||||||
|
if not handle:
|
||||||
|
raise ConfigError("Orca terminal create 没有返回 handle")
|
||||||
|
run_id = "run_" + uuid.uuid4().hex
|
||||||
|
record = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": run_id,
|
||||||
|
"binding": args.binding,
|
||||||
|
"adapter": value["adapter"],
|
||||||
|
"profileId": value["profileId"],
|
||||||
|
"profileHash": hashlib.sha256(json.dumps(value["profile"], sort_keys=True).encode()).hexdigest(),
|
||||||
|
"environment": environment,
|
||||||
|
"project": value["location"]["project"],
|
||||||
|
"path": value["location"]["path"],
|
||||||
|
"worktreeId": worktree["id"],
|
||||||
|
"terminalHandle": handle,
|
||||||
|
"command": make_command(value),
|
||||||
|
"sourceCommit": project_root_commit(root),
|
||||||
|
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"status": "started",
|
||||||
|
}
|
||||||
|
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(record, ensure_ascii=False, indent=2))
|
||||||
|
if args.wait_ms:
|
||||||
|
wait = orca_json(["terminal", "wait", "--terminal", handle, "--for", "exit", "--timeout-ms", str(args.wait_ms)], environment)
|
||||||
|
record["wait"] = wait
|
||||||
|
record["status"] = "completed" if wait.get("exited") or wait.get("status") == "exited" else "waited"
|
||||||
|
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(wait, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def load_run(root: Path, run_id: str) -> dict[str, Any]:
|
||||||
|
path = run_path(root, run_id)
|
||||||
|
if not path.is_file():
|
||||||
|
raise ConfigError(f"找不到 run 记录: {run_id}")
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigError(f"run 记录无效: {run_id}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run_status(args: argparse.Namespace) -> int:
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
record = load_run(root, args.run)
|
||||||
|
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
|
||||||
|
print(json.dumps({"run": record, "terminal": result}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run_attach(args: argparse.Namespace) -> int:
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
record = load_run(root, args.run)
|
||||||
|
result = orca_json(["terminal", "read", "--terminal", record["terminalHandle"]], record.get("environment"))
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run_resume(args: argparse.Namespace) -> int:
|
||||||
|
"""Explicitly reattach to a recorded terminal; never search for another one."""
|
||||||
|
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
record = load_run(root, args.run)
|
||||||
|
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
|
||||||
|
print(json.dumps({"resumed": True, "run": record, "terminal": result}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run_stop(args: argparse.Namespace) -> int:
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
record = load_run(root, args.run)
|
||||||
|
result = orca_json(["terminal", "close", "--terminal", record["terminalHandle"]], record.get("environment"))
|
||||||
|
record["status"] = "stopped"
|
||||||
|
record["stoppedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
run_path(root, args.run).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps({"run": record, "close": result}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
root_parent = argparse.ArgumentParser(add_help=False)
|
||||||
|
root_parent.add_argument("--root", default=".")
|
||||||
|
parser = argparse.ArgumentParser(description="共享 Agent 配置、worktree 和 session 运行时")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
validate_parser = sub.add_parser("validate", parents=[root_parent])
|
||||||
|
validate_parser.set_defaults(func=cmd_validate)
|
||||||
|
show = sub.add_parser("show", parents=[root_parent])
|
||||||
|
show.add_argument("--binding", required=True)
|
||||||
|
show.set_defaults(func=cmd_show)
|
||||||
|
wt = sub.add_parser("worktree")
|
||||||
|
wt_sub = wt.add_subparsers(dest="worktree_command", required=True)
|
||||||
|
resolve = wt_sub.add_parser("resolve", parents=[root_parent])
|
||||||
|
resolve.add_argument("--binding", required=True)
|
||||||
|
resolve.set_defaults(func=cmd_worktree_resolve)
|
||||||
|
create = wt_sub.add_parser("create", parents=[root_parent])
|
||||||
|
create.add_argument("--binding", required=True)
|
||||||
|
create.add_argument("--name", required=True)
|
||||||
|
create.add_argument("--base", default="")
|
||||||
|
create.set_defaults(func=cmd_worktree_create)
|
||||||
|
run = sub.add_parser("run")
|
||||||
|
run_sub = run.add_subparsers(dest="run_command", required=True)
|
||||||
|
start = run_sub.add_parser("start", parents=[root_parent])
|
||||||
|
start.add_argument("--binding", required=True)
|
||||||
|
start.add_argument("--wait-ms", type=int, default=0)
|
||||||
|
start.set_defaults(func=cmd_run_start)
|
||||||
|
for name, func in (("status", cmd_run_status), ("attach", cmd_run_attach), ("resume", cmd_run_resume), ("stop", cmd_run_stop)):
|
||||||
|
item = run_sub.add_parser(name, parents=[root_parent])
|
||||||
|
item.add_argument("--run", required=True)
|
||||||
|
item.set_defaults(func=func)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
parsed = parser().parse_args()
|
||||||
|
raise SystemExit(parsed.func(parsed))
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"environment-fail: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# android-tester
|
||||||
|
|
||||||
|
在 Android 真机或 Debian Linux Android 模拟器上执行测试;每次先检查环境,最后生成可追溯的测试结果。
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- 「检查 Android 真机测试环境」
|
||||||
|
- 「检查 Debian Android 模拟器环境」
|
||||||
|
- 「用真机跑 Android smoke」
|
||||||
|
- 「用 AVD 跑 Android full」
|
||||||
|
- 「安装 Debian 上的 Android 模拟器」
|
||||||
|
- 「查看本次 Android 测试结果」
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- 项目已有 `.pouch/tester/` 和测试用例。
|
||||||
|
- 真机:开启 USB 调试并在设备上授权当前电脑。
|
||||||
|
- 模拟器:准备支持虚拟化的 Debian amd64 主机;首次使用可让 skill 安装 KVM、SDK
|
||||||
|
Command-line Tools、Platform-Tools、Emulator 和指定系统镜像。
|
||||||
|
- 需要安装或启动应用时,提供 APK、包名和启动组件,或提供项目自己的测试命令。
|
||||||
|
- 安装模式会使用 sudo、apt 和用户组变更;运行前会先展示变更范围。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
检查 Android 真机环境,然后跑 smoke 测试
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
在 Debian 上安装 Android 模拟器,使用项目需要的 Android API 创建 AVD
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 Pixel_API_35 模拟器跑 Android full,失败时保留 logcat 和截图
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 明确选择真机或模拟器,不在两者之间自动切换。
|
||||||
|
2. 先检查 ADB、KVM、AVD、设备授权、启动状态和项目测试配置。
|
||||||
|
3. 模拟器路径可在 Debian amd64 上安装主机依赖和 Android SDK 模拟器组件。
|
||||||
|
4. 执行 `.pouch/tester/` 中命中的 Android 用例。
|
||||||
|
5. 生成 YAML 运行快照、CSV 成绩单,失败时附带 logcat 和截图。
|
||||||
|
|
||||||
|
安装、安装 APK、清数据和修改设备设置都属于有副作用的操作,必须得到明确授权。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- 环境检查有明确的通过项和阻塞项。
|
||||||
|
- 每条用例都有 `pass`、`fail`、`environment-fail`、`skipped` 或 `not-run` 结果。
|
||||||
|
- 运行记录位于 `.pouch/tester/runs/`,且 YAML 与 CSV 的 run id 一致。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: android-tester
|
||||||
|
description: >-
|
||||||
|
检查并运行 Android 真机或模拟器测试;支持 Debian amd64 上安装
|
||||||
|
Android Emulator、KVM 依赖、SDK 工具和 AVD。触发词:Android 测试、安卓测试、
|
||||||
|
真机测试、Android 模拟器、AVD、ADB、安装安卓模拟器、检查 Android 环境。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Android Tester
|
||||||
|
|
||||||
|
项目根是 `git rev-parse --show-toplevel`。本 skill 复用项目已有的
|
||||||
|
`.pouch/tester/` 用例和运行记录,不创建第二套测试目录。
|
||||||
|
|
||||||
|
## 模式路由
|
||||||
|
|
||||||
|
- 用户要求安装或配置 Android 模拟器:先读 `references/install-debian.md`。
|
||||||
|
- 用户要求检查环境:先读 `references/environment.md`,再按目标读 `device.md` 或
|
||||||
|
`emulator.md`。
|
||||||
|
- 用户要求用真机测试:目标固定为 `device`,读 `device.md`。
|
||||||
|
- 用户要求用模拟器测试:目标固定为 `emulator`,读 `emulator.md`。
|
||||||
|
- 用户要求报告结果或复盘失败:读 `references/reporting.md`。
|
||||||
|
|
||||||
|
## 全局硬规则
|
||||||
|
|
||||||
|
- 每次运行测试前必须重新检查环境;检查失败记为 `environment-fail`,不伪装成产品失败。
|
||||||
|
- 真机与模拟器必须由用户明确选择,禁止自动 fallback。
|
||||||
|
- 运行范围和结果语义遵循 `tester`:默认 smoke、默认 readonly,使用
|
||||||
|
`pass/fail/environment-fail/skipped/not-run`。
|
||||||
|
- 不自动构建 APK;只使用项目提供的测试命令或已有 APK。
|
||||||
|
- 安装 APK、清除数据、授予权限、修改设备设置属于 mutating,必须有明确授权。
|
||||||
|
- 不自动清空真机、重启真机、创建或删除未知 AVD。
|
||||||
|
- Debian 安装模式只接受 `ID=debian` 且 `amd64/x86_64`;Ubuntu、其他发行版、ARM
|
||||||
|
主机必须停止并说明不在第一版范围内。
|
||||||
|
- 没有 `.pouch/tester/catalog.yaml` 时,不静默初始化,提示用户先初始化 `tester`。
|
||||||
|
|
||||||
|
## 运行顺序
|
||||||
|
|
||||||
|
1. 解析测试范围和目标模式。
|
||||||
|
2. 检查 `.pouch/tester/`、本地配置和工具链。
|
||||||
|
3. 检查真机或模拟器目标是否可用。
|
||||||
|
4. 必要时只启动本轮自己创建的模拟器会话。
|
||||||
|
5. 按 tester 用例顺序执行测试。
|
||||||
|
6. 失败时采集 logcat 和截图,生成 YAML/CSV 运行记录。
|
||||||
|
7. 按 `references/reporting.md` 输出结果,并执行目标会话的安全收尾。
|
||||||
|
|
||||||
|
## 安装安全边界
|
||||||
|
|
||||||
|
安装模式会运行 `apt`、修改用户组、下载 Android SDK 文件并可能创建 AVD。只有用户
|
||||||
|
明确要求安装时才执行;执行前先列出包、下载目录、SDK 镜像和需要重新登录的事项。
|
||||||
|
不要把 sudo 密码、账号或第三方 token 写入文件或输出。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `pouch check android-tester` 通过。
|
||||||
|
- Debian 安装后 `kvm-ok` 和 `emulator -accel-check` 通过。
|
||||||
|
- 真机和模拟器各完成一条 readonly smoke 用例,并分别生成运行快照和成绩单。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Android 真机路径
|
||||||
|
|
||||||
|
## 环境检查
|
||||||
|
|
||||||
|
按顺序执行并记录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb start-server
|
||||||
|
adb devices -l
|
||||||
|
adb -s <serial> shell getprop ro.product.model
|
||||||
|
adb -s <serial> shell getprop ro.build.version.sdk
|
||||||
|
adb -s <serial> shell getprop sys.boot_completed
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 没有设备:`environment-fail`。
|
||||||
|
- `unauthorized`:提示用户在手机上授权,不自动绕过。
|
||||||
|
- `offline`:停止,不把它当作产品失败。
|
||||||
|
- 有多台设备但没有 `ANDROID_SERIAL`:停止,不猜设备。
|
||||||
|
- `sys.boot_completed` 不是 `1`:等待有限时间,超时记 `environment-fail`。
|
||||||
|
|
||||||
|
## 执行
|
||||||
|
|
||||||
|
- 复用用户指定序列号,整轮测试不切换设备。
|
||||||
|
- 只执行选中的 `driver: mobile` 用例。
|
||||||
|
- 不自动安装 APK;安装必须有 APK、包名和明确的 mutating 授权。
|
||||||
|
- 测试失败时收集目标设备的 logcat 尾部和当前屏幕截图。
|
||||||
|
|
||||||
|
## 收尾
|
||||||
|
|
||||||
|
- 不关机、不重启、不清除应用数据。
|
||||||
|
- 停止由本轮启动的 ADB server 之前要确认没有其他测试会话依赖它。
|
||||||
|
- 设备保留原状态,并在结果中记录。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Android 模拟器路径
|
||||||
|
|
||||||
|
## 环境检查
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emulator -accel-check
|
||||||
|
emulator -list-avds
|
||||||
|
```
|
||||||
|
|
||||||
|
必须同时满足:
|
||||||
|
|
||||||
|
- KVM 可用。
|
||||||
|
- 指定 AVD 存在。
|
||||||
|
- AVD 的 system image 与 x86_64 主机匹配。
|
||||||
|
- 没有被 VirtualBox、VMware 或 Docker 等外层虚拟化阻断。
|
||||||
|
|
||||||
|
## 启动与等待
|
||||||
|
|
||||||
|
- 如果目标 AVD 已运行,检查序列号和 AVD 名称后复用。
|
||||||
|
- 如果没有运行,启动用户指定的 AVD,不创建替代 AVD。
|
||||||
|
- 使用 ADB 等待设备出现,并等待 `sys.boot_completed=1`。
|
||||||
|
- 启动失败、启动超时或 KVM 失效都记为 `environment-fail`。
|
||||||
|
- 无桌面服务器时可使用 `-no-window -no-audio`,但必须先确认测试不需要可视化交互。
|
||||||
|
|
||||||
|
## 执行与收尾
|
||||||
|
|
||||||
|
- 一轮测试只绑定一个 AVD。
|
||||||
|
- 需要安装 APK 或清数据时按 mutating 规则处理。
|
||||||
|
- 测试失败时保存 logcat、模拟器信息和截图。
|
||||||
|
- 只关闭本轮由 skill 启动且配置为临时会话的模拟器。
|
||||||
|
- 不删除 AVD、不修改系统镜像、不覆盖用户已有配置。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Android 通用环境检查
|
||||||
|
|
||||||
|
本文件只读检查,不安装软件、不修改设备。
|
||||||
|
|
||||||
|
## 项目检查
|
||||||
|
|
||||||
|
1. 确认项目根目录。
|
||||||
|
2. 确认 `.pouch/tester/catalog.yaml` 存在且 `validate` 通过。
|
||||||
|
3. 只选择 `driver: mobile` 的用例。
|
||||||
|
4. 读取 `.pouch/tester/local.yaml`;缺少 Android 目标配置时停止,不猜目标。
|
||||||
|
|
||||||
|
推荐的本地配置字段:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ANDROID_TARGET_MODE: device
|
||||||
|
ANDROID_SERIAL: ""
|
||||||
|
ANDROID_AVD: ""
|
||||||
|
ANDROID_PACKAGE: ""
|
||||||
|
ANDROID_APK: ""
|
||||||
|
ANDROID_ACTIVITY: ""
|
||||||
|
```
|
||||||
|
|
||||||
|
## 主机检查
|
||||||
|
|
||||||
|
- `uname -m` 必须是 `x86_64`。
|
||||||
|
- `adb` 必须可执行。
|
||||||
|
- 模拟器路径还必须有 `emulator`、`sdkmanager` 和 KVM。
|
||||||
|
- 记录 Android SDK 根目录、工具版本和当前用户。
|
||||||
|
|
||||||
|
未安装工具属于 `environment-fail`。不要在普通测试运行中顺手安装;安装只能进入
|
||||||
|
明确的 Debian 安装模式。
|
||||||
|
|
||||||
|
## 运行前报告
|
||||||
|
|
||||||
|
先报告:
|
||||||
|
|
||||||
|
```text
|
||||||
|
目标: 真机 | 模拟器
|
||||||
|
项目测试目录: 已找到 | 缺失
|
||||||
|
ADB: 通过 | 缺失
|
||||||
|
模拟器工具链: 通过 | 缺失 | 不适用
|
||||||
|
KVM: 通过 | 缺失 | 不适用
|
||||||
|
目标设备: 已解析 | 未解析
|
||||||
|
结论: 可执行 | environment-fail
|
||||||
|
```
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Debian 安装 Android 模拟器
|
||||||
|
|
||||||
|
只支持 Debian amd64/x86_64。不要在 Ubuntu、其他发行版、ARM 主机或嵌套虚拟机中执行
|
||||||
|
本流程。Android 官方 Linux 模拟器依赖 KVM;Android SDK Command-line Tools 提供
|
||||||
|
`sdkmanager`,Emulator 和 Platform-Tools 作为 SDK 包安装。
|
||||||
|
|
||||||
|
## 1. 检查并确认变更
|
||||||
|
|
||||||
|
读取 `/etc/os-release`,必须满足:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test "$(. /etc/os-release && printf '%s' "$ID")" = debian
|
||||||
|
test "$(dpkg --print-architecture)" = amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
安装前向用户展示:
|
||||||
|
|
||||||
|
- 将通过 apt 安装的 Debian 包。
|
||||||
|
- Android SDK 目录,默认 `~/Android/Sdk`。
|
||||||
|
- 要下载的 Emulator、Platform-Tools、Command-line Tools 和 system image。
|
||||||
|
- 会把当前用户加入 `kvm`、`libvirt` 组,通常需要重新登录。
|
||||||
|
- 是否创建 AVD,以及 AVD 名称、API、镜像和设备 profile。
|
||||||
|
|
||||||
|
没有明确安装请求或用户未确认这些变更时,只输出检查结果,不执行 sudo、apt 或下载。
|
||||||
|
|
||||||
|
## 2. 安装 Debian 主机依赖
|
||||||
|
|
||||||
|
在用户确认后安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install cpu-checker qemu-kvm libvirt-daemon-system \
|
||||||
|
libvirt-clients bridge-utils curl ca-certificates unzip
|
||||||
|
sudo usermod -aG kvm,libvirt "$USER"
|
||||||
|
```
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kvm-ok
|
||||||
|
ls -l /dev/kvm
|
||||||
|
```
|
||||||
|
|
||||||
|
若没有硬件虚拟化或 `/dev/kvm`,停止并报告安装失败。不要降级为极慢的软件模拟。
|
||||||
|
|
||||||
|
## 3. 安装 Android SDK 工具
|
||||||
|
|
||||||
|
优先使用 Android 官方 Linux Command-line Tools。若机器已有 `sdkmanager`,复用现有
|
||||||
|
SDK 根目录;否则从 Android 官方下载页获取 Linux Command-line Tools,解压为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Android/Sdk/cmdline-tools/latest/
|
||||||
|
```
|
||||||
|
|
||||||
|
安装并接受 SDK 许可后,执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sdkmanager --install \
|
||||||
|
"platform-tools" \
|
||||||
|
"emulator" \
|
||||||
|
"cmdline-tools;latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
系统镜像由项目的 compile/target SDK 和用户明确选择的 API 决定。安装前先用
|
||||||
|
`sdkmanager --list` 查找可用的 `google_apis;x86_64` 镜像,不猜一个不可用的版本。
|
||||||
|
|
||||||
|
## 4. 创建 AVD
|
||||||
|
|
||||||
|
只有用户明确要求创建 AVD 时执行。优先使用当前 SDK 提供的 Android CLI;兼容环境可
|
||||||
|
使用 `avdmanager` 创建一个明确命名、明确 API 和明确 x86_64 镜像的 AVD。创建后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emulator -list-avds
|
||||||
|
emulator -accel-check
|
||||||
|
emulator @<已确认的AVD名称>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<已确认的AVD名称>` 只表示用户已经选择的实际名称,不允许由 skill 静默猜测。
|
||||||
|
|
||||||
|
## 5. 安装完成报告
|
||||||
|
|
||||||
|
报告必须包含:
|
||||||
|
|
||||||
|
- Debian 版本和架构。
|
||||||
|
- apt 安装包及结果。
|
||||||
|
- SDK 根目录和工具版本。
|
||||||
|
- 已安装的 system image。
|
||||||
|
- AVD 名称和配置。
|
||||||
|
- KVM 检查结果。
|
||||||
|
- 是否需要重新登录。
|
||||||
|
- 下一步是运行模拟器检查,还是直接执行测试。
|
||||||
|
|
||||||
|
安装 Android Studio 不是本模式的必要条件;该流程安装的是可由命令行运行的 Emulator
|
||||||
|
工具链。参考:[Android 安装文档](https://developer.android.com/studio/install)、
|
||||||
|
[KVM 加速文档](https://developer.android.com/studio/run/emulator-acceleration)。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Android 结果报告
|
||||||
|
|
||||||
|
每轮运行都生成 `.pouch/tester/runs/<run-id>.yaml` 和同名 `.csv`。附加证据放在同名
|
||||||
|
目录中,不覆盖已有 run。
|
||||||
|
|
||||||
|
对话报告格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## android-tester 运行:完成 | 部分完成 | 阻塞
|
||||||
|
|
||||||
|
路径: 真机 | 模拟器
|
||||||
|
目标: 序列号/型号/API 或 AVD/API
|
||||||
|
环境: 通过项;阻塞项
|
||||||
|
计数: pass=N fail=N environment-fail=N skipped=N not-run=N
|
||||||
|
失败: 用例 id + 一句证据
|
||||||
|
跳过: 用例 id + 原因
|
||||||
|
证据: logcat / 截图目录
|
||||||
|
快照: .pouch/tester/runs/<run-id>.yaml
|
||||||
|
成绩单: .pouch/tester/runs/<run-id>.csv
|
||||||
|
下一步: 一句话
|
||||||
|
```
|
||||||
|
|
||||||
|
判定规则:
|
||||||
|
|
||||||
|
- 工具缺失、设备不可用、启动超时:`environment-fail`。
|
||||||
|
- 产品断言不满足:`fail`。
|
||||||
|
- 风险未授权、用例主动不跑:`skipped`。
|
||||||
|
- 失败即停后没有轮到的用例:`not-run`。
|
||||||
|
- 不能把 ADB 命令退出码为 0 直接当成产品通过,必须对照用例的 `expected`。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# bugfix-ship(已弃用)
|
||||||
|
|
||||||
|
`bugfix-ship` 已改名为 [short-ship](../short-ship/README.md)。本目录只是兼容占位,
|
||||||
|
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
|
||||||
|
`short-ship/`。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
使用 bugfix-ship,修复这个线上回归并部署到测试环境。
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 命中后会立刻转到 `short-ship`,按同一套规则执行。新请求建议直接用新名。
|
||||||
|
|
||||||
|
## 迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch add short-ship # 新名,当前项目
|
||||||
|
pouch add short-ship -g # 新名,全局
|
||||||
|
```
|
||||||
|
|
||||||
|
- 旧软链保持有效,不需要先删除。
|
||||||
|
- 各项目 `.pouch.yaml` 里的 `bugfix-ship` 可以分批改成 `short-ship`。
|
||||||
|
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: bugfix-ship
|
||||||
|
description: >-
|
||||||
|
【已弃用,兼容占位】bugfix-ship 已改名为 short-ship。命中本 skill 时不要在此执行任何
|
||||||
|
流程,直接读取 ../short-ship/SKILL.md 并按其执行。小需求、小改动、修 bug、改文案、
|
||||||
|
加按钮、修复后发 PR 或部署都属于 short-ship。
|
||||||
|
---
|
||||||
|
|
||||||
|
# bugfix-ship(已弃用)
|
||||||
|
|
||||||
|
本 skill 已改名为 [short-ship](../short-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
|
||||||
|
|
||||||
|
- 用户提到 `bugfix-ship`、`/bugfix-ship` 或 `$bugfix-ship` 时:读取
|
||||||
|
[../short-ship/SKILL.md](../short-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
|
||||||
|
里寻找步骤、references 或 templates。
|
||||||
|
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `short-ship/`。
|
||||||
|
- 旧项目 `.pouch.yaml` 里仍写 `bugfix-ship` 也能正常安装,软链不会悬空;方便时改成
|
||||||
|
`short-ship`。
|
||||||
|
- 确认没有项目再按旧名启用后,可以直接删除本目录。
|
||||||
+194
-14
@@ -1,51 +1,231 @@
|
|||||||
# builder
|
# builder
|
||||||
|
|
||||||
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
|
按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布,
|
||||||
|
或把产物拷到目录 / 容器。规范本体见
|
||||||
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
||||||
可执行校验器。
|
可执行校验器。
|
||||||
|
|
||||||
|
构建执行位置使用共享 `.pouch/agent.yaml` 的任意 `builder.<name>` binding 配置;本地
|
||||||
|
Android、远程 Mac iOS 或未来其它平台都可以各自绑定 environment/project/path/profile。
|
||||||
|
|
||||||
## 什么时候使用
|
## 什么时候使用
|
||||||
|
|
||||||
- "帮我构建这个项目的 DEB / Docker 镜像"
|
- "用 builder 初始化这个项目"
|
||||||
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
- "帮我构建这个项目的 DEB / Docker 镜像 / APK"
|
||||||
- "检查这个项目的 Makefile 是否符合 builder 契约"
|
- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
|
||||||
|
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
|
||||||
- "看看项目现在的发布流程"
|
- "看看项目现在的发布流程"
|
||||||
|
- "看这个 SHA 的 CI 构建 / obtain"
|
||||||
|
|
||||||
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pouch add builder # 当前项目
|
||||||
|
pouch add builder -g # 全局
|
||||||
|
```
|
||||||
|
|
||||||
|
不要 `pouch init builder`。对新项目说「用 builder 初始化」。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- Python 3 和 `make`。
|
||||||
|
- DEB 轨道需要 `dpkg-deb`;Docker 轨道需要 Docker 和 buildx。
|
||||||
|
- 发布时在项目根 `.env.builder` 填写仓库地址和 token(不要写进 `.env`,也不要进 git)。
|
||||||
|
- 正式版本号由 [releaser](../releaser/README.md) 打 annotated tag;builder 只读当前 HEAD。
|
||||||
|
- 脏工作树默认拒绝发布。
|
||||||
|
|
||||||
## 项目接入契约
|
## 项目接入契约
|
||||||
|
|
||||||
1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version`
|
对新项目说「用 builder 初始化」。Agent 会探测轨道、按
|
||||||
+ 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。
|
`templates/makefile.builder` 写出项目根 `makefile.builder`,并列出缺的发布配置。
|
||||||
2. 运行 `python3 -I -S <builder>/scripts/check.py .` 直到全部 PASS。
|
不改用户已有的 `Makefile` 或 `.env`。
|
||||||
3. 在项目根 `.env` 配置发布环境变量:
|
|
||||||
|
配置跟随 `makefile.builder` 所在的 Git 根。如果 APK/IPA 位于 Git submodule,
|
||||||
|
就在 submodule 内配置 `.env.builder` 和 `.pouch/builder/`;父仓库只固定 submodule commit
|
||||||
|
并负责上层编排。
|
||||||
|
|
||||||
|
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
|
||||||
|
以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE`。
|
||||||
|
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
|
||||||
|
多架构清单是 `ARCHES`,见下方「多架构」。
|
||||||
|
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
|
||||||
|
缺发布键只挡住上传,不挡住构建。
|
||||||
|
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`)。项目内 APK/IPA
|
||||||
|
分发脚本的路径写在 `makefile.builder` 的 `COPY_SCRIPT`,脚本接收产物路径作为位置参数;
|
||||||
|
未配置项目脚本时使用 `COPY_DEST` 的本地目录或容器目录后备逻辑:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DEB_SERVER_URL=https://deb.example.com
|
DEB_SERVER_URL=https://deb.example.com
|
||||||
DEB_REPOSITORY=main
|
DEB_REPOSITORY=main
|
||||||
DEB_TOKEN=<token> # 只放 .env 或密钥系统,不进 git
|
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
|
||||||
DOCKER_REGISTRY=registry.example.com
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
COPY_DEST=file-server:/srv
|
||||||
|
COPY_AS=latest.apk
|
||||||
|
# COPY_SCRIPT= # 仅外部/临时脚本覆盖;项目内脚本写 makefile.builder
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 日常发布就是两条命令:`make deb && make push-deb`、`make push-docker`。
|
`BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。`make -f makefile.builder obtain`(或 `push-docker`)等待该 commit 的流水线并 inspect registry,不在本机 `docker push`。
|
||||||
|
|
||||||
|
4. 日常发布就是两条命令:
|
||||||
|
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
|
||||||
|
`make -f makefile.builder push-docker`。
|
||||||
|
写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。
|
||||||
|
|
||||||
|
跨机器或需要继续查看的构建先用 `agent-runtime` 精确解析 binding 和 Orca worktree;
|
||||||
|
builder 只在授权后执行对应 `makefile.builder` target,不自动上传。
|
||||||
|
|
||||||
|
## 多架构
|
||||||
|
|
||||||
|
项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进
|
||||||
|
`.env.builder`(那是密钥,换机器会丢)。
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
ARCHES ?= amd64 arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
`ARCH` 是「这一次」:默认等于清单第一项。不传 `ARCH=` 时,`deb` / `push-deb`
|
||||||
|
按清单逐个做完;`push-docker` 把清单映射成 `linux/amd64,linux/arm64`。
|
||||||
|
只做其中一个:`make -f makefile.builder deb ARCH=arm64`。
|
||||||
|
|
||||||
|
合法值只有 `amd64` 和 `arm64`(空格分隔)。不要默认写成双架构:本机编不出
|
||||||
|
另一边时,只留一项。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -s -f makefile.builder arches # 打印清单,例如 amd64 arm64
|
||||||
|
make -f makefile.builder deb # 打出两个 .deb
|
||||||
|
make -f makefile.builder push-deb # 两个都上传(需你授权发布)
|
||||||
|
make -f makefile.builder push-docker # 一次 buildx 推两个平台
|
||||||
|
```
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/<name>_<version>_amd64.deb
|
||||||
|
dist/<name>_<version>_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
`build` 配方必须用 `$(ARCH)`,否则两个包内容会一样。初始化时 Agent 按仓库里
|
||||||
|
已有命令来写(Go 一般是 `GOOS=linux GOARCH=$(ARCH) go build …`),不另做一套
|
||||||
|
运行时探测脚本。看不出交叉编法就不要写双架构。
|
||||||
|
|
||||||
|
`packaging/DEBIAN/control` 的 `Architecture:` 必须随 `$(ARCH)` 变,不要写死
|
||||||
|
`amd64`。模板会先拷到 `dist/packaging-$(ARCH)` 再改这一行。
|
||||||
|
|
||||||
|
本机临时只推一个平台,可以在 `.env.builder` 设 `PLATFORMS=linux/amd64`,这是
|
||||||
|
覆盖不是清单。项目清单始终以 `ARCHES` 为准。
|
||||||
|
|
||||||
|
存量 `makefile.builder` 没有 `ARCHES` 时行为与以前相同(默认 `amd64`,
|
||||||
|
`ARCH=arm64` 仍合法)。要改成双架构:加上面那一行,确认 `build` 吃 `$(ARCH)`,
|
||||||
|
再跑 `python3 -I -S <builder>/scripts/check.py . --ready`。
|
||||||
|
|
||||||
|
## 版本号
|
||||||
|
|
||||||
|
产物版本从 **当前 HEAD 的 Git 祖先** 推导,不调用 releaser。
|
||||||
|
正式 tag 仍由 releaser 创建;builder 只读取。完整规则见
|
||||||
|
[contract.md「版本号」](references/contract.md#版本号)。
|
||||||
|
|
||||||
|
| 谁 | 做什么 |
|
||||||
|
| --- | --- |
|
||||||
|
| releaser | 选定下一个正式 SemVer,打 annotated tag `vX.Y.Z` |
|
||||||
|
| builder | 读 HEAD:落在稳定 tag 上则打正式产物,否则打测试产物 |
|
||||||
|
| `make -f makefile.builder version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
|
||||||
|
|
||||||
|
**不要**用 `git tag \| sort -V \| tail -1` 取全仓库最大号,也不要把
|
||||||
|
`git describe --dirty` 的原始字符串写进 DEB 或镜像 tag。
|
||||||
|
|
||||||
|
### 正式(HEAD 恰好是稳定 tag `v1.4.2`)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Git tag v1.4.2
|
||||||
|
规范版本 1.4.2
|
||||||
|
DEB foo_1.4.2_amd64.deb Version: 1.4.2
|
||||||
|
Docker registry/ns/foo:1.4.2
|
||||||
|
```
|
||||||
|
|
||||||
|
稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc`、`-app-N` 等后缀。
|
||||||
|
不是 exact-match 就不是正式包。
|
||||||
|
|
||||||
|
### 测试(其它任何 commit)
|
||||||
|
|
||||||
|
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
|
||||||
|
patch 机械 +1,再加构建时间 `YYYYMMDDTHHMM`、清洗后的分支名、相对距离、短 SHA。
|
||||||
|
这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt /
|
||||||
|
Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` <
|
||||||
|
`1.4.3~20260918T1430…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包;
|
||||||
|
同号正式版仍盖住测试。
|
||||||
|
|
||||||
|
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`,
|
||||||
|
构建时间 `20260918T1430`(带分钟,同一天重建才排得对):
|
||||||
|
|
||||||
|
```text
|
||||||
|
规范 / DEB 1.4.3~20260918T1430.feat-login-v2.7+gabc1234
|
||||||
|
文件名 foo_1.4.3~20260918T1430.feat-login-v2.7+gabc1234_amd64.deb
|
||||||
|
Docker registry/ns/foo:1.4.3-20260918T1430.feat-login-v2.7.gabc1234
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
|
||||||
|
无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号
|
||||||
|
正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建时间取本机当前本地
|
||||||
|
时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。`+g<sha>`
|
||||||
|
只用于记录 commit、不参与排序:只写到日期时,同一天重复构建的版本只差
|
||||||
|
`+g<sha>`,Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。
|
||||||
|
注意切换格式当天:Debian 把字母排在标点之前,`20260918T1430…` 小于旧格式
|
||||||
|
的 `20260918.…`,同一天已上传的旧格式包仍会压住新包,需要在仓库里把那些
|
||||||
|
旧包移除或停用;从第二天起新格式始终更大。
|
||||||
|
|
||||||
|
### 分支名清洗
|
||||||
|
|
||||||
|
小写;`/`、`_` 改为 `-`;去掉其它非法字符;压缩连续 `-`;过长截断。
|
||||||
|
detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` /
|
||||||
|
`GITHUB_REF_NAME`。脏工作树不把 `-dirty` 写进版本(发布本身会拒绝)。
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
```text
|
```text
|
||||||
用 builder 检查这个项目的 Makefile 是否符合契约。
|
用 builder 初始化这个项目。
|
||||||
|
用 builder 检查这个项目的 makefile.builder 是否符合契约。
|
||||||
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
||||||
用 builder 把 dist/example_1.2.3_amd64.deb 发布到项目已配置的测试仓库。
|
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
|
||||||
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
||||||
|
用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。
|
||||||
|
用 builder 打一份测试 APK 并拷到 File Browser。
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
|
||||||
|
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
|
||||||
|
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
|
||||||
|
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
|
||||||
|
|
||||||
|
Docker 只有你点名镜像时才走。token 不会写进输出、提交或回复。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- 初始化:`check.py --ready` 的构建项 PASS。只缺发布键算部分完成,仍可构建、不能上传。
|
||||||
|
- 构建:产物路径、规范版本、架构与 `make -f makefile.builder version` 一致。
|
||||||
|
- 发布:HTTP 成功或远端 digest 对得上;回复里有包名或镜像引用、SHA-256 / digest、源 commit。
|
||||||
|
- 测试包不会通过无参 `apt upgrade` 盖住同号正式包;会作为下一 patch 候选盖住上一正式包。
|
||||||
|
|
||||||
## 脚本一览
|
## 脚本一览
|
||||||
|
|
||||||
| 脚本 | 用途 |
|
| 脚本 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `scripts/check.py` | 校验项目 Makefile 是否符合契约(`--build` 实构核对产物) |
|
| `scripts/check.py` | 校验契约(`--ready` 含工具链与发布键名,`--build` 实构核对产物) |
|
||||||
|
| `templates/makefile.builder` | 初始化用的契约文件骨架,拷到项目根 |
|
||||||
|
| `templates/env.builder` | 初始化用的 `.env.builder` 骨架(注释键,不含值) |
|
||||||
|
| `scripts/version.sh` | 从 Git 祖先稳定 tag 推导规范版本 / Docker tag |
|
||||||
|
| `scripts/version.mk` | `makefile.builder` `include`,设置 `VERSION` 与 `IMAGE_TAG` |
|
||||||
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
||||||
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||||
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
||||||
|
| `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` |
|
||||||
|
| `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect |
|
||||||
|
| `scripts/wait_ci.sh` | 只等该 SHA 的 GitLab/GitHub/Gitea 流水线,不触发;Gitea 读取 workflow jobs |
|
||||||
|
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
|
||||||
|
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
|
||||||
|
|
||||||
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.skills/skills/builder/scripts/`)、
|
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、
|
||||||
脏工作树策略等完整规则见 contract.md。
|
脏工作树策略等完整规则见 contract.md。
|
||||||
|
|||||||
+78
-116
@@ -1,133 +1,95 @@
|
|||||||
---
|
---
|
||||||
name: builder
|
name: builder
|
||||||
description: >-
|
description: >-
|
||||||
按统一契约构建并发布项目的 DEB 包与 Docker 镜像:先校验项目 Makefile 是否符合
|
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
|
||||||
builder 契约(check.py),再 make 构建产物,经授权后用 skill 自带脚本上传并验证。
|
或把产物拷到目录/容器。产物可在本机或 CI 上构建;拿到产物用 obtain。触发词:初始化
|
||||||
触发词:构建 deb、发布 deb、上传 deb、推送 apt 仓库、打 Debian 包、构建镜像、
|
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker 镜像、
|
||||||
发布镜像、推送 Docker 镜像、make push、检查 Makefile 是否符合规范。仅分析打包
|
打 APK/IPA、拷到 File Browser、make push、看构建、监听 CI、obtain。
|
||||||
逻辑或只构建不上传时也可使用;不会在未获授权时执行任何上传。Docker 轨道保持
|
未授权不上传。Docker 仅用户点名镜像时才走。
|
||||||
显式触发:用户点名(builder/publish docker)时才走镜像发布。
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Builder:DEB / Docker 构建发布
|
# Builder:DEB / Docker / Android / iOS 构建发布
|
||||||
|
|
||||||
复用项目已有发布约定,安全地完成"校验 → 构建 → 检查 → 授权 → 上传 → 验证"。
|
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||||
|
项目状态是**当前 builder 项目根**的 `makefile.builder` 与 `.env.builder`;项目自定义分发脚本放在
|
||||||
|
同一项目根的 `.pouch/builder/`,不要改用户的 `Makefile` / `makefile` / `.env`。如果父仓库包含
|
||||||
|
submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.env.builder` 和
|
||||||
|
`.pouch/builder/` 必须放在 submodule 内,父仓库只负责调用或固定 submodule commit。
|
||||||
|
|
||||||
分工原则:**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
配置来源原则:项目要构建什么、发布哪些轨道、使用哪个已提交的分发脚本,放在
|
||||||
|
`makefile.builder`;本机路径和工具链覆盖放在被忽略的 `.pouch/builder/local.mk`;凭据、
|
||||||
|
发布目标和本轮别名放在被忽略的 `.env.builder`。`COPY_SCRIPT` 若指向项目内
|
||||||
|
`.pouch/builder/` 的脚本,优先写入 `makefile.builder`,这样迁移项目时配置与脚本一起进入 Git。
|
||||||
|
|
||||||
## 何时使用
|
构建执行位置、Agent CLI、模型、推理强度和 session 生命周期由共享 `agent-runtime` 的
|
||||||
|
`.pouch/agent.yaml` 管理。`builder.<name>` 是可自由增加的 binding:可以指向本地
|
||||||
|
worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远程主机、模型或 terminal 参数
|
||||||
|
硬编码进 `makefile.builder`。跨机器或跨 session 时先读 `agent-runtime`,构建目标仍只能
|
||||||
|
由 `makefile.builder` 提供。
|
||||||
|
|
||||||
- 用户要求构建、发布、上传 `.deb` 包或 Docker/OCI 镜像。
|
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
|
||||||
- 用户要求检查项目 Makefile 是否符合 builder 契约。
|
`git rev-parse --show-toplevel` 解析项目根。
|
||||||
- 用户要求梳理或接通项目现有的 DEB/镜像发布流程。
|
|
||||||
|
|
||||||
不适用:本地安装/卸载 DEB;RPM/APK/语言包管理器;从零设计全新打包体系(先出方案);
|
## 选择模式
|
||||||
普通编码与 Dockerfile 编辑。
|
|
||||||
|
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
|
||||||
|
- 检查契约或发布配置:执行「检查」。
|
||||||
|
- 构建、发布、上传:执行「工作流」。不要静默初始化。
|
||||||
|
- 看构建、监听 CI、ACK/short-ship/long-ship 要产物:执行「拿到产物」。不要当成第五条轨道。
|
||||||
|
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
|
||||||
|
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
|
||||||
|
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
|
||||||
|
|
||||||
|
## 初始化
|
||||||
|
|
||||||
|
1. 确认项目根。优先以包含目标 `makefile.builder` 的仓库根为准;若目标位于 submodule,则以 submodule 根为准,不向父仓库回溯配置。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
|
||||||
|
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
|
||||||
|
3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
|
||||||
|
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
|
||||||
|
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
|
||||||
|
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
|
||||||
|
|
||||||
|
```text
|
||||||
|
## builder 初始化:完成 | 部分完成 | 阻塞
|
||||||
|
|
||||||
|
已具备: …
|
||||||
|
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||||
|
工具链: make / docker / dpkg-deb(缺则怎么装,不擅自安装)
|
||||||
|
下一步: 一句话
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
# .env.builder 键名示例
|
||||||
|
DEB_SERVER_URL=https://deb.example.com
|
||||||
|
DEB_REPOSITORY=main
|
||||||
|
DEB_TOKEN=
|
||||||
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
COPY_DEST=file-server:/srv
|
||||||
|
COPY_AS=latest.apk
|
||||||
|
# 外部/临时脚本才放这里;项目内脚本优先在 makefile.builder 设置 COPY_SCRIPT
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查
|
||||||
|
|
||||||
|
只读。运行 `check.py <project-dir> --ready`,用同一报告格式,标题改为 `## builder 检查:…`。不写 `makefile.builder` / `.env.builder`,不改用户 Makefile 或 `.env`。用户明确要求修复后再转入初始化。
|
||||||
|
|
||||||
## 工作流
|
## 工作流
|
||||||
|
|
||||||
### 0. 校验契约
|
契约见 [contract.md](references/contract.md)。Docker 的 registry/tag 不明确时再读 [registry.md](references/registry.md)。
|
||||||
|
|
||||||
```bash
|
1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。
|
||||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> # 静态检查
|
2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。
|
||||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --build # 额外实构 deb 并核对产物
|
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 releaser,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。若通过 `builder.<name>` 执行,先精确解析 `environment + host + project + path` 对应的 worktree;远程 binding 不能 fallback 到本机。持久 session 只显式 attach/resume,不能按标题复用旧 terminal。
|
||||||
```
|
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
|
||||||
|
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh` / `obtain.sh`。`BUILD_VENUE=ci` 时 `push-docker` 就是 obtain。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道至少配置 `COPY_DEST`(本地目录或 `container:/dir`)或 `COPY_SCRIPT`(项目内脚本优先在 `makefile.builder` 设置;同时配置时命令行/环境值优先)。自定义脚本按 `FILE [FILE ...]` 接收产物路径,自行完成重命名、上传、远端校验和通知;相对路径按项目根解析,`BUILDER_PROJECT_ROOT` 提供项目根。`COPY_AS` 只由内置 `COPY_DEST` 路径处理。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不要把 token 当命令行参数;脚本可随项目放在 `.pouch/builder/` 下。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
|
||||||
|
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
|
||||||
|
|
||||||
任一 FAIL:停下修复(引导用 create-makefile skill 补齐),不要绕过校验继续发布。
|
## 拿到产物
|
||||||
完整要求见 [contract.md](references/contract.md)。存量项目未接契约时走第 6 节
|
|
||||||
fallback;成功交付一次后引导用户迁移到契约。
|
|
||||||
|
|
||||||
### 1. 确认发布边界
|
调用方是 short-ship、long-ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
|
||||||
|
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
|
||||||
|
|
||||||
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;只要求查看、诊断或构建
|
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
|
||||||
则停在相应阶段。
|
- `BUILD_VENUE=ci`:等该 SHA 的 GitLab/GitHub/Gitea 流水线,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。
|
||||||
|
- 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。
|
||||||
执行上传前确认:
|
- 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。
|
||||||
|
|
||||||
- 目标服务和仓库来自项目配置(`.env`)或用户输入,不猜测生产端点。
|
|
||||||
- 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复,
|
|
||||||
不用 `set -x` 执行含凭据的命令。
|
|
||||||
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。
|
|
||||||
- Docker 轨道需要用户已明确指定目标 registry/repository/tag 后才继续。
|
|
||||||
|
|
||||||
脏工作树默认拒绝发布;用户明确接受时设置 `ALLOW_UNCOMMITTED=1` 并在汇报中注明
|
|
||||||
包含的未提交修改。
|
|
||||||
|
|
||||||
### 2. 构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build ARCH=<amd64|arm64> VERSION=<version> # 主产物
|
|
||||||
make deb ARCH=<amd64|arm64> # DEB 项目
|
|
||||||
```
|
|
||||||
|
|
||||||
版本缺省由 make 从 `git describe --tags --always --dirty` 推导。构建目标若会自动
|
|
||||||
上传而当前仅获构建授权,改用纯构建目标。执行前确认所需工具可用(docker、
|
|
||||||
dpkg-deb 等)。不得擅自清理宽泛目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。
|
|
||||||
|
|
||||||
### 3. 上传前检查
|
|
||||||
|
|
||||||
```bash
|
|
||||||
find $(DIST_DIR) -maxdepth 2 -type f -name '*.deb' -print
|
|
||||||
<skill-dir>/scripts/verify_deb.sh <exact-package-path.deb> [期望版本] [期望架构]
|
|
||||||
```
|
|
||||||
|
|
||||||
verify_deb.sh 输出元数据、关键内容清单和 SHA-256。匹配到多个包时不凭文件时间猜测,
|
|
||||||
向用户确认唯一产物。镜像轨道无需单独校验步骤(publish_docker.sh 自带远端 inspect)。
|
|
||||||
|
|
||||||
### 4. 发布
|
|
||||||
|
|
||||||
优先 `make push[-deb|-docker]`(契约要求的薄包装);直接调用等价:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEB_SERVER_URL=… DEB_TOKEN=… DEB_REPOSITORY=… \
|
|
||||||
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
|
||||||
|
|
||||||
DOCKER_REGISTRY=… \
|
|
||||||
<skill-dir>/scripts/publish_docker.sh # env 优先,flag 可覆盖
|
|
||||||
```
|
|
||||||
|
|
||||||
环境变量缺失时脚本会自动向上查找项目 `.env` 加载(shell 显式值优先)。不把 token
|
|
||||||
作为命令行参数;不把脚本复制进项目。upload_deb.sh 默认请求 `/api/v2/upload/package`
|
|
||||||
(multipart 字段 `package`/`token`/`repository_name`,接受 200/201),协议不符时设
|
|
||||||
`DEB_UPLOAD_PATH` 或改用项目专属逻辑。publish_docker.sh 用 buildx 一步完成构建+推送,
|
|
||||||
多平台只能走它,不能拆进 make。
|
|
||||||
|
|
||||||
### 5. 验证与汇报
|
|
||||||
|
|
||||||
发布成功不能只依据"curl 已执行"/"push 已执行"。综合检查:
|
|
||||||
|
|
||||||
- 上传命令退出码为零,HTTP 状态与响应体明确成功;镜像以 `imagetools inspect`
|
|
||||||
的远端 digest 为准。
|
|
||||||
- 若仓库提供查询/索引/下载地址,确认该版本已可见;索引异步时报告
|
|
||||||
"上传已接受,索引尚待更新",不声称完全可用。
|
|
||||||
|
|
||||||
最终回复给出:包名/镜像引用、版本、架构/platform、产物路径与 SHA-256 或远端 digest、
|
|
||||||
源 commit 与工作区状态、各阶段验证结果、未完成项或覆盖风险。
|
|
||||||
|
|
||||||
## 存量项目 fallback(legacy)
|
|
||||||
|
|
||||||
从项目根目录查找,不预设文件位置:
|
|
||||||
|
|
||||||
```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/`、构建脚本和发布文档中的入口、变量传递方式、
|
|
||||||
端点与认证方式。优先复用已有构建入口;上传仍用 builder 脚本。交付后引导迁移到契约
|
|
||||||
(create-makefile + check.py 通过为准)。
|
|
||||||
|
|
||||||
## 修改 builder 自身时
|
|
||||||
|
|
||||||
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
|
|
||||||
业务项目。
|
|
||||||
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`。
|
|
||||||
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
|
|
||||||
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
|
|
||||||
|
|
||||||
## 完成标准
|
|
||||||
|
|
||||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
|
||||||
- 仅校验:check.py 结果逐条可解释,修复建议明确。
|
|
||||||
- 仅构建:产物已生成并通过 verify_deb.sh,未发生上传。
|
|
||||||
- 发布:构建检查通过,服务端接受上传,仓库可见性已验证或准确标记为待更新。
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user