Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 47bd454fa3 | |||
| e7a139e2cb | |||
| e4d4319919 | |||
| 31bc5f45ce | |||
| 1c52a3996c | |||
| 5a906f24fa | |||
| d33bc3ccaf | |||
| e0fffd6845 | |||
| b7b6d814b2 | |||
| 1a74b15ed5 | |||
| 02fafb4bf6 | |||
| 7dfdf80e9e | |||
| c113f68bf4 | |||
| 5ff8899b48 | |||
| 5018a1801d | |||
| b9c82b5520 | |||
| 0954ad542a | |||
| 4d078e8258 | |||
| 6855ac7078 | |||
| e9b34747cc | |||
| f5bf35c722 | |||
| 34bbb97406 | |||
| 337f1a9098 | |||
| f02a34e751 | |||
| e7f42e835a | |||
| a0f1c15b85 | |||
| f8d03fad4d | |||
| 5b0e41199d | |||
| 2c3d91c75c | |||
| f08edb6452 | |||
| d7997091bd | |||
| ae3bce7b5d | |||
| ee66dbe9ce | |||
| 7d1994cf93 | |||
| 076d87e303 | |||
| e308d6a04c | |||
| e667f58bf1 | |||
| 262c138bee | |||
| 18ebb70911 | |||
| afbdde157f | |||
| 5f90758e22 | |||
| 983183d5b3 | |||
| b78126830b | |||
| 3411e54b13 | |||
| b9963b66de | |||
| 86cd1fa36d | |||
| 8b82539f6f | |||
| b3765ecad6 | |||
| ae05845ee7 | |||
| af823e867f | |||
| 61ef55c514 | |||
| d11f90fd66 | |||
| f33992a0aa | |||
| 6bd03c2f6d | |||
| d7a74578dc | |||
| 58de2650c2 | |||
| c4c68572c3 | |||
| f1ed320991 | |||
| 9a3285fba6 | |||
| af0efc7870 |
+2
-1
@@ -1,4 +1,4 @@
|
||||
# Python(skiff CLI 运行时产物)
|
||||
# Python(pouch CLI 运行时产物)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
@@ -31,3 +31,4 @@ Thumbs.db
|
||||
# 本地环境配置(勿提交密钥)
|
||||
.env
|
||||
.env.*
|
||||
.pouch
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
||||
version: 1
|
||||
updatedAt: "2026-08-01T12:43:06+08:00"
|
||||
project:
|
||||
name: "skills"
|
||||
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
|
||||
verificationRegistry: {}
|
||||
entries: []
|
||||
@@ -0,0 +1,71 @@
|
||||
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
|
||||
|
||||
> 本项目基于 ACK Skill v0.19.0。通用规范由 `/ack` 从 Skill 自身的
|
||||
> `references/` 读取;本文件只保存当前仓库差异。
|
||||
|
||||
## 项目概览
|
||||
|
||||
- 项目:`skills`
|
||||
- 技术栈:Python 3、Markdown、JSON Schema
|
||||
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
|
||||
- Base URL:`n/a`
|
||||
- 任务板:`.pouch/ack/tasks.yaml`
|
||||
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||
- 覆盖层文件:`.pouch/ack/project.md`
|
||||
|
||||
## 通用规范(由 ACK Skill 按需读取)
|
||||
|
||||
- 开始需求:`references/kickoff.md`
|
||||
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
|
||||
- 模型档位与升级规则:`references/model-routing.md`
|
||||
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
|
||||
- 验收信号与三轮策略:`references/optimization-method.md`
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令:`references/orca-adapter.md`
|
||||
|
||||
## Worker 路由
|
||||
|
||||
机器可校验的 worker profile、允许 worktree 和 receipt 只以
|
||||
`.pouch/ack/tasks.yaml` 为准。本次 Developer 使用当前项目根 `/home/ace/.skills`;Test
|
||||
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
|
||||
|
||||
## 路径权限
|
||||
|
||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||
|------|:-----------:|:----:|:---------:|------|
|
||||
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
|
||||
| `.pouch/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
|
||||
| `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_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
|
||||
| 其它 `tests/test_ack*.py` | Read-only | Read-only | Read-only | 既有 ACK 回归测试 |
|
||||
| 临时测试目录 | Read-only | R/W | R/W | 必须由 `tempfile`/`mktemp -d` 创建,不保存凭据 |
|
||||
| `~/.lark-cli/` | Read-only | Read-only | Read-only | 不读取、不修改真实飞书 profile;测试只能使用假 CLI |
|
||||
|
||||
## 命令
|
||||
|
||||
Developer 白盒验证:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'
|
||||
python3 -m unittest tests.test_ack_skill tests.test_ack_tasks_validation
|
||||
python3 -m compileall -q skills/ack/scripts
|
||||
```
|
||||
|
||||
Test 黑盒复测:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
|
||||
python3 -m unittest discover -s tests -p 'test_ack*.py'
|
||||
python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||
python3 skills/ack/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
|
||||
```
|
||||
|
||||
## 本次任务硬规则
|
||||
|
||||
- 只支持官方 `lark-cli`,所有读取命令必须显式携带项目配置的 `--profile`,不能依赖当前 active profile。
|
||||
- 项目只保存 profile 名称、Base/table/view 标识和字段映射;App Secret、token 与用户凭据不得进入仓库、任务板或日志。
|
||||
- 飞书接入第一版只读,不更新或删除表格记录,不切换 CLI active profile。
|
||||
- 外部 CLI 缺失、profile 不存在、返回结构异常、附件下载失败时 fail closed,不回退到网页抓取。
|
||||
- 只有 `ACK Ready` 视图中的记录可进入整理;每条记录保留稳定 `record_id` 来源引用,重复读取不得制造重复 ACK 来源。
|
||||
- 不修改 `AGENTS.md`、`CLAUDE.md`,不提交或推送。
|
||||
@@ -0,0 +1,839 @@
|
||||
version: 1
|
||||
updatedAt: "2026-08-23T20:30:26+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.19.0"
|
||||
project:
|
||||
name: "skills"
|
||||
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
baseUrl: "n/a"
|
||||
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
overlayFile: ".pouch/ack/project.md"
|
||||
knowledgeFile: ".pouch/ack/knowledge.yaml"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
allowedWorktrees:
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
- "/home/ace/.skills"
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
standard: ["gpt-5.6-terra"]
|
||||
strong: ["gpt-5.6-sol"]
|
||||
test:
|
||||
standard: ["gpt-5.6-luna"]
|
||||
omp:
|
||||
developer:
|
||||
standard: ["opencode-go/gpt-5.6-luna"]
|
||||
strong: ["opencode-go/gpt-5.6-luna"]
|
||||
test:
|
||||
standard: ["opencode-go/gpt-5.6-luna"]
|
||||
profiles:
|
||||
codex-dev-standard:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
codex-test-standard:
|
||||
role: "test"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
codex-dev-strong:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "strong"
|
||||
model: "gpt-5.6-sol"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
omp-dev-standard:
|
||||
role: "developer"
|
||||
cli: "omp"
|
||||
tier: "standard"
|
||||
model: "opencode-go/gpt-5.6-luna"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
omp-test-standard:
|
||||
role: "test"
|
||||
cli: "omp"
|
||||
tier: "standard"
|
||||
model: "opencode-go/gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
omp-dev-strong:
|
||||
role: "developer"
|
||||
cli: "omp"
|
||||
tier: "strong"
|
||||
model: "opencode-go/gpt-5.6-luna"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
defaults:
|
||||
developer: "codex-dev-standard"
|
||||
test: "codex-test-standard"
|
||||
developerUpgraded: "codex-dev-strong"
|
||||
|
||||
workerReceipts:
|
||||
- receiptVersion: 1
|
||||
id: "WR-1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
|
||||
launchId: "1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
|
||||
profileId: "codex-dev-standard"
|
||||
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
|
||||
launchFingerprint: "sha256:7de2f9ebf3065e7c044150fa87aed39582a8018b63a0dabac22194bf52ad52b7"
|
||||
slot: 1
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A1"
|
||||
role: "developer"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-terra"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=medium"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_4d6278c9-83ac-4876-a29d-022adc346e6c"
|
||||
incarnationId: "6daffb4c-e705-47f3-b7c2-d1ee1fb16c56"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T04:46:51.629061Z"
|
||||
createdAt: "2026-08-01T04:46:42.869861Z"
|
||||
receiptHash: "sha256:93e7e4454438a4d1c9a64eb7429563bb4b71348715cc4cb3e42c96cb9ca75fa4"
|
||||
- receiptVersion: 1
|
||||
id: "WR-035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
|
||||
launchId: "035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
|
||||
profileId: "codex-test-standard"
|
||||
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
|
||||
launchFingerprint: "sha256:981fe3c23594573f564de9ab321e724324962958ed689ad3c0c98d7d165c1aa9"
|
||||
slot: 2
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A1"
|
||||
role: "test"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-luna"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=low"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_ab858ba7-b972-45fb-bf50-61e96ced2830"
|
||||
incarnationId: "9e20c158-7ac3-4a18-8dca-f9fec50126d6"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T04:57:33.909375Z"
|
||||
createdAt: "2026-08-01T04:57:25.086609Z"
|
||||
receiptHash: "sha256:1245532d66814f5d3b2a6a3b22293ead597f04390e628333569a7ba8763d0a84"
|
||||
- receiptVersion: 1
|
||||
id: "WR-3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
|
||||
launchId: "3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
|
||||
profileId: "codex-dev-standard"
|
||||
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
|
||||
launchFingerprint: "sha256:8264af6531e9828c0f994845b3a24931e22abea72d8a1d54a7c764577e544873"
|
||||
slot: 3
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A2"
|
||||
role: "developer"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-terra"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=medium"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c"
|
||||
incarnationId: "f77f05ff-fb2b-4099-ab03-070f61671673"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:01:26.824435Z"
|
||||
createdAt: "2026-08-01T05:01:17.965038Z"
|
||||
receiptHash: "sha256:c20c47c7491b5b399d2c29fce67a7035b432969bda0ccb74948eb03c3d085380"
|
||||
- receiptVersion: 1
|
||||
id: "WR-12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
|
||||
launchId: "12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
|
||||
profileId: "codex-test-standard"
|
||||
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
|
||||
launchFingerprint: "sha256:621c7d5d6b7182d9a1ebf211fa11a80d7b100a3b682f8800f61031a651f5bf2a"
|
||||
slot: 4
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A2"
|
||||
role: "test"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-luna"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=low"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_1ec9d2e1-a1b6-45db-8477-097e39a93ee4"
|
||||
incarnationId: "a6f79ac3-9cc6-40a1-a38a-e7b7c2a49b02"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:05:36.189001Z"
|
||||
createdAt: "2026-08-01T05:05:27.500696Z"
|
||||
receiptHash: "sha256:824227bda0e78558807e10da9d89ec32ee4d7f37d782b866bca4629c5ac9e8fb"
|
||||
- receiptVersion: 1
|
||||
id: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
|
||||
launchId: "90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
|
||||
profileId: "codex-dev-standard"
|
||||
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
|
||||
launchFingerprint: "sha256:97ef498caf4a5f0d48fef22773c942a5065dfcf5cecefa22a6ad21df8d04bcd1"
|
||||
slot: 5
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
role: "developer"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-terra"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=medium"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_88079d90-29a3-48ac-9b97-bec011abc249"
|
||||
incarnationId: "5d4f785f-e0b5-4ab3-9de3-72ebc2da3b06"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:16:40.565911Z"
|
||||
createdAt: "2026-08-01T05:16:31.788668Z"
|
||||
receiptHash: "sha256:c62417b2ee8e9815228a18b2ee1be84b2844dada50eb9703a061e08e51e57eb7"
|
||||
- receiptVersion: 1
|
||||
id: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
|
||||
launchId: "8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
|
||||
profileId: "codex-test-standard"
|
||||
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
|
||||
launchFingerprint: "sha256:6c7a45f7dd119bb6ef3bfaed7961c30f257285d940f248e0b16678dcad1bd19d"
|
||||
slot: 6
|
||||
createdFor:
|
||||
taskId: "FEISHU-001"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
role: "test"
|
||||
worktree:
|
||||
path: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
device: 2049
|
||||
inode: 1705498
|
||||
gitCommonDir: "/home/ace/.skills/.git"
|
||||
gitCommonDevice: 2049
|
||||
gitCommonInode: 558841
|
||||
requested:
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
executableDevice: 2049
|
||||
executableInode: 1453403
|
||||
cliVersion: "codex-cli 0.146.0"
|
||||
argv:
|
||||
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
|
||||
- "--strict-config"
|
||||
- "--model"
|
||||
- "gpt-5.6-luna"
|
||||
- "--config"
|
||||
- "model_reasoning_effort=low"
|
||||
- "--sandbox"
|
||||
- "workspace-write"
|
||||
- "--ask-for-approval"
|
||||
- "never"
|
||||
- "--cd"
|
||||
- "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
|
||||
environmentPolicy: "per-cli-allowlist-v1"
|
||||
binding:
|
||||
orchestrator: "orca"
|
||||
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
|
||||
handle: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
|
||||
incarnationId: "6e37ac75-5725-410c-87af-63f8bb42a911"
|
||||
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
|
||||
connected: true
|
||||
writable: true
|
||||
boundAt: "2026-08-01T05:25:08.564556Z"
|
||||
createdAt: "2026-08-01T05:24:59.720042Z"
|
||||
receiptHash: "sha256:dcbed5b2a3fa99312deba61951253992741eca885068e8f649613bd97eb3f59f"
|
||||
|
||||
- {"binding":{"boundAt":"2026-08-23T12:28:51.112660Z","connected":true,"handle":"term_10563050-a88d-4b59-b5c5-78c734185605","incarnationId":"b7dee6bb-8b2f-4385-8592-bc9bf44c1f5e","observedWorktreePath":"/home/ace/.skills","orchestrator":"orca","runtimeId":"76fbdbba-9fef-42d8-97be-94c9892b859c","writable":true},"boardHash":"sha256:88926bb895a370b2f6fcf7a3f7945fd00c40ad9e719e7da47b7d65605f63f16d","createdAt":"2026-08-23T12:28:43.628960Z","createdFor":{"attemptId":"ACK-OMP-001-A1","role":"developer","taskId":"ACK-OMP-001"},"id":"WR-446b1eb450c160509eea68eae4ad3aedcc730876cb5cbcfa5097ddd739c97212","launchFingerprint":"sha256:4e9bb7e8ec6035d175429f7b728ea45e01018b54d834950c6d2f6eacf8962d2a","launchId":"446b1eb450c160509eea68eae4ad3aedcc730876cb5cbcfa5097ddd739c97212","profileHash":"sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b","profileId":"codex-dev-standard","projectRoot":"/home/ace/.skills","receiptHash":"sha256:2caf851ffbf84c82352915068d9740ebf2868aadbe083475b025f0e627c69e27","receiptVersion":1,"requested":{"argv":["/home/ace/.codex/packages/standalone/releases/0.149.0-x86_64-unknown-linux-musl/bin/codex","--strict-config","--model","gpt-5.6-terra","--config","model_reasoning_effort=medium","--sandbox","workspace-write","--ask-for-approval","never","--cd","/home/ace/.skills"],"argvHash":"sha256:81ef379bd5cfad3e178e2936873d4ed342c2b33bbf05feb2d47da38dc20fe829","cli":"codex","cliVersion":"codex-cli 0.149.0","environmentPolicy":"per-cli-allowlist-v1","executable":"/home/ace/.codex/packages/standalone/releases/0.149.0-x86_64-unknown-linux-musl/bin/codex","executableDevice":2049,"executableInode":1979460,"model":"gpt-5.6-terra","permissionMode":"workspace-write","reasoningEffort":"medium","tier":"standard"},"slot":3,"worktree":{"device":2049,"gitCommonDevice":2049,"gitCommonDir":"/home/ace/.skills/.git","gitCommonInode":558841,"inode":558835,"path":"/home/ace/.skills"}}
|
||||
summary:
|
||||
verified: ["FEISHU-001"]
|
||||
open: ["ACK-OMP-002", "ACK-OMP-003"]
|
||||
failedRetest: []
|
||||
leftovers: []
|
||||
|
||||
tasks:
|
||||
- id: "FEISHU-001"
|
||||
type: "feature"
|
||||
title: "ACK 从飞书多维表格按项目 profile 读取并整理 Bug"
|
||||
priority: "P1"
|
||||
status: "verified"
|
||||
assignee: "test"
|
||||
component: "skills/ack"
|
||||
specRefs:
|
||||
- "docs/ack-feishu-bug-intake.md"
|
||||
testRefs:
|
||||
- "tests/test_ack_feishu_intake_unit.py"
|
||||
- "tests/test_ack_feishu_intake_e2e.py"
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
description: >
|
||||
扩展完整 ACK Skill,使项目可以在 tasks.yaml 中声明飞书多维表格 Bug 收件箱和
|
||||
lark-cli profile。ACK 通过可信、无 shell 的读取器读取 ACK Ready 视图、按需
|
||||
下载截图并输出可幂等整理的标准化记录;不同项目不会串用飞书租户。
|
||||
|
||||
stepsToReproduce:
|
||||
- "创建两个假 lark-cli profile:tenant-a 为 active,项目配置 tenant-b"
|
||||
- "在 tenant-b 的 ACK Ready 视图返回一条带截图附件的 Bug 记录"
|
||||
- "运行 ACK 飞书读取器 check/fetch,并重复提交同一标准化结果"
|
||||
|
||||
expected: >
|
||||
读取器每次显式使用 tenant-b,仅读取配置 view,正确标准化文字与截图附件,
|
||||
对所有错误 fail closed;ACK 按稳定 source.ref 整理,重复来源不产生新任务,
|
||||
未配置飞书的旧项目保持兼容。
|
||||
|
||||
actual: >
|
||||
当前 ACK 没有飞书 Bug 数据源、CLI profile 配置、附件读取或来源幂等规则。
|
||||
|
||||
evidence:
|
||||
browser: "飞书多维表格记录可包含文字字段和截图附件;本次使用假 CLI 离线复现"
|
||||
api: "官方 lark-cli base +record-list 与 +record-download-attachment"
|
||||
logs: "本机当前没有 lark-cli,验证必须通过隔离假 CLI 完成"
|
||||
|
||||
verification:
|
||||
commands:
|
||||
- "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*.py'"
|
||||
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
|
||||
browser:
|
||||
page: "n/a"
|
||||
checks:
|
||||
- "假 CLI 记录证明所有数据命令显式使用 tenant-b profile"
|
||||
- "标准化输出包含唯一 sourceRef 与本地截图路径"
|
||||
- "重复来源整理结果只对应一个 ACK 任务"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
taskId: "task_9bc1dba1e21a"
|
||||
dispatchId: "ctx_0791582a526f"
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
|
||||
attemptId: "FEISHU-001-A3"
|
||||
taskId: "task_4f70d2496d13"
|
||||
dispatchId: "ctx_66ab3460dda2"
|
||||
rounds:
|
||||
- round: 1
|
||||
attemptId: "FEISHU-001-A1"
|
||||
result: failed
|
||||
evidence: >
|
||||
Isolated fake tenant and screenshot intake passed profile isolation,
|
||||
download containment, fail-closed behavior, organization rules and
|
||||
legacy validation, but normalized output leaked baseToken inside
|
||||
sourceRef and exposed the attachment file token.
|
||||
- round: 2
|
||||
attemptId: "FEISHU-001-A2"
|
||||
result: failed
|
||||
evidence: >
|
||||
Strengthened isolated E2E passed after the secret-output fix, but
|
||||
Coordinator final gate compared the adapter with official lark-cli
|
||||
source: profile list returns a raw JSON array and has no --format
|
||||
flag, while the adapter requires an object and sends --format.
|
||||
Executable lookup also trusts arbitrary PATH instead of the fixed
|
||||
trusted directories required by the product spec; template source
|
||||
reference still documents the obsolete raw form.
|
||||
- round: 3
|
||||
attemptId: "FEISHU-001-A3"
|
||||
result: passed
|
||||
evidence: >
|
||||
Fresh Test ran a file-backed fake Feishu submission containing all
|
||||
Bug text fields and a screenshot through the production check/fetch
|
||||
entry points. Six focused E2E tests and five unit tests passed. The
|
||||
run verified the official raw profile array and data.data matrix,
|
||||
explicit tenant-b selection, trusted executable resolution,
|
||||
screenshot containment, stable opaque sourceRef deduplication and
|
||||
fail-closed handling for malformed, ambiguous, non-progressing and
|
||||
over-limit responses. Validators, pouch check, compileall and diff
|
||||
checks also passed.
|
||||
|
||||
resolution:
|
||||
fixedBy: "term_88079d90-29a3-48ac-9b97-bec011abc249"
|
||||
verifiedBy: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
|
||||
verifiedAt: "2026-08-01T13:31:40+08:00"
|
||||
leftoverReason: null
|
||||
evidence:
|
||||
developer: >
|
||||
FEISHU-001-A1 implemented ACK 0.12.0 Feishu Base intake. Focused unit
|
||||
tests, ACK contract tests, compileall, task validation and diff check
|
||||
passed. The fresh worker could not deliver worker_done through its
|
||||
workspace-write sandbox because Orca relay/FUSE was unavailable;
|
||||
Coordinator preserved the terminal evidence in Orca task
|
||||
task_ed87b3386a80 and did not treat it as final verification.
|
||||
testRound1: >
|
||||
Fresh Test term_ab858ba7-b972-45fb-bf50-61e96ced2830 created the
|
||||
isolated E2E fixture and found secret-safe output failing while all
|
||||
other targeted signals passed. Evidence is retained in Orca task
|
||||
task_251a9b5265cd; Test relay was unavailable for the same sandbox
|
||||
reason, so this remains a failed retest rather than verification.
|
||||
developerRound2: >
|
||||
Fresh Developer term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c changed
|
||||
sourceRef to a domain-separated SHA-256 digest and kept file tokens
|
||||
internal to download calls. Focused unit, ACK contract, read-only E2E,
|
||||
compileall and diff checks passed; independent round-2 Test is still
|
||||
required.
|
||||
coordinatorGateRound2: >
|
||||
Targeted Test evidence was green, but final source-contract review
|
||||
against official larksuite/cli cmd/profile/list.go rejected the round:
|
||||
actual profile list output is a JSON array, the command exposes no
|
||||
format flag, and the current reader resolves lark-cli through caller
|
||||
PATH rather than fixed trusted directories.
|
||||
developerRound3: >
|
||||
Fresh Developer term_88079d90-29a3-48ac-9b97-bec011abc249 added the
|
||||
trusted resolver, official raw profile-list and data.data parsing,
|
||||
strict envelopes, sanitized execution PATH, revised unit coverage and
|
||||
complete setup/source examples. Five focused unit tests, 34 ACK
|
||||
contracts (one skipped), compileall, task validation and diff check
|
||||
passed; final independent Test remains required.
|
||||
testRound3: >
|
||||
Fresh Test term_44ab2675-445a-4f51-a0f5-83960bc1ca3b passed six
|
||||
isolated E2E cases and all five Feishu unit tests. It exercised the
|
||||
production entry points with a real fake-CLI subprocess and a separate
|
||||
submitted-bug JSON file, downloaded a screenshot into a temporary
|
||||
directory, repeated the fetch to prove a stable opaque sourceRef, and
|
||||
simulated ACK source.ref organization without duplicates. The four
|
||||
failures in the wider legacy suite reproduce unchanged on base commit
|
||||
f08edb6 and are classified as existing environment/layout failures.
|
||||
Orca worker_done transport was unavailable inside the worker sandbox
|
||||
because FUSE was absent; the full fresh-terminal evidence is retained
|
||||
in Orca task task_4f70d2496d13.
|
||||
|
||||
|
||||
- id: "ACK-OMP-001"
|
||||
type: "feature"
|
||||
title: "支持 OMP worker CLI"
|
||||
priority: "P1"
|
||||
status: "blocked"
|
||||
assignee: "developer"
|
||||
component: "skills/ack/scripts"
|
||||
specRefs:
|
||||
- "docs/PRD-ack-omp-worker.md#范围"
|
||||
testRefs:
|
||||
- "tests/test_ack_omp_worker.py"
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
description: >
|
||||
ACK 当前 worker CLI allowlist 只支持 codex、cursor-agent 和 grok,无法使用当前
|
||||
Oh My Pi(OMP)CLI 启动 Developer 或 Test worker。增加 omp CLI 支持,Orca 继续
|
||||
负责 terminal orchestration,模型仍由 profile 明确指定。
|
||||
|
||||
fixLogic: >
|
||||
扩展结构化 CLI allowlist、模型 allowlist 校验、可信 executable 解析、按 CLI
|
||||
隔离的 worker 环境和固定 argv 渲染。OMP argv 使用精确 provider/model、thinking、
|
||||
approval mode、绝对 cwd 和 no-session;workspace-write 映射 yolo,read-only
|
||||
映射 always-ask(用户已明确授权),禁止 auto-approve、plan-yolo 与会话复用。CLI、模型、版本、
|
||||
审批模式和 worktree 继续进入 launch fingerprint 与 receipt。
|
||||
|
||||
acceptanceCriteria:
|
||||
- "合法 omp profile 可通过路由校验,模型必须命中 omp/role/tier allowlist"
|
||||
- "OMP worker argv 固定包含 model、thinking、approval-mode、cwd 和 no-session"
|
||||
- "auto-approve、plan-yolo、会话复用和不可信 executable 均 fail closed;workspace-write yolo 需显式授权"
|
||||
- "既有 codex、cursor-agent、grok 的路由与 receipt 行为保持不变"
|
||||
|
||||
stepsToReproduce:
|
||||
- "在 ACK profile 中声明 cli 为 omp,模型为 opencode-go/gpt-5.6-luna"
|
||||
- "运行 worker profile 校验或 launcher plan"
|
||||
- "观察当前实现将 omp 判定为不支持的 CLI"
|
||||
|
||||
expected: >
|
||||
ACK 可以通过结构化 profile 安全选择 OMP worker,并为 Orca terminal 生成可核对的
|
||||
交互式启动计划;不允许借配置注入任意命令或绕过审批。
|
||||
|
||||
actual: >
|
||||
当前 worker_profiles.CLIS、launcher 支持集合和凭据策略没有 omp,launcher 会拒绝
|
||||
OMP worker。
|
||||
|
||||
evidence:
|
||||
browser: "n/a"
|
||||
api: "本机 omp 17.2.11 支持 --model、--thinking、--cwd、--approval-mode 和 --no-session;当前 ACK 不接受 omp"
|
||||
logs: "当前 ACK 路由单元测试 44 项通过,但只覆盖 codex/cursor-agent/grok"
|
||||
|
||||
verification:
|
||||
commands:
|
||||
- "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 .pouch/ack/tasks.yaml"
|
||||
browser:
|
||||
page: "n/a"
|
||||
checks:
|
||||
- "OMP profile plan 输出 cli、model、版本、审批模式和固定 argv"
|
||||
- "危险参数与不可信 executable 不会进入启动计划"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: "WR-446b1eb450c160509eea68eae4ad3aedcc730876cb5cbcfa5097ddd739c97212"
|
||||
attemptId: "ACK-OMP-001-A1"
|
||||
taskId: "task_35bbcdbeeb1f"
|
||||
dispatchId: "ctx_abeb35c059a8"
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds: []
|
||||
environmentIncidents:
|
||||
- id: "ACK-OMP-001-ENV-1"
|
||||
attemptId: "ACK-OMP-001-A1"
|
||||
role: "coordinator"
|
||||
phase: "launch"
|
||||
status: "resolved"
|
||||
summary: "配置中的旧 worker worktree 已不存在,Developer 启动计划被拒绝"
|
||||
evidence: "/home/ace/orca/workspaces/.skills/record-bug 不存在;git worktree list 仅列出 /home/ace/.skills 和 agent-swarm"
|
||||
impact: "首次 Developer 派发未创建终端,不消耗产品复验轮次"
|
||||
recoveryAction: "核对 Git worktree,登记当前项目根 /home/ace/.skills 到 allowedWorktrees,并重新校验任务板"
|
||||
userAction: "无需操作;Coordinator 将使用当前已登记项目根重新生成并审阅启动计划"
|
||||
reportedAt: "2026-08-23T20:27:41+08:00"
|
||||
resolvedAt: "2026-08-23T20:27:41+08:00"
|
||||
- id: "ACK-OMP-001-ENV-2"
|
||||
attemptId: "ACK-OMP-001-A1"
|
||||
role: "coordinator"
|
||||
phase: "launch"
|
||||
status: "resolved"
|
||||
summary: "移除不存在的旧 worktree 会使历史 receipt 无法通过 allowlist 校验"
|
||||
evidence: "validate_tasks 报告 6 个历史 receipt.worktree.path 不在 allowlist;历史 receipt 均绑定 /home/ace/orca/workspaces/.skills/record-bug"
|
||||
impact: "配置收紧后 Developer 计划仍无法生成,未创建终端且不消耗产品复验轮次"
|
||||
recoveryAction: "按历史 receipt 恢复旧路径为同一 Git 仓库的 detached worktree,并保留当前项目根作为本轮 worker 路径"
|
||||
userAction: "无需操作;旧路径仅为历史 receipt 校验保留,本轮 worker 使用 /home/ace/.skills"
|
||||
reportedAt: "2026-08-23T20:27:41+08:00"
|
||||
resolvedAt: "2026-08-23T20:27:41+08:00"
|
||||
- id: "ACK-OMP-001-ENV-3"
|
||||
attemptId: "ACK-OMP-001-A1"
|
||||
role: "developer"
|
||||
phase: "tooling"
|
||||
status: "open"
|
||||
summary: "Codex Developer worker 命中 OpenAI 使用额度限制,无法执行任务"
|
||||
evidence: "worker terminal 显示 You've hit your usage limit;仅提供切换到 gpt-5.6-luna 或等待额度恢复,当前 profile 固定为 gpt-5.6-terra"
|
||||
impact: "Developer 尚未修改代码或运行验证;任务未产生产品失败,不消耗复验轮次"
|
||||
recoveryAction: "停止当前阻塞等待,保留 terminal 与 dispatch 供协调;需改用已授权且有额度的 profile 后重新 plan/launch"
|
||||
userAction: "请决定等待额度恢复,或授权 Coordinator 将本次 Developer profile 切换到可用的已配置 CLI/model;不能把当前 worker 自行改成 Luna"
|
||||
reportedAt: "2026-08-23T20:30:26+08:00"
|
||||
resolvedAt: null
|
||||
|
||||
|
||||
resolution:
|
||||
fixedBy: null
|
||||
verifiedBy: null
|
||||
verifiedAt: null
|
||||
leftoverReason: null
|
||||
|
||||
- id: "ACK-OMP-002"
|
||||
type: "feature"
|
||||
title: "补充 OMP 路由回归测试"
|
||||
priority: "P1"
|
||||
status: "open"
|
||||
assignee: "developer"
|
||||
component: "tests/test_ack_omp_worker.py"
|
||||
specRefs:
|
||||
- "docs/PRD-ack-omp-worker.md#可观测验收标准"
|
||||
testRefs:
|
||||
- "tests/test_ack_omp_worker.py"
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
description: >
|
||||
新增 OMP profile、argv、可信 executable、审批模式和环境隔离的白盒覆盖,并确保
|
||||
现有 CLI 路由回归测试继续通过。
|
||||
|
||||
fixLogic: >
|
||||
测试合法与非法 OMP profile、provider/model 精确 allowlist、thinking 映射、审批
|
||||
模式边界、no-session、恶意参数、可信路径、版本记录和凭据隔离;只测试可观察
|
||||
行为,不测试源代码文本或偶然默认值。
|
||||
|
||||
acceptanceCriteria:
|
||||
- "测试覆盖 OMP 的合法 profile、模型 allowlist、thinking、argv 和环境策略"
|
||||
- "测试覆盖危险审批模式、非法模型、恶意 executable 和自由参数拒绝"
|
||||
- "既有 ACK worker profile、launcher 与任务板测试全部通过"
|
||||
|
||||
stepsToReproduce:
|
||||
- "运行现有 ACK worker profile 和 launcher 测试"
|
||||
- "检查测试没有 OMP profile、argv 或环境断言"
|
||||
|
||||
expected: >
|
||||
OMP 支持具有可执行的回归护栏,后续路由或安全边界回退会被测试捕获。
|
||||
|
||||
actual: >
|
||||
当前测试矩阵只包含 codex、cursor-agent 和 grok,没有 OMP 覆盖。
|
||||
|
||||
evidence:
|
||||
browser: "n/a"
|
||||
api: "n/a"
|
||||
logs: "现有 worker profile 测试 44 项通过"
|
||||
|
||||
verification:
|
||||
commands:
|
||||
- "python3 -m unittest tests.test_ack_omp_worker tests.test_ack_worker_profiles tests.test_ack_launch_worker tests.test_ack_tasks_validation"
|
||||
- "python3 -m compileall -q skills/ack/scripts"
|
||||
browser:
|
||||
page: "n/a"
|
||||
checks:
|
||||
- "白盒测试对每个 OMP 安全边界给出确定 pass/fail"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds: []
|
||||
environmentIncidents: []
|
||||
|
||||
resolution:
|
||||
fixedBy: null
|
||||
verifiedBy: null
|
||||
verifiedAt: null
|
||||
leftoverReason: null
|
||||
|
||||
- id: "ACK-OMP-003"
|
||||
type: "verification"
|
||||
title: "独立复测 OMP worker 派发"
|
||||
priority: "P1"
|
||||
status: "open"
|
||||
assignee: "test"
|
||||
component: "skills/ack"
|
||||
specRefs:
|
||||
- "docs/PRD-ack-omp-worker.md#可观测验收标准"
|
||||
testRefs:
|
||||
- "tests/test_ack_omp_e2e.py"
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
|
||||
description: >
|
||||
在独立临时项目和隔离 OMP 配置目录中验证 fresh OMP worker 的 Orca terminal 绑定、
|
||||
任务注入、工作目录对齐和 lifecycle 回报。
|
||||
|
||||
fixLogic: >
|
||||
Test 只使用隔离配置和受控测试入口,不读取真实凭据;先核对 worktree、CLI 版本、
|
||||
profile 和服务状态,再验证任务输入确实被 OMP worker 接收。协议、审批或环境阻塞
|
||||
必须记录为环境事件,不转写成产品失败。
|
||||
|
||||
acceptanceCriteria:
|
||||
- "fresh OMP worker 绑定到指定 worktree 且 receipt 记录 cli、model、版本和 argv"
|
||||
- "Orca dispatch 的任务输入被 OMP worker 接收并产生可核对 lifecycle 证据"
|
||||
- "测试不读取或持久化真实 OMP 凭据,协议/环境失败有明确证据"
|
||||
|
||||
stepsToReproduce:
|
||||
- "创建临时项目、隔离 OMP 配置目录和受控 profile"
|
||||
- "按 ACK launcher plan/launch 创建 OMP worker"
|
||||
- "通过 Orca dispatch 注入任务并观察 worker 回报"
|
||||
|
||||
expected: >
|
||||
OMP worker 可在 ACK 安全边界内完成一次独立派发和回报;若当前 Orca 注入协议或
|
||||
OMP 审批模式无法完成,则明确暴露阻塞点而不伪报通过。
|
||||
|
||||
actual: >
|
||||
当前尚未有 OMP worker profile、launcher 分支或独立黑盒演练。
|
||||
|
||||
evidence:
|
||||
browser: "n/a"
|
||||
api: "n/a"
|
||||
logs: "OMP CLI 17.2.11 支持交互式启动参数;实际 Orca 注入兼容性待验证"
|
||||
|
||||
verification:
|
||||
commands:
|
||||
- "python3 -m unittest discover -s tests -p 'test_ack_omp_e2e.py'"
|
||||
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
|
||||
- "python3 skills/ack/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml"
|
||||
browser:
|
||||
page: "n/a"
|
||||
checks:
|
||||
- "独立测试记录 worker worktree、模型、argv、注入结果和 lifecycle 证据"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds: []
|
||||
environmentIncidents: []
|
||||
|
||||
resolution:
|
||||
fixedBy: null
|
||||
verifiedBy: null
|
||||
verifiedAt: null
|
||||
leftoverReason: null
|
||||
@@ -1,31 +1,27 @@
|
||||
# Agent Skills 仓库
|
||||
# pouch
|
||||
|
||||
自研 Agent Skills 的单一事实来源(SSOT)。Skill 内容在本仓库维护;安装与管理由 [skiff](https://git.yumee.top/laily/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** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | 安装、symlink、健康检查 |
|
||||
| 仓库 | 地址 | 职责 |
|
||||
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
|
||||
| **pouch**(本仓库) | [https://git.yumee.top/laily/pouch](https://git.yumee.top/laily/pouch) | skill、规范包、pouch CLI 的当前 SSOT |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 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
|
||||
# 1. 克隆并关联
|
||||
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
|
||||
git clone https://git.yumee.top/laily/skiff.git ~/code/gitea/skiff
|
||||
cd ~/code/gitea/skiff && ./install.sh
|
||||
skiff setup ~/code/gitea/skills
|
||||
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||
cd ~/.pouch && ./install.sh
|
||||
|
||||
# 2. 全局安装 skill
|
||||
skiff install declarative-openspec-loop
|
||||
|
||||
# 3. 查看状态
|
||||
skiff list
|
||||
skiff status
|
||||
# install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
|
||||
pouch select -g
|
||||
pouch list
|
||||
pouch status
|
||||
```
|
||||
|
||||
---
|
||||
@@ -33,51 +29,105 @@ skiff status
|
||||
## 仓库结构
|
||||
|
||||
```
|
||||
skills/
|
||||
├── _template/ # 新建 skill 的脚手架
|
||||
├── declarative-openspec-loop/ # 自研 skill
|
||||
│ ├── SKILL.md
|
||||
│ └── reference.md
|
||||
├── discussion-notes/ # 讨论沉淀笔记
|
||||
│ ├── SKILL.md
|
||||
│ └── reference.md
|
||||
registry.yaml # 外部 Git skill 来源目录
|
||||
AGENTS.md # 本文档
|
||||
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类)
|
||||
pouch/ # CLI 源码(Python 3)
|
||||
bin/pouch # CLI 入口
|
||||
catalog.yaml # pouch 预置 Skill 来源目录
|
||||
AGENTS.md # 本文档
|
||||
```
|
||||
|
||||
**本仓库包含**:`skills/`、`registry.yaml`、`AGENTS.md`
|
||||
**本仓库不包含**:CLI 代码、各项目的 skill 启用清单
|
||||
**本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
|
||||
**本仓库不包含**:各项目的 skill 启用清单
|
||||
|
||||
---
|
||||
|
||||
## Skill 目录
|
||||
|
||||
### 自研(Owned)
|
||||
### 内置(Builtin)
|
||||
|
||||
|
||||
| Skill | 说明 |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
||||
| 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) | 通过 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。
|
||||
|
||||
### 外部(External Git)
|
||||
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
|
||||
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
|
||||
项目状态,不复制或链接 Skill 内容:
|
||||
|
||||
在 `registry.yaml` 中注册,通过 skiff 拉取安装:
|
||||
```bash
|
||||
pouch init ack
|
||||
```
|
||||
|
||||
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(检查配置并引导补齐),
|
||||
不要 `pouch init builder` / `pouch init deployer`。
|
||||
|
||||
### 预置目录(Catalog)
|
||||
|
||||
在 `catalog.yaml` 中预置,通过 pouch 拉取安装:
|
||||
|
||||
|
||||
| Skill | 来源 |
|
||||
| ----------- | -------------------------------------------------------------------------- |
|
||||
| superpowers | [https://github.com/obra/superpowers](https://github.com/obra/superpowers) |
|
||||
| Source | 来源 |
|
||||
| --- | --- |
|
||||
| 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
|
||||
skiff fetch superpowers
|
||||
skiff install-external superpowers
|
||||
pouch fetch waza
|
||||
pouch add waza/think -g
|
||||
pouch add taste-skill/taste-skill -g
|
||||
```
|
||||
|
||||
### 自定义仓库(Custom Sources)
|
||||
|
||||
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
|
||||
|
||||
```bash
|
||||
pouch source add company \
|
||||
git@git.company.com:platform/agent-skills.git \
|
||||
--skills-path skills
|
||||
pouch list --source company
|
||||
pouch add company/internal-review -g
|
||||
```
|
||||
|
||||
也可以接入已有本地 checkout:
|
||||
|
||||
```bash
|
||||
pouch source add company --local ~/code/company-skills --skills-path skills
|
||||
```
|
||||
|
||||
配置保存在 `~/.config/pouch/config.yaml`,Git source 默认 clone 到
|
||||
`~/.local/share/pouch/sources/<source>/`。项目 `.pouch.yaml` 只记录逻辑
|
||||
source 名称,每台机器独立配置实际仓库地址。
|
||||
|
||||
### 社区(External NPM / GitHub)
|
||||
|
||||
推荐使用 Vercel CLI 安装第三方 skill:
|
||||
@@ -92,26 +142,29 @@ npx skills find typescript
|
||||
## 设计原则
|
||||
|
||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
||||
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
|
||||
2. **项目自治** — 每个项目自己维护 `.pouch.yaml`,本仓库不维护项目清单
|
||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||
4. **仓库分离** — skill 内容与 CLI 工具分仓库,互不影响更新
|
||||
4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
|
||||
5. **一体维护** — skill 与 CLI 同仓库维护
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
skills 仓库(本仓库) skiff CLI
|
||||
skills/<name>/ ←── skiff install / enable
|
||||
registry.yaml ←── skiff add / fetch
|
||||
pouch 仓库(本仓库) pouch CLI
|
||||
skills/<name>/ ←── pouch install / enable
|
||||
pouch/ ←── python3 -m pouch
|
||||
catalog.yaml ←── pouch add / fetch
|
||||
↑
|
||||
~/.skills(symlink)
|
||||
~/.pouch(symlink)
|
||||
│
|
||||
┌────┴────┐
|
||||
▼ ▼
|
||||
~/.cursor/skills/ project/.agents/skills/
|
||||
~/.claude/skills/ project/.claude/skills/
|
||||
~/.codex/skills/ project/.skills.yaml
|
||||
~/.codex/skills/ project/.pouch.yaml
|
||||
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
|
||||
```
|
||||
|
||||
### Skill 三层分类
|
||||
@@ -119,8 +172,9 @@ registry.yaml ←── skiff add / fetch
|
||||
|
||||
| 层级 | 位置 | 维护方式 |
|
||||
| ---------------- | ---------------------------------- | ------------------------------- |
|
||||
| **Owned** | `skills/<name>/` | 本仓库 commit |
|
||||
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` |
|
||||
| **Builtin** | `skills/<name>/` | 本仓库 commit |
|
||||
| **Catalog** | `catalog.yaml` + checkout 缓存 | `pouch catalog add / fetch` |
|
||||
| **Custom Source** | `~/.local/share/pouch/sources/` 或本地路径 | `pouch source add/fetch` |
|
||||
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
|
||||
|
||||
|
||||
@@ -132,21 +186,24 @@ registry.yaml ←── skiff add / fetch
|
||||
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
|
||||
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
|
||||
| Codex | `~/.codex/skills/` | `.agents/skills/` |
|
||||
| Agents 标准 | `~/.agents/skills/` | `.agents/skills/`(与 cursor/codex 共用;覆盖 OMP) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 项目级启用
|
||||
|
||||
每个项目**自己维护** `.skills.yaml`,不由本仓库管理:
|
||||
每个项目**自己维护** `.pouch.yaml`,不由本仓库管理:
|
||||
|
||||
```yaml
|
||||
# .skills.yaml(在项目根目录)
|
||||
# .pouch.yaml(在项目根目录)
|
||||
skills:
|
||||
- declarative-openspec-loop
|
||||
- name: superpowers
|
||||
source: registry
|
||||
ref: main
|
||||
- name: declarative-openspec-loop
|
||||
source: builtin
|
||||
- name: think
|
||||
source: catalog:waza
|
||||
- name: internal-review
|
||||
source: company
|
||||
|
||||
targets: # 可选,默认 all
|
||||
- cursor
|
||||
@@ -157,49 +214,50 @@ targets: # 可选,默认 all
|
||||
|
||||
| 概念 | 类比 |
|
||||
| -------------- | --------------------------- |
|
||||
| skills 仓库 | npm registry |
|
||||
| `.skills.yaml` | `package.json` dependencies |
|
||||
| `skiff enable` | `npm install` |
|
||||
| `skiff sync` | `npm ci` |
|
||||
| pouch 仓库 | npm registry |
|
||||
| `.pouch.yaml` | `package.json` dependencies |
|
||||
| `pouch enable` | `npm install` |
|
||||
| `pouch sync` | `npm ci` |
|
||||
|
||||
|
||||
项目级命令(skiff 待实现):
|
||||
项目级命令:
|
||||
|
||||
```bash
|
||||
cd ~/code/my-app
|
||||
skiff enable declarative-openspec-loop
|
||||
skiff disable declarative-openspec-loop
|
||||
skiff sync
|
||||
pouch enable declarative-openspec-loop
|
||||
pouch disable declarative-openspec-loop
|
||||
pouch sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## skiff 命令
|
||||
## pouch 命令
|
||||
|
||||
详见 [skiff README](https://git.yumee.top/laily/skiff)。
|
||||
详见 [pouch README](https://git.yumee.top/laily/pouch)。
|
||||
|
||||
### 已实现
|
||||
|
||||
|
||||
| 命令 | 说明 |
|
||||
| -------------------------------------- | ------------------- |
|
||||
| `skiff setup <path>` | 关联 `~/.skills` 到本仓库 |
|
||||
| `skiff list` | 列出所有 skill |
|
||||
| `skiff status` | 安装状态总览 |
|
||||
| `skiff install <name>` | 全局安装(symlink) |
|
||||
| `skiff uninstall <name>` | 移除 symlink |
|
||||
| `skiff add / fetch / install-external` | 外部 Git skill |
|
||||
| `pouch bootstrap` | 将 pouch 项目 skill 全局安装到所有 Agent |
|
||||
| `pouch list` | 列出所有 skill |
|
||||
| `pouch status` | 安装状态总览 |
|
||||
| `pouch add <name> [-g]` | 项目或全局安装(symlink) |
|
||||
| `pouch remove <name> [-g]` | 移除 symlink |
|
||||
| `pouch catalog add` / `pouch fetch` | 管理和拉取 catalog source |
|
||||
| `pouch update` | 更新 pouch 自身和已拉取的 catalog / custom source |
|
||||
|
||||
|
||||
### 待实现
|
||||
### 草稿与健康检查
|
||||
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ------------------------ | --------------------------- |
|
||||
| `skiff enable / disable` | 项目级启用/关闭 |
|
||||
| `skiff sync` | 按 `.skills.yaml` 重建 symlink |
|
||||
| `skiff create` | 从 `_template/` 脚手架创建 skill |
|
||||
| `skiff doctor` | symlink 健康检查 |
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `pouch create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
|
||||
| `pouch check <name>` | 校验草稿或正式 skill |
|
||||
| `pouch finalize <name>` | 校验并将草稿转为正式 skill |
|
||||
| `pouch doctor [--fix]` | symlink 健康检查与修复 |
|
||||
|
||||
|
||||
---
|
||||
@@ -210,7 +268,8 @@ skiff sync
|
||||
|
||||
```
|
||||
skill-name/
|
||||
├── SKILL.md # 必需
|
||||
├── SKILL.md # 必需,给 Agent
|
||||
├── README.md # 必需,给人类
|
||||
├── reference.md # 可选
|
||||
├── examples.md # 可选
|
||||
└── scripts/ # 可选
|
||||
@@ -234,11 +293,12 @@ description: >-
|
||||
|
||||
### 新建流程
|
||||
|
||||
1. `cp -r skills/_template skills/my-skill`
|
||||
2. 编辑 `skills/my-skill/SKILL.md`
|
||||
3. `skiff install my-skill --target cursor` 验证
|
||||
4. 在本仓库 commit
|
||||
5. 各项目 `skiff enable my-skill`(待实现)
|
||||
1. `pouch create my-skill --idea "..." --from-project .`
|
||||
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md` 和 `README.md`
|
||||
3. `pouch check my-skill`
|
||||
4. 用户确认后执行 `pouch finalize my-skill`
|
||||
5. `pouch add my-skill -a cursor -g -y` 验证
|
||||
6. 在本仓库 commit;需要的项目再用 `pouch add my-skill` 启用
|
||||
|
||||
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
|
||||
|
||||
@@ -250,7 +310,7 @@ symlink 正确时,Agent 在项目里改 skill 文件 = 直接改 SSOT:
|
||||
|
||||
```
|
||||
project/.agents/skills/foo/SKILL.md
|
||||
→ ~/.skills/skills/foo/SKILL.md
|
||||
→ ~/.pouch/skills/foo/SKILL.md
|
||||
→ 在本仓库 commit
|
||||
```
|
||||
|
||||
@@ -265,7 +325,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| Cursor / Codex | symlink,正常 |
|
||||
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
||||
| symlink 被替换 | `skiff doctor`(待实现)→ 重建 symlink |
|
||||
| symlink 被替换 | `pouch doctor --fix` → 重建 symlink |
|
||||
|
||||
|
||||
---
|
||||
@@ -275,7 +335,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
|
||||
| 场景 | 工具 |
|
||||
| -------------- | ---------------------------------------------------- |
|
||||
| 自研 skill 安装/管理 | **skiff** |
|
||||
| 自研 skill 安装/管理 | **pouch** |
|
||||
| 社区 skill 安装 | **Vercel `npx skills add`** |
|
||||
| NPM 包内 skill | **skills-npm** / **skill-indexer** |
|
||||
| 搜索发现 | **npx skills find** / [skills.sh](https://skills.sh) |
|
||||
@@ -288,13 +348,15 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
|
||||
| 我要… | 命令 | 在哪 |
|
||||
| ---------- | --------------------------------- | ---- |
|
||||
| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 |
|
||||
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 |
|
||||
| 全局启用 | `skiff install <name>` | 任意 |
|
||||
| 项目启用 | `skiff enable <name>` | 项目目录 |
|
||||
| 看状态 | `skiff status` | 任意 |
|
||||
| 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
|
||||
| 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 |
|
||||
| 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 |
|
||||
| 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
|
||||
| 全局启用 | `pouch add <name> -g` | 任意 |
|
||||
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
|
||||
| 看状态 | `pouch status` | 任意 |
|
||||
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
|
||||
| 更新外部 skill | `skiff fetch <name>` | 任意 |
|
||||
| 更新外部 skill | `pouch fetch <name>` 或日常命令自动更新 | 任意 |
|
||||
|
||||
|
||||
---
|
||||
@@ -302,8 +364,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
## 参考
|
||||
|
||||
- [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)
|
||||
- [skills.sh](https://skills.sh)
|
||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||
|
||||
|
||||
@@ -1,71 +1,158 @@
|
||||
# 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
|
||||
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills
|
||||
cd ~/code/gitea/skills
|
||||
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||
cd ~/.pouch
|
||||
./install.sh
|
||||
skiff setup ~/code/gitea/skills
|
||||
|
||||
skiff install declarative-openspec-loop
|
||||
skiff list
|
||||
skiff status
|
||||
# install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
|
||||
# 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
|
||||
|
||||
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)
|
||||
skiff/ # CLI 源码(Python 3)
|
||||
bin/skiff # CLI 入口
|
||||
registry.yaml # 外部 Git skill 目录
|
||||
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
|
||||
pouch/ # CLI 源码(Python 3)
|
||||
bin/pouch # CLI 入口
|
||||
catalog.yaml # pouch 预置 Skill 来源目录
|
||||
AGENTS.md # 详细规范与架构说明
|
||||
```
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` |
|
||||
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
|
||||
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 |
|
||||
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ---------------------------- | ----------------------------------------------------- |
|
||||
| [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
|
||||
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
|
||||
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
|
||||
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
||||
|
||||
|
||||
## 自研 Skill
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
||||
|
||||
| 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
|
||||
pouch init ack
|
||||
pouch init ack --project ~/app
|
||||
```
|
||||
|
||||
初始化会生成默认关闭的 `.pouch/ack/delivery.yaml` 和空的 `.pouch/ack/regression.yaml`。
|
||||
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
|
||||
验证通过后收获回归用例。
|
||||
|
||||
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
|
||||
|
||||
新建 skill:
|
||||
|
||||
```bash
|
||||
skiff create my-skill # 从 _template/ 脚手架创建
|
||||
# 编辑 skills/my-skill/SKILL.md
|
||||
skiff install my-skill # 全局安装验证
|
||||
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md 和 README.md
|
||||
pouch check my-skill
|
||||
pouch finalize my-skill
|
||||
pouch add my-skill -g # 全局安装验证
|
||||
```
|
||||
|
||||
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `pouch` skill 收集实际结果与期望结果,修改 `~/.pouch/skills/<name>/` 的 SSOT,并执行 `pouch check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
|
||||
|
||||
## 安装方式
|
||||
|
||||
### 全局(用户级)
|
||||
|
||||
```bash
|
||||
skiff install <name> # 安装到 ~/.cursor/skills/ 等
|
||||
skiff install <name> --target cursor
|
||||
pouch add <name> -g # 安装到 ~/.cursor/skills/ 等
|
||||
pouch add <name> -g -a cursor
|
||||
```
|
||||
|
||||
### 项目级
|
||||
|
||||
在项目根目录维护 `.skills.yaml`:
|
||||
在项目根目录维护 `.pouch.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- declarative-openspec-loop
|
||||
- name: superpowers
|
||||
source: registry
|
||||
ref: main
|
||||
- name: ack
|
||||
source: builtin
|
||||
- name: think
|
||||
source: catalog:waza
|
||||
|
||||
targets: # 可选,默认 all
|
||||
- cursor
|
||||
@@ -74,18 +161,27 @@ targets: # 可选,默认 all
|
||||
```
|
||||
|
||||
```bash
|
||||
skiff enable declarative-openspec-loop
|
||||
skiff sync
|
||||
skiff disable declarative-openspec-loop
|
||||
pouch add ack
|
||||
pouch sync
|
||||
pouch remove ack
|
||||
```
|
||||
|
||||
## 外部 Skill
|
||||
## Catalog 与 Custom Source
|
||||
|
||||
**Git 来源**(经 skiff 管理):
|
||||
安装 catalog 中预置的来源:
|
||||
|
||||
```bash
|
||||
skiff fetch superpowers
|
||||
skiff install-external superpowers
|
||||
pouch fetch waza
|
||||
pouch add waza/think -g
|
||||
pouch add taste-skill/taste-skill -g # 只要核心 frontend skill
|
||||
pouch add taste-skill -g # 或装整个 Taste Skill collection
|
||||
```
|
||||
|
||||
接入团队自己的本地目录或 Git 仓库:
|
||||
|
||||
```bash
|
||||
pouch source add company --local ~/code/company-skills --skills-path skills
|
||||
pouch add company/internal-review -g
|
||||
```
|
||||
|
||||
**社区来源**(Vercel CLI):
|
||||
@@ -99,30 +195,34 @@ npx skills find typescript
|
||||
|
||||
```
|
||||
本仓库
|
||||
├── skills/<name>/ ←── skiff install / enable
|
||||
├── registry.yaml ←── skiff add / fetch
|
||||
└── skiff/ ←── python3 -m skiff
|
||||
├── skills/<name>/ ←── pouch add / select
|
||||
├── catalog.yaml ←── 预置来源发现与 fetch
|
||||
└── pouch/ ←── python3 -m pouch
|
||||
↑
|
||||
~/.skills(symlink)
|
||||
~/.pouch(symlink)
|
||||
│
|
||||
┌────┴────────────────┐
|
||||
▼ ▼
|
||||
~/.cursor/skills/ project/.agents/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>/`
|
||||
2. **项目自治** — 各项目自行维护 `.skills.yaml`
|
||||
2. **项目自治** — 各项目自行维护 `.pouch.yaml`
|
||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||
4. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
||||
4. **能力内聚** — Skill 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
|
||||
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
||||
|
||||
## 文档
|
||||
|
||||
- [docs/getting-started.md](docs/getting-started.md):给同事的安装、打版本、构建教程
|
||||
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
|
||||
- [skiff/README.md](skiff/README.md) — CLI 命令参考与开发说明
|
||||
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
|
||||
- 各 skill 的人类说明见上表 `README.md`
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -130,3 +230,4 @@ npx skills find typescript
|
||||
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
|
||||
- [skills.sh](https://skills.sh)
|
||||
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 跟随软链,确保从 ~/.local/bin/pouch 调用时仍能找到仓库根目录
|
||||
SCRIPT="${BASH_SOURCE[0]}"
|
||||
while [ -L "$SCRIPT" ]; do
|
||||
link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||
SCRIPT="$(readlink "$SCRIPT")"
|
||||
[[ "$SCRIPT" != /* ]] && SCRIPT="$link_dir/$SCRIPT"
|
||||
done
|
||||
REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
|
||||
|
||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec python3 -m pouch "$@"
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec python3 -m skiff "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
# Preconfigured Skill source catalog
|
||||
# Format:
|
||||
# <name>:
|
||||
# repo: <git-url>
|
||||
# ref: <branch|tag> (default: main)
|
||||
# path: <subpath> (default: .)
|
||||
# description: <text> (optional, shown by `pouch select`)
|
||||
# tags: (optional)
|
||||
# - <tag>
|
||||
#
|
||||
# Single-skill example (`path` contains SKILL.md):
|
||||
# example-skills:
|
||||
# repo: https://github.com/example/skills
|
||||
# ref: main
|
||||
# path: .
|
||||
#
|
||||
# Collection example (`path` contains skill directories):
|
||||
# example-collection:
|
||||
# repo: https://github.com/example/skill-collection
|
||||
# ref: main
|
||||
# path: skills
|
||||
|
||||
waza:
|
||||
repo: https://github.com/tw93/Waza.git
|
||||
ref: main
|
||||
path: skills
|
||||
description: Waza engineering workflow skills for planning, UI, review, debugging, writing, research, reading, and agent health.
|
||||
tags:
|
||||
- engineering
|
||||
- workflow
|
||||
- skill-collection
|
||||
|
||||
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
|
||||
@@ -0,0 +1,46 @@
|
||||
# ACK OMP Worker 支持规格
|
||||
|
||||
## 目标
|
||||
|
||||
让 ACK 的 Developer/Test worker 可以通过当前 Oh My Pi(OMP)CLI 工作,同时保留现有的角色路由、模型 allowlist、审批模式、worktree 和 receipt 校验边界。
|
||||
|
||||
## 范围
|
||||
|
||||
1. ACK worker CLI allowlist 增加 `omp`,不增加 `opencode`。
|
||||
2. 增加 OMP 的模型 allowlist 与 role/profile 配置能力;模型使用 OMP 要求的精确 `provider/model` 选择器,例如 `opencode-go/gpt-5.6-luna`,不从当前 Coordinator 会话自动推断。
|
||||
3. launcher 在固定可信目录中解析 `omp`,探测并记录版本,生成确定性的启动 argv,并把 CLI、模型、thinking、审批模式、worktree 和版本纳入 fingerprint/receipt。
|
||||
4. OMP worker 使用交互式 `omp` 命令,通过结构化参数指定模型、thinking、approval mode 和工作目录;Orca 仍负责 terminal orchestration 与任务 dispatch。
|
||||
5. OMP profile 不允许写入自由 command、额外 argv、shell、环境变量或凭据值;不使用 `--auto-approve`、`--plan-yolo` 或会话复用;`--approval-mode yolo` 是 OMP workspace-write worker 的默认审批模式,规则层直接允许。
|
||||
6. 为 OMP 增加 profile 校验、argv 渲染、环境凭据隔离、可信 executable 解析和 launcher plan 的白盒/黑盒测试。
|
||||
7. 保持 Codex、Cursor、Grok 既有行为不变;不修改 ACK 的三角色职责或 delivery 流程。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把 OMP 替换为新的编排后端;Orca 仍是 ACK 的 terminal orchestration backend。
|
||||
- 不支持 OpenCode CLI;本需求只支持 `omp` 可执行文件。
|
||||
- 不根据当前 Coordinator 的 provider、模型或环境变量自动选择 worker profile。
|
||||
- 不实现 OMP ACP 协议;本轮使用 OMP 的交互式 CLI 入口。
|
||||
- 不读取、写入或提交真实 OMP 凭据。
|
||||
|
||||
## 约束与关键假设
|
||||
|
||||
- `omp` 可执行文件必须通过 ACK 固定可信目录解析,不能从调用者 PATH 任意拾取。
|
||||
- OMP 模型 ID 必须由项目 allowlist 明确声明;当前会话中的 `opencode-go/gpt-5.6-luna` 只有在 profile 明确配置后才可使用。
|
||||
- 审批模式由 launcher 固定构造:`workspace-write` → `--approval-mode yolo`、`read-only` → `--approval-mode always-ask`。
|
||||
- `workspace-write` 下的 exec 工具是否会因 OMP 审批提示阻塞,由独立 Test 在黑盒环境中验证;若阻塞,必须记录为环境/运行模式问题,不伪报成功。
|
||||
|
||||
## 可观测验收标准
|
||||
|
||||
1. `validate_orchestration` 接受合法 `omp` profile,拒绝未知 CLI、未在 `omp/role/tier` allowlist 中的模型、Test strong profile 和危险权限模式。
|
||||
2. `render_worker_argv` 对 OMP 只生成固定的 `omp --model {provider/model} --thinking {level} --approval-mode {mode} --cwd {absolute-worktree} --no-session` 形状,并拒绝危险或会话复用参数。
|
||||
3. `resolve_executable`/launcher plan 能在可信 OMP 安装下记录 `cli: omp`、版本、精确 argv、环境策略和 worktree identity;非可信同名 executable fail closed。
|
||||
4. OMP worker 环境只获得基础运行时变量、代理/证书变量和明确允许的 OMP/provider credential 名称,不继承调用者的任意环境变量、PATH 或其它 CLI 凭据。
|
||||
5. 现有 Codex/Cursor/Grok profile 的 argv、allowlist、权限拒绝和 receipt 校验回归测试继续通过。
|
||||
6. 在独立临时项目和隔离 OMP 配置目录中,Test 能确认 fresh OMP worker 被正确绑定到指定 worktree,能接收 Orca dispatch 的任务输入,并能回报 ACK lifecycle 证据;无法完成时记录具体协议/环境证据。
|
||||
7. 文档明确:OMP 是 worker CLI,Orca 是编排层;两者都不自行决定模型,模型由 `tasks.yaml.project.orchestration` profile 决定。
|
||||
|
||||
## 建议任务拆分
|
||||
|
||||
- `ACK-OMP-001`:扩展 ACK worker profile、launcher 与安全环境策略,支持 OMP interactive worker。
|
||||
- `ACK-OMP-002`:补充 OMP profile/argv/launcher 白盒测试与既有 CLI 回归测试。
|
||||
- `ACK-OMP-003`:在隔离临时环境完成 OMP worker 的 Orca dispatch 黑盒复测并记录证据。
|
||||
@@ -0,0 +1,752 @@
|
||||
---
|
||||
title: ACK 设计评审记录
|
||||
date: "2026-07-31T17:15:37+08:00"
|
||||
updated: "2026-07-31T23:31:48+08:00"
|
||||
---
|
||||
|
||||
# ACK 设计评审记录
|
||||
|
||||
> 这是对 ACK v0.8.1 的设计评审快照,用于推动后续版本改进。文中的文件行号对应
|
||||
> 2026-07-31 的仓库状态,不作为 ACK 的运行规范。
|
||||
|
||||
本文同时记录后续方案和实施结果。带“建议”“目标”或“待验证”的内容默认是设计
|
||||
方向,不代表当前版本已经具备;实际运行契约仍以 `skills/ack/SKILL.md`、schema、
|
||||
校验器和测试为准。知识护栏方案的 v0.9.0 落地边界、model-routing v0.10.0 的
|
||||
实施结论见下文状态表。
|
||||
|
||||
## 背景
|
||||
|
||||
本轮评审覆盖 `skills/ack/` 的入口、角色规范、闭环流程、项目模板、任务板
|
||||
schema、校验脚本、Orca 适配器,以及 `pouch init ack` 的真实运行路径。
|
||||
|
||||
评审主要回答两个问题:
|
||||
|
||||
- ACK 的三角色协作方向是否合理。
|
||||
- 当前实现是否已经能稳定完成初始化、派发、独立复测、状态恢复和结果审计。
|
||||
|
||||
## 结论
|
||||
|
||||
ACK 的核心方向合理。显式触发、三角色分权、可观测验收、用户确认、Test 独立
|
||||
复测、Coordinator 单写任务板和三轮止损都值得保留。
|
||||
|
||||
当前版本适合描述为“任务级受监督验收闭环”。它可以组织单个工作空间里的实现和
|
||||
复测,但还没有覆盖批准状态持久化、跨 worktree 交接、编排状态恢复、目标分支集成
|
||||
和交付状态,因此暂时不应把 `verified` 等同于“已集成”或“已交付”。
|
||||
|
||||
概念设计约为 8/10,当前可操作性约为 5 至 6/10。下一阶段应该补齐控制面契约,
|
||||
不需要推翻三角色模型。
|
||||
|
||||
## 值得保留的设计
|
||||
|
||||
- Coordinator、Developer 和 Test 的职责分开,Developer 不能给自己的实现做最终
|
||||
判定。
|
||||
- 新需求先写产品文档、任务拆分和可观测验收信号,用户确认后再派发。
|
||||
- `tasks.yaml` 由 Coordinator 单写,worker 消息只负责传递证据。
|
||||
- Test 在复测前核对 worktree、服务实例和构建产物,减少测错代码和旧进程造成的
|
||||
假通过。
|
||||
- 每个任务最多三轮,失败后进入 `leftover`,不会无限消耗同一个 worker。
|
||||
- 通用规范留在 Skill,项目只保存覆盖层与任务状态。
|
||||
- 闭环核心不依赖 Orca,手动模式仍能保留相同的角色与验收逻辑。
|
||||
|
||||
## P0:状态和证据必须先成为可验证协议
|
||||
|
||||
### `verified` 目前可以没有证据
|
||||
|
||||
`roles-and-permissions.md` 定义的完成条件包括 Developer 白盒证据、Test 独立证据、
|
||||
环境对齐和 Coordinator 终检,但任务 schema 只强制 `id`、`title` 和 `status`。
|
||||
内置校验器同样没有检查 `verified` 所需的证据。
|
||||
|
||||
本轮探针确认,下面这种任务可以通过内置校验:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
project:
|
||||
name: demo
|
||||
tasks:
|
||||
- id: T-1
|
||||
title: hollow green
|
||||
status: verified
|
||||
```
|
||||
|
||||
相关位置:
|
||||
|
||||
- `skills/ack/templates/tasks.schema.json:76`
|
||||
- `skills/ack/scripts/validate_tasks.py:70`
|
||||
- `skills/ack/references/roles-and-permissions.md:140`
|
||||
|
||||
建议为不同状态定义语义约束。`verified` 至少要求:
|
||||
|
||||
- Developer 的修改文件、验证命令和结果。
|
||||
- 独立 Test 的身份、逐条验收结果和原始证据。
|
||||
- 被测代码或构建产物的不可变指纹。
|
||||
- worktree、服务进程、Base URL 和测试时间。
|
||||
- Coordinator 的 gate 结果与写入时间。
|
||||
|
||||
### 用户确认没有持久化
|
||||
|
||||
Skill 要求用户确认后再派发,但状态机从 `open` 直接进入 `dispatched`。会话在确认前
|
||||
中断后,新的 Coordinator 无法知道任务是待确认,还是已经批准。
|
||||
|
||||
建议增加 `proposed` 和 `approved`,并记录 `approvedAt`、`approvedBy` 与验收版本。
|
||||
|
||||
### 推荐状态流
|
||||
|
||||
```text
|
||||
proposed
|
||||
-> approved
|
||||
-> implementing
|
||||
-> retesting
|
||||
-> verified_in_workspace
|
||||
-> integrated
|
||||
-> verified
|
||||
|
||||
blocked
|
||||
-> approved
|
||||
-> cancelled
|
||||
|
||||
product_failed x 3
|
||||
-> leftover
|
||||
```
|
||||
|
||||
`verified_in_workspace` 只说明指定工作空间中的代码已经通过独立复测。只有目标分支
|
||||
集成并完成集成后验证,才能进入最终 `verified`。
|
||||
|
||||
## P0:Orca 适配器需要改成双任务模型
|
||||
|
||||
当前文档让 Developer 对一个 Orca task 发送 `worker_done`,然后把同一个 task 再次
|
||||
派给 Test。截至评审日期,当前 Orca 的 orchestration 规则会在收到有效
|
||||
`worker_done` 后自动把 task 和 dispatch 标记为 completed。第二次派发同一个 task
|
||||
缺少可靠的生命周期语义。
|
||||
|
||||
相关位置:
|
||||
|
||||
- `skills/ack/references/orca-adapter.md:144`
|
||||
- `skills/ack/references/orca-adapter.md:164`
|
||||
- `skills/ack/references/orca-adapter.md:199`
|
||||
|
||||
建议每个 ACK attempt 创建两个 Orca 子任务:
|
||||
|
||||
```text
|
||||
ACK task
|
||||
Developer task
|
||||
Test task, depends on Developer task
|
||||
```
|
||||
|
||||
任务板分别保存:
|
||||
|
||||
```yaml
|
||||
devTaskId: null
|
||||
devDispatchId: null
|
||||
testTaskId: null
|
||||
testDispatchId: null
|
||||
```
|
||||
|
||||
Developer 和 Test 都对自己的 dispatch 发送 `worker_done`,Coordinator 最后只读
|
||||
证据并执行 gate。
|
||||
|
||||
## P0:明确 worktree 和代码交接方式
|
||||
|
||||
当前流程先在 Coordinator worktree 写 PRD 和 `tasks.yaml`,随后才决定是否创建隔离
|
||||
worktree。权威任务板只保留在 Coordinator worktree,但 worker prompt 又要求在自己
|
||||
的 worktree 读取相对路径。Developer 的未提交代码如何传给独立的 Test worktree
|
||||
也没有定义。
|
||||
|
||||
相关位置:
|
||||
|
||||
- `skills/ack/references/kickoff.md:30`
|
||||
- `skills/ack/references/closed-loop.md:65`
|
||||
- `skills/ack/references/closed-loop.md:87`
|
||||
- `skills/ack/references/prompt-templates.md:31`
|
||||
- `skills/ack/references/orca-adapter.md:74`
|
||||
|
||||
建议默认让 Developer 和 Test 串行使用同一个 worktree。必须隔离时,在 attempt 中
|
||||
明确:
|
||||
|
||||
```yaml
|
||||
transfer:
|
||||
type: commit | patch | artifact
|
||||
source: "<immutable reference>"
|
||||
digest: "<sha256 or diff hash>"
|
||||
```
|
||||
|
||||
派发内容应携带验收快照和规范的绝对路径,不能假设每个 worktree 都有同一份未提交
|
||||
文档。
|
||||
|
||||
## P0:Worker 启动策略改为安全默认
|
||||
|
||||
> v0.10 已以结构化 profile 和可信 launcher 取代本节描述的自由 command。下方是
|
||||
> 当时的评审记录,不再代表当前实现;其中“full-access 写入任务板授权”的建议已被
|
||||
> v0.10 决策取代,当前明确为 Deferred。
|
||||
|
||||
当前校验器强制 Codex 使用 bypass、Cursor 使用 YOLO,并写死模型和 CLI。项目文档
|
||||
允许收紧权限或替换模型,但合法的安全覆盖会被校验器拒绝。
|
||||
|
||||
校验器还只检查自由 shell 字符串里的部分 token。本轮探针确认,带有 shell 控制符
|
||||
和额外命令的字符串仍可通过校验。这样的结果会给调用方错误的安全感。
|
||||
|
||||
相关位置:
|
||||
|
||||
- `skills/ack/scripts/validate_worker_command.py:49`
|
||||
- `skills/ack/references/model-routing.md:98`
|
||||
- `skills/ack/templates/project.template.md:31`
|
||||
|
||||
建议把项目覆盖层改成结构化配置:
|
||||
|
||||
```yaml
|
||||
orchestration:
|
||||
mode: orca
|
||||
|
||||
workers:
|
||||
developer:
|
||||
cli: codex
|
||||
model: "<project-approved-model>"
|
||||
effort: medium
|
||||
permissionMode: sandbox
|
||||
test:
|
||||
cli: codex
|
||||
model: "<project-approved-model>"
|
||||
effort: low
|
||||
permissionMode: sandbox
|
||||
```
|
||||
|
||||
可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过。
|
||||
原建议设想 full-access 由用户单独授权并写入任务板;v0.10 没有实现这条授权通道,
|
||||
项目文件也不能充当授权证据。full-access 的可信授权、期限、撤销和外层隔离均为
|
||||
Deferred,当前 launcher 必须 fail closed。
|
||||
|
||||
## P0:初始化需要原子化
|
||||
|
||||
`pouch init ack` 会先写 `project.md` 和 `tasks.yaml`,再运行任务板校验。缺少 PyYAML
|
||||
时,命令会失败但保留两个文件。再次运行又会因为拒绝覆盖而失败。
|
||||
|
||||
相关位置:
|
||||
|
||||
- `requirements.txt:1`
|
||||
- `skills/ack/scripts/validate_tasks.py:37`
|
||||
- `pouch/cli.py:1144`
|
||||
- `pouch/cli.py:1156`
|
||||
|
||||
本轮在不含第三方包的隔离 Python 环境中复现了这个状态。
|
||||
|
||||
建议:
|
||||
|
||||
- 在临时目录渲染和校验,全部通过后再原子 rename。
|
||||
- 失败时只清理由本次调用创建的临时文件。
|
||||
- 提供 `pouch init ack --repair` 或等价恢复路径。
|
||||
- 默认模板使用 `tasks: []`,完整示例继续放在 `examples/`。
|
||||
- CLI 输出“脚手架已创建,待配置”,检查通过后再称为“初始化完成”。
|
||||
|
||||
## P1:任务板改成追加式 attempts
|
||||
|
||||
当前 `dispatch.rounds` 只记录轮次、结果和一段证据,无法支持恢复、并发和审计。
|
||||
建议改成追加式 `attempts[]`:
|
||||
|
||||
```yaml
|
||||
attempts:
|
||||
- id: "T-1-A1"
|
||||
round: 1
|
||||
idempotencyKey: "<stable key>"
|
||||
startedAt: "<timestamp>"
|
||||
workspace:
|
||||
path: "<worktree>"
|
||||
baseCommit: "<sha>"
|
||||
diffHash: "<hash>"
|
||||
developer:
|
||||
worker: "<identity>"
|
||||
taskId: "<runtime task id>"
|
||||
dispatchId: "<runtime dispatch id>"
|
||||
evidence: {}
|
||||
test:
|
||||
worker: "<independent identity>"
|
||||
taskId: "<runtime task id>"
|
||||
dispatchId: "<runtime dispatch id>"
|
||||
evidence: {}
|
||||
gate:
|
||||
result: pending | passed | failed
|
||||
failureKind: null
|
||||
```
|
||||
|
||||
`failureKind` 建议区分:
|
||||
|
||||
- `product_failed`:实现不满足验收,消耗三轮预算。
|
||||
- `environment_blocked`:环境或服务不可用,不消耗轮次。
|
||||
- `needs_decision`:需要用户决定范围,不消耗轮次。
|
||||
- `acceptance_invalid`:验收标准有误,返回 `proposed`。
|
||||
- `worker_lost`:worker 消失,由 Coordinator 重新派发。
|
||||
|
||||
外部 dispatch 前先持久化 intent。Coordinator 重启后,用 `idempotencyKey` 和 runtime
|
||||
ID 查询 Orca,再决定继续等待、恢复状态或重新派发。
|
||||
|
||||
## P1:统一 schema 和语义校验
|
||||
|
||||
安装 `jsonschema` 时,`validate_tasks.py` 只运行 schema;未安装时只运行内置规则。
|
||||
两条路径的约束不同。schema 会放过重复 ID、四轮 dispatch 和空
|
||||
`leftoverReason`,内置规则也会放过部分错误结构。
|
||||
|
||||
建议:
|
||||
|
||||
- 始终先执行 schema,再无条件执行语义 invariant。
|
||||
- 为 `structural` 和 `ready` 提供两个显式模式。
|
||||
- `ready` 模式检查状态对应的证据、环境、批准和轮次。
|
||||
- 显式传入的 schema 路径不存在时直接失败,不能静默降级。
|
||||
- `summary` 从 `tasks` 派生,避免双写。
|
||||
- 收紧 `additionalProperties`,扩展字段统一放入 `extensions`。
|
||||
|
||||
## P1:补齐完成边界和批次验证
|
||||
|
||||
ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过,也可能尚未进入目标
|
||||
分支。最终报告需要明确区分:
|
||||
|
||||
- 已在指定工作空间验证。
|
||||
- 已集成到目标分支。
|
||||
- 已完成集成后回归。
|
||||
- 已提交、已推送或已发布。
|
||||
|
||||
多个任务分别通过后,还需要一次批次级集成回归,避免组合后出现冲突或行为变化。
|
||||
|
||||
## P2:减少规范重复和模型漂移
|
||||
|
||||
- 保留一份权威状态机、一份角色权限表和一份模型档位规则。
|
||||
- `kickoff.md` 只做一页运行手册,adapter 只保存工具命令。
|
||||
- 具体模型名称放到可更新的映射或项目覆盖层,稳定核心只描述能力档位和升级条件。
|
||||
- `ackVersion` 不能只记录旧版本。需要独立的 `schemaVersion`、兼容范围和迁移命令。
|
||||
- 用真实运行数据观察首轮通过率、环境失败率、平均轮次、`leftover` 原因、耗时和
|
||||
token,再决定三轮规则是否需要按任务风险调整。
|
||||
- 为低风险文档或机械改动提供轻量模式,高风险和用户可见行为继续使用完整三角色
|
||||
闭环。
|
||||
|
||||
## 低成本清理
|
||||
|
||||
- `skills/ack/SKILL.md:66` 的旧字段应为 `kitVersion`。
|
||||
- `skills/ack/templates/project.template.md:8` 建议修改或替换 `AGENTS.md`,与 Skill
|
||||
的禁止规则冲突。
|
||||
- `skills/ack/references/orca-adapter.md:201` 应引用 prompt §4。
|
||||
- `skills/ack/scripts/validate_worker_command.py:2` 残留 “Music Pilot”。
|
||||
- `skills/ack/examples/tasks.example.yaml` 缺少 checklist 要求的 `overlayFile`。
|
||||
- Test 负责黑盒与集成测试,Developer 负责单元测试;当前优化文档对单元测试所有权
|
||||
的表述需要统一。
|
||||
|
||||
## 建议实施顺序
|
||||
|
||||
1. 收紧任务状态、批准状态和 `verified` 证据门。
|
||||
2. 把 Orca adapter 改成每轮 Developer/Test 双任务。
|
||||
3. 定义同 worktree 默认策略和跨 worktree transfer。
|
||||
4. 改造 worker launcher 与安全授权。
|
||||
5. 原子化 `pouch init ack`,空任务板作为默认模板。
|
||||
6. 引入追加式 attempts、幂等恢复和失败分类。
|
||||
7. 统一 schema 与语义校验,补齐对抗性 fixture。
|
||||
8. 区分工作空间验证、集成验证和发布状态。
|
||||
9. 去重文档,补版本迁移和运行指标。
|
||||
|
||||
## 验证记录
|
||||
|
||||
本轮执行了:
|
||||
|
||||
- `pouch check ack`:通过。这个命令只证明 Skill 的元数据和基础结构有效。
|
||||
- `validate_tasks.py examples/tasks.example.yaml`:通过,当前机器使用内置规则。
|
||||
- `validate_worker_command.py --self-test`:7 项通过。
|
||||
- `python3 -m unittest discover -s tests -v`:58 项通过。
|
||||
- 隔离全局安装 smoke:通过,安装结果为指向 ACK SSOT 的 symlink。
|
||||
- 隔离初始化探针:缺少 PyYAML 时失败,留下半初始化文件,重试被拒绝。
|
||||
- 对抗性任务板探针:无证据的 `verified` 可以通过。
|
||||
- 对抗性 worker 命令探针:附加 shell 控制符的命令字符串可以通过。
|
||||
- 当前 Orca orchestration 指南复核:有效 `worker_done` 会自动完成对应 task 和
|
||||
dispatch。
|
||||
|
||||
现有 ACK 测试主要覆盖字符串、Skill 元数据和初始化冒烟,没有覆盖状态机语义、
|
||||
schema 与 fallback 一致性、Orca 双阶段生命周期、Coordinator 崩溃恢复和跨 worktree
|
||||
代码交接。
|
||||
|
||||
## 待验证
|
||||
|
||||
- [ ] 用真实项目跑一轮同 worktree 的 Developer/Test 闭环。
|
||||
- [ ] 用独立 worktree 验证 commit、patch 和 artifact 三种交接方式。
|
||||
- [ ] 验证 Coordinator 在 task-create、dispatch 和 Test 完成三个时间点崩溃后的
|
||||
恢复行为。
|
||||
- [ ] 为安全 worker profile、路径 containment 和 shell 注入增加对抗性测试。
|
||||
- [ ] 定义 ACK v0.9 的 schema 迁移策略,再决定是否保留旧状态名称。
|
||||
|
||||
## 扩展方案:项目知识护栏库
|
||||
|
||||
### v0.9.0 落地边界
|
||||
|
||||
| 能力 | v0.9.0 状态 |
|
||||
|------|-------------|
|
||||
| `knowledge.yaml` 模板、schema、初始化和跨文件校验 | 已实现 |
|
||||
| active/stale/superseded/archived、作用域、冲突、复查时间和来源校验 | 已实现 |
|
||||
| 确定性选择、全项目规则优先、固定 revision 引用 | 已实现 |
|
||||
| registry ID + 结构化 argv + fd 固定根目录的安全检查入口 | 已实现 |
|
||||
| Developer candidate、Test check、Coordinator 单写的协作约定 | 已实现为协议和校验字段 |
|
||||
| 不同 revision 的历史内容不可篡改或可独立恢复 | 延后;v0.9.0 没有外部不可变账本 |
|
||||
| 依赖或路径变化后自动转 stale | 延后;当前由 `reviewAfter` 和人工复核驱动 |
|
||||
| 在运行时强制只有获授权身份能写入或激活知识 | 延后;当前依赖 Coordinator 单写边界 |
|
||||
| 自动识别任意提示注入、PII 或所有秘密 | 延后;当前只有结构禁区和常见秘密模式 |
|
||||
| `approved`、追加式 attempts、崩溃恢复和最终集成 gate | 延后到控制面改造 |
|
||||
|
||||
### 定位
|
||||
|
||||
ACK 需要增加第三类项目事实,用来保存跨任务复用、会改变后续开发或验证行为的已
|
||||
验证知识。它不保存聊天记忆,也不承担项目 Wiki 的职责。
|
||||
|
||||
| 项目事实 | 保存内容 | 写入者 |
|
||||
|----------|----------|--------|
|
||||
| `project.md` | 相对稳定的项目配置、路径和命令 | Coordinator 或项目维护者 |
|
||||
| `tasks.yaml` | 当前任务状态、attempt 和执行证据 | Coordinator |
|
||||
| `knowledge.yaml` | 跨任务复用的已验证经验 | Coordinator |
|
||||
|
||||
建议新增项目级 SSOT:`.pouch/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
|
||||
工具无关;ACK 负责在任务闭环中生产、选择和消费这些知识。
|
||||
|
||||
这项设计会修改当前“`.pouch/ack/` 只保存 `project.md` 与 `tasks.yaml`”的边界。
|
||||
新增文件保存项目事实,不复制 ACK Skill 的通用规范,因此不违反 Skill 内容仍以
|
||||
`skills/ack/` 为 SSOT 的原则。
|
||||
|
||||
### 知识类型
|
||||
|
||||
第一版只支持三类知识:
|
||||
|
||||
| 类型 | 含义 | 例子 |
|
||||
|------|------|------|
|
||||
| `guardrail` | 必须执行或明确禁止的项目约束 | 修改数据库迁移时必须验证回滚 |
|
||||
| `pitfall` | 已证实的失败模式、触发条件和避免方法 | Test 连到了另一个 worktree 的旧服务 |
|
||||
| `verification` | 特定条件下必须增加的检查 | 修改缓存键后执行跨版本兼容测试 |
|
||||
|
||||
架构决策正文继续写入 ADR 或正式规格,知识项只引用决策及其适用条件。任务进度和
|
||||
单次失败证据继续留在 `tasks.yaml`。通用且可跨项目复用的规则应回流 ACK Skill,
|
||||
不能作为某个项目的知识长期保存。
|
||||
|
||||
### 生命周期
|
||||
|
||||
```text
|
||||
Developer / Test 发现经验
|
||||
-> candidate
|
||||
-> Test 独立验证 + Coordinator gate
|
||||
-> active
|
||||
-> stale
|
||||
-> superseded | archived
|
||||
```
|
||||
|
||||
- Developer 和 Test 只能提交 candidate,不能直接写入或激活知识。
|
||||
- candidate 保存在当前 task 或 attempt 的证据中,不参与后续任务的自动选择。
|
||||
- 根因得到证实、修复通过独立 Test、Coordinator 完成 gate 后,candidate 才能转为
|
||||
`active`。
|
||||
- 全项目范围的 `must`、`never` 或权限类规则需要 User 或 Decision Owner 确认。
|
||||
- 依赖、配置、路径或适用版本发生变化后,相关知识转为 `stale`,默认不再派发。
|
||||
- 新知识替代旧知识时必须记录 `supersedes`,不能静默改写历史。
|
||||
- 临时 workaround 必须有复查时间和移除条件,不能无限期保持 `active`。
|
||||
|
||||
同一 Agent 不能把自己读到的旧知识直接作为新证据再次激活。新的 candidate 必须
|
||||
包含当前任务产生的独立观测,避免形成自我强化的错误闭环。
|
||||
|
||||
### 最小数据结构
|
||||
|
||||
每条知识至少包含:
|
||||
|
||||
```yaml
|
||||
id: K-001
|
||||
revision: 1
|
||||
kind: pitfall
|
||||
status: active
|
||||
title: "复测前确认服务来自当前 worktree"
|
||||
|
||||
scope:
|
||||
components: [web]
|
||||
paths: ["web/**"]
|
||||
dependencies: []
|
||||
versions: []
|
||||
tags: [long-running-service]
|
||||
|
||||
appliesWhen: "修改常驻 Web 服务或前端构建产物"
|
||||
directive: "复测前重启服务,并核对服务实例对应的 commit"
|
||||
rationale: "曾因复测旧进程产生假通过"
|
||||
|
||||
verification:
|
||||
ref: service-worktree-alignment
|
||||
expected: "服务实例、worktree 和 commit 一致"
|
||||
|
||||
provenance:
|
||||
taskId: BUG-017
|
||||
attemptId: BUG-017-A2
|
||||
codeRef: "<commit or tree hash>"
|
||||
evidenceRef: "tasks.yaml#BUG-017"
|
||||
|
||||
owner: "<owner>"
|
||||
author: "<candidate author>"
|
||||
reviewer: "<independent reviewer>"
|
||||
createdAt: "<timestamp>"
|
||||
lastValidatedAt: "<timestamp>"
|
||||
reviewAfter: "<timestamp or null>"
|
||||
removalCondition: null
|
||||
supersedes: []
|
||||
conflictsWith: []
|
||||
```
|
||||
|
||||
`revision` 是逻辑版本标识。修改知识的约束含义时必须新增 revision,并保留被引用
|
||||
的旧条目;任务引用 `K-001@1` 后,可以说明当时选择的逻辑版本。v0.9.0 尚未用
|
||||
content hash、签名或外部账本封存条目内容,因此不能只凭这个字符串证明历史字节
|
||||
不可变;项目需要把状态纳入版本控制,强不可变审计留待后续实现。
|
||||
|
||||
`directive` 只能描述应采取的动作。需要执行命令时,`verification.ref` 应引用项目中
|
||||
已审查的测试或检查 ID,由可信配置解析成结构化 argv。知识库不保存可自动执行的
|
||||
自由 shell 命令。
|
||||
|
||||
### 与三角色闭环的集成
|
||||
|
||||
| 角色 | 权限和职责 |
|
||||
|------|------------|
|
||||
| Coordinator | 单写 `knowledge.yaml`;选择本轮适用知识;激活、废弃和处理冲突 |
|
||||
| Developer | 只读本轮知识;回报遵守情况;提交 `knowledgeCandidates` |
|
||||
| Test | 只读本轮知识;执行额外检查;独立验证 candidate |
|
||||
| User / Decision Owner | 批准全项目强制规则、权限规则和无法通过测试证明的政策 |
|
||||
|
||||
`prepare(task)` 时,Coordinator 按 component、path、dependency、version、tag 和失败
|
||||
特征筛选 `active` 知识。匹配结果只作为候选,Coordinator 确认后把固定版本引用
|
||||
写入本轮上下文:
|
||||
|
||||
```yaml
|
||||
knowledgeRefs:
|
||||
- K-001@1
|
||||
- K-014@2
|
||||
```
|
||||
|
||||
引入前文建议的追加式 `attempts[]` 后,引用和回报建议放在:
|
||||
|
||||
```text
|
||||
attempts[].context.knowledgeRefs
|
||||
attempts[].developer.knowledgeApplied
|
||||
attempts[].developer.knowledgeCandidates
|
||||
attempts[].test.knowledgeChecks
|
||||
```
|
||||
|
||||
派发时把已引用知识的必要内容内联到 Developer 和 Test 的 prompt,避免 worker 因
|
||||
worktree 不同而读不到 Coordinator worktree 中未提交的知识文件。每次只派发当前
|
||||
任务命中的少量条目,不全量注入知识库。
|
||||
|
||||
Developer 回报实际遵守了哪些知识,以及新发现的 candidate。Test 对每条适用的
|
||||
`verification` 回报 pass、fail 和证据。要求执行的知识检查没有覆盖时,
|
||||
Coordinator 不得把任务写成 `verified_in_workspace` 或 `verified`。
|
||||
|
||||
### 检索和冲突规则
|
||||
|
||||
第一版使用确定性匹配,不引入向量数据库、Embedding 或语义 RAG:
|
||||
|
||||
- 先按 path、component、dependency、version 和 tag 选择条目。
|
||||
- 失败排查时可以额外匹配错误签名和相关 symbol。
|
||||
- 只返回 `active` 且适用条件成立的条目。
|
||||
- 设置条目数和上下文预算,详情按需读取。`scope.all=true` 的全项目规则优先占用
|
||||
预算;全项目规则本身超过预算时显式失败,不能静默丢弃。
|
||||
- `stale`、`superseded` 和 `archived` 默认不返回。
|
||||
- 没有匹配结果只表示本轮没有找到知识,不能据此声称项目没有相关约束。
|
||||
|
||||
同一 subject 和 scope 不能存在互相矛盾的 `active` 条目。校验器发现冲突时应阻止
|
||||
进入 ready 状态,由 Coordinator 或 Decision Owner 选择保留项并记录
|
||||
`supersedes`。运行时不能使用“最后写入者获胜”解决冲突。
|
||||
|
||||
自动匹配只负责推荐。Coordinator 写入当前 attempt 的显式 `knowledgeRefs` 才是
|
||||
本轮权威上下文。这个边界可以降低作用域标注不准造成的漏选和误选。
|
||||
|
||||
### 知识晋升后的去向
|
||||
|
||||
知识库不是所有经验的最终归宿。不同内容应继续进入对应的权威载体:
|
||||
|
||||
| 内容 | 最终位置 |
|
||||
|------|----------|
|
||||
| 当前任务状态和单次验证证据 | `tasks.yaml` |
|
||||
| 稳定项目路径、命令和运行配置 | `project.md` |
|
||||
| 架构或产品决策及其取舍 | ADR 或正式规格 |
|
||||
| 安全、正确性和兼容性约束 | 测试、lint、CI 或正式规范 |
|
||||
| 原始日志、截图和构建产物 | 受控 artifact 存储 |
|
||||
| 跨项目通用规则 | ACK Skill |
|
||||
| 项目特有且跨任务复用的经验 | `knowledge.yaml` |
|
||||
|
||||
关键 `verification` 应逐步转成可执行测试或 CI gate。知识项继续保存触发条件、原因
|
||||
和证据引用,不能用自然语言规则替代可执行控制。
|
||||
|
||||
### 禁止写入的内容
|
||||
|
||||
- Token、密钥、Cookie、含凭据 URL、生产数据、PII、客户内容和未脱敏日志。
|
||||
- 系统提示、开发者提示、角色或权限覆盖,以及绕过审批、测试和安全限制的指令。
|
||||
- 可自动执行的破坏性命令或生产命令。
|
||||
- 原始聊天、整段 issue 或网页内容、巨量日志、截图和构建产物。
|
||||
- 未复现猜测、LLM 单方推断、一次性巧合和个人评价。
|
||||
- PID、临时端口、个人 worktree 绝对路径等短命机器状态。
|
||||
- 对代码、配置和正式文档的重复抄写。
|
||||
- 没有作用域、证据和失效条件的 `always`、`never` 或 workaround。
|
||||
- 需要权限隔离的漏洞 PoC、敏感内部拓扑和安全调查材料。
|
||||
|
||||
issue、日志和外部网页只能作为不可信 evidence。进入知识库前必须提炼为可审查的
|
||||
项目结论,不能原样晋升,也不能获得高于用户指令、批准规格、当前代码、配置和测试
|
||||
的优先级。
|
||||
|
||||
### 第一版范围
|
||||
|
||||
第一版只实现一个 `.pouch/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
|
||||
开始影响审阅和选择时,再平滑迁移为 `.pouch/ack/knowledge/index.yaml` 加独立知识卡,
|
||||
条目 schema 和引用格式保持不变。
|
||||
|
||||
第一版包括:
|
||||
|
||||
1. `knowledge.yaml` schema 与语义校验。
|
||||
2. task 或 attempt 的 `knowledgeRefs`。
|
||||
3. Developer 的 `knowledgeApplied` 与 `knowledgeCandidates`。
|
||||
4. Test 的 `knowledgeChecks`。
|
||||
5. Coordinator 在 prepare、gate 和任务收尾三个节点处理知识。
|
||||
6. 基于 scope 的确定性匹配。
|
||||
7. stale、冲突、revision 和 supersedes 校验。
|
||||
|
||||
第一版不包括:
|
||||
|
||||
- 自动抓取或总结全部对话。
|
||||
- Agent 自动激活、删除或提交知识。
|
||||
- 向量数据库、Embedding 和语义 RAG。
|
||||
- 自动改写 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
- 从知识正文直接执行 shell 命令。
|
||||
- 组织级、跨仓库知识同步。
|
||||
|
||||
### 目标验收与当前状态
|
||||
|
||||
| 目标 | v0.9.0 状态 |
|
||||
|------|-------------|
|
||||
| candidate 不会作为 active 派发 | 已实现 |
|
||||
| 未授权 worker 不能激活、废弃或删除知识 | 协议约束;运行时身份授权延后 |
|
||||
| path、component、version 只返回匹配条目 | 已实现确定性匹配;准确率仍取决于 scope 标注 |
|
||||
| 非 active 条目默认不返回 | 已实现 |
|
||||
| 冲突 active 规则被拒绝 | 已实现显式冲突和相同 subject/scope 检查 |
|
||||
| 历史 attempt 的知识内容不可变 | 延后;当前只有 revision 约定,没有不可变账本 |
|
||||
| `verification.ref` 无法解析时校验失败 | 已实现 |
|
||||
| evidence 中的提示注入不会变成命令 | 部分实现;禁止自由执行字段,语义晋升仍需人工 gate |
|
||||
| 秘密和生产数据不能进入知识库 | 部分实现;常见秘密模式会阻断,不能替代完整 DLP |
|
||||
| 依赖、路径变化后自动转 stale | 延后;当前依赖复查日期和 Coordinator |
|
||||
| 增长后仍在预算内且不漏全项目规则 | 已实现;全项目规则超预算时显式失败 |
|
||||
|
||||
### 与 ACK 改造顺序的关系
|
||||
|
||||
知识护栏库最终仍依赖可信的任务状态、attempt、证据来源和 ready 校验。v0.9.0 先
|
||||
落地存储、选择和基础 gate,不能据此声称前述控制面问题已经解决。后续应继续:
|
||||
|
||||
1. 确定知识 schema、生命周期和角色权限。
|
||||
2. 在 attempt 中固定 `knowledgeRefs` 和 candidate 证据。
|
||||
3. 接入 Coordinator 的匹配、派发和晋升流程。
|
||||
4. 增加 stale、冲突、权限、注入和敏感信息的对抗性 fixture。
|
||||
5. 选一个真实项目试运行,再根据活跃条目数量决定是否拆分存储。
|
||||
|
||||
这套方案最依赖 scope 标注的质量。项目知识如果很难用组件、路径、依赖、版本或
|
||||
触发条件限定,自动匹配会漏掉关键项或产生大量噪声。这类知识应继续由人维护在 ADR
|
||||
或项目文档中,不能强行进入自动派发流程。
|
||||
|
||||
## v0.10.0 实施结论:结构化 model-routing
|
||||
|
||||
### 当前决策
|
||||
|
||||
旧的 `validate_worker_command.py --command '<shell>'` 只能检查少量 token,无法证明
|
||||
整段 shell 没有追加命令、重复参数、环境注入或 cwd 漂移。v0.10 删除这条自由命令
|
||||
配置面:旧脚本只保留 fail-closed 迁移提示;自动 worker 的唯一入口是
|
||||
`launch_worker.py profile-hash|plan|launch`。
|
||||
|
||||
机器 SSOT 固定为 `tasks.yaml.project.orchestration`。profile 只声明 role、CLI、
|
||||
tier、model、reasoning effort 和 permission mode;model 必须命中按
|
||||
CLI/role/tier 分组的 allowlist。自动权限只允许 `read-only` 与
|
||||
`workspace-write`,full-access、Codex bypass、Cursor YOLO/force 和关闭 sandbox
|
||||
全部 fail closed。
|
||||
|
||||
### 已落地约束
|
||||
|
||||
| 控制面 | v0.10.0 实现 |
|
||||
|--------|--------------|
|
||||
| 自由 command / extra argv / env / cwd | schema 与语义校验拒绝;旧 validator fail closed |
|
||||
| Agent argv | 由 `worker_profiles.py` 按 profile 生成唯一 argv;子进程 `shell=False` |
|
||||
| plan → launch 漂移 | `launch` 强制接收刚审阅的 `--expected-launch-fingerprint`,slot 也纳入 hash |
|
||||
| PATH / loader / 凭据串用 | 不按调用者 PATH 找可执行文件;固定可信目录;控制进程无供应商凭据,worker 使用 `per-cli-allowlist-v1` |
|
||||
| worktree 冒充 | 绝对规范路径、无 symlink、同 Git common-dir,并命中 `git worktree list -z` |
|
||||
| Orca command 注入 | `--command` 只含固定 bootstrap 和随机 launch ID |
|
||||
| bootstrap 抢跑 | 先绑定 runtime/handle/incarnation/worktree,再用 terminal stdin nonce/proof 授权 |
|
||||
| cleanup / bootstrap 竞态 | Popen 前在同一 record lock 内复核 awaiting 状态;cleanup 先发生则拒绝启动 Agent |
|
||||
| create 部分失败 | 取得确定 handle 前的超时、transport/解码异常、中断、非零、畸形/缺字段响应均记为 indeterminate,禁止自动重试 |
|
||||
| handle 后失败清理 | record 写失败不阻断 close;只有 `terminal close --tab --json` 完整匹配且最终 failed 状态可靠落盘才记普通失败,否则保持 indeterminate/reconcile-required |
|
||||
| receipt 漂移 | 规范化 profileHash、argvHash、slot、launchFingerprint 与 receiptHash 交叉校验 |
|
||||
| dispatch 旧 receipt 改挂 | 每个角色显式记录 attemptId;receipt 必须绑定当前 ACK task.id、同 role/profile/attempt,receiptId/attemptId 同空同填 |
|
||||
| 配置兼容 | ackVersion 必须是合法 SemVer;v0.10+ 的 orchestration/workerReceipts 必须同时存在,v0.9 旧板仍可只读校验和手动协作 |
|
||||
|
||||
### 不能被弱证明掩盖的边界
|
||||
|
||||
`receiptHash` 是无密钥 checksum,不是签名。项目内可写方能修改 receipt 后重算
|
||||
hash;而当前 Orca terminal metadata 不提供原始 Agent argv、模型或权限
|
||||
attestation。因此 v0.10 **不根据持久化 receipt 自动复用旧终端**。每次自动派发都
|
||||
重新执行 plan,并用 expected fingerprint 启动 fresh worker;持久化 receipt 只作
|
||||
审计和 dispatch 关联。
|
||||
|
||||
`launchFingerprint` 只证明完整计划没有漂移,不是一次性授权或幂等键;同一计划重复
|
||||
执行仍会创建新的 fresh terminal。成功后不得重放,结果不确定时必须先 reconcile。
|
||||
自动防重放要依赖后端原子 idempotency/claim 或项目外可信 launch intent,不能由
|
||||
checksum 伪装提供。
|
||||
|
||||
任务板里的 dispatch 关联也不是宽松的历史索引。`dispatch.<role>` 显式记录
|
||||
`profileId`、`receiptId` 和 `attemptId`,校验器要求 receipt 的 `createdFor` 精确绑定
|
||||
当前 ACK `tasks[].id`、同一角色和同一 attempt,并要求 profile 一致。该约束阻止把
|
||||
旧任务、旧角色或旧轮次 receipt 改挂到当前 dispatch,但不会把无密钥 receipt 升级成
|
||||
可信 attestation。
|
||||
|
||||
同样,receipt 证明的是 launcher 请求和本地 CLI/runtime 绑定,不证明模型供应商最终
|
||||
执行的模型。开放自动复用需要 Orca/ACP 提供启动参数 attestation,或项目外可信签发
|
||||
与校验通道。开放 full-access 还需要不可由项目文本伪造的用户授权、期限、撤销和外层
|
||||
隔离。这两项均留待后续版本。
|
||||
|
||||
威胁模型也明确到 Coordinator 账户边界:v0.10 防止任务板、任务内容、普通环境和
|
||||
受限 worker 把数据变成第二个启动命令入口;不抵御已经完全控制 Coordinator OS
|
||||
账户、Orca runtime、受信 Agent/Orca 可执行文件或用户级 Agent 配置/插件/MCP 的
|
||||
攻击者。`permissionMode` 也不是模型供应商或外部工具能力 attestation;后者必须依赖
|
||||
独立 OS 身份、受控 Agent 配置或平台能力。
|
||||
|
||||
## 后续版本提案:ACP 与可替换编排后端
|
||||
|
||||
### 当前决策
|
||||
|
||||
ACK 当前版本继续以 **Orca** 作为唯一自动编排后端。闭环语义保持工具无关,但
|
||||
worker 创建、终端身份绑定、dispatch 和 wait 的可执行适配仍由 Orca 完成。
|
||||
|
||||
ACP(Agent Client Protocol)作为后续版本的优先候选,当前只记录方案,不加入运行
|
||||
时依赖、不新增半成品 adapter,也不让现有流程在 Orca 与 ACP 之间自动猜测。
|
||||
|
||||
| 能力 | 当前版本 | 后续方向 |
|
||||
|------|----------|----------|
|
||||
| 自动创建并监督 worker | Orca | ACP capability negotiation 后可增加 ACP adapter |
|
||||
| 结构化模型与权限 profile | ACK 自己定义,Orca 只承载启动 | 保持为 ACK 稳定契约,不交给后端自由解释 |
|
||||
| dispatch / wait / terminal identity | Orca runtime handle | 映射到 ACP session / request / event identity |
|
||||
| 无自动后端时运行闭环 | 手动模式 | 可增加 PTY / Zellij 类低层 fallback,但不冒充语义协议 |
|
||||
| 后端选择 | 项目显式配置 | 未来仍需显式配置,不按已安装命令静默切换 |
|
||||
|
||||
### 后端无关的最小语义
|
||||
|
||||
未来 adapter 只应实现以下能力,不应接管 ACK 的角色、状态机或安全策略:
|
||||
|
||||
1. `createWorker(profile, worktree)`:按 ACK 已验证的结构化 profile 创建 worker。
|
||||
2. `inspectWorker(identity)`:返回可绑定的 runtime、session/incarnation 和活性。
|
||||
3. `dispatch(identity, taskContext)`:把一个明确任务投递给已验证 worker。
|
||||
4. `wait(identity, eventTypes)`:等待完成、复测、升级或 decision gate 事件。
|
||||
5. `closeWorker(identity)`:只关闭本次创建且身份仍匹配的 worker。
|
||||
|
||||
模型、reasoning effort、权限模式、worktree 和 receipt hash 仍由 ACK 校验。adapter
|
||||
不能重新开放自由 shell、任意 argv、任意环境变量或“后端默认模型”作为旁路。
|
||||
|
||||
### ACP 采用门槛
|
||||
|
||||
满足以下条件后再实现 ACP adapter:
|
||||
|
||||
- 目标 Agent CLI 对 ACP 的启动、会话身份、取消和事件语义足够稳定。
|
||||
- 能把 Developer/Test 的独立身份、worktree 和结构化 profile 映射到可核对字段。
|
||||
- 能区分“已请求的模型/权限”和“运行时可观测事实”,不把客户端请求冒充供应商证明。
|
||||
- 超时、重连、重复 dispatch 和 runtime 重启具有明确的幂等或恢复语义。
|
||||
- 与 Orca adapter 使用同一组 ACK conformance tests,且不会降低安全默认。
|
||||
|
||||
在这些门槛满足前,ACP 保持 **Deferred**。PTY 或 Zellij 一类方案只能作为较低层的
|
||||
进程/终端承载,不提供 session 语义、模型证明或任务状态机;如果以后加入,也必须
|
||||
经过独立 adapter,不能散落为文档中的自由命令。
|
||||
|
||||
### 参考依据
|
||||
|
||||
- [GitHub 仓库自定义指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions)
|
||||
区分仓库级、路径级和就近生效的 Agent 指令。
|
||||
- [Claude Code 项目记忆](https://code.claude.com/docs/zh-CN/memory)区分持久指令与
|
||||
自动记忆,并建议把较大的内容按作用域拆分、按需加载。
|
||||
- [Architectural Decision Records](https://adr.github.io/)用于保存单个重要决策的
|
||||
理由、取舍和后果,适合承载不应混入项目知识库的决策正文。
|
||||
@@ -0,0 +1,106 @@
|
||||
# ACK 飞书多维表格 Bug 收件箱
|
||||
|
||||
## 目标
|
||||
|
||||
让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并
|
||||
反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、
|
||||
可幂等追踪的 ACK 任务并启动三角色闭环。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不用 Skill 承担人工记录入口。
|
||||
- 不抓取公开网页或依赖浏览器登录态。
|
||||
- 不把 App Secret、access token 或飞书用户凭据写入项目。
|
||||
- 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书。
|
||||
- 不把多条互不相关的 Bug 合成一次 Developer 派发。
|
||||
|
||||
## 项目配置契约
|
||||
|
||||
可选配置位于 `.pouch/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
|
||||
配置存在时必须包含:
|
||||
|
||||
| 字段 | 约束 |
|
||||
|------|------|
|
||||
| `provider` | 固定为 `feishu-base` |
|
||||
| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 |
|
||||
| `profile` | `lark-cli` profile 名称;每次命令显式传入 |
|
||||
| `baseToken` | 飞书 Base token |
|
||||
| `tableId` | 数据表 ID |
|
||||
| `viewId` | 只包含可导入记录的 `ACK Ready` 视图 ID |
|
||||
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
|
||||
|
||||
`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、
|
||||
`acceptance`、`priority`、`attachments`、`updatedAt`。启用审核前协作时还必须映射
|
||||
`fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。
|
||||
|
||||
## CLI 契约
|
||||
|
||||
- 可执行文件固定为可信路径中的 `lark-cli`。
|
||||
- 允许官方 npm 包生成且 package/bin 映射校验通过的 `run.js` wrapper,并解析到包内
|
||||
native binary 执行;拒绝缺少 native binary 或其它同名软链接目标。
|
||||
- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。
|
||||
- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。
|
||||
- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。
|
||||
- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改
|
||||
配置映射的 `fixLogic` 与 `acceptance`。
|
||||
- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务
|
||||
校验器拒绝审核字段与该 hash 不一致的 reviewed task。
|
||||
- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。
|
||||
- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。
|
||||
- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与
|
||||
元数据一致。
|
||||
- 标准输出必须是单一 JSON;CLI stderr 只作为错误摘要,不把可能的凭据写入结果。
|
||||
|
||||
## 标准化结果
|
||||
|
||||
读取器输出批次元数据和 `records`。每条记录至少包含:
|
||||
|
||||
- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256
|
||||
后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本;
|
||||
- `recordId` 与 `updatedAt`;
|
||||
- 绑定来源事实、附件元数据和审核字段的 `draftRevision`;
|
||||
- title、actual、expected、steps、可选 fixLogic、acceptance、priority;
|
||||
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority;
|
||||
- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用;
|
||||
- 原始字段中无法映射但不影响导入的警告。
|
||||
|
||||
输出不得包含 App ID、App Secret、Base token、附件 file token、tenant/user access
|
||||
token 或 CLI 配置文件内容。
|
||||
|
||||
## Coordinator 整理规则
|
||||
|
||||
1. 先运行读取器 `check` 确认 `lark-cli` 和项目 profile,再单独核对 app 已获得所需读写
|
||||
scope;`check` 不把用户 token 状态当作 app/bot scope 证明。
|
||||
2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift
|
||||
整理动作;需要看截图时使用临时下载目录。
|
||||
3. 将记录分类并在飞书补全 `fixLogic` 与 `acceptance`;用户反馈时继续写回同一记录。
|
||||
4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改
|
||||
应用代码。通过后每条导入任务保存 `source.kind=feishu-base`、`source.ref`、
|
||||
`source.recordId`、`source.updatedAt`、`source.workflow`、`source.approvedRevision`、
|
||||
`source.approvedPayloadHash`,并把截图观察转成文字证据。
|
||||
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
|
||||
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
|
||||
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
|
||||
8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed;
|
||||
steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文
|
||||
补齐并写回飞书。只有批准后的最终版本才导入任务板。
|
||||
9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分
|
||||
来源事实的残缺行不得静默跳过。
|
||||
|
||||
## 可观测验收信号
|
||||
|
||||
1. 两个假租户 profile 同时存在时,项目指定 `tenant-b`,所有记录和附件命令都显式包含 `--profile tenant-b`,不会读取 active 的 `tenant-a`。
|
||||
2. 只读取配置的 `ACK Ready` view ID,并投影配置字段;不查询整张表或其它视图。
|
||||
3. 一条含截图附件的假记录被标准化为稳定 `sourceRef`、完整文字字段和本地附件路径,输出中不存在任何 secret/token 凭据。
|
||||
4. CLI 缺失、profile 缺失、畸形 JSON、错位矩阵、分页越界、路径穿越或附件下载失败均返回非零退出码且不输出伪成功结果。
|
||||
5. ACK 文档明确要求按 `source.ref` 幂等整理;同一读取结果重复提交不会生成第二个来源任务。
|
||||
6. 现有无 `bugIntake` 的 ACK 项目仍能通过任务板校验并按原流程工作。
|
||||
7. 缺少 steps、acceptance、priority 的记录仍生成 create/refresh 计划并列出
|
||||
`enrichmentRequired`;全空误建行被跳过,缺 title/actual/expected 的部分记录失败。
|
||||
|
||||
## 最脆弱假设与降级
|
||||
|
||||
本设计假设官方 `lark-cli` 的 Base JSON 输出保持 `fields`、`record_id_list` 与行矩阵
|
||||
对应关系。读取器必须校验三者长度和字段映射;若上游输出契约变化,立即失败并提示
|
||||
升级适配器,不能错列生成 Bug。飞书或 CLI 不可用时,只停止新的同步,已经进入
|
||||
`tasks.yaml` 的任务继续按 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)
|
||||
|
||||
+11
-4
@@ -3,15 +3,22 @@ set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
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"
|
||||
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff"
|
||||
echo "已安装 skiff -> $BIN_DIR/skiff"
|
||||
ln -sf "$REPO/bin/pouch" "$BIN_DIR/pouch"
|
||||
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 pouch bootstrap
|
||||
|
||||
path_already_configured() {
|
||||
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() {
|
||||
|
||||
@@ -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__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Agent 名称解析(兼容 Vercel skills CLI 别名)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pouch.paths import ALL_TARGETS
|
||||
|
||||
AGENT_ALIASES: dict[str, str] = {
|
||||
"cursor": "cursor",
|
||||
"claude": "claude",
|
||||
"claude-code": "claude",
|
||||
"codex": "codex",
|
||||
"agents": "agents",
|
||||
}
|
||||
|
||||
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
||||
|
||||
|
||||
def flatten_agent_args(groups: list[list[str]] | None) -> list[str] | None:
|
||||
if not groups:
|
||||
return None
|
||||
flat = [item for group in groups for item in group]
|
||||
return flat or None
|
||||
|
||||
|
||||
def resolve_agent_args(agents: list[str] | None) -> list[str]:
|
||||
if not agents or agents == ["*"] or "*" in agents:
|
||||
return list(ALL_TARGETS)
|
||||
|
||||
resolved: list[str] = []
|
||||
for raw in agents:
|
||||
key = raw.lower()
|
||||
if key == "*":
|
||||
return list(ALL_TARGETS)
|
||||
target = AGENT_ALIASES.get(key)
|
||||
if target is None:
|
||||
choices = ", ".join(AGENT_CHOICES)
|
||||
raise SystemExit(f"未知 agent: {raw!r},可选: {choices}")
|
||||
if target != "*" and target not in resolved:
|
||||
resolved.append(target)
|
||||
return resolved
|
||||
@@ -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)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""预置 Skill catalog 的配置、发现与本地 checkout 路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pouch import yaml_io
|
||||
from pouch.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
|
||||
|
||||
|
||||
def load_catalog(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
path = path or (
|
||||
CATALOG_FILE if CATALOG_FILE.is_file() else LEGACY_REGISTRY_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"catalog 格式错误: {path}")
|
||||
return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")}
|
||||
|
||||
|
||||
def save_catalog(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||
path = path or CATALOG_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 catalog_repo(entry: dict[str, Any]) -> str:
|
||||
"""Expand a local home-relative repo while leaving remote URLs unchanged."""
|
||||
repo = str(entry.get("repo", ""))
|
||||
return str(Path(repo).expanduser()) if repo.startswith("~") else repo
|
||||
|
||||
|
||||
def catalog_repo_path(entry: dict[str, Any]) -> Path:
|
||||
"""Return the shared checkout path for a repo/ref pair."""
|
||||
from pouch.paths import CATALOG_CACHE_DIR
|
||||
|
||||
repo = str(entry.get("repo", ""))
|
||||
ref = str(entry.get("ref", "main"))
|
||||
digest = hashlib.sha256(f"{repo}\0{ref}".encode()).hexdigest()[:16]
|
||||
return CATALOG_CACHE_DIR / "_repos" / digest
|
||||
|
||||
|
||||
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
|
||||
"""Use a local repo directly, otherwise return its external checkout."""
|
||||
from pouch.paths import CATALOG_CACHE_DIR
|
||||
|
||||
configured_repo = str(entry.get("repo", ""))
|
||||
local_repo = Path(catalog_repo(entry))
|
||||
if configured_repo.startswith("~") and local_repo.is_dir():
|
||||
return local_repo.resolve()
|
||||
|
||||
legacy = CATALOG_CACHE_DIR / name
|
||||
return legacy if legacy.is_dir() else catalog_repo_path(entry)
|
||||
|
||||
|
||||
def catalog_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
||||
entry = entry or load_catalog().get(name, {})
|
||||
subpath = entry.get("path", ".") or "."
|
||||
checkout = catalog_checkout_path(name, entry).resolve()
|
||||
skill_path = (checkout / subpath).resolve()
|
||||
try:
|
||||
skill_path.relative_to(checkout)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(
|
||||
f"catalog 条目 {name!r} 的 path 超出来源仓库: {subpath!r}"
|
||||
) from exc
|
||||
return skill_path
|
||||
|
||||
|
||||
def discover_catalog_skills(
|
||||
name: str,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> dict[str, Path]:
|
||||
"""Discover a catalog provider containing one or more skills."""
|
||||
entry = entry or load_catalog().get(name, {})
|
||||
root = catalog_skill_path(name, entry)
|
||||
if (root / "SKILL.md").is_file():
|
||||
return {name: root}
|
||||
if not root.is_dir():
|
||||
return {}
|
||||
resolved_root = root.resolve()
|
||||
skills: dict[str, Path] = {}
|
||||
for item in sorted(root.iterdir()):
|
||||
skill_md = item / "SKILL.md"
|
||||
if (
|
||||
item.name.startswith("_")
|
||||
or item.is_symlink()
|
||||
or not item.is_dir()
|
||||
or skill_md.is_symlink()
|
||||
or not skill_md.is_file()
|
||||
):
|
||||
continue
|
||||
resolved_item = item.resolve()
|
||||
resolved_skill_md = skill_md.resolve()
|
||||
try:
|
||||
resolved_item.relative_to(resolved_root)
|
||||
resolved_skill_md.relative_to(resolved_item)
|
||||
except ValueError:
|
||||
continue
|
||||
skills[item.name] = item
|
||||
return skills
|
||||
+1921
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""~/.pouch 仓库内的 git 操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pouch.paths import POUCH_HOME
|
||||
|
||||
|
||||
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=not sys.stdout.isatty(),
|
||||
)
|
||||
|
||||
|
||||
def ensure_git_repo(repo: Path) -> None:
|
||||
if not (repo / ".git").exists():
|
||||
raise SystemExit(f"不是 git 仓库: {repo}")
|
||||
|
||||
|
||||
def has_staged_changes(repo: Path) -> bool:
|
||||
result = _run_git(repo, "diff", "--cached", "--quiet", check=False)
|
||||
return result.returncode == 1
|
||||
|
||||
|
||||
def publish(
|
||||
*,
|
||||
paths: list[str],
|
||||
message: str | None,
|
||||
push: bool,
|
||||
no_commit: bool,
|
||||
) -> None:
|
||||
repo = POUCH_HOME.resolve()
|
||||
ensure_git_repo(repo)
|
||||
|
||||
_run_git(repo, "add", "--", *paths)
|
||||
|
||||
if not has_staged_changes(repo):
|
||||
print("没有可提交的变更", file=sys.stdout)
|
||||
return
|
||||
|
||||
if no_commit:
|
||||
print(f"已暂存变更: {', '.join(paths)}", file=sys.stdout)
|
||||
return
|
||||
|
||||
if not message:
|
||||
raise SystemExit("提交需要 -m/--message")
|
||||
|
||||
_run_git(repo, "commit", "-m", message)
|
||||
print(f"已提交: {message}", file=sys.stdout)
|
||||
|
||||
if push:
|
||||
_run_git(repo, "push")
|
||||
print("已推送到远程", file=sys.stdout)
|
||||
+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 pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import yaml_io
|
||||
from skiff.paths import PROJECT_MANIFEST
|
||||
from skiff.skills import resolve_skill_source
|
||||
from pouch import yaml_io
|
||||
from pouch.paths import project_manifest
|
||||
from pouch.skills import normalize_source, resolve_skill_source
|
||||
|
||||
|
||||
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():
|
||||
return path, {"skills": []}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f".skills.yaml 格式错误: {path}")
|
||||
raise SystemExit(f".pouch.yaml 格式错误: {path}")
|
||||
if "skills" not in data:
|
||||
data["skills"] = []
|
||||
return path, data
|
||||
@@ -31,11 +31,13 @@ def save_manifest(path: Path, data: dict[str, Any]) -> None:
|
||||
|
||||
def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(entry, str):
|
||||
return {"name": entry, "source": "owned"}
|
||||
return {"name": entry, "source": "builtin"}
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
raise SystemExit(f".skills.yaml 条目缺少 name: {entry}")
|
||||
source = entry.get("source", "owned")
|
||||
raise SystemExit(f".pouch.yaml 条目缺少 name: {entry}")
|
||||
source = normalize_source(str(entry.get("source", "builtin")))
|
||||
if source == "catalog" and entry.get("registry"):
|
||||
source = f"catalog:{entry['registry']}"
|
||||
return {"name": name, "source": source, **{k: v for k, v in entry.items() if k not in ("name", "source")}}
|
||||
|
||||
|
||||
@@ -44,8 +46,6 @@ def manifest_skill_names(data: dict[str, Any]) -> list[str]:
|
||||
|
||||
|
||||
def _entry_to_yaml(entry: dict[str, Any]) -> str | dict[str, Any]:
|
||||
if entry.get("source", "owned") == "owned" and set(entry.keys()) <= {"name", "source"}:
|
||||
return entry["name"]
|
||||
return entry
|
||||
|
||||
|
||||
@@ -53,25 +53,28 @@ def add_skill_to_manifest(
|
||||
manifest_path: Path,
|
||||
name: str,
|
||||
*,
|
||||
source: str = "owned",
|
||||
source: str = "builtin",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
path = manifest_path
|
||||
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": []})
|
||||
if not path.is_file():
|
||||
file_path = path
|
||||
|
||||
entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
|
||||
if any(e["name"] == name for e in entries):
|
||||
return
|
||||
|
||||
item: dict[str, Any] = {"name": name, "source": source}
|
||||
if extra:
|
||||
item.update(extra)
|
||||
entries.append(item)
|
||||
existing_index = next((i for i, entry in enumerate(entries) if entry["name"] == name), None)
|
||||
if existing_index is None:
|
||||
entries.append(item)
|
||||
elif entries[existing_index] == item:
|
||||
return
|
||||
else:
|
||||
entries[existing_index] = item
|
||||
|
||||
data["skills"] = [_entry_to_yaml(e) for e in entries]
|
||||
save_manifest(file_path, data)
|
||||
@@ -79,8 +82,8 @@ def add_skill_to_manifest(
|
||||
|
||||
def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool:
|
||||
path = manifest_path
|
||||
if not path.is_file():
|
||||
path = path / PROJECT_MANIFEST
|
||||
if path.is_dir() or not path.is_file():
|
||||
path = project_manifest(path if path.is_dir() else path.parent)
|
||||
file_path, data = load_manifest(path)
|
||||
original = data.get("skills", [])
|
||||
kept = [e for e in original if normalize_skill_entry(e)["name"] != name]
|
||||
@@ -97,5 +100,7 @@ def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
|
||||
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
|
||||
name = entry["name"]
|
||||
source = entry.get("source", "owned")
|
||||
source = normalize_source(entry.get("source", "builtin"))
|
||||
if source == "catalog" and entry.get("registry"):
|
||||
source = f"catalog:{entry['registry']}"
|
||||
return resolve_skill_source(name, source=source)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Terminal multi-select UI for skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillChoice:
|
||||
name: str
|
||||
kind: str
|
||||
description: str = ""
|
||||
installed: bool = False
|
||||
readonly_status: str = ""
|
||||
children: tuple[str, ...] = ()
|
||||
indent: int = 0
|
||||
|
||||
|
||||
def fit_to_width(text: str, width: int) -> str:
|
||||
"""Trim text to terminal display width without splitting wide characters."""
|
||||
if width <= 0:
|
||||
return ""
|
||||
result: list[str] = []
|
||||
used = 0
|
||||
for char in text:
|
||||
if unicodedata.combining(char):
|
||||
char_width = 0
|
||||
else:
|
||||
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
|
||||
if used + char_width > width:
|
||||
break
|
||||
result.append(char)
|
||||
used += char_width
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
return sum(
|
||||
0
|
||||
if unicodedata.combining(char)
|
||||
else 2
|
||||
if unicodedata.east_asian_width(char) in {"W", "F"}
|
||||
else 1
|
||||
for char in text
|
||||
)
|
||||
|
||||
|
||||
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
|
||||
needle = query.casefold().strip()
|
||||
if not needle:
|
||||
return list(choices)
|
||||
return [
|
||||
choice
|
||||
for choice in choices
|
||||
if needle in choice.name.casefold()
|
||||
or needle in choice.kind.casefold()
|
||||
or needle in choice.description.casefold()
|
||||
or needle in choice.readonly_status.casefold()
|
||||
]
|
||||
|
||||
|
||||
def select_skills(
|
||||
choices: list[SkillChoice],
|
||||
*,
|
||||
scope_label: str = "当前作用域",
|
||||
wrapper: Callable[..., set[str] | None] | None = None,
|
||||
) -> set[str] | None:
|
||||
"""Open the selector. Return names, or None when cancelled."""
|
||||
if not choices:
|
||||
return set()
|
||||
|
||||
try:
|
||||
import curses
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
|
||||
|
||||
selected = {
|
||||
choice.name
|
||||
for choice in choices
|
||||
if choice.installed and not choice.children
|
||||
}
|
||||
|
||||
def mark_for(choice: SkillChoice) -> str:
|
||||
if not choice.children:
|
||||
return "x" if choice.name in selected else " "
|
||||
selected_children = selected.intersection(choice.children)
|
||||
if len(selected_children) == len(choice.children):
|
||||
return "x"
|
||||
return "-" if selected_children else " "
|
||||
|
||||
def draw(stdscr: object) -> set[str] | None:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
current = 0
|
||||
query = ""
|
||||
source_attr = curses.A_NORMAL
|
||||
status_attr = curses.A_NORMAL
|
||||
description_attr = curses.A_DIM
|
||||
try:
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||
source_attr = curses.color_pair(1)
|
||||
status_attr = curses.color_pair(2)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
while True:
|
||||
visible = filter_choices(choices, query)
|
||||
current = min(current, max(0, len(visible) - 1))
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
text_width = max(0, width - 1)
|
||||
stdscr.addnstr(
|
||||
0,
|
||||
0,
|
||||
fit_to_width(f"安装目标: {scope_label}", text_width),
|
||||
text_width,
|
||||
)
|
||||
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
|
||||
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
|
||||
if query:
|
||||
stdscr.addnstr(
|
||||
2,
|
||||
0,
|
||||
fit_to_width(f"搜索: {query}", text_width),
|
||||
text_width,
|
||||
)
|
||||
|
||||
item_capacity = max(1, (height - 4) // 3)
|
||||
start = max(0, current - item_capacity + 1)
|
||||
for offset, choice in enumerate(visible[start : start + item_capacity]):
|
||||
index = start + offset
|
||||
row = 3 + offset * 3
|
||||
mark = mark_for(choice)
|
||||
description = f" {choice.description}" if choice.description else ""
|
||||
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
|
||||
parts = [
|
||||
(f"{' ' * choice.indent}[{mark}] ", focus_attr),
|
||||
(choice.name, focus_attr | curses.A_BOLD),
|
||||
(f" {choice.kind}", focus_attr | source_attr),
|
||||
]
|
||||
if choice.readonly_status:
|
||||
parts.append(
|
||||
(f" {choice.readonly_status}", focus_attr | status_attr)
|
||||
)
|
||||
column = 0
|
||||
for text, attr in parts:
|
||||
available = text_width - column
|
||||
if available <= 0:
|
||||
break
|
||||
fitted = fit_to_width(text, available)
|
||||
stdscr.addnstr(row, column, fitted, available, attr)
|
||||
column += display_width(fitted)
|
||||
stdscr.addnstr(
|
||||
row + 1,
|
||||
0,
|
||||
fit_to_width(description, text_width),
|
||||
text_width,
|
||||
focus_attr | description_attr,
|
||||
)
|
||||
|
||||
footer = (
|
||||
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
|
||||
)
|
||||
stdscr.addnstr(
|
||||
height - 1,
|
||||
0,
|
||||
fit_to_width(footer, text_width),
|
||||
text_width,
|
||||
)
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
|
||||
if key in ("q", "Q", "\x1b"):
|
||||
return None
|
||||
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||
return set(selected)
|
||||
if key == curses.KEY_UP and visible:
|
||||
current = (current - 1) % len(visible)
|
||||
elif key == curses.KEY_DOWN and visible:
|
||||
current = (current + 1) % len(visible)
|
||||
elif key == " " and visible:
|
||||
choice = visible[current]
|
||||
if choice.children:
|
||||
children = set(choice.children)
|
||||
if children.issubset(selected):
|
||||
selected.difference_update(children)
|
||||
else:
|
||||
selected.update(children)
|
||||
else:
|
||||
selected.symmetric_difference_update({choice.name})
|
||||
elif key == "/":
|
||||
curses.curs_set(1)
|
||||
query = _read_query(stdscr, curses, width, row=2)
|
||||
curses.curs_set(0)
|
||||
current = 0
|
||||
|
||||
runner = wrapper or curses.wrapper
|
||||
return runner(draw)
|
||||
|
||||
|
||||
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
|
||||
query = ""
|
||||
while True:
|
||||
stdscr.move(row, 0)
|
||||
stdscr.clrtoeol()
|
||||
text_width = max(0, width - 1)
|
||||
stdscr.addnstr(
|
||||
row,
|
||||
0,
|
||||
fit_to_width(f"搜索: {query}", text_width),
|
||||
text_width,
|
||||
)
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||
return query
|
||||
if key == "\x1b":
|
||||
return ""
|
||||
if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE:
|
||||
query = query[:-1]
|
||||
elif isinstance(key, str) and key.isprintable():
|
||||
query += key
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
"""自研 skill 发现与解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pouch.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_pouch_home
|
||||
from pouch.catalog import (
|
||||
catalog_skill_path,
|
||||
discover_catalog_skills,
|
||||
load_catalog,
|
||||
)
|
||||
from pouch.sources import (
|
||||
discover_source_skills,
|
||||
list_source_skills,
|
||||
load_sources,
|
||||
)
|
||||
|
||||
|
||||
def list_builtin_skills() -> list[str]:
|
||||
ensure_pouch_home()
|
||||
if not SKILLS_DIR.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
for entry in sorted(SKILLS_DIR.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if entry.name.startswith("_"):
|
||||
continue
|
||||
if (entry / "SKILL.md").is_file():
|
||||
names.append(entry.name)
|
||||
return names
|
||||
|
||||
|
||||
def builtin_skill_path(name: str) -> Path:
|
||||
path = SKILLS_DIR / name
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(f"builtin skill 不存在: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def normalize_source(source: str | None) -> str | None:
|
||||
if source == "owned":
|
||||
return "builtin"
|
||||
if source == "registry":
|
||||
return "catalog"
|
||||
if source and source.startswith("registry:"):
|
||||
return f"catalog:{source.split(':', 1)[1]}"
|
||||
return source
|
||||
|
||||
|
||||
def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]:
|
||||
if "/" not in spec:
|
||||
return spec, normalize_source(source)
|
||||
qualified_source, name = spec.split("/", 1)
|
||||
if not qualified_source or not name or "/" in name:
|
||||
raise SystemExit(f"skill 限定名称无效: {spec!r}(应为 source/name)")
|
||||
qualified_source = normalize_source(qualified_source)
|
||||
source = normalize_source(source)
|
||||
if source and source != qualified_source:
|
||||
raise SystemExit(
|
||||
f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致"
|
||||
)
|
||||
return name, qualified_source
|
||||
|
||||
|
||||
def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
|
||||
sources = load_sources()
|
||||
if source:
|
||||
if source not in sources:
|
||||
raise SystemExit(f"未配置 source: {source}")
|
||||
return {source: list_source_skills(source, sources[source])}
|
||||
return {name: list_source_skills(name, entry) for name, entry in sources.items()}
|
||||
|
||||
|
||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
||||
ensure_pouch_home()
|
||||
name, source = split_skill_spec(name, source)
|
||||
|
||||
builtin = SKILLS_DIR / name
|
||||
if source == "builtin":
|
||||
if not (builtin / "SKILL.md").is_file():
|
||||
raise SystemExit(f"builtin source 中找不到 skill: {name}")
|
||||
return builtin, "builtin"
|
||||
|
||||
catalog = load_catalog()
|
||||
sources = load_sources()
|
||||
if source and source.startswith("catalog:"):
|
||||
provider = source.split(":", 1)[1]
|
||||
if provider not in catalog:
|
||||
raise SystemExit(f"catalog 中找不到 source: {provider}")
|
||||
skills = discover_catalog_skills(provider, catalog[provider])
|
||||
if name not in skills:
|
||||
available = ", ".join(skills) or "(无)"
|
||||
raise SystemExit(
|
||||
f"catalog source {provider!r} 中找不到 skill {name!r}。可用: {available}"
|
||||
)
|
||||
return skills[name], f"catalog:{provider}"
|
||||
|
||||
if source == "catalog":
|
||||
if name not in catalog:
|
||||
raise SystemExit(f"catalog 中找不到 skill source: {name}")
|
||||
path = catalog_skill_path(name, catalog[name])
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(
|
||||
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
|
||||
f"请运行: pouch fetch {name}"
|
||||
)
|
||||
return path, f"catalog:{name}"
|
||||
|
||||
if source:
|
||||
if source not in sources:
|
||||
raise SystemExit(
|
||||
f"项目依赖 source {source!r},但本机尚未配置。"
|
||||
f"请运行: pouch source add {source} <repo>"
|
||||
)
|
||||
skills = discover_source_skills(source, sources[source])
|
||||
if name not in skills:
|
||||
raise SystemExit(f"source {source!r} 中找不到 skill: {name}")
|
||||
return skills[name], source
|
||||
|
||||
candidates: list[tuple[Path, str]] = []
|
||||
if (builtin / "SKILL.md").is_file():
|
||||
candidates.append((builtin, "builtin"))
|
||||
if name in catalog:
|
||||
path = catalog_skill_path(name, catalog[name])
|
||||
if (path / "SKILL.md").is_file() or not path.exists():
|
||||
candidates.append((path, f"catalog:{name}"))
|
||||
for provider, entry in catalog.items():
|
||||
if provider == name:
|
||||
continue
|
||||
skills = discover_catalog_skills(provider, entry)
|
||||
if name in skills:
|
||||
candidates.append((skills[name], f"catalog:{provider}"))
|
||||
for source_name, entry in sources.items():
|
||||
skills = discover_source_skills(source_name, entry)
|
||||
if name in skills:
|
||||
candidates.append((skills[name], source_name))
|
||||
|
||||
unique_candidates: list[tuple[Path, str]] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for path, candidate_source in candidates:
|
||||
resolved = path.resolve()
|
||||
if resolved in seen_paths:
|
||||
continue
|
||||
seen_paths.add(resolved)
|
||||
unique_candidates.append((path, candidate_source))
|
||||
candidates = unique_candidates
|
||||
|
||||
if len(candidates) > 1:
|
||||
choices = ", ".join(f"{candidate_source}/{name}" for _, candidate_source in candidates)
|
||||
raise SystemExit(f"skill 名称存在多个来源,请明确指定: {choices}")
|
||||
if candidates:
|
||||
path, resolved_source = candidates[0]
|
||||
if resolved_source.startswith("catalog:") and not path.exists():
|
||||
provider = resolved_source.split(":", 1)[1]
|
||||
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: pouch fetch {provider}")
|
||||
return path, resolved_source
|
||||
|
||||
raise SystemExit(f"找不到 skill: {name}")
|
||||
|
||||
|
||||
def read_skill_meta(skill_dir: Path) -> dict[str, str]:
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
return {}
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}
|
||||
frontmatter = text[3:end]
|
||||
meta: dict[str, str] = {}
|
||||
|
||||
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||
if name_match:
|
||||
meta["name"] = name_match.group(1).strip().strip("\"'")
|
||||
|
||||
desc_match = re.search(
|
||||
r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)",
|
||||
frontmatter,
|
||||
re.MULTILINE,
|
||||
)
|
||||
if desc_match:
|
||||
lines = [line.strip() for line in desc_match.group(1).splitlines() if line.strip()]
|
||||
meta["description"] = " ".join(lines)
|
||||
else:
|
||||
inline = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||
if inline:
|
||||
meta["description"] = inline.group(1).strip().strip("\"'")
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def skill_description(name: str) -> str | None:
|
||||
meta = read_skill_meta(SKILLS_DIR / name)
|
||||
desc = meta.get("description")
|
||||
return desc.strip() if desc else None
|
||||
|
||||
|
||||
def validate_skill_name(name: str) -> None:
|
||||
import re
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
||||
raise SystemExit(
|
||||
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)"
|
||||
)
|
||||
if name == "_template":
|
||||
raise SystemExit("不能使用保留名 _template")
|
||||
|
||||
|
||||
_PLACEHOLDER_PATTERNS = (
|
||||
(r"^name:\s*skill-name\s*$", "skill-name"),
|
||||
(r"^#\s+Skill 名称\s*$", "Skill 名称"),
|
||||
(r"简要描述 skill 做什么、何时触发", "简要描述 skill 做什么"),
|
||||
(r"^-\s*触发场景 1\s*$", "触发场景 1"),
|
||||
(r"^\d+\.\s*第一步\s*$", "第一步"),
|
||||
)
|
||||
|
||||
|
||||
def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]:
|
||||
"""返回 skill 目录中的校验问题;空列表表示通过。"""
|
||||
issues: list[str] = []
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
readme = skill_dir / "README.md"
|
||||
if not skill_md.is_file():
|
||||
return ["缺少 SKILL.md"]
|
||||
if not readme.is_file():
|
||||
issues.append("缺少 README.md(面向人类的使用说明)")
|
||||
else:
|
||||
readme_text = readme.read_text(encoding="utf-8")
|
||||
if not readme_text.strip():
|
||||
issues.append("README.md 为空")
|
||||
if re.search(r"^#\s+skill-name\s*$", readme_text, re.MULTILINE):
|
||||
issues.append("README.md 存在模板占位内容: skill-name")
|
||||
for placeholder in (
|
||||
"用一句话告诉使用者这个 skill 能解决什么问题",
|
||||
"给出用户可以直接说出的典型请求",
|
||||
"给出一条可以直接交给 Agent 的示例请求",
|
||||
):
|
||||
if placeholder in readme_text:
|
||||
issues.append(f"README.md 存在模板占位内容: {placeholder}")
|
||||
|
||||
text = skill_md.read_text(encoding="utf-8")
|
||||
if not text.strip():
|
||||
return ["SKILL.md 为空"]
|
||||
if not text.startswith("---\n"):
|
||||
return ["SKILL.md 缺少 YAML frontmatter"]
|
||||
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
return ["SKILL.md frontmatter 未闭合"]
|
||||
frontmatter = text[4:end]
|
||||
keys = re.findall(r"^([A-Za-z0-9_-]+):", frontmatter, re.MULTILINE)
|
||||
unexpected = sorted(set(keys) - {"name", "description"})
|
||||
missing = sorted({"name", "description"} - set(keys))
|
||||
if missing:
|
||||
issues.append(f"frontmatter 缺少字段: {', '.join(missing)}")
|
||||
if unexpected:
|
||||
issues.append(f"frontmatter 只允许 name、description,发现: {', '.join(unexpected)}")
|
||||
|
||||
meta = read_skill_meta(skill_dir)
|
||||
if meta.get("name") != expected_name:
|
||||
issues.append(
|
||||
f"目录名与 frontmatter name 不一致: {expected_name} != {meta.get('name', '(缺失)')}"
|
||||
)
|
||||
description = meta.get("description", "").strip()
|
||||
if not description:
|
||||
issues.append("description 不能为空")
|
||||
|
||||
found_placeholders = [
|
||||
label
|
||||
for pattern, label in _PLACEHOLDER_PATTERNS
|
||||
if re.search(pattern, text, re.MULTILINE | re.IGNORECASE)
|
||||
]
|
||||
if found_placeholders:
|
||||
issues.append(f"存在模板占位内容: {', '.join(found_placeholders)}")
|
||||
|
||||
link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
|
||||
for target in link_pattern.findall(text):
|
||||
target = target.strip().split("#", 1)[0]
|
||||
if not target or "://" in target or target.startswith(("mailto:", "/")):
|
||||
continue
|
||||
if not (skill_dir / target).resolve().is_file():
|
||||
issues.append(f"引用文件不存在: {target}")
|
||||
|
||||
for draft_file in ("brief.yaml",):
|
||||
if (skill_dir / draft_file).exists():
|
||||
issues.append(f"正式 skill 不应包含草稿文件: {draft_file}")
|
||||
return issues
|
||||
@@ -0,0 +1,217 @@
|
||||
# Skill 来源模型
|
||||
|
||||
## 背景
|
||||
|
||||
pouch 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
|
||||
`registry` 外部仓库和用户配置的 custom source。
|
||||
|
||||
这些名称混合了不同维度:
|
||||
|
||||
- `owned` 表示所有权。
|
||||
- `registry` 表示来源配置存放在哪里。
|
||||
- `external` 表示内容不在当前仓库。
|
||||
- `collection` 表示一个来源中包含多个 Skill。
|
||||
|
||||
它们不能作为同一层级的互斥类型。例如 custom source 既可能来自外部 Git 仓库,
|
||||
也可能是 collection;因此 `custom` 与 `external` 并不互斥。
|
||||
|
||||
本文建议将用户可见的来源注册方式统一为三类:
|
||||
|
||||
- `builtin`:随当前 pouch 仓库提供的 Skill。
|
||||
- `catalog:<name>`:由 pouch 自带目录预先登记的来源。
|
||||
- `custom:<name>`:用户在本机显式配置的命名来源。
|
||||
|
||||
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
|
||||
来源都可以包含一个或多个 Skill;除 builtin 外,catalog 和 custom 都可以使用
|
||||
Git 仓库或本地目录。
|
||||
|
||||
## 迁移前模型
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S[pouch 可发现的 Skills]
|
||||
|
||||
S --> O["owned<br/>~/.pouch/skills/*"]
|
||||
S --> C["custom source<br/>~/.config/pouch/config.yaml"]
|
||||
S --> R["registry<br/>registry.yaml"]
|
||||
|
||||
O --> O1["本仓库维护<br/>随 pouch 一起分发"]
|
||||
|
||||
C --> C1["本地目录<br/>--local PATH"]
|
||||
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
|
||||
|
||||
R --> R1["外部单 Skill 仓库"]
|
||||
R --> R2["外部 Skill Collection"]
|
||||
R1 --> E["~/.local/share/pouch/externals/"]
|
||||
R2 --> E
|
||||
```
|
||||
|
||||
迁移前实现中:
|
||||
|
||||
- `list` 和 `status` 支持 owned、registry 和 custom source。
|
||||
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
|
||||
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
|
||||
- `.pouch.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
|
||||
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
|
||||
|
||||
## 现行模型
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Skill Provider]
|
||||
|
||||
A --> B["builtin<br/>仓库内隐式注册"]
|
||||
A --> C["catalog:waza<br/>pouch 预置目录"]
|
||||
A --> D["custom:company<br/>用户本机配置"]
|
||||
|
||||
B --> B1["skills/ack"]
|
||||
B --> B2["skills/pouch"]
|
||||
B --> B3["skills/builder"]
|
||||
|
||||
C --> C1["Git 或本地目录"]
|
||||
C1 --> C2["单 Skill"]
|
||||
C1 --> C3["Collection"]
|
||||
C3 --> C4["waza/think"]
|
||||
C3 --> C5["waza/check"]
|
||||
|
||||
D --> D1["Git 或本地目录"]
|
||||
D1 --> D2["单 Skill"]
|
||||
D1 --> D3["Collection"]
|
||||
D3 --> D4["company/security-review"]
|
||||
D3 --> D5["company/release"]
|
||||
```
|
||||
|
||||
### 来源注册方式
|
||||
|
||||
| 类型 | 含义 | 配置来源 | 用户界面展示 |
|
||||
| --- | --- | --- | --- |
|
||||
| `builtin` | 随当前 pouch 仓库提供 | `skills/` | `builtin` |
|
||||
| `catalog` | pouch 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
|
||||
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/pouch/config.yaml` | `custom:<name>` |
|
||||
|
||||
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
|
||||
方式,而不是仓库的所有权关系。
|
||||
|
||||
`catalog` 比 `registry` 更准确:当前文件只是 pouch 随仓库维护的预置来源目录,
|
||||
并不是远程注册中心,也不是一种 Skill 来源协议。
|
||||
|
||||
### 三个正交维度
|
||||
|
||||
每个 provider 应由三个维度描述:
|
||||
|
||||
```text
|
||||
registration: builtin | catalog | custom
|
||||
transport: bundled | local | git
|
||||
layout: single | collection
|
||||
```
|
||||
|
||||
custom source 示例:
|
||||
|
||||
```text
|
||||
registration: custom
|
||||
name: company
|
||||
transport: local | git
|
||||
layout: single | collection
|
||||
```
|
||||
|
||||
catalog entry 示例:
|
||||
|
||||
```text
|
||||
registration: catalog
|
||||
name: waza
|
||||
transport: local | git
|
||||
layout: single | collection
|
||||
```
|
||||
|
||||
`external` 只适合描述物理位置,可作为 catalog 和部分 custom provider 的统称,
|
||||
不应成为与 builtin、custom 并列的持久化类型。
|
||||
|
||||
## `select` 展示
|
||||
|
||||
`pouch select` 应同时展示 builtin、catalog 和 custom Skill:
|
||||
|
||||
```text
|
||||
[ ] ack builtin
|
||||
[ ] builder builtin
|
||||
[-] company custom source
|
||||
[ ] company/release custom:company
|
||||
[ ] company/security-review custom:company
|
||||
[-] waza catalog source
|
||||
[ ] waza/check catalog:waza
|
||||
[ ] waza/think catalog:waza
|
||||
```
|
||||
|
||||
建议遵循以下规则:
|
||||
|
||||
- builtin Skill 直接显示 Skill 名称。
|
||||
- catalog 和 custom collection 都使用来源名称作为父节点。
|
||||
- catalog 和 custom Skill 都使用 `<source>/<skill>` 作为选择器键。
|
||||
- 单 Skill 来源可以省略父节点,但仍显示注册方式和来源名称。
|
||||
- 不同来源安装后名称相同时,继续拒绝同时选择,避免覆盖同一安装路径。
|
||||
|
||||
## 配置表示
|
||||
|
||||
新写入的 `.pouch.yaml` 使用以下形式:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
- name: ack
|
||||
source: builtin
|
||||
- name: security-review
|
||||
source: company
|
||||
- name: think
|
||||
source: catalog:waza
|
||||
```
|
||||
|
||||
custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以
|
||||
独立配置仓库地址,而项目只依赖稳定的来源名称。
|
||||
|
||||
## 兼容策略
|
||||
|
||||
用户可见术语已经调整,并保留以下读取兼容,避免已有项目立即失效:
|
||||
|
||||
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
|
||||
`custom:<name>`。
|
||||
2. 新生成的 `.pouch.yaml` 对内置 Skill 写入 `source: builtin`。
|
||||
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
|
||||
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
|
||||
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
|
||||
`catalog:<name>`。
|
||||
6. 主文件使用 `catalog.yaml`;不存在时兼容读取旧 `registry.yaml`。
|
||||
7. custom source 的逻辑名称和现有 `config.yaml` 结构保持不变。
|
||||
8. 将 `builtin`、`catalog` 和兼容别名 `owned`、`registry` 设为 custom source
|
||||
保留字。
|
||||
9. `select` 同步接入 custom Skill,并让 custom collection 与 catalog collection
|
||||
使用相同的父子展示逻辑。
|
||||
|
||||
## 实现范围
|
||||
|
||||
当前实现覆盖:
|
||||
|
||||
- `pouch/skills.py`:来源解析、归一化和 builtin 命名。
|
||||
- `pouch/project.py`:manifest 默认值、序列化与旧值兼容。
|
||||
- `pouch/sources.py`:来源保留字。
|
||||
- `pouch/catalog.py`:catalog 配置、checkout 与 Skill 发现。
|
||||
- `pouch/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
|
||||
- `pouch/selector.py`:统一 catalog/custom collection 的父子展示。
|
||||
- CLI 与来源解析测试。
|
||||
- 根 README、`pouch/README.md`、`skills/pouch/SKILL.md` 和相关示例。
|
||||
|
||||
## 验证要求
|
||||
|
||||
实施完成后至少覆盖:
|
||||
|
||||
- builtin Skill 可以通过新名称列出、选择、安装和写入 manifest。
|
||||
- 旧的 `source: owned` manifest 仍可同步。
|
||||
- custom local source 和 custom Git source 都出现在 selector 中。
|
||||
- catalog 单 Skill 和 Collection 都保持可选择。
|
||||
- custom 单 Skill 和 Collection 都保持可选择。
|
||||
- 多来源同名 Skill 必须显式指定来源。
|
||||
- 两个选择最终安装为同一个名称时,selector 拒绝冲突。
|
||||
- `list`、`status`、`select` 对同一来源使用一致术语。
|
||||
|
||||
## 设计前提
|
||||
|
||||
迁移前 `registry.yaml` 的真实职责只是维护 pouch 预置的来源目录,而不是提供远程
|
||||
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
|
||||
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
|
||||
@@ -0,0 +1,117 @@
|
||||
"""命名 custom skill source 的配置、发现与 Git 管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pouch.config import load_user_config, save_user_config
|
||||
from pouch.paths import CONFIG_FILE, SOURCES_DIR
|
||||
|
||||
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
|
||||
|
||||
|
||||
def validate_source_name(name: str) -> None:
|
||||
from pouch.skills import validate_skill_name
|
||||
|
||||
validate_skill_name(name)
|
||||
if name in RESERVED_SOURCES:
|
||||
raise SystemExit(f"source 名称为保留字: {name}")
|
||||
|
||||
|
||||
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
data = load_user_config(path)
|
||||
raw = data.get("sources", {})
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise SystemExit(f"pouch 配置 sources 格式错误: {path or CONFIG_FILE}")
|
||||
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||
path = path or CONFIG_FILE
|
||||
data = load_user_config(path)
|
||||
data["sources"] = sources
|
||||
save_user_config(data, path)
|
||||
|
||||
|
||||
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
|
||||
local_path = entry.get("local_path")
|
||||
if local_path:
|
||||
return Path(str(local_path)).expanduser().resolve()
|
||||
checkout = entry.get("checkout")
|
||||
if checkout:
|
||||
return Path(str(checkout)).expanduser().resolve()
|
||||
return (SOURCES_DIR / name).resolve()
|
||||
|
||||
|
||||
def source_skills_root(name: str, entry: dict[str, Any]) -> Path:
|
||||
checkout = source_checkout(name, entry)
|
||||
raw_subpath = str(entry.get("skills_path", "skills") or "skills")
|
||||
subpath = Path(raw_subpath)
|
||||
if subpath.is_absolute():
|
||||
raise SystemExit(f"source {name!r} 的 skills_path 必须是仓库内相对路径")
|
||||
root = (checkout / subpath).resolve()
|
||||
try:
|
||||
root.relative_to(checkout)
|
||||
except ValueError:
|
||||
raise SystemExit(f"source {name!r} 的 skills_path 不能超出仓库目录") from None
|
||||
return root
|
||||
|
||||
|
||||
def discover_source_skills(name: str, entry: dict[str, Any]) -> dict[str, Path]:
|
||||
root = source_skills_root(name, entry)
|
||||
if (root / "SKILL.md").is_file():
|
||||
return {name: root}
|
||||
if not root.is_dir():
|
||||
return {}
|
||||
return {
|
||||
item.name: item
|
||||
for item in sorted(root.iterdir())
|
||||
if item.is_dir()
|
||||
and not item.name.startswith("_")
|
||||
and (item / "SKILL.md").is_file()
|
||||
}
|
||||
|
||||
|
||||
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
|
||||
return list(discover_source_skills(name, entry))
|
||||
|
||||
|
||||
def fetch_source(name: str, entry: dict[str, Any]) -> Path:
|
||||
if entry.get("local_path"):
|
||||
checkout = source_checkout(name, entry)
|
||||
if not checkout.is_dir():
|
||||
raise SystemExit(f"source {name!r} 的本地仓库不存在: {checkout}")
|
||||
return checkout
|
||||
|
||||
repo = entry.get("repo")
|
||||
if not repo:
|
||||
raise SystemExit(f"source {name!r} 缺少 repo 或 local_path")
|
||||
checkout = source_checkout(name, entry)
|
||||
ref = str(entry.get("ref", "main") or "main")
|
||||
if checkout.exists():
|
||||
if not (checkout / ".git").exists():
|
||||
raise SystemExit(f"source checkout 已存在但不是 Git 仓库: {checkout}")
|
||||
subprocess.run(["git", "-C", str(checkout), "fetch", "--all", "--tags"], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "checkout", ref], check=True)
|
||||
subprocess.run(["git", "-C", str(checkout), "pull", "--ff-only"], check=True)
|
||||
else:
|
||||
checkout.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
ref,
|
||||
"--",
|
||||
str(repo),
|
||||
str(checkout),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return checkout
|
||||
@@ -66,8 +66,11 @@ def copy_template(src: Path, dst: Path) -> None:
|
||||
def find_repo_root(start: Path | None = None) -> Path | None:
|
||||
start = (start or Path.cwd()).resolve()
|
||||
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
|
||||
if (directory / "skills").is_dir() and (directory / "registry.yaml").is_file():
|
||||
if (directory / "skills").is_dir() and (
|
||||
(directory / "catalog.yaml").is_file()
|
||||
or (directory / "registry.yaml").is_file()
|
||||
):
|
||||
return directory
|
||||
return None
|
||||
@@ -1,4 +1,4 @@
|
||||
"""轻量 YAML 读写(覆盖 skiff 使用的子集,无第三方依赖)。"""
|
||||
"""轻量 YAML 读写(覆盖 pouch 使用的子集,无第三方依赖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
|
||||
return True
|
||||
if lower == "false":
|
||||
return False
|
||||
if raw == "[]":
|
||||
return []
|
||||
if raw == "{}":
|
||||
return {}
|
||||
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
|
||||
return int(raw)
|
||||
return raw
|
||||
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
|
||||
return f"{pad}{{}}\n"
|
||||
lines: list[str] = []
|
||||
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):
|
||||
lines.append(f"{pad}{key}:")
|
||||
for item in value:
|
||||
@@ -236,11 +244,12 @@ def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
first = True
|
||||
for key, value in item.items():
|
||||
prefix = f"{pad}- " if first else f"{pad} "
|
||||
is_first = first
|
||||
prefix = f"{pad}- " if is_first else f"{pad} "
|
||||
first = False
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
nested = _dump(value, indent + 4 if first else indent + 2)
|
||||
nested = _dump(value, indent + 2 if is_first else indent + 4)
|
||||
lines.append(nested.rstrip())
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {_scalar(value)}")
|
||||
@@ -1,17 +0,0 @@
|
||||
# External skills registry
|
||||
# Format:
|
||||
# <name>:
|
||||
# repo: <git-url>
|
||||
# ref: <branch|tag> (default: main)
|
||||
# path: <subpath> (default: .)
|
||||
#
|
||||
# Example:
|
||||
# superpowers:
|
||||
# repo: https://github.com/obra/superpowers
|
||||
# ref: main
|
||||
# path: .
|
||||
|
||||
superpowers:
|
||||
repo: https://github.com/obra/superpowers
|
||||
ref: main
|
||||
path: .
|
||||
+1
-1
@@ -1 +1 @@
|
||||
# skiff 仅使用 Python 标准库,无第三方依赖
|
||||
# pouch 仅使用 Python 标准库,无第三方依赖
|
||||
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
# skiff
|
||||
|
||||
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/skills # 本仓库根目录
|
||||
./install.sh # 软链到 ~/.local/bin/skiff
|
||||
```
|
||||
|
||||
确保 `~/.local/bin` 在 `PATH` 中。开发时也可直接运行:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
||||
```
|
||||
|
||||
## 首次配置
|
||||
|
||||
```bash
|
||||
skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
|
||||
```
|
||||
|
||||
若仓库本身就在 `~/.skills`(开发场景),`setup` 会自动识别,无需额外操作。
|
||||
|
||||
## 命令参考
|
||||
|
||||
### 仓库关联
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff setup <path>` | 关联 `~/.skills` 到 skills 仓库 |
|
||||
|
||||
### 查看
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff list` | 列出自研 skill 与 registry 中的外部 skill |
|
||||
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
||||
|
||||
### 全局安装(自研 skill)
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff install <name> [--target all]` | 软链到 Agent 全局目录 |
|
||||
| `skiff uninstall <name> [--target all]` | 移除软链 |
|
||||
|
||||
全局目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `~/.cursor/skills/` |
|
||||
| claude | `~/.claude/skills/` |
|
||||
| codex | `~/.codex/skills/` |
|
||||
|
||||
### 外部 Git skill
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
|
||||
| `skiff fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` |
|
||||
| `skiff install-external <name> [--target all]` | 安装外部 skill 到 Agent 目录 |
|
||||
|
||||
### 项目级
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff 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>` | 从 `skills/_template/` 创建自研 skill |
|
||||
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
||||
|
||||
## 常用工作流
|
||||
|
||||
### 新建并全局启用自研 skill
|
||||
|
||||
```bash
|
||||
skiff create my-skill
|
||||
# 编辑 skills/my-skill/SKILL.md
|
||||
skiff install my-skill --target cursor
|
||||
skiff doctor --target cursor
|
||||
```
|
||||
|
||||
### 在项目中启用 skill
|
||||
|
||||
```bash
|
||||
cd ~/code/my-app
|
||||
skiff enable declarative-openspec-loop --target cursor
|
||||
skiff sync
|
||||
```
|
||||
|
||||
### 安装外部 Git skill
|
||||
|
||||
```bash
|
||||
skiff add my-ext https://github.com/org/repo --ref main
|
||||
skiff fetch my-ext
|
||||
skiff install-external my-ext
|
||||
```
|
||||
|
||||
## 源码结构
|
||||
|
||||
```
|
||||
skiff/
|
||||
├── __init__.py # 版本号
|
||||
├── __main__.py # python3 -m skiff 入口
|
||||
├── cli.py # 命令定义与调度
|
||||
├── paths.py # 路径常量与 Agent 目标
|
||||
├── skills.py # 自研 skill 发现
|
||||
├── registry.py # registry.yaml 读写
|
||||
├── project.py # .skills.yaml 管理
|
||||
├── symlinks.py # 软链创建/检查/修复
|
||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||
```
|
||||
|
||||
入口脚本:[../bin/skiff](../bin/skiff)
|
||||
|
||||
## 路径约定
|
||||
|
||||
| 变量 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
||||
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
|
||||
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
|
||||
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止**在 `~/.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.1.0"
|
||||
-429
@@ -1,429 +0,0 @@
|
||||
"""skiff CLI 入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import __version__
|
||||
from skiff.paths import (
|
||||
ALL_TARGETS,
|
||||
EXTERNALS_DIR,
|
||||
REGISTRY_FILE,
|
||||
SKILLS_DIR,
|
||||
SKILLS_HOME,
|
||||
TEMPLATE_DIR,
|
||||
agent_skill_dir,
|
||||
ensure_skills_home,
|
||||
resolve_targets,
|
||||
)
|
||||
from skiff.project import (
|
||||
add_skill_to_manifest,
|
||||
iter_manifest_skills,
|
||||
load_manifest,
|
||||
remove_skill_from_manifest,
|
||||
resolve_manifest_skill,
|
||||
save_manifest,
|
||||
)
|
||||
from skiff.registry import external_skill_path, load_registry, save_registry
|
||||
from skiff.skills import list_owned_skills, owned_skill_path, resolve_skill_source, validate_skill_name
|
||||
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||
|
||||
|
||||
def _print(msg: str = "") -> None:
|
||||
print(msg, file=sys.stdout)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_setup(args: argparse.Namespace) -> None:
|
||||
repo = Path(args.path).resolve()
|
||||
if not (repo / "skills").is_dir():
|
||||
raise SystemExit(f"不是有效的 skills 仓库(缺少 skills/): {repo}")
|
||||
|
||||
# 开发场景:仓库本身就在 ~/.skills
|
||||
if repo == SKILLS_HOME.resolve() and SKILLS_HOME.is_dir() and not SKILLS_HOME.is_symlink():
|
||||
_print(f"skills 仓库已在 ~/.skills: {repo}")
|
||||
return
|
||||
|
||||
if SKILLS_HOME.is_symlink():
|
||||
current = SKILLS_HOME.resolve()
|
||||
if current == repo:
|
||||
_print(f"已关联: ~/.skills -> {repo}")
|
||||
return
|
||||
SKILLS_HOME.unlink()
|
||||
elif SKILLS_HOME.exists():
|
||||
raise SystemExit(f"~/.skills 已存在且不是软链: {SKILLS_HOME}")
|
||||
|
||||
SKILLS_HOME.parent.mkdir(parents=True, exist_ok=True)
|
||||
SKILLS_HOME.symlink_to(repo)
|
||||
_print(f"已关联: ~/.skills -> {repo}")
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
|
||||
_print("自研 (owned):")
|
||||
for name in owned:
|
||||
_print(f" {name}")
|
||||
|
||||
_print("\n外部 (registry):")
|
||||
if not registry:
|
||||
_print(" (无)")
|
||||
else:
|
||||
for name, entry in registry.items():
|
||||
repo = entry.get("repo", "?")
|
||||
_print(f" {name} ({repo})")
|
||||
|
||||
|
||||
def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]:
|
||||
skill_path, _ = resolve_skill_source(name)
|
||||
rows: list[tuple[str, Path, Path]] = []
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=project_root) / name
|
||||
rows.append((target, link, skill_path))
|
||||
return rows
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
targets = resolve_targets(args.target)
|
||||
owned = list_owned_skills()
|
||||
registry = load_registry()
|
||||
all_names = owned + [n for n in registry if n not in owned]
|
||||
|
||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
||||
_print(f"targets: {', '.join(targets)}\n")
|
||||
|
||||
for name in all_names:
|
||||
kind = "owned" if name in owned else "external"
|
||||
_print(f"[{kind}] {name}")
|
||||
try:
|
||||
rows = _installed_links(name, targets)
|
||||
except SystemExit:
|
||||
_print(" (未 fetch)")
|
||||
continue
|
||||
for target, link, expected in rows:
|
||||
status = check_link(link, expected)
|
||||
mark = "✓" if status.ok else "✗"
|
||||
detail = "" if status.ok else f" — {status.issue}"
|
||||
_print(f" {mark} {target}: {link}{detail}")
|
||||
_print("")
|
||||
|
||||
|
||||
def _install_skill(name: str, targets: list[str], project_root: Path | None = None) -> None:
|
||||
skill_path, kind = resolve_skill_source(name)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=project_root) / name
|
||||
create_link(link, skill_path)
|
||||
scope = "项目" if project_root else "全局"
|
||||
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}")
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
targets = resolve_targets(args.target)
|
||||
_install_skill(args.name, targets)
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
targets = resolve_targets(args.target)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target) / args.name
|
||||
if remove_link(link):
|
||||
_print(f"已移除 ({target}): {link}")
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
registry = load_registry()
|
||||
if args.name in registry:
|
||||
raise SystemExit(f"registry 中已存在: {args.name}")
|
||||
|
||||
registry[args.name] = {
|
||||
"repo": args.repo,
|
||||
"ref": args.ref,
|
||||
"path": args.path,
|
||||
}
|
||||
save_registry(registry)
|
||||
_print(f"已添加 registry 条目: {args.name}")
|
||||
|
||||
|
||||
def cmd_fetch(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
registry = load_registry()
|
||||
if args.name not in registry:
|
||||
raise SystemExit(f"registry 中不存在: {args.name}")
|
||||
|
||||
entry = registry[args.name]
|
||||
repo = entry["repo"]
|
||||
ref = entry.get("ref", "main")
|
||||
dest = EXTERNALS_DIR / args.name
|
||||
|
||||
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dest.exists():
|
||||
_print(f"更新: {dest}")
|
||||
subprocess.run(["git", "-C", str(dest), "fetch", "--all", "--tags"], check=True)
|
||||
subprocess.run(["git", "-C", str(dest), "checkout", ref], check=True)
|
||||
subprocess.run(["git", "-C", str(dest), "pull", "--ff-only"], check=True)
|
||||
else:
|
||||
_print(f"克隆: {repo} -> {dest}")
|
||||
subprocess.run(
|
||||
["git", "clone", "--branch", ref, "--", repo, str(dest)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def cmd_install_external(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
registry = load_registry()
|
||||
if args.name not in registry:
|
||||
raise SystemExit(f"registry 中不存在: {args.name}")
|
||||
|
||||
path = external_skill_path(args.name, registry[args.name])
|
||||
if not path.exists():
|
||||
raise SystemExit(f"请先 fetch: skiff fetch {args.name}")
|
||||
|
||||
targets = resolve_targets(args.target)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target) / args.name
|
||||
create_link(link, path)
|
||||
_print(f"已安装外部 ({target}): {args.name} -> {path}")
|
||||
|
||||
|
||||
def _project_root(args: argparse.Namespace) -> Path:
|
||||
if args.project:
|
||||
root = Path(args.project).resolve()
|
||||
else:
|
||||
root = find_repo_root() or Path.cwd()
|
||||
return root
|
||||
|
||||
|
||||
def cmd_enable(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
root = _project_root(args)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
|
||||
# 判断来源
|
||||
registry = load_registry()
|
||||
if args.name in registry:
|
||||
source = "registry"
|
||||
extra = {"source": "registry", "ref": registry[args.name].get("ref", "main")}
|
||||
add_skill_to_manifest(manifest_path, args.name, source="registry", extra=extra)
|
||||
else:
|
||||
owned_skill_path(args.name) # 验证存在
|
||||
add_skill_to_manifest(manifest_path, args.name, source="owned")
|
||||
|
||||
targets = resolve_targets(args.target)
|
||||
_, data = load_manifest(manifest_path)
|
||||
manifest_targets = data.get("targets")
|
||||
if manifest_targets:
|
||||
targets = [t for t in targets if t in manifest_targets]
|
||||
|
||||
_install_skill(args.name, targets, project_root=root)
|
||||
_print(f"已启用项目 skill: {args.name} @ {root}")
|
||||
|
||||
|
||||
def cmd_disable(args: argparse.Namespace) -> None:
|
||||
root = _project_root(args)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
if not remove_skill_from_manifest(manifest_path, args.name):
|
||||
_print(f"manifest 中不存在: {args.name}")
|
||||
return
|
||||
|
||||
targets = resolve_targets(args.target)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=root) / args.name
|
||||
if remove_link(link):
|
||||
_print(f"已禁用 ({target}): {link}")
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
root = _project_root(args)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
if not manifest_path.is_file():
|
||||
raise SystemExit(f"未找到 {manifest_path}")
|
||||
|
||||
_, data = load_manifest(manifest_path)
|
||||
targets = resolve_targets(args.target)
|
||||
manifest_targets = data.get("targets")
|
||||
if manifest_targets:
|
||||
targets = [t for t in targets if t in manifest_targets]
|
||||
|
||||
for entry in iter_manifest_skills(data):
|
||||
name = entry["name"]
|
||||
skill_path, _ = resolve_manifest_skill(entry)
|
||||
for target in targets:
|
||||
link = agent_skill_dir(target, project_root=root) / name
|
||||
create_link(link, skill_path)
|
||||
_print(f"已同步: {link} -> {skill_path}")
|
||||
|
||||
|
||||
def cmd_create(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
validate_skill_name(args.name)
|
||||
if not TEMPLATE_DIR.is_dir():
|
||||
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
|
||||
|
||||
dst = SKILLS_DIR / args.name
|
||||
copy_template(TEMPLATE_DIR, dst)
|
||||
|
||||
skill_md = dst / "SKILL.md"
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
content = re.sub(
|
||||
r"(^name:\s*)skill-name\s*$",
|
||||
rf"\g<1>{args.name}",
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
skill_md.write_text(content, encoding="utf-8")
|
||||
_print(f"已创建 skill: {dst}")
|
||||
_print(f"下一步: 编辑 {skill_md},然后 skiff install {args.name}")
|
||||
|
||||
|
||||
def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
targets = resolve_targets(args.target)
|
||||
issues = 0
|
||||
|
||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
||||
if SKILLS_HOME.is_symlink():
|
||||
if not SKILLS_HOME.resolve().is_dir():
|
||||
_err(f"✗ ~/.skills 指向无效路径: {SKILLS_HOME.resolve()}")
|
||||
issues += 1
|
||||
else:
|
||||
_print("✓ ~/.skills 软链正常")
|
||||
elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir():
|
||||
_print("✓ ~/.skills 为本地仓库目录")
|
||||
else:
|
||||
_err("✗ ~/.skills 未正确配置")
|
||||
issues += 1
|
||||
|
||||
for name in list_owned_skills():
|
||||
for target, link, expected in _installed_links(name, targets):
|
||||
status = check_link(link, expected)
|
||||
if status.ok:
|
||||
continue
|
||||
_err(f"✗ [{name}/{target}] {status.issue}: {link}")
|
||||
issues += 1
|
||||
if args.fix:
|
||||
try:
|
||||
create_link(link, expected)
|
||||
_print(f" 已修复: {link}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_err(f" 修复失败: {exc}")
|
||||
|
||||
registry = load_registry()
|
||||
for name in registry:
|
||||
ext = external_skill_path(name, registry[name])
|
||||
if not ext.exists():
|
||||
_err(f"✗ 外部 skill 未 fetch: {name}")
|
||||
issues += 1
|
||||
|
||||
if issues == 0:
|
||||
_print("\n全部正常")
|
||||
else:
|
||||
_print(f"\n发现 {issues} 个问题")
|
||||
if not args.fix:
|
||||
_print("提示: 使用 skiff doctor --fix 尝试自动修复软链")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="skiff",
|
||||
description="Agent Skills 安装与管理 CLI",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"skiff {__version__}")
|
||||
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_setup = sub.add_parser("setup", help="关联 ~/.skills 到本仓库")
|
||||
p_setup.add_argument("path", help="skills 仓库路径")
|
||||
p_setup.set_defaults(func=cmd_setup)
|
||||
|
||||
p_list = sub.add_parser("list", help="列出所有 skill")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_status = sub.add_parser("status", help="安装状态总览")
|
||||
p_status.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
p_install = sub.add_parser("install", help="全局安装 skill(软链)")
|
||||
p_install.add_argument("name", help="skill 名称")
|
||||
p_install.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_install.set_defaults(func=cmd_install)
|
||||
|
||||
p_uninstall = sub.add_parser("uninstall", help="移除全局软链")
|
||||
p_uninstall.add_argument("name", help="skill 名称")
|
||||
p_uninstall.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_uninstall.set_defaults(func=cmd_uninstall)
|
||||
|
||||
p_add = sub.add_parser("add", help="添加外部 skill 到 registry")
|
||||
p_add.add_argument("name", help="registry 名称")
|
||||
p_add.add_argument("repo", help="Git 仓库 URL")
|
||||
p_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)")
|
||||
p_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)")
|
||||
p_add.set_defaults(func=cmd_add)
|
||||
|
||||
p_fetch = sub.add_parser("fetch", help="拉取外部 Git skill")
|
||||
p_fetch.add_argument("name", help="registry 名称")
|
||||
p_fetch.set_defaults(func=cmd_fetch)
|
||||
|
||||
p_inst_ext = sub.add_parser("install-external", help="安装外部 skill 到 Agent 目录")
|
||||
p_inst_ext.add_argument("name", help="registry 名称")
|
||||
p_inst_ext.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_inst_ext.set_defaults(func=cmd_install_external)
|
||||
|
||||
p_enable = sub.add_parser("enable", help="项目级启用 skill")
|
||||
p_enable.add_argument("name", help="skill 名称")
|
||||
p_enable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_enable.add_argument("--project", help="项目根目录(默认自动检测)")
|
||||
p_enable.set_defaults(func=cmd_enable)
|
||||
|
||||
p_disable = sub.add_parser("disable", help="项目级禁用 skill")
|
||||
p_disable.add_argument("name", help="skill 名称")
|
||||
p_disable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_disable.add_argument("--project", help="项目根目录(默认自动检测)")
|
||||
p_disable.set_defaults(func=cmd_disable)
|
||||
|
||||
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
|
||||
p_sync.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_sync.add_argument("--project", help="项目根目录(默认自动检测)")
|
||||
p_sync.set_defaults(func=cmd_sync)
|
||||
|
||||
p_create = sub.add_parser("create", help="从 _template 创建自研 skill")
|
||||
p_create.add_argument("name", help="skill 名称")
|
||||
p_create.set_defaults(func=cmd_create)
|
||||
|
||||
p_doctor = sub.add_parser("doctor", help="软链健康检查")
|
||||
p_doctor.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
|
||||
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
|
||||
p_doctor.set_defaults(func=cmd_doctor)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,48 +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"
|
||||
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
||||
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
|
||||
PROJECT_MANIFEST = ".skills.yaml"
|
||||
|
||||
AGENT_GLOBAL: dict[str, Path] = {
|
||||
"cursor": HOME / ".cursor" / "skills",
|
||||
"claude": HOME / ".claude" / "skills",
|
||||
"codex": HOME / ".codex" / "skills",
|
||||
}
|
||||
|
||||
AGENT_PROJECT: dict[str, str] = {
|
||||
"cursor": ".agents/skills",
|
||||
"claude": ".claude/skills",
|
||||
"codex": ".agents/skills",
|
||||
}
|
||||
|
||||
ALL_TARGETS = ("cursor", "claude", "codex")
|
||||
|
||||
|
||||
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(
|
||||
f"~/.skills 未配置。请先运行: skiff setup <skills-repo-path>"
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
"""registry.yaml 读写。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import yaml_io
|
||||
from skiff.paths import REGISTRY_FILE
|
||||
|
||||
|
||||
def load_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
path = path or REGISTRY_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"registry 格式错误: {path}")
|
||||
return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")}
|
||||
|
||||
|
||||
def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
|
||||
path = path or REGISTRY_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 external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
|
||||
from skiff.paths import EXTERNALS_DIR
|
||||
|
||||
entry = entry or load_registry().get(name, {})
|
||||
subpath = entry.get("path", ".") or "."
|
||||
return (EXTERNALS_DIR / name / subpath).resolve()
|
||||
@@ -1,64 +0,0 @@
|
||||
"""自研 skill 发现与解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
||||
from skiff.registry import external_skill_path, load_registry
|
||||
|
||||
|
||||
def list_owned_skills() -> list[str]:
|
||||
ensure_skills_home()
|
||||
if not SKILLS_DIR.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
for entry in sorted(SKILLS_DIR.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if entry.name.startswith("_"):
|
||||
continue
|
||||
if (entry / "SKILL.md").is_file():
|
||||
names.append(entry.name)
|
||||
return names
|
||||
|
||||
|
||||
def owned_skill_path(name: str) -> Path:
|
||||
path = SKILLS_DIR / name
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(f"自研 skill 不存在: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
|
||||
ensure_skills_home()
|
||||
|
||||
owned = SKILLS_DIR / name
|
||||
if source in (None, "owned") and (owned / "SKILL.md").is_file():
|
||||
return owned, "owned"
|
||||
|
||||
registry = load_registry()
|
||||
if source in (None, "registry") and name in registry:
|
||||
path = external_skill_path(name, registry[name])
|
||||
if not path.exists():
|
||||
raise SystemExit(
|
||||
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
|
||||
)
|
||||
return path, "external"
|
||||
|
||||
if (owned / "SKILL.md").is_file():
|
||||
return owned, "owned"
|
||||
|
||||
raise SystemExit(f"找不到 skill: {name}")
|
||||
|
||||
|
||||
def validate_skill_name(name: str) -> None:
|
||||
import re
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
||||
raise SystemExit(
|
||||
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)"
|
||||
)
|
||||
if name == "_template":
|
||||
raise SystemExit("不能使用保留名 _template")
|
||||
@@ -0,0 +1,28 @@
|
||||
# skill-name
|
||||
|
||||
用一句话告诉使用者这个 skill 能解决什么问题。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 给出用户可以直接说出的典型请求。
|
||||
- 说明适用范围。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- 列出需要用户提前提供或确认的信息、权限和依赖。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
给出一条可以直接交给 Agent 的示例请求。
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
1. 用面向使用者的语言概括主要步骤。
|
||||
2. 说明可能发生的外部写操作或重要边界。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
- 说明使用者最终会得到什么,以及如何确认结果正确。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: skill-name
|
||||
description: >-
|
||||
简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically.
|
||||
简要描述 skill 做什么、何时触发。写入用户可能使用的触发表达,让 Agent 能自动匹配。
|
||||
---
|
||||
|
||||
# Skill 名称
|
||||
@@ -15,6 +15,10 @@ description: >-
|
||||
- 触发场景 1
|
||||
- 触发场景 2
|
||||
|
||||
## 不适用
|
||||
|
||||
- 不应触发的相近场景
|
||||
|
||||
---
|
||||
|
||||
## 步骤
|
||||
@@ -23,8 +27,39 @@ description: >-
|
||||
2. 第二步
|
||||
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`」。
|
||||
单模式步骤不要写进正文。
|
||||
|
||||
## 验证
|
||||
|
||||
- 说明如何确认 skill 的执行结果正确
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# ACK
|
||||
|
||||
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
|
||||
|
||||
- Coordinator 拆解需求、派发任务并终检
|
||||
- Developer 实现并做白盒验证
|
||||
- Test 独立做黑盒复测
|
||||
|
||||
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
|
||||
|
||||
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
|
||||
|
||||
只在你显式说 `/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
|
||||
pouch add ack # 当前项目
|
||||
pouch add ack -g # 全局
|
||||
```
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- 项目是 Git 仓库。
|
||||
- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok、OMP 或 Pi。
|
||||
- 使用 Pi 时需安装 `pi`;默认模型用 `agents.yaml` 中 `cli: pi`、`model: null` 表示。
|
||||
- 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
|
||||
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
|
||||
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
|
||||
|
||||
## 初始化项目
|
||||
|
||||
```bash
|
||||
pouch init ack
|
||||
pouch init ack --project ~/code/my-app
|
||||
```
|
||||
|
||||
也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
|
||||
|
||||
```text
|
||||
.pouch/ack/
|
||||
├── project.md
|
||||
├── tasks.yaml # 项目配置与 taskStore 指针
|
||||
├── task-records/ # 追加式任务/执行记录
|
||||
├── knowledge.yaml
|
||||
├── delivery.yaml # 默认 enabled: false
|
||||
├── regression.yaml # 默认 cases: []
|
||||
└── agents.yaml # 可选:临时覆盖 Developer/Test 的 CLI、模型和权限
|
||||
```
|
||||
|
||||
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
|
||||
|
||||
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件;需要消除单文件冲突时,先运行 `scripts/migrate_tasks.py` 的 dry-run。
|
||||
|
||||
任务状态追加示例:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
|
||||
--task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
/ack 初始化
|
||||
/ack 检查配置
|
||||
/ack 处理这个需求:给后台加导出 CSV
|
||||
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
|
||||
/ack 处理飞书里待整理的 bug
|
||||
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
|
||||
/ack 重新布一下测试环境,我要测试
|
||||
/ack 发布一个版本
|
||||
/ack 回归
|
||||
/ack 跑 full 回归
|
||||
/ack 回归 REG-login-001
|
||||
/ack 修这些回归失败
|
||||
```
|
||||
|
||||
首次配发版也可以一次说清:
|
||||
|
||||
```text
|
||||
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
|
||||
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
|
||||
```
|
||||
|
||||
之后做需求时只要在确认计划里选 profile:
|
||||
|
||||
```text
|
||||
/ack 处理这个需求:…。任务验证通过后执行 review profile。
|
||||
```
|
||||
|
||||
临时调整角色启动方式时直接说明意图,例如:
|
||||
|
||||
```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` 里记 `ackVersion`。
|
||||
|
||||
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
name: ack
|
||||
description: >-
|
||||
初始化、检查并运行 ACK 三角色闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||
初始化 ACK、检查 .pouch/ack、按 ACK 做需求或修 bug、指挥
|
||||
Coordinator/Developer/Test、配置或运行测试环境与发版、或跑回归时使用。
|
||||
---
|
||||
|
||||
# ACK 项目协作入口
|
||||
|
||||
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
|
||||
`delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。
|
||||
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
|
||||
`CLAUDE.md`。
|
||||
|
||||
执行位置和 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:执行「初始化」。
|
||||
- 检查配置是否完整:执行「检查」。
|
||||
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
|
||||
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
|
||||
`.pouch/ship/`,不要写进缺陷任务板。
|
||||
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
|
||||
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
|
||||
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
|
||||
- 任务板使用 Orca 时,编排命令见 [orca-adapter.md](references/orca-adapter.md)。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不覆盖已有 `.pouch/ack`;除用户确认的任务、agents profile 或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。
|
||||
- 不猜测命令、地址、worker handle 或模型名;无法确定写 `n/a`。
|
||||
- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。
|
||||
- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。
|
||||
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。
|
||||
- 新项目不要把任务状态、worker receipt 或运行历史回写到 `tasks.yaml`;每次状态变化写入
|
||||
`task-records/<recordId>.yaml`,记录必须包含 `taskId`、`parents` 和完整 `task` 快照。
|
||||
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
|
||||
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
|
||||
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
|
||||
- 用户明确授权无沙箱运行后,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。
|
||||
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
|
||||
- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。
|
||||
- `.pouch/ack/tasks.yaml` 收缺陷(飞书、回归失败、leftover)。功能、小改动和本次交付写
|
||||
`.pouch/ship/<ship-id>/`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排,
|
||||
任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。
|
||||
|
||||
## 初始化
|
||||
|
||||
1. 确认 `pouch` 可执行。`.pouch/ack` 不存在则 `pouch init ack --project <project-root>`;已存在则不覆盖、转入「检查」。
|
||||
2. 按 [init-new-project.md](references/init-new-project.md) 完善项目状态。不从 README/CI 猜测并启用交付或知识;初始化 **不** 自动初始化 deployer 或 builder。
|
||||
3. 运行:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/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 .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` 任务。
|
||||
@@ -0,0 +1 @@
|
||||
0.20.0
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "ACK"
|
||||
short_description: "初始化、检查并运行 ACK 开发、验证、测试环境、发版与回归闭环"
|
||||
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:
|
||||
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"
|
||||
@@ -0,0 +1,155 @@
|
||||
version: 1
|
||||
updatedAt: "2026-08-01T10:00:00+08:00"
|
||||
project:
|
||||
name: "notes-api"
|
||||
|
||||
enabled: true
|
||||
defaultProfile: "review"
|
||||
|
||||
intents:
|
||||
testEnvironment:
|
||||
via: deployer
|
||||
env: test
|
||||
release: null
|
||||
|
||||
entrypoints:
|
||||
verify:
|
||||
kind: make
|
||||
target: verify
|
||||
args: []
|
||||
requiredSecrets: []
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 1800
|
||||
build-deb:
|
||||
kind: make
|
||||
target: build-deb
|
||||
args: []
|
||||
requiredSecrets: []
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 3600
|
||||
build-image:
|
||||
kind: make
|
||||
target: build-image
|
||||
args: []
|
||||
requiredSecrets: []
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 3600
|
||||
upload-deb:
|
||||
kind: script
|
||||
path: "scripts/upload-preview-deb.sh"
|
||||
args: []
|
||||
requiredSecrets: ["DEB_TOKEN"]
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 900
|
||||
upload-image:
|
||||
kind: script
|
||||
path: "scripts/publish-preview-image.sh"
|
||||
args: []
|
||||
requiredSecrets: ["REGISTRY_TOKEN"]
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 1800
|
||||
deploy-test:
|
||||
kind: script
|
||||
path: "scripts/deploy-test.sh"
|
||||
args: []
|
||||
requiredSecrets: []
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 900
|
||||
health-test:
|
||||
kind: script
|
||||
path: "scripts/check-test.sh"
|
||||
args: []
|
||||
requiredSecrets: []
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 300
|
||||
rollback-test:
|
||||
kind: script
|
||||
path: "scripts/rollback-test.sh"
|
||||
args: []
|
||||
requiredSecrets: []
|
||||
workingDirectory: "."
|
||||
timeoutSeconds: 900
|
||||
|
||||
artifacts:
|
||||
service-deb:
|
||||
type: deb
|
||||
build: build-deb
|
||||
outputs: ["dist/*.deb"]
|
||||
service-image:
|
||||
type: oci-image
|
||||
build: build-image
|
||||
image: "registry.example.com/notes/service"
|
||||
platforms: ["linux/amd64", "linux/arm64"]
|
||||
|
||||
destinations:
|
||||
preview-apt:
|
||||
type: apt-repository
|
||||
channel: preview
|
||||
endpoint: "https://packages.example.com"
|
||||
repository: "testing"
|
||||
upload: upload-deb
|
||||
preview-registry:
|
||||
type: oci-registry
|
||||
channel: preview
|
||||
registry: "registry.example.com"
|
||||
repository: "notes/service"
|
||||
upload: upload-image
|
||||
|
||||
environments:
|
||||
test-server:
|
||||
type: ssh-host
|
||||
classification: development
|
||||
target: "notes-test"
|
||||
deploy: deploy-test
|
||||
healthCheck: health-test
|
||||
rollback: rollback-test
|
||||
mutex: "notes-test-deploy"
|
||||
|
||||
profiles:
|
||||
review:
|
||||
stopAt: review_ready
|
||||
steps:
|
||||
- id: verify
|
||||
action: verify
|
||||
entrypoint: verify
|
||||
- id: open-pr
|
||||
action: pull-request
|
||||
draft: true
|
||||
remote: origin
|
||||
baseBranch: main
|
||||
- id: build-deb
|
||||
action: build
|
||||
artifact: service-deb
|
||||
- id: publish-deb
|
||||
action: publish
|
||||
artifact: service-deb
|
||||
destination: preview-apt
|
||||
- id: deploy-test
|
||||
action: deploy
|
||||
artifact: service-deb
|
||||
environment: test-server
|
||||
- id: smoke-test
|
||||
action: health-check
|
||||
environment: test-server
|
||||
- id: ready
|
||||
action: mark-ready
|
||||
review-image:
|
||||
stopAt: review_ready
|
||||
steps:
|
||||
- id: verify
|
||||
action: verify
|
||||
entrypoint: verify
|
||||
- id: open-pr
|
||||
action: pull-request
|
||||
draft: true
|
||||
remote: origin
|
||||
baseBranch: main
|
||||
- id: build-image
|
||||
action: build
|
||||
artifact: service-image
|
||||
- id: publish-image
|
||||
action: publish
|
||||
artifact: service-image
|
||||
destination: preview-registry
|
||||
- id: ready
|
||||
action: mark-ready
|
||||
@@ -0,0 +1,47 @@
|
||||
version: 1
|
||||
updatedAt: "2026-07-31T12:00:00+08:00"
|
||||
project:
|
||||
name: "notes-web"
|
||||
verificationRegistry:
|
||||
service-worktree-alignment:
|
||||
path: "tests/ack/check_service_worktree.py"
|
||||
args: ["--require-current-commit"]
|
||||
entries:
|
||||
- id: "K-001"
|
||||
revision: 1
|
||||
kind: "verification"
|
||||
status: "active"
|
||||
title: "复测前确认服务与 worktree/commit 对齐"
|
||||
subject: "service-worktree-alignment"
|
||||
scope:
|
||||
all: false
|
||||
components: ["web"]
|
||||
paths: ["web/**"]
|
||||
dependencies: []
|
||||
versions: []
|
||||
tags: ["long-running-service"]
|
||||
symbols: []
|
||||
errorSignatures: []
|
||||
appliesWhen: "修改常驻 Web 服务或前端构建产物"
|
||||
directive: "复测前重启服务,并核对服务实例对应的 worktree 与 commit"
|
||||
rationale: "历史任务曾因 Test 连接旧进程而产生假通过"
|
||||
verification:
|
||||
ref: "service-worktree-alignment"
|
||||
expected: "服务实例、worktree 和 commit 一致"
|
||||
provenance:
|
||||
taskId: "BUG-002"
|
||||
attemptId: "BUG-002-A2"
|
||||
codeRef: "abc1234"
|
||||
evidenceRef: "tasks.yaml#BUG-002"
|
||||
owner: "web-team"
|
||||
author: "developer"
|
||||
reviewer: "test"
|
||||
approval: null
|
||||
createdAt: "2026-07-30T10:00:00+08:00"
|
||||
lastValidatedAt: "2026-07-31T11:00:00+08:00"
|
||||
reviewAfter: "2099-10-31T11:00:00+08:00"
|
||||
temporary: false
|
||||
removalCondition: null
|
||||
statusReason: null
|
||||
supersedes: []
|
||||
conflictsWith: []
|
||||
@@ -0,0 +1,114 @@
|
||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||
|
||||
> 本项目基于 ack v0.20.0。
|
||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、`delivery.yaml`
|
||||
> 与 `regression.yaml`。
|
||||
|
||||
## 项目概览
|
||||
|
||||
- 项目:`notes-web`
|
||||
- 技术栈:`TypeScript + React (Vite) + Go`
|
||||
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
|
||||
- Base URL:`http://localhost:5173`
|
||||
- 任务板配置:`.pouch/ack/tasks.yaml`
|
||||
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`
|
||||
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||
- 交付契约:`.pouch/ack/delivery.yaml`
|
||||
- 回归目录:`.pouch/ack/regression.yaml`
|
||||
- 覆盖层文件:`.pouch/ack/project.md`
|
||||
|
||||
## 稳定规范(引用,不重复)
|
||||
|
||||
- 开始一个需求(启动手册):`references/kickoff.md`
|
||||
- 角色 / 权限 / 状态机 / 完成定义:`references/roles-and-permissions.md`
|
||||
- 模型档位与升级规则:`references/model-routing.md`
|
||||
- 闭环流程(含手动模式、worktree 对齐):`references/closed-loop.md`
|
||||
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令:`references/orca-adapter.md`
|
||||
- 验证后交付:`references/delivery.md`
|
||||
- 回归目录与运行:`references/regression.md`
|
||||
|
||||
## Worker 路由
|
||||
|
||||
结构化配置默认位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`;存在
|
||||
`.pouch/ack/agents.yaml` 时以后者为准。启动记录位于顶层 `workerReceipts`。
|
||||
本项目默认使用:
|
||||
|
||||
| 角色 | profile ID | 档位 |
|
||||
|------|------------|------|
|
||||
| Developer | `codex-dev-standard` | standard |
|
||||
| Test | `codex-test-standard` | standard |
|
||||
| Developer 升级 | `codex-dev-strong` | strong |
|
||||
|
||||
项目如临时改用 Pi、Cursor 或 Grok,应修改 `agents.yaml` 的结构化 profile、allowlist
|
||||
和 defaults,再运行对应校验。本文件不保存或拼接 worker shell 命令。
|
||||
|
||||
## 路径权限
|
||||
|
||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||
|------|:-----------:|:----:|:---------:|------|
|
||||
| `docs/spec/**` | R/W | Read-only | Read-only | 产品规格(PM 拥有) |
|
||||
| `tests/browser/**` | Read-only | R/W | Read-only | 浏览器回归用例(Test 拥有) |
|
||||
| `.qa-records/**` | Read-only | R/W | Read-only | 复测记录(gitignore) |
|
||||
| `web/**`、`server/**` | Read-only | Read-only | R/W | 应用源码 |
|
||||
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
|
||||
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
|
||||
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
|
||||
| `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 | 项目交付能力,不是执行授权 |
|
||||
|
||||
## 命令
|
||||
|
||||
Developer 白盒验证:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
go test ./...
|
||||
npm run dev # 本地起前端
|
||||
```
|
||||
|
||||
Test 黑盒复测:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5173/api/health
|
||||
curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
|
||||
# 浏览器回归:tests/browser/cases/*.md
|
||||
```
|
||||
|
||||
项目已审查的知识检查入口保存在 `knowledge.yaml.verificationRegistry`,格式为检查
|
||||
ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
|
||||
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
||||
path/args。
|
||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||
`scripts/validate_regression.py` 执行。
|
||||
测试环境由 ACK 内部调用 deployer;发版机器入口以 `delivery.yaml` 为准,本覆盖层
|
||||
不维护第二套发布或部署命令。
|
||||
|
||||
## 硬规则(其余见 references/)
|
||||
|
||||
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
|
||||
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。
|
||||
- 自动 worker 只通过 ACK 的 `scripts/launch_worker.py` 启动;不直接执行自由
|
||||
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
|
||||
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
|
||||
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
|
||||
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
|
||||
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||
`candidate` 不派发,知识库不全量注入。
|
||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
||||
- 交付只在任务 `verified` 后运行;默认 profile 停在 `validation_ready` 或 `review_ready`。stable 发布与
|
||||
production 部署保留显式 approval,配置变更只影响下一次 run。
|
||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
||||
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||
@@ -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=回归笔记"
|
||||
@@ -0,0 +1,284 @@
|
||||
# 填好的手动闭环示例(虚构的 notes-web 项目)。历史轮次没有自动 launcher receipt;
|
||||
# 因此使用 manual 模式。可用 scripts/validate_tasks.py 校验通过。
|
||||
version: 1
|
||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.20.0"
|
||||
project:
|
||||
name: "notes-web"
|
||||
baseUrl: "http://localhost:5173"
|
||||
overlayFile: ".pouch/ack/project.md"
|
||||
knowledgeFile: ".pouch/ack/knowledge.yaml"
|
||||
deliveryFile: ".pouch/ack/delivery.yaml"
|
||||
regressionFile: ".pouch/ack/regression.yaml"
|
||||
orchestration:
|
||||
profileVersion: 1
|
||||
mode: "manual"
|
||||
# allowedWorktrees 已废弃(v0.19 起);worker 默认在 --project-root 工作。
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
standard: ["gpt-5.6-terra"]
|
||||
strong: ["gpt-5.6-sol"]
|
||||
test:
|
||||
standard: ["gpt-5.6-luna"]
|
||||
cursor-agent:
|
||||
developer:
|
||||
standard: ["auto"]
|
||||
test:
|
||||
standard: ["auto"]
|
||||
grok:
|
||||
developer:
|
||||
standard: ["grok-4.5"]
|
||||
strong: ["grok-4.6"]
|
||||
test:
|
||||
standard: ["grok-4.5"]
|
||||
profiles:
|
||||
codex-dev-standard:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
codex-test-standard:
|
||||
role: "test"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
codex-dev-strong:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "strong"
|
||||
model: "gpt-5.6-sol"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
cursor-dev-standard:
|
||||
role: "developer"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
cursor-test-standard:
|
||||
role: "test"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
grok-dev-standard:
|
||||
role: "developer"
|
||||
cli: "grok"
|
||||
tier: "standard"
|
||||
model: "grok-4.5"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
grok-test-standard:
|
||||
role: "test"
|
||||
cli: "grok"
|
||||
tier: "standard"
|
||||
model: "grok-4.5"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
grok-dev-strong:
|
||||
role: "developer"
|
||||
cli: "grok"
|
||||
tier: "strong"
|
||||
model: "grok-4.6"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
defaults:
|
||||
developer: "codex-dev-standard"
|
||||
test: "codex-test-standard"
|
||||
developerUpgraded: "codex-dev-strong"
|
||||
|
||||
workerReceipts: []
|
||||
deliveryRuns: []
|
||||
regressionRuns: []
|
||||
|
||||
summary:
|
||||
verified: ["BUG-002"]
|
||||
open: []
|
||||
failedRetest: []
|
||||
leftovers: ["BUG-003"]
|
||||
|
||||
tasks:
|
||||
- id: "BUG-002"
|
||||
type: "bug"
|
||||
title: "预览变更点击后不显示 diff 行"
|
||||
priority: "P0"
|
||||
status: "verified"
|
||||
assignee: "developer"
|
||||
component: "web/app/fix/page.tsx"
|
||||
specRefs:
|
||||
- "docs/spec/fix-preview.md#preview"
|
||||
testRefs:
|
||||
- "tests/browser/cases/01-preview.md"
|
||||
knowledgeRefs:
|
||||
- "K-001@1"
|
||||
knowledgeApplied:
|
||||
- ref: "K-001@1"
|
||||
result: "applied"
|
||||
evidence: "重启服务并核对 commit 后再交给 Test"
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks:
|
||||
- ref: "K-001@1"
|
||||
result: "passed"
|
||||
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
|
||||
checkedBy: "test-worker-1"
|
||||
checkedAt: "2026-07-06T09:36:00+08:00"
|
||||
# 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。
|
||||
source:
|
||||
kind: "feishu-base"
|
||||
ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
recordId: "recExample"
|
||||
updatedAt: "2026-07-06T09:00:00Z"
|
||||
description: >
|
||||
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
||||
fixLogic: >
|
||||
修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。
|
||||
acceptanceCriteria:
|
||||
- "预览后确认区显示 title、修改前值、修改后值和 coverChanged"
|
||||
- "取消预览不会产生保存请求"
|
||||
- "原先确认区为空的问题不再出现"
|
||||
stepsToReproduce:
|
||||
- "打开 /fix?fileId=1"
|
||||
- "搜索并选择候选项"
|
||||
- "点击“预览变更”"
|
||||
expected: >
|
||||
确认区出现 title、修改前值、修改后值、coverChanged 提示四行。
|
||||
actual: >
|
||||
API 返回 code=0,但页面确认区为空。
|
||||
evidence:
|
||||
browser: "确认区 DOM 为空,无 diff 行"
|
||||
api: "POST /api/fix/preview -> code=0, 含 diff 字段"
|
||||
logs: "无报错"
|
||||
verification:
|
||||
commands:
|
||||
- "npm run build"
|
||||
- "curl -s -X POST localhost:5173/api/fix/preview -d @fixtures/preview.json"
|
||||
browser:
|
||||
page: "/fix?fileId=1"
|
||||
checks:
|
||||
- "预览后出现 4 行 diff:title/before/after/coverChanged"
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds:
|
||||
- round: 1
|
||||
attemptId: "BUG-002-A1"
|
||||
result: failed
|
||||
evidence: "只渲染了 title,缺少 before/after/coverChanged"
|
||||
- round: 2
|
||||
attemptId: "BUG-002-A2"
|
||||
result: passed
|
||||
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
|
||||
environmentIncidents:
|
||||
- id: "BUG-002-ENV-1"
|
||||
attemptId: "BUG-002-A2"
|
||||
role: test
|
||||
phase: service
|
||||
status: resolved
|
||||
summary: "第一次复测连接到旧构建"
|
||||
evidence: "服务进程的 worktree 与当前任务不一致"
|
||||
impact: "该次执行没有形成产品通过或失败结论,不计复验轮次"
|
||||
recoveryAction: "重启当前 worktree 的最新服务后重新复验"
|
||||
userAction: "无需操作;Coordinator 已完成服务对齐"
|
||||
reportedAt: "2026-07-06T09:30:00+08:00"
|
||||
resolvedAt: "2026-07-06T09:32:00+08:00"
|
||||
resolution:
|
||||
fixedBy: "dev-worker-1"
|
||||
verifiedBy: "test-worker-1"
|
||||
verifiedAt: "2026-07-06T09:38:00+08:00"
|
||||
leftoverReason: null
|
||||
|
||||
- id: "BUG-003"
|
||||
type: "bug"
|
||||
title: "并发保存偶发覆盖他人修改"
|
||||
priority: "P1"
|
||||
status: "leftover"
|
||||
assignee: "developer"
|
||||
component: "server/store/notes.go"
|
||||
specRefs:
|
||||
- "docs/spec/concurrency.md"
|
||||
testRefs: []
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates:
|
||||
- kind: "pitfall"
|
||||
title: "乐观锁检查必须覆盖批量导入路径"
|
||||
claim: "只在 API 写入路径检查版本号会漏掉批量导入"
|
||||
scope:
|
||||
components: ["server-store"]
|
||||
paths: ["server/store/**"]
|
||||
tags: ["concurrency"]
|
||||
appliesWhen: "修改 note 的并发写入或批量导入逻辑"
|
||||
directive: "同时验证 API 与批量导入的版本冲突处理"
|
||||
rationale: "第二轮修复只覆盖 API,批量导入仍可静默覆盖"
|
||||
evidenceRefs:
|
||||
- "tasks.yaml#BUG-003-round-2"
|
||||
proposedBy: "test-worker-1"
|
||||
proposedAt: "2026-07-06T09:32:00+08:00"
|
||||
knowledgeChecks: []
|
||||
description: >
|
||||
两个会话同时保存同一条 note 时,后写覆盖先写,无冲突提示。
|
||||
stepsToReproduce:
|
||||
- "会话 A、B 同时打开 note 42"
|
||||
- "A 保存,B 保存"
|
||||
expected: >
|
||||
B 保存时应检测到版本变化并提示冲突,而非静默覆盖。
|
||||
actual: >
|
||||
B 直接覆盖 A 的修改,无提示。
|
||||
evidence:
|
||||
api: "两次 PUT /api/notes/42 均返回 200,无版本校验"
|
||||
verification:
|
||||
commands:
|
||||
- "go test ./server/store/..."
|
||||
browser:
|
||||
page: "/notes/42"
|
||||
checks:
|
||||
- "并发保存时后写方收到冲突提示"
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds:
|
||||
- round: 1
|
||||
attemptId: "BUG-003-A1"
|
||||
result: failed
|
||||
evidence: "加了版本号但未在写入路径校验"
|
||||
- round: 2
|
||||
attemptId: "BUG-003-A2"
|
||||
result: failed
|
||||
evidence: "校验只覆盖 API,未覆盖批量导入路径"
|
||||
- round: 3
|
||||
attemptId: "BUG-003-A3"
|
||||
result: failed
|
||||
evidence: "乐观锁与前端重试逻辑冲突,需重新设计"
|
||||
resolution:
|
||||
fixedBy: null
|
||||
verifiedBy: null
|
||||
verifiedAt: null
|
||||
leftoverReason: "failed after 3 supervised developer rounds; 需重新设计并发模型"
|
||||
@@ -0,0 +1,130 @@
|
||||
# ACK 接入清单
|
||||
|
||||
## 安装与初始化
|
||||
|
||||
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
||||
- [ ] 已运行 `pouch init ack --project <project-root>`。
|
||||
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
|
||||
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
|
||||
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
||||
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
||||
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
|
||||
- [ ] 若存在 `.pouch/ack/agents.yaml`,已运行 `validate_agents.py`;它优先于任务板
|
||||
`project.orchestration`,且只包含结构化 profile,不包含自由 command/argv/env。
|
||||
|
||||
## 项目覆盖层
|
||||
|
||||
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
|
||||
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
||||
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
||||
`.pouch/ack/knowledge.yaml`。
|
||||
- [ ] 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,顶层有
|
||||
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
||||
- [ ] 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,顶层有
|
||||
`regressionRuns: []`;旧项目未采用回归能力时可无这两项。
|
||||
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||
allowlist,默认 profile 与角色/档位一致。
|
||||
- [ ] `agents.yaml`(若存在)使用受支持的 profileVersion,Pi 默认模型使用
|
||||
`cli: pi` + `model: null`,配置 hash 会绑定启动 plan/fingerprint/receipt。
|
||||
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
|
||||
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
|
||||
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
||||
`--project-root/.pouch/ack/` 推导,worker 路径由 `--worktree` 显式指定。
|
||||
|
||||
## 路径权限
|
||||
|
||||
- [ ] Coordinator 可写规格和任务板。
|
||||
- [ ] Test 可写集成测试与复测记录,但不能改源码。
|
||||
- [ ] Developer 可写源码与单元测试,但不能改规格或黑盒验收。
|
||||
- [ ] 私有配置只读且不提交。
|
||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||
- [ ] `regression.yaml` 只有 Coordinator 写;Test 只通过 `regressionCandidates` 提名。
|
||||
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||
|
||||
## 任务板
|
||||
|
||||
- [ ] 已替换项目名、仓库路径、Base URL、worktree 和覆盖层路径。
|
||||
- [ ] 没有真实任务时使用 `tasks: []`。
|
||||
- [ ] 真实任务的验收是可观测信号。
|
||||
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
|
||||
|
||||
## 可选交付
|
||||
|
||||
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
|
||||
- [ ] 测试环境绑定 deployer(`intents.testEnvironment.via: deployer`),发版写在
|
||||
同一份 `delivery.yaml` 的 `intents.release`。没有第二份操作文档。未说明的
|
||||
intent 保持 `null`。旧 profile ID 字符串已迁移。
|
||||
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
|
||||
shell、自由 command、凭据值或环境变量值。
|
||||
- [ ] artifact、destination、environment 和 profile 引用均通过
|
||||
`validate_delivery.py --tasks ... --project-root ...` 校验。
|
||||
- [ ] 默认 profile 只使用 preview/staging 与非 production 环境,停止在
|
||||
`review_ready`。
|
||||
- [ ] stable 发布和 production 部署之前存在 approval 步骤;production 环境有
|
||||
rollback 入口。
|
||||
- [ ] 本次 ACK 计划明确 profile、task IDs、目标、停止点和审批步骤;配置本身没有
|
||||
被当作提交、推送、发布或部署授权。
|
||||
- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`;
|
||||
失败不会把已验证任务回退为失败。
|
||||
|
||||
## 项目知识
|
||||
|
||||
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
||||
不虚构 active 条目。
|
||||
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_knowledge.py` 并通过;同时校验任务引用。
|
||||
- [ ] Coordinator 只从 `active` 条目按 scope 推荐知识,并显式确认固定 revision 的
|
||||
`knowledgeRefs`。
|
||||
- [ ] `candidate` 留在任务证据中,不派发;`stale`、`superseded`、`archived`
|
||||
不默认选择。
|
||||
- [ ] 每轮只内联命中的少量知识,不全量注入 `knowledge.yaml`。
|
||||
- [ ] Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||
`knowledgeChecks`。
|
||||
- [ ] `verification.ref` 只引用 `verificationRegistry` 中已审查的仓库内相对 path
|
||||
和结构化 args;执行时只把 registry ID 交给 `run_verification.py`,不从
|
||||
知识正文或选择器输出拼接 shell。
|
||||
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
|
||||
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
|
||||
## 回归
|
||||
|
||||
- [ ] 新项目 `regression.yaml` 为 `cases: []`,没有虚构用例。
|
||||
- [ ] 已运行 `validate_regression.py --tasks ...` 并通过。
|
||||
- [ ] 任务 `verified` 后给出新增/更新/退役/无回归四选一,确认后才写入目录。
|
||||
- [ ] 运行回归先走 deployer 测试环境,再派独立 Test;失败不自动开修。
|
||||
|
||||
## 编排
|
||||
|
||||
- [ ] 已选择 Orca 或手动模式。
|
||||
- [ ] 自动创建只调用 `launch_worker.py plan|launch`,没有直接拼接
|
||||
`orca terminal create --command` 或自由 worker 命令。
|
||||
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
|
||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- [ ] 只考虑同轮、空闲、角色/profile/worktree/启动身份匹配的复用候选;历史消息必须
|
||||
由受信后端清理并返回新会话身份。任一条件不符或无法确认清理成功时启动 fresh
|
||||
worker;没有把历史 receipt checksum 或 Orca live metadata 当作复用授权。
|
||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
|
||||
- [ ] Test 使用的服务来自正确 worktree。
|
||||
|
||||
## 闭环
|
||||
|
||||
- [ ] `worker_done` 不直接视为完成。
|
||||
- [ ] Test 独立复测后由 Coordinator 读取证据终检。
|
||||
- [ ] 每轮写入 `dispatch.rounds`。
|
||||
- [ ] 三轮失败后标记 `leftover` 并填写原因。
|
||||
- [ ] `leftover` 不阻塞其它任务。
|
||||
- [ ] Coordinator 标记整轮完成后,已关闭所有只属于 `verified` 任务的 worker;仍关联
|
||||
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
|
||||
|
||||
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
||||
`.pouch/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
||||
或 `scripts/`,并更新 `VERSION`。项目特有、跨任务复用且已经验证的经验才写入
|
||||
`.pouch/ack/knowledge.yaml`。
|
||||
@@ -0,0 +1,342 @@
|
||||
# 闭环流程(稳定核心,编排无关)
|
||||
|
||||
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
|
||||
|
||||
原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层
|
||||
materialize 成任务视图);已经验证且可跨
|
||||
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
|
||||
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
|
||||
Developer 只实现。
|
||||
|
||||
---
|
||||
|
||||
## 编排抽象
|
||||
|
||||
无论用什么工具,闭环都由这几个能力组成。注意 `dispatch` 有两个目标(Developer 与 Test):
|
||||
|
||||
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|
||||
|----------|------|-----------|----------|
|
||||
| `prepare(task)` | Coordinator 把任务和验收信号写进 `task-records/`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
|
||||
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
|
||||
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
|
||||
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
|
||||
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
|
||||
| `writeback(task, result)` | Coordinator 把任务结果追加到 `task-records/`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
|
||||
|
||||
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
|
||||
|
||||
---
|
||||
|
||||
## 标准闭环
|
||||
|
||||
```text
|
||||
Coordinator 发现或读取 open 任务
|
||||
-> prepare:写/补全 task-records/ 中的任务验收信号
|
||||
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
|
||||
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
|
||||
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||
-> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration
|
||||
-> 运行 launcher plan 并审阅 launchFingerprint
|
||||
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
|
||||
-> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md)
|
||||
-> dispatch 给 Developer(--to <worker handle>)
|
||||
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
||||
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
||||
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||
-> writeback fixed_by_dev
|
||||
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先按 deployer
|
||||
绑定拉起 `.pouch/deployer/<env>`,再派 Test;Test 不发明编译或启动命令
|
||||
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
||||
-> dispatch 给 Test(retesting)
|
||||
-> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
||||
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
||||
直到 Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
||||
-> Test 通过:gate(Coordinator 读证据对齐意图)
|
||||
-> 通过 gate:writeback verified
|
||||
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
|
||||
-> Test 因环境无法完成:记录 environmentIncidents,不计轮次;有界恢复并明确告知用户下一步
|
||||
-> Test 观察到产品失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮有效产品复验)
|
||||
-> 累计三轮有效产品失败:writeback leftover,继续下一个任务
|
||||
```
|
||||
|
||||
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
||||
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
|
||||
`orca-adapter.md`。历史 receipt、标题、preview、分支名、worker 自报和无密钥 receipt
|
||||
checksum 都不能代替可信启动 attestation 或历史消息清理证明。只复用同一轮内明确空闲、
|
||||
身份完全匹配且得到新会话身份的 worker;清理能力缺失或结果不确定时必须 fresh launch。
|
||||
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
|
||||
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和
|
||||
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
|
||||
轮次允许暂时缺省;但要把该轮证据晋升为知识前,必须按确定性格式补齐。晋升时,
|
||||
`provenance.taskId` 和 `provenance.attemptId` 必须能精确回到该任务及轮次。
|
||||
|
||||
### 知识选择与晋升
|
||||
|
||||
`prepare(task)` 时可以使用 Skill 自带的 `scripts/select_knowledge.py` 按
|
||||
component、path、dependency、version 和 tag 确定性推荐条目。选择器只返回
|
||||
`active`,并受条目数限制;`stale`、`superseded` 和 `archived` 不默认派发。自动
|
||||
匹配只是推荐。条目中每个非空 scope 维度都必须被任务上下文命中,因此调用时应
|
||||
提供当前任务已知的全部 component、path、dependency、version、tag、symbol 和错误
|
||||
特征。Coordinator 确认并写入当前任务的显式 `knowledgeRefs` 后才成为本轮权威
|
||||
上下文。
|
||||
|
||||
`scope.paths` 使用 POSIX 路径 glob:`*` 不跨目录分隔符,`**` 才能跨目录。多个
|
||||
pattern 是同一维度内的 OR,候选越多表示范围越宽;超过选择预算时,选择器优先保留
|
||||
命中维度更多、OR 候选更少、字面约束更多且通配符更少的条目。
|
||||
|
||||
派发时只内联本轮引用的少量知识,不要求 worker 全量读取 `knowledge.yaml`。这也
|
||||
避免隔离 worktree 读不到 Coordinator 工作树中尚未提交的项目状态。
|
||||
|
||||
进行中的任务只能引用 `active` 条目。已经进入 `verified` 或 `leftover` 的终态任务
|
||||
可以继续保留固定 revision 的历史引用,即使对应知识后来变成 `stale`、
|
||||
`superseded` 或 `archived`;这些旧条目不会再次被选择器派发。
|
||||
|
||||
Developer 或 Test 发现经验时只能通过 `knowledgeCandidates` 提名,并带上当前任务
|
||||
的独立观测、scope、触发条件和证据。candidate 留在任务证据中,不进入知识选择器,
|
||||
也不能被下一轮当作 active 指令。只有 Test 独立验证、Coordinator gate 通过后,
|
||||
Coordinator 才能写入或更新 `knowledge.yaml`;全项目强制或权限类规则还需
|
||||
User / Decision Owner 确认。
|
||||
|
||||
Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择需要纠正。该引用
|
||||
仍在 `knowledgeRefs` 中就不能把任务标为 `verified`;Coordinator 应记录原因、移除
|
||||
误选引用并重新校验,或继续补充验证。
|
||||
|
||||
知识中的 `verification.ref` 只能引用
|
||||
`knowledge.yaml.verificationRegistry` 里已审查的仓库内相对 path 和结构化 args,
|
||||
不能从知识正文拼接或执行自由 shell。Developer/Test 只把 `verification.ref` 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py`;该入口从项目根目录逐段安全打开检查
|
||||
文件,拒绝 symlink 路径,并从匿名稳定快照执行。关键约束应下沉为测试、lint、CI
|
||||
或正式规范。检查进程从固定的项目根 fd cwd 运行;检查脚本应使用 cwd 或
|
||||
`ACK_PROJECT_ROOT` 定位资源。后者是 fd 路径,原始展示路径只在
|
||||
`ACK_PROJECT_ROOT_DISPLAY` 中用于日志;不能依赖 `$0` / `__file__` 的目录。
|
||||
|
||||
---
|
||||
|
||||
## 子任务放哪:新 worktree 还是当前 worktree
|
||||
|
||||
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
||||
|
||||
**方式 1:当前 worktree 复用安全重置的空闲子 agent,或起 fresh 子 agent(终端)**
|
||||
|
||||
```bash
|
||||
# 当前 Orca 不能可信清理历史消息,因此先审阅 fresh launch plan:
|
||||
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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-current-worktree> --slot <1..99>
|
||||
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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-current-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
|
||||
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
|
||||
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
|
||||
- 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把
|
||||
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
|
||||
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
|
||||
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
|
||||
一个裸 handle,也不能引用旧任务或旧轮次 receipt。
|
||||
|
||||
**方式 2:新建隔离 worktree,再在其中起子 agent**
|
||||
|
||||
创建成功后,确认新 worktree 与权威项目根属于同一 Git 仓库(launcher 按
|
||||
`git worktree list` 注册表 + 同 common-dir 约束校验,v0.19 起不再需要
|
||||
`allowedWorktrees` 白名单登记)。
|
||||
|
||||
```bash
|
||||
orca worktree create --name <feature> --base-branch <base> --json
|
||||
# 对返回的绝对路径调用 ACK launcher
|
||||
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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-new-worktree> --slot <1..99>
|
||||
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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-new-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
- 适合:并行多个互不依赖的子任务、大/高风险/实验性改动、要保持基线分支干净(如 prod 不动、feature 走独立分支)、要独立 build 或跑独立服务实例、best-of-N 尝试。
|
||||
- 优点:隔离 + 并行 + 易回滚(删 worktree 即可)。
|
||||
- 成本:各自 build/依赖、服务要用不同端口、必须处理下节的 worktree/服务对齐。
|
||||
|
||||
**决策速查:**
|
||||
|
||||
| 情况 | 选择 |
|
||||
|------|------|
|
||||
| 一次一个任务、串行修复 | 当前 worktree 子 agent |
|
||||
| 多个任务并行、互不依赖 | 每个任务一个新 worktree |
|
||||
| 高风险 / 实验 / 可能整体丢弃 | 新 worktree |
|
||||
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
||||
| 小改动、追求快 | 当前 worktree |
|
||||
|
||||
**项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、
|
||||
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
|
||||
副本(通常在基线/协调所在 worktree)。任务执行状态追加到
|
||||
`.pouch/ack/task-records/<recordId>.yaml`;Coordinator 单写任务记录、知识、回归目录、
|
||||
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
||||
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||
|
||||
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
|
||||
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
|
||||
的 sandbox 绕过请求必须 fail closed。
|
||||
|
||||
---
|
||||
|
||||
## 手动模式(无 Orca)
|
||||
|
||||
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
|
||||
|
||||
1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。
|
||||
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
|
||||
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
|
||||
手动发给 Developer(另一个会话/终端/人)。
|
||||
3. Developer 完成后按 worker_done 模板回报。
|
||||
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
|
||||
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
|
||||
6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。
|
||||
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
|
||||
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
|
||||
不派 Developer、不消耗产品轮次。
|
||||
|
||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||
写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
|
||||
|
||||
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
||||
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
|
||||
创建 fresh worker。
|
||||
|
||||
---
|
||||
|
||||
## Test 复测(编排无关)
|
||||
|
||||
即使 Developer 的 worker_done 写了"全部通过",Test 仍必须独立复测:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
<test_commands>
|
||||
curl -s <base_url>/health-or-summary
|
||||
```
|
||||
|
||||
浏览器复测建议记录:
|
||||
|
||||
```text
|
||||
BASE_URL:
|
||||
page:
|
||||
steps:
|
||||
expected:
|
||||
actual:
|
||||
snapshot evidence:
|
||||
```
|
||||
|
||||
Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。
|
||||
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
|
||||
`not_applicable` 及证据。
|
||||
|
||||
---
|
||||
|
||||
## Coordinator 终检(gate)
|
||||
|
||||
Test 报通过后,Coordinator 不重测,而是做一次读证据的终检:
|
||||
|
||||
- Test 证据是否覆盖了任务的**每一条**验收信号(见 `optimization-method.md` §1)。
|
||||
- 当前任务显式 `knowledgeRefs` 的必需 `knowledgeChecks` 是否全部覆盖,检查引用
|
||||
是否能解析到项目已审查的入口。
|
||||
- 结果是否符合任务的**原始意图**,而不只是通过了字面文案。
|
||||
- 运行环境是否对齐(见下方「服务与 worktree 对齐」)。
|
||||
|
||||
终检不通过(例如意图漂移、验收信号写漏)时,回写 `failed_retest` 并把意图差异带给下一轮 Developer,必要时先补全验收信号。
|
||||
|
||||
---
|
||||
|
||||
## 服务与 worktree 对齐(防假通过/假失败)
|
||||
|
||||
Test 复测前记录运行环境:
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git rev-parse --short HEAD
|
||||
```
|
||||
|
||||
```text
|
||||
serverPid:
|
||||
serverCommand:
|
||||
BASE_URL:
|
||||
frontendDir:
|
||||
worktreePath:
|
||||
```
|
||||
|
||||
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以 deployer 绑定为准,不另写一套启动命令。
|
||||
|
||||
---
|
||||
|
||||
## 结果回写
|
||||
|
||||
Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见
|
||||
`templates/tasks.schema.json`):
|
||||
|
||||
```yaml
|
||||
# 通过(Test 复测 + Coordinator 终检)
|
||||
status: verified
|
||||
resolution:
|
||||
verifiedAt: "<timestamp>"
|
||||
verifiedBy: "test"
|
||||
evidence:
|
||||
verification: "<commands passed>"
|
||||
browser: "<snapshot or API evidence>"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 失败但未满三轮
|
||||
status: failed_retest
|
||||
dispatch:
|
||||
rounds:
|
||||
- round: 1
|
||||
result: failed
|
||||
evidence: "<latest evidence from Test or gate>"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 累计三轮失败
|
||||
status: leftover
|
||||
resolution:
|
||||
leftoverReason: "failed after 3 supervised developer rounds"
|
||||
evidence:
|
||||
final: "<latest failing evidence>"
|
||||
```
|
||||
|
||||
环境失败不进入上面的 `rounds`,单独记录:
|
||||
|
||||
```yaml
|
||||
status: fixed_by_dev
|
||||
dispatch:
|
||||
environmentIncidents:
|
||||
- id: "BUG-001-ENV-1"
|
||||
role: test
|
||||
phase: service
|
||||
status: open
|
||||
summary: "无法访问待测服务"
|
||||
evidence: "preflight connection refused"
|
||||
impact: "未执行产品验收信号"
|
||||
recoveryAction: "启动正确 worktree 的最新服务后重试"
|
||||
userAction: "无需操作;Coordinator 将重启服务"
|
||||
reportedAt: "<timestamp>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Worker 收尾
|
||||
|
||||
Coordinator 写入本轮全部最终状态并完成已确认的 delivery 停止点后,再回收 worker。
|
||||
按 terminal handle 聚合所有 receipt 引用:只关闭全部关联任务均为 `verified`、且没有
|
||||
未解决环境事件的 handle。任何关联任务仍为 `open`、`dispatched`、`fixed_by_dev`、
|
||||
`retesting`、`blocked`、`failed_retest`、`leftover` 或状态不明时都保留该终端,不设置
|
||||
TTL。关闭前后按 `orca-adapter.md` 核对 runtime/handle/incarnation 和关闭回执;关闭
|
||||
不确定时报告并保留审计记录。
|
||||
@@ -0,0 +1,164 @@
|
||||
# ACK 交付阶段
|
||||
|
||||
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
|
||||
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
||||
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
|
||||
任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。
|
||||
|
||||
## 1. 配置与授权不是一回事
|
||||
|
||||
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。启用交付
|
||||
时,ACK 在 kickoff 的既有用户确认点默认展示 `defaultProfile`、remote、产物目标、环境
|
||||
和停止点,不得静默省略;用户
|
||||
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
|
||||
channel、environment 或 source revision 漂移时重新确认。
|
||||
|
||||
`approval` 步骤始终是运行时硬门。`stable` 发布和 `production` 部署不能由 kickoff
|
||||
的一般确认代替,必须在该步骤取得本次明确授权。配置、历史 approval 或项目文档不能
|
||||
替用户授权合并 PR、创建正式 tag、覆盖版本、删除分支或生产发布。
|
||||
|
||||
## 2. 配置快照与变更生效
|
||||
|
||||
普通任务在 kickoff 时从可信 base commit 读取交付契约并记录 `configRevision`。本次
|
||||
分支对 `delivery.yaml`、引用的部署入口、CI 或 Agent 指令文件的修改不能扩大当前运行
|
||||
权限;这些改动经审核合并后从下一次任务生效。
|
||||
|
||||
用户明确要求维护交付配置时:
|
||||
|
||||
1. 读取现有配置、项目构建入口、CI、打包和部署事实。
|
||||
2. 用自然语言总结将新增、删除或改变的 artifact、destination、environment、profile
|
||||
和权限边界。
|
||||
3. 只做最小配置修改,不把项目脚本复制进 ACK。
|
||||
4. 运行 `validate_delivery.py`;可安全执行的本地入口使用 dry-run 或无凭据环境检查。
|
||||
5. 把配置或入口变更作为待审核变更交付。本轮不使用新配置执行 publish/deploy。
|
||||
|
||||
普通功能任务中若发现配置漂移,记录 `contract_drift` 并停止受影响的交付步骤,不为了
|
||||
通过流程而静默修改配置或跳过步骤。
|
||||
|
||||
## 3. 交付契约结构
|
||||
|
||||
- `entrypoints`:项目已有的 Make、Just、Task、Dagger 或仓库内可执行脚本入口。
|
||||
- `artifacts`:`deb`、`oci-image` 或 `file`,引用一个 build entrypoint。
|
||||
- `destinations`:APT、OCI registry 或 CI artifact;`channel` 区分 preview、staging、
|
||||
stable。
|
||||
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
|
||||
deploy 和 health check,production 还必须声明 rollback。
|
||||
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `validation_ready` 或
|
||||
`review_ready`,不能发布 stable 或部署 production。`validation_ready` profile 必须
|
||||
至少部署一个 development/staging 环境,并对每个部署目标执行健康检查。
|
||||
|
||||
配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径
|
||||
或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部
|
||||
凭据系统或执行环境注入。复杂逻辑放在受版本控制的项目入口中。entrypoint 使用 argv
|
||||
语义执行,不能拼成 `sh -c` 字符串。
|
||||
|
||||
## 3.1 测试环境与发版写在同一份契约
|
||||
|
||||
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一文档。不要另写操作手册,
|
||||
也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或
|
||||
「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`。
|
||||
|
||||
```yaml
|
||||
intents:
|
||||
testEnvironment:
|
||||
via: deployer
|
||||
env: test # 项目 .pouch/deployer/test;尚未说明时为 null
|
||||
release: null # profile ID,或 null
|
||||
```
|
||||
|
||||
- `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」
|
||||
「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.pouch/deployer/<env>`
|
||||
按服务执行 sync + up 和健康检查。环境目录不存在或 deployer `check.py` 未通过
|
||||
时,加载 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。用户说「发布一个版本」时执行它。
|
||||
口头「发版」不能代替 stable/production 的 `approval` 步骤。
|
||||
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
|
||||
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
|
||||
- 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填
|
||||
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
|
||||
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
|
||||
`verified` 任务。
|
||||
|
||||
## 4. 运行前检查
|
||||
|
||||
1. 从 `tasks.yaml.project.deliveryFile` 解析文件;未引用或 `enabled=false` 时保持旧 ACK
|
||||
行为,收尾停在 `verified`。
|
||||
2. 运行:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_delivery.py \
|
||||
.pouch/ack/delivery.yaml --tasks .pouch/ack/tasks.yaml \
|
||||
--project-root <project-root>
|
||||
```
|
||||
|
||||
3. 确认选中 profile 是 kickoff 已确认的 profile,所有 task 已是 `verified`,工作区与
|
||||
服务对应正确 source revision。
|
||||
4. 检查 referenced entrypoint、delivery config、CI 和凭据边界是否在本次变更中被
|
||||
修改;被修改时禁止用它们执行带远端写权限或 secret 的步骤。
|
||||
5. 检查本次步骤引用的 `requiredSecrets` 是否由外部环境提供,只报告名称和是否存在,
|
||||
不读取、打印或持久化值。缺失时在第一次相关写操作前标记 blocked。
|
||||
6. 将已确认工作树固化为本地 source revision,再创建 `deliveryRuns` 的 `planned`
|
||||
记录,绑定 task IDs、profile、source revision 和 config revision;推送仍等到对应
|
||||
`pull-request` 步骤。
|
||||
|
||||
## 5. 步骤语义
|
||||
|
||||
按 profile 中的顺序执行,不自行插入或省略步骤:
|
||||
|
||||
- `verify`:运行指定 entrypoint,失败即停止。
|
||||
- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft
|
||||
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
|
||||
明确要求时才调用独立的 `releaser`。没有对应能力或认证时标记 blocked,不用
|
||||
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
|
||||
- `build`:对当前 source revision 调用已安装的 `builder` `obtain.sh --sha <revision>`,
|
||||
不要假定本机 `make`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线;没有流水线或 job 失败
|
||||
则本步 `failed`。DEB 必须记录包名、版本、架构和 SHA-256;OCI image 必须记录完整
|
||||
引用、platform 和 digest。产物必须绑定当前 source revision,不能在目标机器重新拉
|
||||
源码构建。
|
||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
|
||||
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
|
||||
时才加载,否则必须走契约中已审查的 upload entrypoint。`BUILD_VENUE=ci` 且 obtain
|
||||
已带回 registry digest 时,本步只复核,不再本机 push。项目入口只接受
|
||||
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
||||
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
||||
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
||||
mutex 后执行,不能并发部署同一目标。
|
||||
- `health-check`:在对应 deploy 成功后运行环境 health check,记录可观测证据。失败时
|
||||
按项目入口执行 rollback;rollback 未证明成功时不得声称恢复。
|
||||
- `approval`:停止并展示准确 artifact、destination/environment、source revision 和
|
||||
回滚计划,等待用户本次确认。
|
||||
- `mark-ready`:所有前序步骤成功后将 Draft PR/MR 标为 ready,并写入最终证据。
|
||||
|
||||
## 6. 状态与恢复
|
||||
|
||||
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
|
||||
`blocked`、`failed`、`validation_ready`、`review_ready`、`released` 或 `skipped`。
|
||||
`validation_ready` 表示已把不可变产物部署到开发/测试环境并通过健康检查,可以交给
|
||||
用户手工验证,不要求存在 PR/MR。部署或 Forge 暂时失败不把
|
||||
任务改回 `failed_retest`。
|
||||
|
||||
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
|
||||
ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成同一运行的资源。
|
||||
|
||||
若恢复过程中修改了任何 tracked file,原 source revision 和交付证据失效:回到 ACK
|
||||
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
|
||||
时,才可从失败步骤继续。
|
||||
|
||||
`validation_ready` 至少记录:source/config revision、所有产物引用与 digest、部署环境、
|
||||
访问地址和用户下一步,以及健康检查证据。`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
||||
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
|
||||
“完成”掩盖其中某一阶段失败或待审批。
|
||||
|
||||
## 7. 与低层 Skill 的边界
|
||||
|
||||
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
||||
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`releaser` 和
|
||||
`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载
|
||||
deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失
|
||||
builder 或 releaser 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用
|
||||
时把对应步骤标为 `blocked`。低层 skill 自身要求显式调用时,ACK 不能绕过它的
|
||||
触发与授权边界;ACK 内部调用 deployer 布测试环境是该 skill 的合法调用路径。
|
||||
@@ -0,0 +1,148 @@
|
||||
# 飞书 Base Bug 整理与审核
|
||||
|
||||
飞书 Base 是 Bug 在审核通过前的唯一协作区。用户只需填写 `标题`,可选填写
|
||||
`详细描述`,并把截图、录屏或日志放在 `附件`。Coordinator 结合来源事实、附件和项目
|
||||
现状整理 `问题说明`、`期望效果`、`验收标准`,但不在收件箱写修复逻辑。
|
||||
|
||||
审核通过前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker、派发 Developer/Test
|
||||
或修改应用代码。字段已填满、记录进入某个 view、用户暂时没有回复,都不等于审核通过。
|
||||
|
||||
## 项目配置与 Base 结构
|
||||
|
||||
`.pouch/ack/tasks.yaml` 的 `project.bugIntake` 必须声明 `provider: feishu-base`、
|
||||
`workflow: clarified-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、`viewId` 和字段映射:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
title: 标题
|
||||
details: 详细描述
|
||||
problemStatement: 问题说明
|
||||
expectedOutcome: 期望效果
|
||||
acceptance: 验收标准
|
||||
intakeStatus: 处理状态
|
||||
ackTaskId: ACK任务ID
|
||||
attachments: 附件
|
||||
updatedAt: 更新时间
|
||||
```
|
||||
|
||||
人维护 `标题`、`详细描述`、`附件`;ACK 维护 `问题说明`、`期望效果`、`验收标准`;系统字段
|
||||
是 `处理状态`、`ACK任务ID`、`更新时间`。状态按
|
||||
`待整理 → 需补充/待审核 → 已确认 → 已导入` 流转。优先级在批准后做任务规划时确定,
|
||||
不属于收件箱审核内容。原 `read-only-v1` 和 `reviewed-writeback-v1` 继续兼容旧项目。
|
||||
|
||||
结构变更必须先执行只读计划,再携带计划指纹应用;适配器只新增目标字段、迁移来源信息并
|
||||
调整当前 view 的可见字段,不删除旧列:
|
||||
|
||||
```bash
|
||||
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 .pouch/ack/tasks.yaml \
|
||||
--expected-schema-fingerprint <schemaFingerprint>
|
||||
```
|
||||
|
||||
迁移时,旧 `期望结果` 会以 `用户原始期望:…` 合并进 `详细描述`,旧字段继续保留但从
|
||||
当前 view 隐藏;空状态初始化为 `待整理`。不要保存 App Secret、access token 或 profile
|
||||
凭据。
|
||||
|
||||
## Profile 和安全边界
|
||||
|
||||
在账号级可信目录安装官方 CLI:
|
||||
|
||||
```bash
|
||||
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
||||
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
|
||||
--name project-feishu --app-id "$FEISHU_APP_ID" \
|
||||
--app-secret-stdin --brand feishu
|
||||
```
|
||||
|
||||
使用官方 `lark-cli`,项目只保存 profile 名。不要依赖 active profile;所有读取、附件下载、
|
||||
写回和结构迁移都显式传配置 profile。profile 需要 Base 读写和附件下载权限。
|
||||
所需 scope 至少包括 `base:record:read`、`base:record:write` 和
|
||||
`docs:document.media:download`。不要把 `lark-cli auth check` 当作 app/bot scope 的证明,
|
||||
它只检查当前用户的 stored user token。
|
||||
|
||||
适配器只搜索账号和系统的可信工具目录,子进程只收到实际账号 HOME、可信 PATH 和基础
|
||||
locale;调用者环境中的凭据和运行时注入变量不会传入。不得绕过适配器直接操作 Base。
|
||||
每条记录最多 10 个附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,
|
||||
整批下载最多 5 分钟。
|
||||
|
||||
## 读取与整理
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check .pouch/ack/tasks.yaml
|
||||
tmpdir=$(mktemp -d)
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch .pouch/ack/tasks.yaml \
|
||||
--output-dir "$tmpdir"
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan .pouch/ack/tasks.yaml \
|
||||
--output-dir "$tmpdir"
|
||||
```
|
||||
|
||||
`fetch` 输出标准化 JSON,并为每条记录计算覆盖来源事实、附件身份、整理字段和状态的
|
||||
`draftRevision`。标题和更新时间是必需来源事实;详细描述和附件可为空。问题说明、期望
|
||||
效果、验收标准缺失时返回 `enrichmentRequired`。`plan` 只给出查重和漂移预览,审核前
|
||||
不得据此创建任务。
|
||||
|
||||
Coordinator 对每条 Bug:
|
||||
|
||||
1. 读取标题、详细描述、附件及相关产品/代码上下文;证据不足时明确假设,不伪装成用户原文。
|
||||
2. 写 `problemStatement`:说清现象、影响范围和边界,不包含修复方案。
|
||||
3. 写 `expectedOutcome`:说明正确情况下用户能观察到的行为,不包含实现方式。
|
||||
4. 写 `acceptance`:形成可独立复测的、可观察的标准,不扩张用户未表达的产品范围。
|
||||
5. 将草案保存为不超过 64 KiB 的 JSON,且只含上述三个键:
|
||||
|
||||
```json
|
||||
{
|
||||
"problemStatement": "非空字符串",
|
||||
"expectedOutcome": "非空字符串",
|
||||
"acceptance": ["非空验收项"]
|
||||
}
|
||||
```
|
||||
|
||||
使用当前 `sourceRef` 和 `draftRevision` 写回:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
|
||||
.pouch/ack/tasks.yaml --record-id <record-id> \
|
||||
--expected-source-ref <sourceRef> \
|
||||
--expected-draft-revision <draftRevision> --input <draft.json>
|
||||
```
|
||||
|
||||
适配器在写前检查记录仍位于配置 view 且 revision 未漂移,只覆盖问题说明、期望效果、
|
||||
验收标准和处理状态(设为 `待审核`),随后回读并返回新 revision。把新 revision 连同整理
|
||||
结果交给用户审核。用户反馈后重新读取、修订和写回;不要在聊天或本地维护分叉版本。
|
||||
|
||||
## 审核门禁与导入
|
||||
|
||||
只有用户针对当前 revision 明确表示审核通过,并由用户本人在飞书把处理状态改为
|
||||
`已确认`,才可继续导入。Coordinator 使用的适配器不提供把草案自行标成已确认的命令;
|
||||
状态和任务 ID 不参与内容 revision,因此用户确认状态不会改变已批准内容的 revision。
|
||||
|
||||
重新读取并核对状态和 revision 后执行:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
|
||||
.pouch/ack/tasks.yaml --record-id <record-id> \
|
||||
--expected-source-ref <approved-sourceRef> \
|
||||
--expected-draft-revision <approvedDraftRevision>
|
||||
```
|
||||
|
||||
任一来源事实、附件或整理字段变化都会使旧批准失效。导出的 `taskDraft` 把问题说明映射为
|
||||
`description`、期望效果映射为 `expected`、验收标准映射为 `acceptanceCriteria`;不包含
|
||||
修复逻辑、复现步骤或优先级。Coordinator 在后续任务规划中补充优先级,但不得改写已审核
|
||||
字段;校验器会重算 `approvedPayloadHash`。
|
||||
|
||||
把 `taskDraft` 写入 `tasks.yaml`、补齐任务 ID 和规划字段并通过任务板校验后,再把最终
|
||||
任务 ID 与同一批准 revision 写回飞书:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py mark-imported \
|
||||
.pouch/ack/tasks.yaml --record-id <record-id> --task-id <ack-task-id> \
|
||||
--expected-source-ref <approved-sourceRef> \
|
||||
--expected-draft-revision <approvedDraftRevision>
|
||||
```
|
||||
|
||||
适配器只在任务板恰有一条 ID 匹配、来源引用、记录 ID、批准 revision 和 payload hash
|
||||
全部一致的任务时,写入 `ACK任务ID` 并把状态推进到 `已导入`;回读不一致则失败。重复执行
|
||||
同一绑定是幂等的,不能把一条记录改挂到另一个任务。
|
||||
|
||||
按 `source.ref` 查重:仅未派发的 `open` 任务可刷新;其它状态只报告来源漂移,不覆盖。
|
||||
来源消失、不可访问或同步失败时,不删除已有 ACK 任务。
|
||||
@@ -0,0 +1,186 @@
|
||||
# 在新项目初始化 ACK
|
||||
|
||||
本文件说明如何把 ACK 的项目状态初始化到目标项目。ACK Skill 自身已经通过 Agent
|
||||
的 Skill 机制安装;项目不复制、不链接 Skill 内容。
|
||||
|
||||
## 前提与边界
|
||||
|
||||
开始前确认:
|
||||
|
||||
1. 目标项目根目录。
|
||||
2. ACK Skill 已全局安装或安装到当前项目。
|
||||
3. `pouch` 命令可用。
|
||||
|
||||
不要覆盖已有的 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
|
||||
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml`、`.pouch/ack/regression.yaml`、
|
||||
`AGENTS.md` 或其它 Agent
|
||||
指令文件。ACK 不会自动
|
||||
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
||||
内容或其它私有配置写入 ACK 项目状态。
|
||||
|
||||
## 初始化
|
||||
|
||||
在目标项目执行:
|
||||
|
||||
```bash
|
||||
pouch init ack
|
||||
```
|
||||
|
||||
或显式指定项目:
|
||||
|
||||
```bash
|
||||
pouch init ack --project <project-root>
|
||||
```
|
||||
|
||||
命令从 ACK Skill 自带的 `templates/` 生成:
|
||||
|
||||
```text
|
||||
.pouch/ack/
|
||||
├── project.md
|
||||
├── tasks.yaml
|
||||
├── knowledge.yaml
|
||||
├── delivery.yaml # 默认 enabled: false
|
||||
└── regression.yaml # 默认 cases: []
|
||||
```
|
||||
|
||||
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
||||
`framework` 或其它指向 Skill 的软链接。
|
||||
|
||||
### 旧项目补充知识库
|
||||
|
||||
旧项目已经有 `project.md` 和 `tasks.yaml`、但没有 `knowledge.yaml` 时,不要重跑
|
||||
`pouch init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
|
||||
`templates/knowledge.template.yaml` 生成 `.pouch/ack/knowledge.yaml`,替换项目名和
|
||||
当前时间,保留 `entries: []`。如果现有任务板缺少
|
||||
`project.knowledgeFile`,同一次授权只补
|
||||
`knowledgeFile: .pouch/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
||||
知识库和跨文件引用校验。
|
||||
|
||||
### 旧项目补充交付配置
|
||||
|
||||
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
|
||||
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
|
||||
任务板补 `project.deliveryFile: .pouch/ack/delivery.yaml` 与顶层
|
||||
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
||||
解析结果后才启用。不要重跑 `pouch init ack`,也不要改写已有任务或知识。
|
||||
|
||||
### 旧项目补充回归目录
|
||||
|
||||
`regression.yaml` 对旧项目是可选能力。用户明确要求回归或授权补齐时,从
|
||||
`templates/regression.template.yaml` 生成 `.pouch/ack/regression.yaml`,并只补
|
||||
`project.regressionFile: .pouch/ack/regression.yaml` 与顶层 `regressionRuns: []`。
|
||||
保持 `cases: []`,不要从聊天虚构用例。
|
||||
|
||||
## 完善项目覆盖层
|
||||
|
||||
编辑 `.pouch/ack/project.md`,填入:
|
||||
|
||||
- 项目名、技术栈、运行命令和 Base URL。
|
||||
- Coordinator、Developer、Test 的实际模型档位。
|
||||
- 规格、集成测试、源码、单元测试和私有配置的路径权限。
|
||||
- Developer 白盒验证命令。
|
||||
- Test 黑盒复测命令。
|
||||
|
||||
无法从项目证据确定的值写为 `n/a`,不要猜测。
|
||||
|
||||
## 完善任务板
|
||||
|
||||
编辑 `.pouch/ack/tasks.yaml`:
|
||||
|
||||
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
||||
自动补交付配置。
|
||||
- `updatedAt` 使用当前带时区时间。
|
||||
- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。
|
||||
ACK 从命令行 `--project-root` 下固定的 `.pouch/ack/` 布局解析项目状态,不把
|
||||
`repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取,
|
||||
不再参与路径绑定。
|
||||
- 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,并保留顶层
|
||||
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||
- 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,并保留顶层
|
||||
`regressionRuns: []`。旧项目只有在采用回归能力时才补这两个字段。
|
||||
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
||||
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
||||
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||
- `.pouch/ack/agents.yaml` 是可选的临时 worker 路由覆盖;存在时优先于任务板中的
|
||||
`project.orchestration`。它必须使用结构化 profile;Pi 默认模型写成 `cli: pi`、
|
||||
`model: null`,不能写自由命令。
|
||||
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
||||
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
||||
|
||||
每个真实任务的验收必须是可观测信号,例如可见文本、API 状态和字段,或明确的交互
|
||||
结果;不要只写“功能正常”。
|
||||
|
||||
## 初始化项目知识
|
||||
|
||||
新项目的 `.pouch/ack/knowledge.yaml` 保持 `verificationRegistry: {}` 与
|
||||
`entries: []`。不要从聊天、README、issue 或单次失败中猜测并激活知识。
|
||||
|
||||
项目运行 ACK 后,Developer 和 Test 可以通过回报提名 `knowledgeCandidates`;
|
||||
candidate 留在任务证据中,不会被派发。只有 Test 独立验证且 Coordinator gate
|
||||
通过后,Coordinator 才能把它写成 `active` 条目。全项目范围的强制或权限类规则
|
||||
还需要 User / Decision Owner 确认。
|
||||
|
||||
知识条目只引用项目已审查的 `verification.ref`。对应入口保存在
|
||||
`knowledge.yaml.verificationRegistry`,只允许仓库内相对 path 和结构化 args,
|
||||
不保存或自动执行自由 shell 命令。需要执行时只把 registry ID 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
|
||||
最终下沉为测试、lint、CI 或正式规范。
|
||||
|
||||
## 初始化项目交付
|
||||
|
||||
新项目的 `.pouch/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
||||
`intents.testEnvironment: null` 与 `intents.release: null`。
|
||||
不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator
|
||||
把 `intents.testEnvironment` 写成 `{via: deployer, env: <env>}`,若
|
||||
`.pouch/deployer/<env>` 尚未就绪则加载 deployer skill 的「初始化」;发版仍指向
|
||||
profile。ACK 初始化不自动跑 deployer 初始化。
|
||||
配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式
|
||||
approval 步骤。
|
||||
|
||||
## 初始化回归目录
|
||||
|
||||
新项目的 `.pouch/ack/regression.yaml` 保持 `cases: []`。不要从 README 或聊天猜测
|
||||
用例。任务 `verified` 后按 `regression.md` 收获。
|
||||
|
||||
## 校验
|
||||
|
||||
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/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 .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
|
||||
```
|
||||
|
||||
同时确认:
|
||||
|
||||
- `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 和
|
||||
`regression.yaml` 没有未替换的 `<...>` 占位符。
|
||||
- `project.overlayFile` 指向真实文件。
|
||||
- `project.knowledgeFile` 指向 `.pouch/ack/knowledge.yaml`。
|
||||
- 新项目的 `project.deliveryFile` 指向 `.pouch/ack/delivery.yaml`;交付默认关闭。
|
||||
- Developer 与 Test 的验证命令可执行。
|
||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||
- 若存在 `agents.yaml`,其 profile/allowlist/defaults 也通过校验,且启动配置 hash
|
||||
会绑定 plan、fingerprint 和 receipt。
|
||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
|
||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
|
||||
|
||||
## 初始化报告
|
||||
|
||||
按 `SKILL.md`「初始化」最后一步的格式报告(`## ack 初始化:完成 | 部分完成 | 阻塞`),
|
||||
列出已具备项、待配置项(路径 + 字段 + 示例)、工具链和下一步。
|
||||
|
||||
只有结构校验通过且必填项目事实完整时才称「完成」;否则称「部分完成」或「阻塞」。
|
||||
除非用户明确要求,不提交、不推送。
|
||||
@@ -0,0 +1,207 @@
|
||||
# 如何开始一个需求(Kickoff)
|
||||
|
||||
从零开一个需求的启动手册。本文件只覆盖确认前的产品文档、任务拆分与验收信号。
|
||||
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
|
||||
不要在本文件开头预读其它规范。
|
||||
|
||||
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + long-ship)。ACK 任务板
|
||||
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
|
||||
|
||||
---
|
||||
|
||||
## 前提:谁是 Coordinator
|
||||
|
||||
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
|
||||
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
|
||||
具体 CLI、模型、reasoning effort 和执行模式的机器事实源优先是
|
||||
`.pouch/ack/agents.yaml`,不存在时回退到 `.pouch/ack/tasks.yaml` 的
|
||||
`project.orchestration`。`.pouch/ack/project.md` 只解释项目
|
||||
差异,不能提供另一套启动命令。
|
||||
|
||||
---
|
||||
|
||||
## 第 0 步:给 Coordinator 的开场指令(复制改需求)
|
||||
|
||||
```text
|
||||
我要做一个新需求:<一句话需求>。
|
||||
你作为 ack 的 Coordinator(PM),按 ACK Skill 执行:
|
||||
|
||||
1. 先读 .pouch/ack/project.md,并用 `scripts/select_tasks.py .pouch/ack/tasks.yaml`
|
||||
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
|
||||
完整 tasks.yaml 注入上下文。校验 .pouch/ack/knowledge.yaml 并用
|
||||
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目。
|
||||
本 kickoff 只做确认前的产品文档与任务拆分;用户确认后的闭环、飞书、启动
|
||||
worker、交付和回归按 ACK Skill 的模式路由按条件加载,不要在本步预读其它
|
||||
references。配置本身不是执行授权。
|
||||
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
||||
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
||||
4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。
|
||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
||||
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
||||
fingerprint 创建 fresh worker;
|
||||
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
|
||||
dispatch 测试独立复测 → 你读证据终检 → 追加 task-records;
|
||||
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
||||
记录、恢复并告诉我下一步,不占产品复验轮次。
|
||||
7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
|
||||
.pouch/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写
|
||||
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
||||
或 review_ready,stable/production 步骤再次向我确认。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第 1 步:Coordinator 产出(确认前)
|
||||
|
||||
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
|
||||
2. 任务板 → `.pouch/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||
3. 项目知识 → 从 `.pouch/ack/knowledge.yaml` 按 component、path、dependency、version
|
||||
和 tag 推荐 `active` 条目,Coordinator 确认后写入固定 revision 的显式
|
||||
`knowledgeRefs`。candidate 不参与选择。
|
||||
4. 校验结构与引用:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
|
||||
```
|
||||
|
||||
有效 worker routing(`agents.yaml` 优先,否则 `project.orchestration`)是 profile 的机器
|
||||
SSOT;未知字段、非 allowlist 模型、不匹配的角色/档位和非安全权限必须在派发前 fail closed。
|
||||
|
||||
读取任务上下文使用:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml \
|
||||
--task-id <ack-task-id>
|
||||
```
|
||||
|
||||
选择器解析并校验完整任务板,但只输出命中任务及其 receipt/delivery run;超过预算时
|
||||
显式失败,不得改用 `cat` 或整文件 `sed`。
|
||||
|
||||
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
||||
|
||||
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
|
||||
发布 channel、部署环境、PR 是否创建/标 ready、停止点,以及哪些步骤会再次审批。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 步:决定 worktree
|
||||
|
||||
- 需求大 / 要并行 / 要保基线分支干净 → 新建隔离 worktree。
|
||||
- 小改动 / 串行修复 → 当前 worktree 起子 agent。
|
||||
用户确认后的闭环步骤由 SKILL.md「工作」加载,不要在本步预读其它规范。
|
||||
|
||||
---
|
||||
|
||||
## 第 3 步:审阅并启动 worker
|
||||
|
||||
先读有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)的结构化 profiles。历史 receipt 或既有 Orca
|
||||
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
|
||||
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
|
||||
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
|
||||
复用边界以 SKILL.md「启动 worker」为准,不要在本步预读其它规范。
|
||||
|
||||
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
||||
不能用于匹配或复用旧 receipt / 既有终端:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
|
||||
--project-root <project-root> --profile-id <profile-id>
|
||||
```
|
||||
|
||||
生成只读计划:
|
||||
|
||||
```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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
||||
```
|
||||
|
||||
审阅计划后执行唯一创建入口:
|
||||
|
||||
```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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
`launch` 的 fingerprint 必须来自刚才审阅的 plan;若中间任何关键事实漂移,重新
|
||||
plan,不得覆盖检查。标准输出是单一 JSON。Coordinator 校验其中的 receipt 后,把
|
||||
receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该任务
|
||||
`dispatch.<role>.receiptId`、把 `profileId` 写入 `dispatch.<role>.profileId`,并把
|
||||
本轮稳定 ID 写入 `dispatch.<role>.attemptId`。三者必须与 receipt 的当前
|
||||
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
|
||||
dispatch。后续
|
||||
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
|
||||
receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||
底层创建命令。
|
||||
|
||||
新 worktree 时可先运行
|
||||
`orca worktree create --name <feature> --base-branch <base> --json`,再对返回的绝对
|
||||
worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher
|
||||
前,确认新 worktree 与权威项目根同一 Git 仓库即可(v0.19 起不再需要登记
|
||||
`allowedWorktrees`)。profile
|
||||
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
||||
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
||||
选型与升级以 SKILL.md「启动 worker」为准。
|
||||
|
||||
---
|
||||
|
||||
## 第 4 步:跑闭环(每个任务)
|
||||
|
||||
```text
|
||||
task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done
|
||||
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
||||
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
||||
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
|
||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
||||
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
|
||||
```
|
||||
|
||||
Orca / 手动命令与派发文案由 SKILL.md「启动 worker」按条件加载。
|
||||
|
||||
Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 worker 全量读取
|
||||
知识库。知识正文不得作为自由 shell 执行;需要命令时只能引用项目已审查的检查
|
||||
入口,并把它的 registry ID 交给 `<ack-skill-dir>/scripts/run_verification.py`,
|
||||
不自行拼接 path/args。Test 独立验证且 gate 通过后,Coordinator 才能把 candidate
|
||||
写成 active 知识;关键约束应继续下沉为测试、lint、CI 或正式规范。
|
||||
|
||||
---
|
||||
|
||||
## 第 5 步:可选交付
|
||||
|
||||
用户说「重新布测试环境」或「发布一个版本」时,转入 SKILL.md 对应模式,不要在本步预读交付规范。intent 为 null 时先做交付配置维护。
|
||||
|
||||
所选任务 `verified` 后,转入 SKILL.md「运行回归」给出新增/更新/退役/无回归四选一,用户确认后写入 `.pouch/ack/regression.yaml`。用户说「回归」时先布测试环境,再派 Test 按目录执行。
|
||||
|
||||
所选任务都由 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`。
|
||||
|
||||
## 第 6 步:收尾
|
||||
|
||||
一轮结束时 Coordinator 必须能回答:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。复验轮次争议以 SKILL.md「边界」为准。
|
||||
Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先 dry-run
|
||||
审阅决策、再 `--apply` 关闭所有只关联 `verified` 任务的 Developer/Test 终端并核对
|
||||
回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、`leftover`
|
||||
或其它未完成任务/未解决环境事件的终端保留,不设置 TTL。一个终端只要仍被任一这类
|
||||
任务引用,就不能因为同时关联了 verified 任务而关闭。
|
||||
|
||||
---
|
||||
|
||||
## 一句话
|
||||
|
||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||
有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)解析安全 profile → 审阅 plan 并用 expected
|
||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||
`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile
|
||||
到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收
|
||||
仅属于 verified 任务的 worker,保留 blocked/failed/leftover worker。
|
||||
@@ -0,0 +1,285 @@
|
||||
# 模型路由(稳定核心)
|
||||
|
||||
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的规范源。
|
||||
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
||||
工作,把机械执行交给较弱模型。
|
||||
|
||||
角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer
|
||||
实现)。本文件只补一层正交的「用哪个档位、以什么安全 profile 启动 worker」。
|
||||
|
||||
---
|
||||
|
||||
## 默认档位
|
||||
|
||||
| 角色 | 默认模型档位 | 理由 |
|
||||
|------|--------------|------|
|
||||
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
|
||||
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
||||
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
||||
|
||||
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
|
||||
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
|
||||
同时保持「验证者 != 实现者」。
|
||||
|
||||
---
|
||||
|
||||
## 什么时候用强模型
|
||||
|
||||
- 新需求理解、产品取舍、范围决策。
|
||||
- 架构与数据模型决策。
|
||||
- 把验收写成可观测信号(见 `optimization-method.md` §1)。
|
||||
- 需求含糊、规格与实现/测试冲突时的裁决。
|
||||
- Coordinator 终检:读证据、对齐原始意图。
|
||||
- 重复失败后的根因复盘与重新拆分。
|
||||
|
||||
## 什么时候用中低模型
|
||||
|
||||
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
|
||||
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
|
||||
|
||||
---
|
||||
|
||||
## 升级规则
|
||||
|
||||
**升级到 Coordinator(强模型)复盘**,当:
|
||||
|
||||
- 同一验收路径 Developer 连续失败三轮(见 `optimization-method.md` §4)。
|
||||
- Test 两次仍无法给出清晰失败证据。
|
||||
- 任务需要改动产品范围或验收标准。
|
||||
- 修复涉及持久化数据、破坏性文件操作、安全或回滚。
|
||||
- 规格、测试、实现三者出现冲突。
|
||||
|
||||
**升级 Developer 模型档位**,当:
|
||||
|
||||
- 任务横跨多个子系统。
|
||||
- 改动涉及数据模型或迁移。
|
||||
- 需要设计新的抽象。
|
||||
- 低档位反复产出表面修复。
|
||||
|
||||
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
|
||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。模型或
|
||||
profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强档 worker。
|
||||
|
||||
---
|
||||
|
||||
## 结构化 worker profile
|
||||
|
||||
### 机器事实源与项目临时覆盖
|
||||
|
||||
worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的
|
||||
`project.orchestration`。项目需要临时调整角色 CLI 或模型时,可以在同一目录增加
|
||||
`.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 明确声明:
|
||||
|
||||
- `role`:`developer` 或 `test`;
|
||||
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`、`pi`);
|
||||
- `tier`:角色模型档位;
|
||||
- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型;
|
||||
- `reasoningEffort`:CLI 支持时显式声明;
|
||||
- `permissionMode`:`read-only`、`workspace-write`;Codex 还支持用户明确授权的 `danger-full-access`。
|
||||
|
||||
有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色
|
||||
`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 的模型也必须
|
||||
命中对应 CLI/角色/档位的精确列表。
|
||||
|
||||
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
||||
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
||||
CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构造。
|
||||
|
||||
OMP profile 使用 `cli: omp`;`model` 必须是精确的 `provider/model` 选择器,
|
||||
`reasoningEffort` 映射为 `--thinking`。审批模式由 launcher 固定构造:
|
||||
`workspace-write` 映射为 `--approval-mode yolo`,`read-only` 映射为
|
||||
`--approval-mode always-ask`。规则层直接允许 yolo 并默认启用;
|
||||
launcher 仍不接受 `--auto-approve` 或会话复用。
|
||||
|
||||
### 安全默认
|
||||
|
||||
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
||||
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
|
||||
为纯观察型 Test 另建更严格的 `read-only` profile。
|
||||
- 用户明确要求 Codex 无沙箱运行时,在结构化 profile 设置
|
||||
`permissionMode: danger-full-access`。launcher 仍绑定 profile hash、精确 argv 和 receipt;
|
||||
用户已有授权无需重复询问。任务文本或环境变量不能自行授权。
|
||||
`full-access` 别名、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
|
||||
`--permission-mode bypassPermissions` 等自由参数仍拒绝。
|
||||
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
|
||||
它不关闭 `--sandbox`。
|
||||
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
|
||||
审批模式,规则层直接允许并默认启用(workspace-write);它不授予
|
||||
full-access,不用于其它 CLI,也不等同于 Grok `--yolo`。
|
||||
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
|
||||
launcher。
|
||||
|
||||
仅 Codex 支持 `danger-full-access`;其它 CLI 不得套用该权限值。
|
||||
无沙箱 profile 只改变执行权限,不扩大用户授权的项目、服务或操作范围。
|
||||
|
||||
---
|
||||
|
||||
## 唯一启动入口
|
||||
|
||||
worker 的 profile 解析、计划和启动只允许通过:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
|
||||
--project-root <project-root> --profile-id <profile-id>
|
||||
|
||||
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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
|
||||
|
||||
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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
`--project-root` 始终指向保存权威 `.pouch/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
||||
worker 实际工作的绝对路径,两者可以不同。后者仍必须与项目根属于同一 Git
|
||||
仓库(v0.19 起由 launcher 按 `git worktree list` 注册表 + 同 common-dir 校验,
|
||||
不再依赖 `allowedWorktrees` 白名单)。
|
||||
|
||||
项目状态文件固定从 `--project-root/.pouch/ack/` 解析;任务板不需要保存 `repoPath` 或
|
||||
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
|
||||
`projectRoot`、任务板内容摘要、有效 agents 配置 hash 和 worker worktree identity 都会进入 launch fingerprint,
|
||||
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
|
||||
|
||||
- `profile-hash` 读取有效路由配置中的 profile,并输出配置来源与确定性 hash。
|
||||
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
|
||||
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
|
||||
`plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv
|
||||
任一事实漂移都会在创建前失败。它内部固定 Orca 调用和 Agent argv,标准输出只有
|
||||
一份 JSON 结果。
|
||||
- launcher 的内部 bootstrap 属于私有协议,Coordinator 不直接调用、不复制到项目
|
||||
文档,也不自行拼装 Orca 的底层终端创建文本。
|
||||
|
||||
Coordinator 在执行 `launch` 前必须先审阅 `plan`。任何校验失败、字段未知、路径不
|
||||
匹配、CLI 不可用或权限模式不支持,都停止创建并回报,不回退到裸 Agent 命令。
|
||||
|
||||
---
|
||||
|
||||
## 可执行信任边界
|
||||
|
||||
v0.10 的 launcher 还执行以下约束:
|
||||
|
||||
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
|
||||
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
|
||||
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
|
||||
Grok 官方安装是 `~/.local/bin/grok` → `~/.grok/downloads/grok-<os>-<arch>`,
|
||||
launcher 只接受这个真实 `~/.grok` 目录下、当前用户同组拥有的 vendor artifact
|
||||
名称;其它路径上的 `grok-linux-*` 仍拒绝。`~/.grok` 本身是 symlink 时 fail closed。
|
||||
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用
|
||||
`per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据,
|
||||
Grok 只取得 `XAI_API_KEY`(OAuth 会话仍走已传入的 `HOME`/`~/.grok`),
|
||||
不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、
|
||||
`LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GROK_HOME`、`GROK_SANDBOX`、
|
||||
`GIT_*` 和调用者 PATH 均不会透传给 worker。Grok argv 只允许
|
||||
`--model`、`--reasoning-effort`、`--permission-mode plan|acceptEdits`、
|
||||
`--always-approve`、`--sandbox read-only|workspace` 和 `--cwd`;不得带 `--yolo`
|
||||
或 `bypassPermissions`,也不得省略 sandbox。
|
||||
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
|
||||
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
|
||||
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
|
||||
绑定 runtime/handle/incarnation/worktree,再通过该终端 stdin 发送一次性 nonce;
|
||||
bootstrap 校验 nonce 后才允许启动 Agent CLI,并把 child PID 与 nonce/launch
|
||||
fingerprint 绑定的 proof 原子写入 ready 状态;父进程看到并核对 proof 后才把
|
||||
worker 视为可派发。
|
||||
- `orca terminal create` 一经发出,在取得确定 handle 前遇到超时、transport/解码
|
||||
异常、进程中断、非零退出、畸形 JSON 或缺字段,一律标记为 `indeterminate`,
|
||||
禁止自动重试。
|
||||
- 取得 handle 后若任一绑定或握手检查失败,launcher 会先用
|
||||
`orca terminal close --tab --json` 请求持久关闭,并严格核对 runtime、handle、
|
||||
tab 和 close mode。只有关闭回执完整匹配才记为普通 `failed`;关闭无法确认时保持
|
||||
`indeterminate/reconcile-required`,调用方必须先人工核对,不能直接重试。
|
||||
- bootstrap 在真正启动 Agent 前,会在同一 launch-record 锁内再次确认状态仍为
|
||||
`awaiting-authorization` 且没有 cleanup;父进程一旦先进入 cleanup/reconcile,
|
||||
bootstrap 必须拒绝 `Popen`,不能让取消后的 workspace-write worker 继续启动。
|
||||
- 异常路径的 launch record 写入失败不能阻断上述关闭动作,也不能遮蔽原始
|
||||
`indeterminate`。即使关闭已确认,只要最终状态无法可靠持久化,也保守返回
|
||||
`indeterminate/reconcile-required`。
|
||||
|
||||
这些约束防止任务板、任务文本和普通环境变量变成第二个启动命令入口。它们不抵御已经
|
||||
完全控制 Coordinator 用户账户、Orca 运行时、受信 Agent/Orca 可执行文件或
|
||||
Coordinator 用户级 Agent 配置/插件/MCP 的攻击者;该场景需要独立 OS 身份、受控
|
||||
Agent 配置或平台签发能力。`permissionMode` 约束的是 Agent CLI 的 shell/文件系统
|
||||
执行模式,不是模型供应商或外部工具能力的 attestation。
|
||||
|
||||
---
|
||||
|
||||
## Receipt、审计与复用边界
|
||||
|
||||
成功启动返回的 JSON 含一个可校验 receipt。Coordinator 把 receipt 追加到
|
||||
`tasks.yaml` 顶层 `workerReceipts`,并把它的 `id` 写入当前任务
|
||||
`dispatch.<role>.receiptId`、把 `profileId` 写入 `dispatch.<role>.profileId`,同时把
|
||||
本次 `<task-id>-A<round>` 写入 `dispatch.<role>.attemptId`。
|
||||
|
||||
这三个字段不是可任意组合的历史索引。任务板校验器要求被引用 receipt 的
|
||||
`createdFor.taskId` 等于当前 ACK `tasks[].id`,`createdFor.role` 等于 dispatch
|
||||
角色,`profileId` 完全相同,且 `createdFor.attemptId` 等于
|
||||
`dispatch.<role>.attemptId`。`receiptId: null` 时 `attemptId` 必须同为 `null`;
|
||||
`receiptId` 非空时 `attemptId` 必须存在并命中本轮。`dispatch.<role>.taskId` 是 Orca
|
||||
运行时 task ID,不能拿来替代 ACK task ID 或稳定 attempt ID。
|
||||
|
||||
receipt 至少把以下事实绑定在一起:
|
||||
|
||||
- `profileHash` 与请求的角色、模型、effort、权限和精确 argv;
|
||||
- `slot`,确保 fingerprint 也绑定审阅过的 worker 槽位和派生标题;
|
||||
- 任务、attempt、绝对 worktree 及 path/device/inode/git common-dir 等稳定身份;
|
||||
- Orca `runtimeId`、terminal `handle` 和 `incarnationId`;
|
||||
- 创建时间、launcher 指纹和 `receiptHash`。
|
||||
|
||||
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
|
||||
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
|
||||
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 ACK
|
||||
明确禁止根据持久化 receipt 自动复用既有终端。复用只允许发生在同一轮 ACK 内,并且
|
||||
必须先证明 worker 空闲、角色/profile/worktree/runtime/incarnation 完全匹配,再由
|
||||
受信后端清理历史消息并返回新的 conversation/session identity 与本次 task/attempt
|
||||
绑定。正在工作、等待回报、状态不明或关联未完成任务的 worker 都不是空闲候选。任一
|
||||
条件不满足、清理失败或清理结果无法确认时,重新走 `plan` → 带 expected fingerprint
|
||||
的 `launch`,使用 fresh handle 派发。
|
||||
|
||||
`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份
|
||||
计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint
|
||||
再次启动。若结果不确定,必须先 reconcile,不能把重跑当作恢复方式。未来需要自动
|
||||
防重放时,应由 Orca/ACP 的原子 idempotency/claim 能力或项目外可信 launch intent
|
||||
提供,而不是把 checksum 冒充成一次性令牌。
|
||||
|
||||
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
|
||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。只有
|
||||
Orca/ACP 同时提供启动参数 attestation、明确空闲状态、可信历史清理和新会话身份,或
|
||||
ACK 接入等价的项目外可信签发与校验通道,才实际启用自动复用。当前 Orca 不满足这些
|
||||
条件,所以仍创建 fresh worker。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||
|
||||
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
|
||||
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
|
||||
出现,避免 schema-only 校验把半迁移任务板误判为可自动派发。
|
||||
|
||||
---
|
||||
|
||||
## 成本原则
|
||||
|
||||
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
||||
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
|
||||
改动文件清单。
|
||||
|
||||
这样把昂贵推理挡在重复执行之外。
|
||||
|
||||
---
|
||||
|
||||
## 一句话
|
||||
|
||||
Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手
|
||||
默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker
|
||||
都必须经 launcher 产生可核对的 receipt。
|
||||
@@ -0,0 +1,240 @@
|
||||
# 优化方法(稳定核心)
|
||||
|
||||
减少"开发声称完成但复测不过"的循环成本。本文件是**验收信号写法**和**三轮失败策略**的 SSOT,其它文件引用这里。
|
||||
|
||||
三角色分工(Coordinator 编排 / Test 验证 / Developer 实现)见 `roles-and-permissions.md`;复测步骤、Coordinator 终检与环境对齐见 `closed-loop.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 先把验收写成可观测信号(SSOT)
|
||||
|
||||
不要只写"页面可用""体验更好"。每个任务至少写三类信号:
|
||||
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| 可见文本 | 页面出现 `确认应用标签`、`取消`、`将修改` |
|
||||
| API 结果 | `POST /api/...` 返回 `code=0` 且字段存在 |
|
||||
| 交互结果 | 点击按钮后出现确认区;取消后不触发写入 |
|
||||
|
||||
好的验收:
|
||||
|
||||
```text
|
||||
点击"预览变更"后,页面必须出现 API 返回的 diff 行:
|
||||
- title
|
||||
- 修改前值
|
||||
- 修改后值
|
||||
- coverChanged 提示
|
||||
```
|
||||
|
||||
不好的验收:
|
||||
|
||||
```text
|
||||
预览功能正常。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 回报必须带证据,不带结论
|
||||
|
||||
Developer 的 `worker_done` 应报告:改了哪些文件、跑了哪些命令、自己如何复现验收路径、仍可能有的风险。Test 的复测报告同理:跑了哪些命令、命中/缺失了哪些验收信号、实际观察到什么、证据(snapshot / API 结果)。两者的"结论"都不作数;任务结论只有 Coordinator 落盘到 `tasks.yaml` 才算数,跨任务知识只有 Coordinator 落盘到 `knowledge.yaml` 才会生效。
|
||||
|
||||
不要写:
|
||||
|
||||
```text
|
||||
已完成,应该可以了。
|
||||
```
|
||||
|
||||
要写:
|
||||
|
||||
```text
|
||||
修改 web/app/fix/page.tsx。已运行 npm run build、go test ./...。
|
||||
手测 /fix?fileId=1:搜索、选择候选、预览、确认区都可见。
|
||||
风险:未执行真实写文件 apply。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 验证权在 Test,Coordinator 只信证据
|
||||
|
||||
即使 Developer 的 worker_done 写了"全部通过",也必须由独立的 Test 复测(验证者 ≠ 实现者)。完整复测步骤与 worktree/服务对齐见 `closed-loop.md`。独立复测可避免三类假通过:测错服务实例、测到旧构建产物、开发只验证静态文本没验证真实交互。
|
||||
|
||||
Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它覆盖了每条验收信号且符合原始意图,避免"过了字面没过意图"。终检不通过就回写 `failed_retest`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 有效复验、环境失败与三轮策略(SSOT)
|
||||
|
||||
### 4.1 什么才计算一轮
|
||||
|
||||
三轮预算只计算**有效产品复验**:Test 已确认正确 worktree、最新服务、必要测试数据和
|
||||
可用验证工具,并实际执行目标验收信号;结果要么全部通过,要么观察到由待测产品行为
|
||||
导致的信号失败。
|
||||
|
||||
以下情况属于环境失败,不是产品失败,也不占复验轮次:worker 未启动或消息未投递、
|
||||
sandbox/权限阻止访问待测服务、服务实例或构建不匹配、必要测试数据缺失、浏览器或测试
|
||||
工具不可用、编排 IPC 失败。若已有独立的产品信号明确失败,只把该产品失败计入轮次;
|
||||
其余环境问题另行记录,不能用“环境失败”掩盖产品证据。
|
||||
|
||||
Coordinator 派发后必须确认消息已投递且 worker 已开始执行:只凭 `check --wait`
|
||||
超时无法区分慢任务与未执行,等待期间要用终端活性探测(`scripts/worker_probe.py`)
|
||||
定期检查。检测到卡在审批提示、投递后未回车或命中额度限制时,按环境失败记录并做
|
||||
有界恢复,不消耗产品复验轮次。
|
||||
|
||||
环境失败写入 `dispatch.environmentIncidents`,不要追加到 `dispatch.rounds`,也不要把
|
||||
任务写成 `failed_retest`。实现已经完成时保持 `fixed_by_dev`;恢复后再进入
|
||||
`retesting`。确实需要用户或外部条件才能继续时可暂时写 `blocked`,环境恢复后回到
|
||||
原闭环状态。
|
||||
|
||||
每条环境事件必须包含:
|
||||
|
||||
```yaml
|
||||
dispatch:
|
||||
environmentIncidents:
|
||||
- id: "BUG-001-ENV-1"
|
||||
attemptId: "BUG-001-A1"
|
||||
role: test
|
||||
phase: browser
|
||||
status: resolved
|
||||
summary: "测试环境没有可用浏览器"
|
||||
evidence: "chromium/playwright lookup 均为空"
|
||||
impact: "没有执行点击级验收,不能据此判断产品失败"
|
||||
recoveryAction: "改用受支持的浏览器运行时并启动 fresh Test"
|
||||
userAction: "无需操作;Coordinator 继续恢复"
|
||||
reportedAt: "<timestamp>"
|
||||
resolvedAt: "<timestamp>"
|
||||
```
|
||||
|
||||
`userAction` 必须明确:无需用户操作时写清 Coordinator 下一步;需要用户介入时给出一个
|
||||
具体决定、命令或外部条件,不能只写“请处理环境”。Coordinator 可以先做一次不扩大权限、
|
||||
不改变产品数据的有界恢复;仍未解决、需要用户动作或阻断本轮时,在当前会话立即报告。
|
||||
即使事件已自动恢复,最终报告也必须列出环境事件、影响和恢复结果,让用户知道发生过什么。
|
||||
|
||||
### 4.2 三轮有效产品失败
|
||||
|
||||
每个任务最多自动派发三轮:
|
||||
|
||||
```text
|
||||
round 1: 常规修复
|
||||
round 2: 带复测失败证据的定向修复
|
||||
round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
|
||||
failed after round 3: 标记 leftover,继续下一个任务
|
||||
```
|
||||
|
||||
三轮有效产品失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、
|
||||
Worker 对问题模型理解错了,或需要人工观察和专项调试。环境事件数量不受三轮预算限制,
|
||||
但必须有界恢复和透明报告,不能无限重试。
|
||||
|
||||
留档字段(结构见 `templates/tasks.schema.json`):
|
||||
|
||||
```yaml
|
||||
status: leftover
|
||||
resolution:
|
||||
leftoverReason: "failed after 3 supervised developer rounds"
|
||||
dispatch:
|
||||
rounds:
|
||||
- round: 1
|
||||
result: failed
|
||||
evidence: "<why failed>"
|
||||
- round: 2
|
||||
result: failed
|
||||
evidence: "<why failed>"
|
||||
- round: 3
|
||||
result: failed
|
||||
evidence: "<why failed>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务排序
|
||||
|
||||
1. P0 阻塞主流程。
|
||||
2. P1 高频用户路径。
|
||||
3. P1/P2 体验改进。
|
||||
4. 重构和内部质量。
|
||||
|
||||
三轮失败的 P0 可以留档,但发布前需人工决策:降级范围、改验收标准、换新 Agent / 新 worktree 重做、人工修复。
|
||||
|
||||
---
|
||||
|
||||
## 6. 每轮派发只修一个明确问题
|
||||
|
||||
不要一次让 Developer Worker 修太多失败项,除非它们共享同一根因:
|
||||
|
||||
```text
|
||||
one dispatch = one bug = one acceptance path
|
||||
```
|
||||
|
||||
优点:复测更快、失败原因更清楚、三轮失败留档更准确。
|
||||
|
||||
---
|
||||
|
||||
## 7. 把失败证据写给下一轮 worker
|
||||
|
||||
第二轮以后,派发 prompt 应突出"上一轮为什么没过",而非重复原始描述。模板见 `prompt-templates.md` §「复测失败再派发模板」。
|
||||
|
||||
---
|
||||
|
||||
## 8. 优先让测试可执行化
|
||||
|
||||
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。黑盒回归目录是
|
||||
`.pouch/ack/regression.yaml`(见 `regression.md`);可执行脚本仍由 Test 维护在
|
||||
`<integration_test_paths>`。优先级:
|
||||
|
||||
1. 把本轮验收信号收获进 `regression.yaml`。
|
||||
2. API smoke。
|
||||
3. 浏览器脚本或 `automationRef`。
|
||||
4. 单元测试。
|
||||
5. 人工检查清单。
|
||||
|
||||
目标不是全部自动化,而是把最容易反复误判的路径变成可重复跑的回归用例。任务
|
||||
`verified` 后必须给出新增/更新/退役/无回归四选一。
|
||||
|
||||
---
|
||||
|
||||
## 9. 让经验成为有证据、会过期的知识护栏
|
||||
|
||||
项目特有、跨任务复用且会改变后续开发或验证行为的经验,可以提名到项目知识护栏。
|
||||
不要把全部对话、日志或单次猜测自动保存成“记忆”。
|
||||
|
||||
```text
|
||||
Developer / Test 当前任务观测
|
||||
-> knowledgeCandidates(只留在任务证据)
|
||||
-> Test 独立验证 + Coordinator gate
|
||||
-> Coordinator 写入 active knowledge
|
||||
-> stale
|
||||
-> superseded / archived
|
||||
```
|
||||
|
||||
`prepare(task)` 时只按 component、path、dependency、version 和 tag 推荐相关
|
||||
`active` 条目;自动匹配只负责推荐,Coordinator 确认固定 revision 的显式
|
||||
`knowledgeRefs` 才是本轮权威上下文。每轮只派发命中的少量条目,不全量注入知识库。
|
||||
|
||||
Developer 回报实际采用的 `knowledgeApplied` 和带当前观测证据的
|
||||
`knowledgeCandidates`。Test 对本轮显式引用回报 `knowledgeChecks`,并用独立观测
|
||||
确认或否定 candidate。同一 Agent 不能把自己读到的旧知识复述成新证据,避免错误
|
||||
知识自我强化。
|
||||
|
||||
知识中的验证只能引用 `knowledge.yaml.verificationRegistry` 中已经审查的仓库内
|
||||
相对 path 和结构化 args,不能把正文或选择器输出拼接成自由 shell 执行。实际运行
|
||||
只把 registry ID 交给 `<ack-skill-dir>/scripts/run_verification.py`。安全、正确性
|
||||
和兼容性等关键约束一旦稳定,应下沉为测试、lint、CI 或正式规范;知识项继续解释
|
||||
触发条件、原因与证据,不替代可执行控制。
|
||||
|
||||
依赖、配置、路径或版本变化后应重新审查相关知识。临时 workaround 必须有失效或
|
||||
移除条件;冲突规则不能靠“最后写入者获胜”处理。
|
||||
|
||||
---
|
||||
|
||||
## 10. 结束条件
|
||||
|
||||
一轮闭环结束时,必须能回答:
|
||||
|
||||
- 哪些任务 verified?哪些 leftover?
|
||||
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
|
||||
- 当前工作树有哪些未提交改动?
|
||||
- 是否还有 open / failed_retest 未处理?
|
||||
- 本轮有哪些环境事件?是否已解决?用户下一步是“无需操作”还是一个明确动作?
|
||||
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`?
|
||||
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
|
||||
|
||||
答不清楚,闭环就还没结束。
|
||||
@@ -0,0 +1,373 @@
|
||||
# Orca 适配器(稳定核心,一种编排实现)
|
||||
|
||||
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
|
||||
|
||||
所有结论仍以读取层 materialize 的任务视图为准,底层最终记录追加在
|
||||
`task-records/`;Orca 消息不是最终记录。
|
||||
|
||||
---
|
||||
|
||||
## 何时用 Orca 编排
|
||||
|
||||
用:需要监督、等待结果、闭环修复、三轮重试;有多个可独立派发的任务;需要 `worker_done` 后再复测。
|
||||
|
||||
不用:单次问答;用户只要求完整 handoff 不需监督;不需要复测的小改动。
|
||||
|
||||
---
|
||||
|
||||
## 运行前检查
|
||||
|
||||
```bash
|
||||
orca status --json
|
||||
orca terminal list --json
|
||||
orca orchestration task-list --json
|
||||
orca orchestration inbox --limit 20 --json
|
||||
```
|
||||
|
||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||
的活跃编排任务。Developer/Test worker 先按下节检查安全复用候选;当前 Orca 无可信
|
||||
历史清理能力时必须通过本次 launcher 新建。
|
||||
|
||||
---
|
||||
|
||||
## 既有 worker 的处理
|
||||
|
||||
先找同一轮 ACK 内可复用的空闲 worker,再决定是否创建。候选必须同时满足:
|
||||
|
||||
- Orca 明确报告终端存活且空闲;不得复用仍在工作、等待 `worker_done` / 复测报告 /
|
||||
escalation,或运行状态不明的 worker;
|
||||
- 没有关联 `blocked`、`failed_retest`、`leftover`、未解决环境事件或其它未完成任务;
|
||||
- 角色、profile、worktree、runtime/incarnation 与本次目标完全匹配,Developer 与 Test
|
||||
永不互相复用;
|
||||
- 受信后端能够清理历史消息,并返回可核对的新 conversation/session identity 和本次
|
||||
task/attempt 绑定;只有看到成功回执后才把 worker 视为已重置。
|
||||
|
||||
`receiptHash` 是无密钥 checksum,标题、preview、分支名、worker 自报或普通 Orca
|
||||
terminal metadata 都不能证明历史上下文已清理。任何条件不符、清理接口不存在、清理
|
||||
失败或结果无法确认时,都重新 `plan`/`launch` fresh worker。
|
||||
|
||||
| 情况 | 动作 |
|
||||
|------|------|
|
||||
| 同轮空闲,身份完全匹配,历史消息清理得到可信回执 | 生成新的 task/attempt 绑定后复用 |
|
||||
| worker 正在工作、等待回报、状态不明或关联未完成任务 | 不复用;保留原终端并重新 launch |
|
||||
| profile、角色、worktree、runtime/incarnation 任一不匹配 | 不复用;重新 launch |
|
||||
| 无历史消息清理能力,或无法确认清理成功 | 不复用;重新 launch |
|
||||
| 需要并发 worker | 使用下一 `slot` 重新 launch |
|
||||
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
|
||||
|
||||
当前 Orca terminal API 没有返回新会话身份的可信历史消息清理操作,因此当前适配器
|
||||
不会复用 Orca worker;仍只使用刚刚成功返回的 `launch` fresh handle。以后后端补齐
|
||||
reset/attestation 后才能启用上述复用路径,不能用项目文档声明跳过条件。
|
||||
|
||||
---
|
||||
|
||||
## 通过 launcher 创建 worker
|
||||
|
||||
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
|
||||
模型、reasoning effort 和权限由 common `agent.yaml` 的 `ack.<name>` binding 映射到
|
||||
`.pouch/ack/agents.yaml`(存在时)或
|
||||
`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。
|
||||
|
||||
先只读生成计划:
|
||||
|
||||
```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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
||||
```
|
||||
|
||||
确认计划中的任务、attempt、角色、profile hash、绝对 worktree 和安全权限模式后,再
|
||||
执行唯一启动入口:
|
||||
|
||||
```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-or-test> \
|
||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
`launch` 必须携带刚才审阅的 `plan.launchFingerprint`;若 profile、任务/attempt、
|
||||
worktree、CLI identity/version 或精确 argv 漂移,创建前就失败。它内部调用当前
|
||||
Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的底层终端创建文本,
|
||||
也不直接调用 launcher 的私有 bootstrap。
|
||||
若 launcher 报告 `indeterminate` 或 `reconcile required`,说明终端可能已经创建且
|
||||
持久关闭尚未得到可信确认;此时先按返回的 launch ID/record 和 Orca live state
|
||||
人工核对,禁止直接重复执行 `launch`。
|
||||
标准输出是单一 JSON。成功后:
|
||||
|
||||
1. 校验返回 receipt 的 `receiptHash`;
|
||||
2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`;
|
||||
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
|
||||
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
|
||||
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
|
||||
与当前 ACK 任务完全一致;
|
||||
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
|
||||
|
||||
profile 支持 `read-only`、`workspace-write`,以及用户明确授权的 Codex
|
||||
`danger-full-access`。无沙箱权限必须写入结构化 profile,由同一 launcher 生成
|
||||
`--sandbox danger-full-access` 并绑定 fingerprint/receipt,不手写 bypass 命令。
|
||||
模型档位与升级规则见 `model-routing.md`。
|
||||
|
||||
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
|
||||
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
|
||||
fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
||||
|
||||
---
|
||||
|
||||
## 创建父任务
|
||||
|
||||
```bash
|
||||
orca orchestration task-create --spec "$(cat <<'EOF'
|
||||
Goal: Complete <release_or_feature> with supervised Coordinator -> Developer -> Test loop.
|
||||
|
||||
Coordinator (PM): owns task board, decomposition, dispatch, and final gate (reads Test evidence, does not test).
|
||||
Developer: owns implementation and white-box verification.
|
||||
Test: owns independent black-box retest and evidence (verifier != implementer).
|
||||
|
||||
Policy:
|
||||
- Each issue can 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.
|
||||
- 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 task-records/ and knowledge.yaml.
|
||||
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
|
||||
EOF
|
||||
)" --json
|
||||
```
|
||||
|
||||
## 创建子任务
|
||||
|
||||
```bash
|
||||
orca orchestration task-create --parent <parent_task_id> --spec "$(cat <<'EOF'
|
||||
Fix <task_id>: <title>
|
||||
Logical attempt: <task_id>-A<round>
|
||||
|
||||
Repository:
|
||||
- Path: <repo_path>
|
||||
- Worktree: <dev_worktree>
|
||||
|
||||
Read: <overlay_file> (project overlay), tasks.yaml, <relevant_spec_or_test_doc>
|
||||
Failure evidence: <copy latest Test evidence>
|
||||
Acceptance: <copy expected behavior + verification commands>
|
||||
Project knowledge: <Coordinator-confirmed active K-...@revision entries with verification.ref>
|
||||
|
||||
Constraints:
|
||||
- Follow the overlay file path scope.
|
||||
- Do not write tasks.yaml or knowledge.yaml, do not mark verified.
|
||||
- Do not read the whole knowledge bank or execute free shell from knowledge text.
|
||||
- Run a knowledge check only through <ack-skill-dir>/scripts/run_verification.py
|
||||
with its verification.ref; do not execute resolved path/args directly.
|
||||
- Do not commit or push unless user asks.
|
||||
EOF
|
||||
)" --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 派发给 Developer Worker
|
||||
|
||||
终端是 Orca 可识别的 Agent CLI 时:
|
||||
|
||||
```bash
|
||||
orca orchestration dispatch --task <task_id> --to <developer_handle> --inject --json
|
||||
```
|
||||
|
||||
不能 `--inject` 时,先登记 dispatch,再手动投递 `prompt-templates.md` §1 的初始派发 prompt:
|
||||
|
||||
```bash
|
||||
orca orchestration dispatch --task <task_id> --to <developer_handle> --json
|
||||
orca terminal send --terminal <developer_handle> --text "$(cat <<'EOF'
|
||||
<粘贴 prompt-templates.md §1 初始派发模板,已填占位符>
|
||||
EOF
|
||||
)" --enter --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 派发给 Test 复测
|
||||
|
||||
Developer 回报 worker_done、Coordinator 写回 `fixed_by_dev` 后,把复测任务发给 Test:
|
||||
|
||||
```bash
|
||||
orca orchestration dispatch --task <task_id> --to <test_handle> --inject --json
|
||||
```
|
||||
|
||||
不能 `--inject` 时,先登记再手动投递 `prompt-templates.md` §3 的复测派发 prompt:
|
||||
|
||||
```bash
|
||||
orca orchestration dispatch --task <task_id> --to <test_handle> --json
|
||||
orca terminal send --terminal <test_handle> --text "$(cat <<'EOF'
|
||||
<粘贴 prompt-templates.md §3 复测派发模板,已填占位符>
|
||||
EOF
|
||||
)" --enter --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 等待结果:派发后的活性监督
|
||||
|
||||
派发或手动投递后**不能只依赖 `check --wait` 盲等**:卡在审批提示、投递后未回车、
|
||||
命中额度限制的 worker 不会自己发 `worker_done`。先确认 worker 真的开始执行,等待
|
||||
期间周期性探测活性。
|
||||
|
||||
1. 投递后立即确认开始执行:
|
||||
- `--inject` 路径:`orca terminal read --terminal <handle>`,确认 TASK 段已出现
|
||||
且终端进入工作指示(Working / Running)。
|
||||
- 手动投递路径:`orca terminal send` 必须带 `--enter`;投递后同样 read 确认。
|
||||
- 确认失败或终端仍停在欢迎提示:按「消息未投递」记录环境失败,不消耗产品轮次。
|
||||
2. 等待期间滚动 probe(每 60–120 秒一次;不要在 30 秒窗口后每次都 probe):
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/worker_probe.py \
|
||||
--task-id <task_id> --terminal <worker_handle>
|
||||
```
|
||||
|
||||
输出 JSON `status`:`running` / `progress` / `stall` / `not-started` / `unknown`。
|
||||
3. 探测结果处理:
|
||||
- `stall`:读 terminal tail 确认原因(审批 / 模型切换 / 额度限制),按环境失败
|
||||
记录 `environmentIncidents` 并做有界恢复;需要用户决定时立即报告。
|
||||
- `not-started`:检查是否漏投递或未回车;重新投递或记录环境失败,不占轮次。
|
||||
- `running` / `progress`:继续滚动 wait。
|
||||
- `unknown`:按 `dispatch-show` 与 Orca live state 人工核对,不自动重试。
|
||||
4. `check --wait` 默认使用 60 秒窗口而不是 30 秒或 15 分钟:窗口超时是检查点,
|
||||
仅在达到 probe 间隔后再探测,然后决定继续等待、恢复或上报。新鲜 heartbeat 才能
|
||||
说明 progress;按 worker 每 5 分钟一次的约定,连续三个周期(超过 15 分钟)未更新、
|
||||
格式非法或时间在未来的 heartbeat 只能报告 `unknown`,不得当作持续进展。
|
||||
|
||||
```bash
|
||||
orca orchestration check \
|
||||
--terminal <coordinator_handle> \
|
||||
--wait \
|
||||
--types worker_done,retest_result,escalation,decision_gate \
|
||||
--timeout-ms 60000 \
|
||||
--json
|
||||
```
|
||||
|
||||
`worker_done` 来自 Developer,`retest_result`(无该类型时用 `worker_done` + subject
|
||||
区分)来自 Test。
|
||||
|
||||
---
|
||||
|
||||
## Developer 回报 worker_done
|
||||
|
||||
字段含义见 `prompt-templates.md` §4:
|
||||
|
||||
```bash
|
||||
orca orchestration send \
|
||||
--to <coordinator_handle> \
|
||||
--type worker_done \
|
||||
--subject "<task_id> fix ready round <n>" \
|
||||
--body "<修了什么。跑了哪些验证。如何复现。还有什么风险。>" \
|
||||
--payload '{
|
||||
"taskId": "<orca_task_id>",
|
||||
"dispatchId": "<orca_dispatch_id>",
|
||||
"attemptId": "<task_id>-A<n>",
|
||||
"filesModified": ["<file_a>", "<file_b>"],
|
||||
"verification": ["<command_a>: passed", "<command_b>: passed"],
|
||||
"knowledgeApplied": [
|
||||
{"ref": "<K-001@1>", "result": "applied", "evidence": "<what was done>"}
|
||||
],
|
||||
"knowledgeCandidates": [
|
||||
{
|
||||
"kind": "pitfall",
|
||||
"title": "<title>",
|
||||
"claim": "<evidence-backed claim>",
|
||||
"scope": {"components": ["<component>"], "paths": ["<path glob>"]},
|
||||
"appliesWhen": "<trigger>",
|
||||
"directive": "<action, not a shell command>",
|
||||
"rationale": "<why it matters>",
|
||||
"evidenceRefs": ["<current task evidence>"]
|
||||
}
|
||||
],
|
||||
"risk": "<remaining risk or none>"
|
||||
}' \
|
||||
--json
|
||||
```
|
||||
|
||||
收到无错误的 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、失败或环境异常时不要执行。
|
||||
|
||||
---
|
||||
|
||||
## Test 回报复测结果
|
||||
|
||||
字段含义见 `prompt-templates.md` §5:
|
||||
|
||||
```bash
|
||||
orca orchestration send \
|
||||
--to <coordinator_handle> \
|
||||
--type retest_result \
|
||||
--subject "<task_id> retest round <n>" \
|
||||
--body "<逐条验收信号结论 + 实际观察 + 证据>" \
|
||||
--payload '{
|
||||
"taskId": "<orca_task_id>",
|
||||
"dispatchId": "<orca_dispatch_id>",
|
||||
"attemptId": "<task_id>-A<n>",
|
||||
"env": {"worktree": "<path>", "branch": "<branch>", "commit": "<sha>", "baseUrl": "<base_url>"},
|
||||
"signals": ["<signal 1>: pass", "<signal 2>: fail (<evidence>)"],
|
||||
"knowledgeChecks": [
|
||||
{"ref": "<K-001@1>", "result": "passed", "evidence": "<independent evidence>"}
|
||||
],
|
||||
"knowledgeCandidates": [],
|
||||
"conclusion": "all-signals-pass | signals-failed"
|
||||
}' \
|
||||
--json
|
||||
```
|
||||
|
||||
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
||||
区分。收到无错误的 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`。任务通过
|
||||
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
|
||||
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
|
||||
|
||||
---
|
||||
|
||||
## 整轮结束时回收 worker
|
||||
|
||||
未在角色完成时回收的 worker,或需要整轮兜底回收的 worker,才在 Coordinator 把本轮
|
||||
最终结论写入 `tasks.yaml` 后执行整轮回收。统一使用回收脚本,不要手工逐个 close:
|
||||
|
||||
```bash
|
||||
# 第一步:dry-run 审阅决策(不关闭任何终端)
|
||||
python3 <ack-skill-dir>/scripts/reclaim_workers.py --project-root <project-root>
|
||||
|
||||
# 第二步:审阅输出后真正回收
|
||||
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
|
||||
--project-root <project-root> --apply
|
||||
```
|
||||
|
||||
脚本按 receipt 的 `binding.handle` 聚合任务引用,规则固定:
|
||||
|
||||
- **关闭**:handle 关联的全部任务都为 `verified`,且没有未解决环境事件。
|
||||
- **保留(不设置 TTL)**:任一关联任务处于 `open`、`dispatched`、`fixed_by_dev`、
|
||||
`retesting`、`blocked`、`failed_retest`、`leftover`,或存在未解决
|
||||
`environmentIncidents`,或 receipt 引用未知任务。失败三次的 worker(
|
||||
`failed_retest` / `leftover` / `blocked`)按此保留。
|
||||
- **不触碰**:`workerReceipts` 之外的 handle(Coordinator 终端、用户 Shell)。
|
||||
|
||||
`--apply` 时脚本对每个待关闭 handle 执行 `orca terminal show` 核对身份 →
|
||||
`orca terminal close --tab` → 重新 `terminal list` 确认 handle 已消失;任何一步
|
||||
不确定都保留并标记 `uncertain`,禁止盲目重试。关闭终端不删除 `workerReceipts`、
|
||||
dispatch 证据或测试记录;这些仍是审计事实。保留下来的 blocked/failed worker 只在
|
||||
后续任务得到验证或用户明确要求清理时回收。
|
||||
@@ -0,0 +1,315 @@
|
||||
# 派发 Prompt 模板(稳定核心)
|
||||
|
||||
Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发复测。复制时替换尖括号占位符。模板与编排工具无关:Orca 模式用 `orca-adapter.md` 的命令投递,手动模式直接把文本发给对应 Agent。
|
||||
|
||||
角色分工见 `roles-and-permissions.md`;闭环顺序见 `closed-loop.md`。
|
||||
|
||||
派发时除了具体任务,Coordinator 应把对应角色的**能力要求**一并带上(`roles-and-permissions.md` §「三角色能力清单」的 Must Do / Must Not)。下面模板已内置关键条目,复制即可。
|
||||
|
||||
---
|
||||
|
||||
## 0. 可选 skills 路由(装了才用,不阻塞)
|
||||
|
||||
如果 worker 所在环境已安装以下 skill,可在对应环节调用以获得更强 playbook;未安装则按 `roles-and-permissions.md` 的能力清单执行:
|
||||
|
||||
- Coordinator 规划复杂需求:`/think` 或 `superpowers:brainstorming` / `writing-plans`。
|
||||
- Developer 排查缺陷:`/hunt` 或 `superpowers:systematic-debugging`;行为变更:`superpowers:test-driven-development`。
|
||||
- Test 复测 / 合并前检查:`/check` 或 `superpowers:verification-before-completion`。
|
||||
|
||||
派发时可加一行:「若已安装 <skill>,本环节可用它;未安装按 ack 角色能力清单执行。」
|
||||
|
||||
---
|
||||
|
||||
## 1. 初始派发给 Developer
|
||||
|
||||
```text
|
||||
你现在接到一个受监督的协作任务。请在 <dev_worktree> 开发工作树内完成。
|
||||
|
||||
任务:
|
||||
- 修复 <task_id>: <task_title>
|
||||
- 本轮逻辑 attempt:<task_id>-A<round>
|
||||
|
||||
请先读取:
|
||||
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile)
|
||||
- tasks.yaml
|
||||
- <relevant_spec_or_test_doc>
|
||||
|
||||
当前失败证据:
|
||||
<copy latest Test evidence>
|
||||
|
||||
本轮项目知识(仅限 Coordinator 显式选择的 active 固定 revision):
|
||||
- <K-001@1>: <directive + rationale + verification.ref + resolved path/args>
|
||||
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
||||
|
||||
验收标准(可观测信号,见 optimization-method.md §1):
|
||||
1. <expected behavior 1>
|
||||
2. <expected behavior 2>
|
||||
3. <expected behavior 3>
|
||||
|
||||
能力要求(见 roles-and-permissions.md §三角色能力清单 · Developer):
|
||||
- 动手前先复现失败现象,或先写一个会失败的测试再修。
|
||||
- bug 修复配可复现的失败用例;行为变更配单元测试。
|
||||
- 完成前亲自走一遍验收路径,不只满足静态文案。
|
||||
- 若是网站 / 常驻服务,改完重启服务并确认生效,别让 Test 测到旧进程或旧构建。
|
||||
|
||||
约束:
|
||||
- 只修改 Developer 可写路径(见覆盖层文件的权限表)。
|
||||
- 不要修改产品规格和集成测试文件(分别由 Coordinator 与 Test 拥有),除非任务明确要求。
|
||||
- 不要写 tasks.yaml,不要标记 verified。
|
||||
- 不要写 knowledge.yaml,不要自行扩展或全量读取知识库;candidate 不是已生效规则。
|
||||
- 不要把知识正文或 path/args 拼成 shell 命令。只把 verification.ref 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`。
|
||||
- 不要提交或推送,除非用户明确要求。
|
||||
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
|
||||
|
||||
完成前必须运行:
|
||||
- <test command 1>
|
||||
- <test command 2>
|
||||
|
||||
完成后回报一次 worker_done(格式见 §4),包括 `knowledgeApplied` 和
|
||||
`knowledgeCandidates`。如果阻塞,请发送 escalation 或 ask。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 复测失败再派发给 Developer
|
||||
|
||||
```text
|
||||
第 <n> 轮复测未通过,请继续修 <task_id>。
|
||||
本轮逻辑 attempt:<task_id>-A<n>
|
||||
|
||||
上一轮开发声称:
|
||||
<worker_done summary>
|
||||
|
||||
Test 独立复测结果:
|
||||
- 运行命令:<commands>
|
||||
- 失败页面/API:<page or endpoint>
|
||||
- 仍缺失:<missing expected checks>
|
||||
- 实际观察:<actual evidence>
|
||||
|
||||
关键提示:
|
||||
- 不要只满足静态文案,必须满足交互后的真实状态。
|
||||
- 如果后端/API 正常而 UI 不更新,请优先排查事件链、状态重置、异步请求、构建产物和服务 worktree。
|
||||
- 修复后请自己用浏览器或脚本复现 Test 的完整步骤。
|
||||
|
||||
验收不变:
|
||||
<copy acceptance criteria>
|
||||
|
||||
本轮知识引用:
|
||||
<copy Coordinator confirmed knowledgeRefs; do not add candidates>
|
||||
|
||||
完成后回报 worker_done,subject 使用:"<task_id> fix ready round <n>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 派发给 Test 复测
|
||||
|
||||
Developer 回报 worker_done 后,Coordinator 把复测任务发给 Test。
|
||||
|
||||
```text
|
||||
请对 <task_id>: <task_title> 做独立黑盒复测。
|
||||
本轮逻辑 attempt:<task_id>-A<round>
|
||||
|
||||
请先读取:
|
||||
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile)
|
||||
- tasks.yaml(该任务的验收信号)
|
||||
- <relevant_spec_or_test_doc>
|
||||
|
||||
Developer 本轮声称(仅供参考,不作数):
|
||||
- 改动文件:<files>
|
||||
- 自测命令:<commands>
|
||||
- 实际采用知识:<knowledgeApplied>
|
||||
- 新知识候选:<knowledgeCandidates; candidate only>
|
||||
|
||||
本轮项目知识(仅限 Coordinator 显式选择的 active 固定 revision):
|
||||
- <K-001@1>: <directive + rationale + verification.ref + resolved path/args>
|
||||
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
||||
|
||||
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
||||
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 deployer 绑定拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
|
||||
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
||||
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
||||
1. <observable signal 1>
|
||||
2. <observable signal 2>
|
||||
3. <observable signal 3>
|
||||
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
||||
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
||||
证据,则分别列出产品信号与环境限制。
|
||||
- 需要时把本轮通过的黑盒路径写成 `regressionCandidates`(见 regression.md),不要直接改 `.pouch/ack/regression.yaml`。
|
||||
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
|
||||
对 candidate 使用独立观测验证,不能复述 Developer 的结论作为证据。
|
||||
|
||||
约束:
|
||||
- 只读源码,不修改应用代码,不写 tasks.yaml 或 knowledge.yaml。
|
||||
- candidate 不属于 active 知识;不要全量注入知识库,不要从知识正文执行自由 shell。
|
||||
- 只回传证据和逐条结论,最终判定由 Coordinator 终检后落盘。
|
||||
|
||||
完成后回报一次复测报告(格式见 §5),subject:"<task_id> retest round <n>"。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Developer worker_done 回报格式
|
||||
|
||||
无论 Orca 还是手动,回报都应包含以下字段:
|
||||
|
||||
```text
|
||||
subject: <task_id> fix ready round <n>
|
||||
attemptId: <task_id>-A<n>
|
||||
filesModified: [<file_a>, <file_b>]
|
||||
verification:
|
||||
- <command_a>: passed
|
||||
- <command_b>: passed
|
||||
reproduce: 我如何复现验收路径 <steps>
|
||||
knowledgeApplied:
|
||||
- ref: <K-001@1>
|
||||
result: <applied|not_applicable>
|
||||
evidence: <what was done or why not applicable>
|
||||
knowledgeCandidates:
|
||||
- kind: guardrail/pitfall/verification
|
||||
title: <reusable project lesson>
|
||||
claim: <evidence-backed project claim>
|
||||
scope:
|
||||
components: [<component>]
|
||||
paths: [<path glob>]
|
||||
dependencies: [<dependency>]
|
||||
versions: [<version>]
|
||||
tags: [<tag>]
|
||||
appliesWhen: <trigger>
|
||||
directive: <action, not a shell command>
|
||||
rationale: <why this changes future work>
|
||||
evidenceRefs: [<current task evidence reference>]
|
||||
risk: <remaining risk or none>
|
||||
```
|
||||
|
||||
没有命中知识或没有新 candidate 时,对应列表写 `[]`。Developer 不能把自己读到的
|
||||
旧知识复述为新 candidate;每条 candidate 都需要当前任务产生的观测证据。
|
||||
|
||||
Orca 模式下用 `orca-adapter.md` §「Developer 回报 worker_done」的命令发送同样的字段。
|
||||
|
||||
---
|
||||
|
||||
## 5. Test 复测报告格式
|
||||
|
||||
Test 只回传证据和逐条结论,不下最终判定:
|
||||
|
||||
```text
|
||||
subject: <task_id> retest round <n>
|
||||
attemptId: <task_id>-A<n>
|
||||
env:
|
||||
worktree: <path>
|
||||
branch: <branch>
|
||||
commit: <short sha>
|
||||
baseUrl: <base_url>
|
||||
commands:
|
||||
- <command_a>: passed/failed
|
||||
signals:
|
||||
- <signal 1>: pass/fail (<evidence>)
|
||||
- <signal 2>: pass/fail (<evidence>)
|
||||
browser: <snapshot / DOM / API evidence>
|
||||
knowledgeChecks:
|
||||
- ref: <K-001@1>
|
||||
result: <passed|failed|not_applicable>
|
||||
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:
|
||||
- kind: guardrail/pitfall/verification
|
||||
title: <new lesson found by Test>
|
||||
claim: <evidence-backed project claim>
|
||||
scope:
|
||||
components: [<component>]
|
||||
paths: [<path glob>]
|
||||
dependencies: [<dependency>]
|
||||
versions: [<version>]
|
||||
tags: [<tag>]
|
||||
appliesWhen: <trigger>
|
||||
directive: <action, not a shell command>
|
||||
rationale: <why this changes future work>
|
||||
evidenceRefs: [<independent evidence reference>]
|
||||
conclusion: all-signals-pass / signals-failed
|
||||
environmentFailure: null / <phase, evidence, impact, recovery suggestion>
|
||||
notes: <observations, suspected cause, and independent candidate validation evidence>
|
||||
```
|
||||
|
||||
没有适用项时列表写 `[]`。Test 的知识结论仍只是证据;只有 Coordinator 能写入或
|
||||
激活 `knowledge.yaml`。
|
||||
|
||||
Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发送同样的字段。
|
||||
|
||||
---
|
||||
|
||||
## 6. Coordinator 最终报告模板
|
||||
|
||||
```text
|
||||
本轮闭环完成。
|
||||
|
||||
已验证(Test 复测通过 + 终检):
|
||||
- <task_id>: <what passed>
|
||||
|
||||
环境事件:
|
||||
- <task_id>: <原因、影响、是否恢复、Coordinator 下一步、userAction>
|
||||
|
||||
遗留:
|
||||
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
|
||||
|
||||
验证命令:
|
||||
- <command>: passed
|
||||
|
||||
项目知识:
|
||||
- 本轮采用:<knowledgeRefs and checks>
|
||||
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
||||
- 待验证 candidate:<remaining candidates or none>
|
||||
|
||||
回归:
|
||||
- 收获:新增/更新/退役/无回归 <case ids or none>
|
||||
- 最近一次回归运行:<RR-id / passed|failed|n/a>
|
||||
|
||||
交付(未启用时写 n/a):
|
||||
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
||||
- PR/MR:<URL and head/base>
|
||||
- 产物:<reference, digest, destination>
|
||||
- 部署:<environment, health/rollback evidence>
|
||||
- 待审批或未完成:<approval/blocked step or none>
|
||||
|
||||
工作树状态:
|
||||
- <repo_path>: <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 时按点名范围执行。
|
||||
@@ -0,0 +1,215 @@
|
||||
# 角色与权限(稳定核心)
|
||||
|
||||
本文件是**角色模型、路径权限、任务状态机、完成定义**的单一事实源(SSOT)。其它文件只引用本文件,不重复定义。
|
||||
|
||||
目标:让每个 Agent 只处理自己能验证的事情,减少上下文污染和越权修改。
|
||||
|
||||
---
|
||||
|
||||
## 角色模型(三角色)
|
||||
|
||||
ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。
|
||||
|
||||
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
||||
|------|----------|----------|------------|
|
||||
| Coordinator (PM) | 需求拆解、定验收信号、单写 `task-records/` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
||||
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
||||
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
||||
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||
|
||||
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有追加到 `task-records/` 才算数。
|
||||
|
||||
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
||||
|
||||
---
|
||||
|
||||
## 三角色能力清单(SSOT)
|
||||
|
||||
上面的表定义了**边界**(谁能碰什么),这一节定义**能力**(每个角色到底该怎么做好自己的事)。每个角色用同一骨架描述:`Outcome`(产出什么)/ `Must Do`(必须做)/ `Must Not`(不能做)/ `Evidence`(拿什么证明)/ `Output`(交付格式)。派发 prompt 会引用这里,见 `prompt-templates.md`。
|
||||
|
||||
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `.pouch/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
|
||||
|
||||
### Coordinator (PM):拆解与终检
|
||||
|
||||
- **Outcome**:把一句话需求变成可执行、验收可观测的任务集,并跑完闭环得到明确结论(verified / leftover)。
|
||||
- **Must Do**
|
||||
- 先澄清意图再动手:目标、成功标准、约束、明确「不做什么」。歧义有多解或多来源冲突时,先问清再拆。
|
||||
- 每个任务写**可观测验收信号**(可见文本 / API 结果 / 交互结果,见 `optimization-method.md` §1),而不是「功能正常」。
|
||||
- 拆任务时点明最脆弱的假设:「本任务假设 X,若 X 不成立则 Y」;列出被否掉的方案与原因。
|
||||
- 拆分/验收先给用户确认,再派发(`kickoff.md` 第 1 步的停顿点)。
|
||||
- `prepare(task)` 时按 component、path、dependency、version 和 tag 从
|
||||
`knowledge.yaml` 推荐相关 `active` 知识;人工确认后把固定 revision 的
|
||||
`knowledgeRefs` 写入当前任务上下文。
|
||||
- 新逻辑轮次默认分配稳定的 `<task-id>-A<round>`,记录到
|
||||
`dispatch.rounds[].attemptId`;旧轮次作为知识来源前再补齐,不要用编排工具的
|
||||
`dispatchId` 代替。
|
||||
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks` 和
|
||||
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
|
||||
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮有效
|
||||
产品复验。环境失败单独记录、恢复和报告,不占轮次。
|
||||
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
|
||||
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行。
|
||||
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
|
||||
- **Output**:确认前给「产品文档 + 任务拆分 + 验收信号」;闭环结束给最终报告(`prompt-templates.md` §6)。
|
||||
- **可选 skills**:复杂需求可先用 `/think` 或 `superpowers:brainstorming` / `writing-plans` 收敛设计与计划。
|
||||
|
||||
### Developer:实现与白盒验证
|
||||
|
||||
- **Outcome**:在授权路径内做出满足验收信号的最小改动,并用白盒证据证明它可复现。
|
||||
- **Must Do**
|
||||
- 动手前先读覆盖层文件、`tasks.yaml` 对应任务、相关规格;复现失败现象或先写会失败的测试。
|
||||
- 只使用 Coordinator 本轮显式派发的 `active` 知识,按固定 revision 回报
|
||||
`knowledgeApplied`;发现跨任务可复用的项目经验时提交带当前观测证据的
|
||||
`knowledgeCandidates`。
|
||||
- 最小 diff,只改一个明确问题的根因,不顺手重构无关代码。
|
||||
- 行为变更配单元测试;bug 修复先有一个能复现的失败用例再修。
|
||||
- 完成前跑覆盖层里规定的命令(构建 / 单测 / 本地运行),亲自走一遍验收路径。
|
||||
- **网站 / 常驻服务**:改完重启服务(或触发热更并确认生效),保证运行实例跑的是新代码,避免 Test 测到旧进程 / 旧构建。
|
||||
- **Must Not**:改产品规格与集成测试、写 `tasks.yaml` 或 `knowledge.yaml`、标
|
||||
`verified`、把 candidate 当作已生效规则、绕过测试声称完成、把 bug 修复扩成大
|
||||
重构(需要就先停下说明并请示)。
|
||||
- **Evidence**:改了哪些文件、跑了哪些命令及结果、如何复现验收路径、实际采用的
|
||||
`knowledgeRefs`、新 candidate 的当前任务证据、残留风险。
|
||||
- **Output**:一次 `worker_done`,字段见 `prompt-templates.md` §4(只报证据,不下最终结论)。
|
||||
- **可选 skills**:排查用 `/hunt` 或 `superpowers:systematic-debugging`(先根因后修);实现行为变更用 `superpowers:test-driven-development`。
|
||||
|
||||
### Test:独立黑盒复测
|
||||
|
||||
- **Outcome**:以独立视角复现验收路径,逐条给出通过/失败的可观测证据,供 Coordinator 终检。
|
||||
- **Must Do**
|
||||
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 `closed-loop.md`),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
|
||||
- 逐条验证验收信号,验证交互后的真实状态,而不是只看静态文案。
|
||||
- 对 Coordinator 派发的每条 `knowledgeRef` 执行适用的额外检查,回报
|
||||
`knowledgeChecks`;对本轮 candidate 使用独立观测验证,不能复述 Developer
|
||||
结论当作证据。
|
||||
- **网站类任务优先用浏览器复测**真实交互(点击 / 跳转 / 渲染),其次才是 API / 脚本;纯后端 / CLI 则以 API smoke 或脚本为主。
|
||||
- 把最容易反复误判的路径沉淀成可执行测试(`optimization-method.md` §8)。
|
||||
- 只回传证据 + 逐条结论,最终判定留给 Coordinator。
|
||||
- **Must Not**:改应用源码、改产品规格、写 `tasks.yaml` 或 `knowledge.yaml`、凭
|
||||
Developer 的 `worker_done` 直接下结论、把 candidate 作为 active 知识执行。
|
||||
- **Evidence**:运行环境快照、命令结果、每条信号 pass/fail + 证据
|
||||
(snapshot / DOM / API 结果)、每条适用知识的
|
||||
`passed` / `failed` / `not_applicable` + 证据。
|
||||
- **Output**:一次复测报告,字段见 `prompt-templates.md` §5。
|
||||
- **可选 skills**:合并 / 发版前检查可用 `/check` 或 `superpowers:verification-before-completion`(证据先于结论)。
|
||||
|
||||
---
|
||||
|
||||
## 路径权限模板
|
||||
|
||||
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `.pouch/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
|
||||
|
||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||
|------|:-----------:|:----:|:---------:|------|
|
||||
| `<spec_paths>` | R/W | Read-only | Read-only | PRD、API spec、设计文档,Coordinator(PM)拥有 |
|
||||
| `<integration_test_paths>` | Read-only | R/W | Read-only | 浏览器用例、API smoke、回归清单,Test 拥有 |
|
||||
| `<test_records_path>` | Read-only | R/W | Read-only | 复测记录,通常可 gitignore |
|
||||
| `<source_paths>` | Read-only | Read-only | R/W | 应用源码 |
|
||||
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
||||
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只保存稳定项目配置;任务状态见 `task-records/` |
|
||||
| `task-records/` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
||||
| `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 | 声明项目交付能力,不保存凭据或执行授权 |
|
||||
|
||||
---
|
||||
|
||||
## 任务状态机(SSOT)
|
||||
|
||||
```text
|
||||
open
|
||||
-> dispatched (派发给 Developer)
|
||||
-> fixed_by_dev (Developer 声称已修)
|
||||
-> retesting (派发给 Test 复测)
|
||||
-> verified (Test 通过 + Coordinator 终检)
|
||||
```
|
||||
|
||||
失败分支:
|
||||
|
||||
```text
|
||||
dispatched -> blocked
|
||||
retesting -> failed_retest -> dispatched
|
||||
failed_retest(累计 3 轮) -> leftover
|
||||
```
|
||||
|
||||
环境分支不进入 `failed_retest`:
|
||||
|
||||
```text
|
||||
launch / service / data / browser / tooling environment failure
|
||||
-> dispatch.environmentIncidents
|
||||
-> 有界恢复并告知用户下一步
|
||||
-> fixed_by_dev(待恢复)或 blocked(需外部动作)
|
||||
-> 环境恢复后 retesting
|
||||
```
|
||||
|
||||
状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同):
|
||||
|
||||
| 状态 | 依据来源 | 含义 |
|
||||
|------|----------|------|
|
||||
| `open` | Coordinator 自己发现/记录 | 已发现,等待处理 |
|
||||
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
|
||||
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
|
||||
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
|
||||
| `failed_retest` | Test 的产品复测报告 | 环境对齐后观察到产品验收失败,可继续派发 Developer |
|
||||
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
|
||||
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
|
||||
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
|
||||
|
||||
有效复验与环境失败的处理细则见 `optimization-method.md` §4。
|
||||
|
||||
## 交付状态(与任务状态正交)
|
||||
|
||||
任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在
|
||||
`tasks.yaml.deliveryRuns`:
|
||||
|
||||
```text
|
||||
planned -> running -> validation_ready | review_ready | released
|
||||
-> blocked | failed
|
||||
planned -> skipped
|
||||
```
|
||||
|
||||
`validation_ready` 表示开发/测试环境已部署且健康检查通过,等待用户手工验证;
|
||||
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
|
||||
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
|
||||
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
|
||||
审批点和恢复规则见 `delivery.md`。
|
||||
|
||||
---
|
||||
|
||||
## 项目状态写入约定(并发安全)
|
||||
|
||||
`task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置,
|
||||
`knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源,
|
||||
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
||||
|
||||
- **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。
|
||||
Test 与 Developer 对它们都是只读的。
|
||||
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
||||
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
|
||||
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
|
||||
知识。
|
||||
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
|
||||
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
|
||||
- `delivery.yaml` 只在用户显式要求维护配置时修改;运行只写
|
||||
`tasks.yaml.deliveryRuns`,不能反向改写能力定义。
|
||||
|
||||
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
|
||||
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
|
||||
证据引用,不替代可执行控制。
|
||||
|
||||
---
|
||||
|
||||
## 完成定义(Definition of Done)
|
||||
|
||||
一个任务只有同时满足以下条件,才能标记 `verified`:
|
||||
|
||||
- Developer 已提供修改文件和白盒验证证据(`worker_done`)。
|
||||
- Test 在正确 worktree 和正确服务实例上独立复测通过(对齐检查见 `closed-loop.md`),并产出可观测证据。
|
||||
- 相关单元测试、构建、集成或浏览器检查通过。
|
||||
- 当前任务显式 `knowledgeRefs` 对应的必需 `knowledgeChecks` 已由 Test 覆盖;
|
||||
未覆盖或检查引用无法解析时不能标记 `verified`。
|
||||
- **Coordinator 终检**:读 Test 的证据,确认它满足任务的原始意图与验收信号(不是重测,是审证据 + 对齐意图;避免"过了字面没过意图")。
|
||||
- `tasks.yaml` 中记录了复测证据与 `resolution.verifiedBy`。
|
||||
- 用户可见行为符合验收标准。
|
||||
@@ -0,0 +1,11 @@
|
||||
参考项目1
|
||||
|
||||
[https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team](https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team)
|
||||
|
||||
参考项目2
|
||||
|
||||
[https://github.com/DannyMac180/sol-advisor](https://github.com/DannyMac180/sol-advisor)
|
||||
|
||||
参考项目3
|
||||
|
||||
[https://cursor.com/cn/blog/agent-swarm-model-economics](https://cursor.com/cn/blog/agent-swarm-model-economics)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Canonical reviewed Bug task payload shared by intake and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
LEGACY_PAYLOAD_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"priority",
|
||||
"actual",
|
||||
"expected",
|
||||
"stepsToReproduce",
|
||||
"fixLogic",
|
||||
"acceptanceCriteria",
|
||||
)
|
||||
CLARIFIED_PAYLOAD_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"actual",
|
||||
"expected",
|
||||
"acceptanceCriteria",
|
||||
)
|
||||
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
|
||||
|
||||
|
||||
def review_items(value: str) -> list[str]:
|
||||
"""Recover line or numbered-list review text as stable non-empty items."""
|
||||
lines = [line.strip(" \t-*•") for line in value.splitlines() if line.strip()]
|
||||
if len(lines) > 1:
|
||||
return lines
|
||||
text = value.strip()
|
||||
matches = list(NUMBERED_ITEM.finditer(text))
|
||||
if matches:
|
||||
items: list[str] = []
|
||||
for index, match in enumerate(matches):
|
||||
start = match.end()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
item = text[start:end].strip()
|
||||
if item:
|
||||
items.append(item)
|
||||
if items:
|
||||
return items
|
||||
return [text] if text else []
|
||||
|
||||
|
||||
def approval_payload_hash(task: dict[str, Any]) -> str:
|
||||
"""Hash the exact reviewed fields that Developer and Test consume."""
|
||||
source = task.get("source")
|
||||
workflow = source.get("workflow") if isinstance(source, dict) else None
|
||||
fields = (
|
||||
CLARIFIED_PAYLOAD_FIELDS
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else LEGACY_PAYLOAD_FIELDS
|
||||
)
|
||||
payload = {field: task.get(field) for field in fields}
|
||||
encoded = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+1790
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reclaim ACK worker terminals at the end of a coordination round.
|
||||
|
||||
Reads the authoritative tasks.yaml and decides, per worker receipt handle,
|
||||
whether the terminal may be closed:
|
||||
|
||||
- close: every task referencing the handle is ``verified`` and no referenced
|
||||
task has an unresolved ``dispatch.environmentIncidents`` entry;
|
||||
- retain: any referenced task is still open/dispatched/fixed_by_dev/retesting/
|
||||
blocked/failed_retest/leftover, has an open environment incident, or the
|
||||
receipt references an unknown task. Failed-three-times workers stay open by
|
||||
design (leftover/failed_retest/blocked are all retained);
|
||||
- skip: handles not present in workerReceipts (coordinator terminal, user
|
||||
shells) are never touched.
|
||||
|
||||
Default mode is dry-run: print decisions only. Pass ``--apply`` to actually
|
||||
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
|
||||
live terminal list; anything uncertain stays retained and is reported instead
|
||||
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:
|
||||
{"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from launch_worker import ( # noqa: E402
|
||||
LaunchError,
|
||||
load_authoritative_board,
|
||||
resolve_executable,
|
||||
run_json,
|
||||
)
|
||||
|
||||
KEEP_STATUSES = frozenset(
|
||||
{"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]]:
|
||||
"""Map each worker receipt handle to its referenced task facts."""
|
||||
tasks_by_id = {task.get("id"): task for task in board.get("tasks", []) if isinstance(task, dict)}
|
||||
handles: dict[str, dict[str, object]] = {}
|
||||
for receipt in board.get("workerReceipts", []):
|
||||
if not isinstance(receipt, dict):
|
||||
continue
|
||||
binding = receipt.get("binding")
|
||||
created_for = receipt.get("createdFor")
|
||||
if not isinstance(binding, dict) or not isinstance(created_for, dict):
|
||||
continue
|
||||
handle = binding.get("handle")
|
||||
task_id = created_for.get("taskId")
|
||||
if not isinstance(handle, str) or not handle:
|
||||
continue
|
||||
entry = handles.setdefault(handle, {"taskIds": [], "tasks": []})
|
||||
if isinstance(task_id, str) and task_id not in entry["taskIds"]:
|
||||
entry["taskIds"].append(task_id)
|
||||
entry["tasks"].append(tasks_by_id.get(task_id))
|
||||
return handles
|
||||
|
||||
|
||||
def unresolved_incidents(task: dict | None) -> list[str]:
|
||||
if not isinstance(task, dict):
|
||||
return []
|
||||
dispatch = task.get("dispatch")
|
||||
if not isinstance(dispatch, dict):
|
||||
return []
|
||||
incidents = dispatch.get("environmentIncidents")
|
||||
if not isinstance(incidents, list):
|
||||
return []
|
||||
return [
|
||||
str(incident.get("id"))
|
||||
for incident in incidents
|
||||
if isinstance(incident, dict) and incident.get("status") == "open"
|
||||
]
|
||||
|
||||
|
||||
def decide(handle: str, entry: dict[str, object]) -> tuple[str, str, list[str]]:
|
||||
task_ids = entry["taskIds"]
|
||||
tasks = entry["tasks"]
|
||||
statuses: list[str] = []
|
||||
for task in tasks:
|
||||
if isinstance(task, dict):
|
||||
statuses.append(str(task.get("status")))
|
||||
else:
|
||||
statuses.append("unknown-task")
|
||||
incidents: list[str] = []
|
||||
for task in tasks:
|
||||
incidents.extend(unresolved_incidents(task))
|
||||
if incidents:
|
||||
return (
|
||||
"retain",
|
||||
f"unresolved environment incident(s): {', '.join(incidents)}",
|
||||
statuses,
|
||||
)
|
||||
bad = [status for status in statuses if status in KEEP_STATUSES or status == "unknown-task"]
|
||||
if bad:
|
||||
return (
|
||||
"retain",
|
||||
f"referenced task(s) not verified: {', '.join(bad)} (task ids: {', '.join(task_ids)})",
|
||||
statuses,
|
||||
)
|
||||
if not task_ids:
|
||||
return "retain", "receipt has no task reference", statuses
|
||||
return "close", "all referenced tasks verified", statuses
|
||||
|
||||
|
||||
def close_terminal(orca: Path, handle: str) -> None:
|
||||
"""Show-verify identity, close the tab, then confirm it left the live list."""
|
||||
try:
|
||||
show = run_json(
|
||||
[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")
|
||||
if not isinstance(terminal, dict) or terminal.get("handle") != handle:
|
||||
raise LaunchError(f"terminal show 未返回匹配的 handle: {handle}")
|
||||
|
||||
close = run_json(
|
||||
[str(orca), "terminal", "close", "--terminal", handle, "--tab", "--json"],
|
||||
"terminal close",
|
||||
)
|
||||
result = close.get("result")
|
||||
if isinstance(result, dict):
|
||||
closed_handle = result.get("handle") or (result.get("terminal") or {}).get("handle")
|
||||
if closed_handle not in (None, handle):
|
||||
raise LaunchError(f"terminal close 回执 handle 不匹配: {closed_handle!r}")
|
||||
|
||||
listing = run_json(
|
||||
[str(orca), "terminal", "list", "--json"],
|
||||
"terminal list",
|
||||
)
|
||||
terminals = listing.get("result", {}).get("terminals")
|
||||
if isinstance(terminals, list) and any(
|
||||
isinstance(item, dict) and item.get("handle") == handle for item in terminals
|
||||
):
|
||||
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:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根")
|
||||
parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策")
|
||||
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)
|
||||
|
||||
try:
|
||||
project_root, board = load_authoritative_board(args.project_root)
|
||||
except LaunchError as exc:
|
||||
print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False))
|
||||
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)
|
||||
results: list[dict[str, object]] = []
|
||||
for handle in sorted(handles):
|
||||
if args.handle and handle != args.handle:
|
||||
continue
|
||||
entry = handles[handle]
|
||||
decision, reason, statuses = decide(handle, entry)
|
||||
closed: bool | None = None
|
||||
if decision == "close" and args.apply:
|
||||
try:
|
||||
close_terminal(resolve_executable("orca"), handle)
|
||||
closed = True
|
||||
except LaunchError as exc:
|
||||
decision = "uncertain"
|
||||
reason = f"close failed: {exc}"
|
||||
closed = None
|
||||
results.append(
|
||||
{
|
||||
"handle": handle,
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
"tasks": entry["taskIds"],
|
||||
"statuses": statuses,
|
||||
"closed": closed,
|
||||
}
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{"mode": "apply" if args.apply else "dry-run", "handles": results},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
Executable
+430
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""安全执行 knowledge.yaml 中已审查的 verificationRegistry 条目。
|
||||
|
||||
本入口只接受 registry ID,不接受额外命令或参数。执行前只打开一次项目根目录
|
||||
fd,知识库、检查目标和子进程 cwd 都固定到该 fd;检查文件逐段以 O_NOFOLLOW
|
||||
打开后复制到匿名、尽可能 sealed 的稳定快照,再使用结构化 argv 和 shell=False
|
||||
启动,避免检查与执行之间被替换。
|
||||
|
||||
退出码: 0..125 沿用检查结果 / 1 知识或执行失败 / 2 环境、路径或用法错误。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from validate_knowledge import ( # type: ignore
|
||||
infer_project_root,
|
||||
load_yaml_text,
|
||||
validate_all,
|
||||
)
|
||||
|
||||
MAX_KNOWLEDGE_BYTES = 16 * 1024 * 1024
|
||||
MAX_TARGET_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def _project_root(
|
||||
value: str | None,
|
||||
knowledge_path: Path,
|
||||
) -> tuple[Path | None, str | None]:
|
||||
inferred = infer_project_root(knowledge_path)
|
||||
if value is not None:
|
||||
candidate = Path(value).expanduser()
|
||||
if not candidate.is_dir():
|
||||
return None, f"项目根目录不存在: {candidate}"
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if inferred is not None and resolved != inferred:
|
||||
return (
|
||||
None,
|
||||
f"--project-root {resolved} 与 knowledge.yaml 推断的项目根目录 "
|
||||
f"{inferred} 不一致",
|
||||
)
|
||||
return resolved, None
|
||||
if inferred is None:
|
||||
return None, "无法从 knowledge.yaml 确定现有项目根目录"
|
||||
return inferred, None
|
||||
|
||||
|
||||
def _validate_knowledge_location(
|
||||
knowledge_path: Path,
|
||||
project_root: Path,
|
||||
) -> str | None:
|
||||
expected = project_root / ".pouch" / "ack" / "knowledge.yaml"
|
||||
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
|
||||
if lexical != expected:
|
||||
return (
|
||||
"只允许执行项目权威知识库 "
|
||||
f"{expected},当前输入为 {lexical}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _open_regular_beneath(
|
||||
project_root: Path | int,
|
||||
relative_path: str,
|
||||
*,
|
||||
require_executable: bool,
|
||||
) -> tuple[int | None, str | None]:
|
||||
"""从根目录 fd 逐段打开目标,不允许任一段通过 symlink 跳转。"""
|
||||
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
|
||||
return None, "当前平台不支持安全的 O_NOFOLLOW/O_DIRECTORY 路径解析"
|
||||
parts = PurePosixPath(relative_path).parts
|
||||
if (
|
||||
not parts
|
||||
or PurePosixPath(relative_path).is_absolute()
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
return None, "检查目标必须是规范的项目内相对路径"
|
||||
|
||||
directory_fds: list[int] = []
|
||||
target_fd: int | None = None
|
||||
try:
|
||||
root_fd = (
|
||||
os.dup(project_root)
|
||||
if isinstance(project_root, int)
|
||||
else os.open(
|
||||
project_root,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
)
|
||||
)
|
||||
directory_fds.append(root_fd)
|
||||
current_fd = root_fd
|
||||
for segment in parts[:-1]:
|
||||
current_fd = os.open(
|
||||
segment,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
dir_fd=current_fd,
|
||||
)
|
||||
directory_fds.append(current_fd)
|
||||
target_fd = os.open(
|
||||
parts[-1],
|
||||
os.O_RDONLY | os.O_NOFOLLOW,
|
||||
dir_fd=current_fd,
|
||||
)
|
||||
metadata = os.fstat(target_fd)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
os.close(target_fd)
|
||||
return None, "检查目标不是普通文件"
|
||||
if require_executable and metadata.st_mode & 0o111 == 0:
|
||||
os.close(target_fd)
|
||||
return None, "检查目标不可执行"
|
||||
return target_fd, None
|
||||
except OSError as exc:
|
||||
if target_fd is not None:
|
||||
os.close(target_fd)
|
||||
return None, f"检查目标不存在、不可访问或路径包含软链接: {exc}"
|
||||
finally:
|
||||
for directory_fd in reversed(directory_fds):
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _stable_metadata(before: os.stat_result, after: os.stat_result) -> bool:
|
||||
fields = (
|
||||
"st_dev",
|
||||
"st_ino",
|
||||
"st_mode",
|
||||
"st_size",
|
||||
"st_mtime_ns",
|
||||
"st_ctime_ns",
|
||||
)
|
||||
return all(getattr(before, field) == getattr(after, field) for field in fields)
|
||||
|
||||
|
||||
def _read_stable_bytes(
|
||||
source_fd: int,
|
||||
*,
|
||||
maximum: int,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
before = os.fstat(source_fd)
|
||||
if before.st_size > maximum:
|
||||
return None, f"知识库超过大小上限 {maximum} bytes"
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
try:
|
||||
os.lseek(source_fd, 0, os.SEEK_SET)
|
||||
while True:
|
||||
chunk = os.read(source_fd, min(1024 * 1024, maximum - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
return None, f"知识库超过大小上限 {maximum} bytes"
|
||||
except OSError as exc:
|
||||
return None, f"无法读取权威知识库快照: {exc}"
|
||||
after = os.fstat(source_fd)
|
||||
if not _stable_metadata(before, after):
|
||||
return None, "权威知识库在读取期间发生变化,拒绝执行"
|
||||
return b"".join(chunks), None
|
||||
|
||||
|
||||
def load_authoritative_knowledge(
|
||||
project_root: Path | int,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
source_fd, open_error = _open_regular_beneath(
|
||||
project_root,
|
||||
".pouch/ack/knowledge.yaml",
|
||||
require_executable=False,
|
||||
)
|
||||
if open_error is not None or source_fd is None:
|
||||
return None, open_error or "无法安全打开权威知识库"
|
||||
try:
|
||||
content, read_error = _read_stable_bytes(
|
||||
source_fd,
|
||||
maximum=MAX_KNOWLEDGE_BYTES,
|
||||
)
|
||||
finally:
|
||||
os.close(source_fd)
|
||||
if read_error is not None or content is None:
|
||||
return None, read_error or "无法读取权威知识库"
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
return None, f"权威知识库不是有效 UTF-8: {exc}"
|
||||
return load_yaml_text(text, "知识库"), None
|
||||
|
||||
|
||||
def _write_all(file_descriptor: int, chunk: bytes) -> None:
|
||||
remaining = memoryview(chunk)
|
||||
while remaining:
|
||||
written = os.write(file_descriptor, remaining)
|
||||
if written <= 0:
|
||||
raise OSError("无法写入检查快照")
|
||||
remaining = remaining[written:]
|
||||
|
||||
|
||||
def _snapshot_executable(source_fd: int) -> tuple[int | None, str | None]:
|
||||
"""复制到匿名快照;Linux 上进一步 seal,冻结本次执行内容。"""
|
||||
before = os.fstat(source_fd)
|
||||
if before.st_size > MAX_TARGET_BYTES:
|
||||
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
|
||||
snapshot_fd: int | None = None
|
||||
seal_snapshot = all(
|
||||
hasattr(owner, name)
|
||||
for owner, name in (
|
||||
(os, "memfd_create"),
|
||||
(os, "MFD_ALLOW_SEALING"),
|
||||
(os, "MFD_CLOEXEC"),
|
||||
(fcntl, "F_ADD_SEALS"),
|
||||
(fcntl, "F_SEAL_SEAL"),
|
||||
(fcntl, "F_SEAL_SHRINK"),
|
||||
(fcntl, "F_SEAL_GROW"),
|
||||
(fcntl, "F_SEAL_WRITE"),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if seal_snapshot:
|
||||
snapshot_fd = os.memfd_create( # type: ignore[attr-defined]
|
||||
"ack-verification",
|
||||
os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING, # type: ignore[attr-defined]
|
||||
)
|
||||
else:
|
||||
snapshot_fd, snapshot_path = tempfile.mkstemp(
|
||||
prefix="ack-verification-"
|
||||
)
|
||||
os.unlink(snapshot_path)
|
||||
|
||||
os.lseek(source_fd, 0, os.SEEK_SET)
|
||||
total = 0
|
||||
while True:
|
||||
chunk = os.read(
|
||||
source_fd,
|
||||
min(1024 * 1024, MAX_TARGET_BYTES - total + 1),
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
_write_all(snapshot_fd, chunk)
|
||||
total += len(chunk)
|
||||
if total > MAX_TARGET_BYTES:
|
||||
os.close(snapshot_fd)
|
||||
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
|
||||
after = os.fstat(source_fd)
|
||||
if not _stable_metadata(before, after):
|
||||
os.close(snapshot_fd)
|
||||
return None, "检查目标在创建执行快照期间发生变化,拒绝执行"
|
||||
|
||||
os.fchmod(snapshot_fd, before.st_mode & 0o777)
|
||||
os.lseek(snapshot_fd, 0, os.SEEK_SET)
|
||||
if seal_snapshot:
|
||||
seals = (
|
||||
fcntl.F_SEAL_SEAL
|
||||
| fcntl.F_SEAL_SHRINK
|
||||
| fcntl.F_SEAL_GROW
|
||||
| fcntl.F_SEAL_WRITE
|
||||
)
|
||||
fcntl.fcntl(snapshot_fd, fcntl.F_ADD_SEALS, seals)
|
||||
return snapshot_fd, None
|
||||
except OSError as exc:
|
||||
if snapshot_fd is not None:
|
||||
os.close(snapshot_fd)
|
||||
return None, f"无法创建稳定的检查执行快照: {exc}"
|
||||
|
||||
|
||||
def open_target(
|
||||
data: dict[str, Any],
|
||||
verification_ref: str,
|
||||
project_root: Path | int,
|
||||
) -> tuple[int | None, list[str] | None, str | None]:
|
||||
registry = data.get("verificationRegistry")
|
||||
if not isinstance(registry, dict) or verification_ref not in registry:
|
||||
return None, None, f"verification.ref {verification_ref!r} 未在 registry 注册"
|
||||
target = registry.get(verification_ref)
|
||||
if not isinstance(target, dict):
|
||||
return None, None, f"verificationRegistry.{verification_ref} 不是对象"
|
||||
relative_path = target.get("path")
|
||||
args = target.get("args")
|
||||
if not isinstance(relative_path, str) or not isinstance(args, list):
|
||||
return None, None, f"verificationRegistry.{verification_ref} 结构无效"
|
||||
if any(not isinstance(arg, str) for arg in args):
|
||||
return (
|
||||
None,
|
||||
None,
|
||||
f"verificationRegistry.{verification_ref}.args 必须是字符串数组",
|
||||
)
|
||||
source_fd, error = _open_regular_beneath(
|
||||
project_root,
|
||||
relative_path,
|
||||
require_executable=True,
|
||||
)
|
||||
if error is not None or source_fd is None:
|
||||
return None, None, error or "无法安全打开检查目标"
|
||||
try:
|
||||
target_fd, snapshot_error = _snapshot_executable(source_fd)
|
||||
finally:
|
||||
os.close(source_fd)
|
||||
if snapshot_error is not None or target_fd is None:
|
||||
return None, None, snapshot_error or "无法创建检查执行快照"
|
||||
return target_fd, args, None
|
||||
|
||||
|
||||
def _fd_executable_path(target_fd: int) -> str | None:
|
||||
for prefix in ("/proc/self/fd", "/dev/fd"):
|
||||
candidate = f"{prefix}/{target_fd}"
|
||||
if Path(candidate).exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _fd_directory_path(directory_fd: int) -> str | None:
|
||||
for prefix in ("/proc/self/fd", "/dev/fd"):
|
||||
candidate = f"{prefix}/{directory_fd}"
|
||||
if Path(candidate).is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="执行 ACK verificationRegistry 中已审查的检查"
|
||||
)
|
||||
parser.add_argument("knowledge", help="knowledge.yaml 路径")
|
||||
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
help="项目根目录;默认从 knowledge.yaml 的 .pouch/ack 布局或 Git 推断",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
knowledge_path = Path(args.knowledge)
|
||||
project_root, root_error = _project_root(args.project_root, knowledge_path)
|
||||
if project_root is None:
|
||||
sys.stderr.write(f"{root_error or '无法确定现有项目根目录'}\n")
|
||||
return 2
|
||||
location_error = _validate_knowledge_location(knowledge_path, project_root)
|
||||
if location_error is not None:
|
||||
sys.stderr.write(f"{location_error}\n")
|
||||
return 2
|
||||
|
||||
try:
|
||||
project_root_fd = os.open(
|
||||
project_root,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"无法安全打开项目根目录: {exc}\n")
|
||||
return 2
|
||||
|
||||
try:
|
||||
stable_root_path = _fd_directory_path(project_root_fd)
|
||||
if stable_root_path is None:
|
||||
sys.stderr.write("当前平台无法固定项目根目录文件描述符\n")
|
||||
return 2
|
||||
data, knowledge_error = load_authoritative_knowledge(project_root_fd)
|
||||
if knowledge_error is not None or data is None:
|
||||
sys.stderr.write(f"{knowledge_error or '无法读取权威知识库'}\n")
|
||||
return 2
|
||||
schema_path = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "templates"
|
||||
/ "knowledge.schema.json"
|
||||
)
|
||||
errors, mode = validate_all(
|
||||
data,
|
||||
schema_path,
|
||||
project_root=Path(stable_root_path),
|
||||
)
|
||||
if errors:
|
||||
sys.stderr.write(f"知识库校验失败({mode}),拒绝执行:\n")
|
||||
for error in errors:
|
||||
sys.stderr.write(f" - {error}\n")
|
||||
return 1
|
||||
|
||||
target_fd, target_args, error = open_target(
|
||||
data,
|
||||
args.verification_ref,
|
||||
project_root_fd,
|
||||
)
|
||||
if error is not None or target_fd is None or target_args is None:
|
||||
sys.stderr.write(f"{error or '无法解析检查目标'}\n")
|
||||
return 2
|
||||
|
||||
try:
|
||||
executable_path = _fd_executable_path(target_fd)
|
||||
if executable_path is None:
|
||||
sys.stderr.write(
|
||||
"当前平台无法从已打开的文件描述符安全执行检查\n"
|
||||
)
|
||||
return 2
|
||||
environment = os.environ.copy()
|
||||
environment["ACK_PROJECT_ROOT"] = stable_root_path
|
||||
environment["ACK_PROJECT_ROOT_DISPLAY"] = str(project_root)
|
||||
environment["ACK_VERIFICATION_REF"] = args.verification_ref
|
||||
registry = data.get("verificationRegistry")
|
||||
target = (
|
||||
registry.get(args.verification_ref)
|
||||
if isinstance(registry, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(target, dict) and isinstance(target.get("path"), str):
|
||||
environment["ACK_VERIFICATION_PATH"] = target["path"]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[executable_path, *target_args],
|
||||
cwd=stable_root_path,
|
||||
env=environment,
|
||||
shell=False,
|
||||
check=False,
|
||||
pass_fds=(project_root_fd, target_fd),
|
||||
)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"检查启动失败: {exc}\n")
|
||||
return 1
|
||||
finally:
|
||||
os.close(target_fd)
|
||||
finally:
|
||||
os.close(project_root_fd)
|
||||
if 0 <= completed.returncode <= 125:
|
||||
return completed.returncode
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按显式项目上下文确定性选择 active ACK 知识。
|
||||
|
||||
匹配采用大小写敏感 glob;entry 中每个非空 scope 维度都必须被查询上下文命中。
|
||||
scope.all=true 的条目始终命中并优先占用 --limit,数量超过预算时显式失败。其余
|
||||
结果按作用域具体程度及稳定引用排序。本脚本只输出数据,绝不执行 knowledge.yaml
|
||||
中的任何文本。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from validate_knowledge import ( # type: ignore
|
||||
SCOPE_FIELDS,
|
||||
infer_project_root,
|
||||
load_yaml,
|
||||
stable_ref,
|
||||
validate_builtin_structure,
|
||||
validate_semantics,
|
||||
)
|
||||
|
||||
DEFAULT_LIMIT = 10
|
||||
MAX_LIMIT = 100
|
||||
|
||||
|
||||
def _path_glob_matches(value: str, pattern: str) -> bool:
|
||||
"""路径 glob:* 只匹配单段,只有完整的 ** 段可以跨越 /。"""
|
||||
value_parts = tuple(value.split("/"))
|
||||
pattern_parts = tuple(pattern.split("/"))
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def match(pattern_index: int, value_index: int) -> bool:
|
||||
if pattern_index == len(pattern_parts):
|
||||
return value_index == len(value_parts)
|
||||
pattern_part = pattern_parts[pattern_index]
|
||||
if pattern_part == "**":
|
||||
return match(pattern_index + 1, value_index) or (
|
||||
value_index < len(value_parts)
|
||||
and match(pattern_index, value_index + 1)
|
||||
)
|
||||
return (
|
||||
value_index < len(value_parts)
|
||||
and fnmatch.fnmatchcase(value_parts[value_index], pattern_part)
|
||||
and match(pattern_index + 1, value_index + 1)
|
||||
)
|
||||
|
||||
return match(0, 0)
|
||||
|
||||
|
||||
def scope_matches(
|
||||
scope: dict[str, Any], context: dict[str, list[str]]
|
||||
) -> bool:
|
||||
if scope.get("all") is True:
|
||||
return True
|
||||
constrained = False
|
||||
for field in SCOPE_FIELDS:
|
||||
patterns = scope.get(field)
|
||||
if not isinstance(patterns, list) or not patterns:
|
||||
continue
|
||||
constrained = True
|
||||
values = context.get(field, [])
|
||||
matcher = _path_glob_matches if field == "paths" else fnmatch.fnmatchcase
|
||||
if not values or not any(
|
||||
matcher(value, pattern)
|
||||
for pattern in patterns
|
||||
if isinstance(pattern, str)
|
||||
for value in values
|
||||
):
|
||||
return False
|
||||
return constrained
|
||||
|
||||
|
||||
def _pattern_specificity(pattern: str) -> tuple[int, int, int, int, int]:
|
||||
wildcard_count = sum(pattern.count(char) for char in ("*", "?", "["))
|
||||
double_star_count = sum(1 for part in pattern.split("/") if part == "**")
|
||||
literal_count = sum(char not in "*?[]!" for char in pattern)
|
||||
exact = int(wildcard_count == 0)
|
||||
depth = len(pattern.split("/"))
|
||||
return (exact, literal_count, -double_star_count, -wildcard_count, depth)
|
||||
|
||||
|
||||
def _specificity(entry: dict[str, Any]) -> tuple[int, int, int, int, int, int, int]:
|
||||
scope = entry.get("scope")
|
||||
if not isinstance(scope, dict) or scope.get("all") is True:
|
||||
return (0, 0, 0, 0, 0, 0, 0)
|
||||
populated = 0
|
||||
exact = 0
|
||||
literal = 0
|
||||
double_star = 0
|
||||
wildcard = 0
|
||||
depth = 0
|
||||
extra_or_patterns = 0
|
||||
for field in SCOPE_FIELDS:
|
||||
values = scope.get(field)
|
||||
if isinstance(values, list) and values:
|
||||
populated += 1
|
||||
scores = [
|
||||
_pattern_specificity(value)
|
||||
for value in values
|
||||
if isinstance(value, str)
|
||||
]
|
||||
if scores:
|
||||
dimension_score = min(scores)
|
||||
exact += dimension_score[0]
|
||||
literal += dimension_score[1]
|
||||
double_star += dimension_score[2]
|
||||
wildcard += dimension_score[3]
|
||||
depth += dimension_score[4]
|
||||
extra_or_patterns += len(scores) - 1
|
||||
return (
|
||||
populated,
|
||||
exact,
|
||||
literal,
|
||||
double_star,
|
||||
wildcard,
|
||||
depth,
|
||||
-extra_or_patterns,
|
||||
)
|
||||
|
||||
|
||||
def select_entries(
|
||||
data: dict[str, Any],
|
||||
context: dict[str, list[str]],
|
||||
*,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> list[dict[str, Any]]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
|
||||
raise ValueError(f"limit 必须在 1..{MAX_LIMIT} 之间")
|
||||
entries = data.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
matched = [
|
||||
entry
|
||||
for entry in entries
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("status") == "active"
|
||||
and isinstance(entry.get("scope"), dict)
|
||||
and scope_matches(entry["scope"], context)
|
||||
and stable_ref(entry) is not None
|
||||
]
|
||||
global_entries = [
|
||||
entry
|
||||
for entry in matched
|
||||
if isinstance(entry.get("scope"), dict)
|
||||
and entry["scope"].get("all") is True
|
||||
]
|
||||
scoped_entries = [entry for entry in matched if entry not in global_entries]
|
||||
global_entries.sort(key=lambda entry: stable_ref(entry) or "")
|
||||
if len(global_entries) > limit:
|
||||
raise ValueError(
|
||||
f"命中的全项目知识有 {len(global_entries)} 条,超过 --limit={limit};"
|
||||
"提高 limit 后重试,不能静默丢弃全项目护栏"
|
||||
)
|
||||
scoped_entries.sort(
|
||||
key=lambda entry: (
|
||||
*(-part for part in _specificity(entry)),
|
||||
stable_ref(entry) or "",
|
||||
)
|
||||
)
|
||||
return [
|
||||
*global_entries,
|
||||
*scoped_entries[: limit - len(global_entries)],
|
||||
]
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="选择当前任务适用的 active ACK 知识")
|
||||
parser.add_argument(
|
||||
"knowledge", nargs="?", default="knowledge.yaml", help="知识库路径"
|
||||
)
|
||||
parser.add_argument("--component", action="append", default=[])
|
||||
parser.add_argument("--path", action="append", default=[])
|
||||
parser.add_argument("--dependency", action="append", default=[])
|
||||
parser.add_argument("--version", action="append", default=[])
|
||||
parser.add_argument("--tag", action="append", default=[])
|
||||
parser.add_argument("--symbol", action="append", default=[])
|
||||
parser.add_argument("--error-signature", action="append", default=[])
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
help="可选项目根目录,用于 verificationRegistry symlink containment 校验",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format", choices=("json", "refs"), default="json", dest="output_format"
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
knowledge_path = Path(args.knowledge)
|
||||
if not knowledge_path.is_file():
|
||||
sys.stderr.write(f"找不到知识库文件: {knowledge_path}\n")
|
||||
return 2
|
||||
if not 1 <= args.limit <= MAX_LIMIT:
|
||||
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
|
||||
return 2
|
||||
|
||||
if args.project_root:
|
||||
project_root = Path(args.project_root).expanduser()
|
||||
if not project_root.is_dir():
|
||||
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
|
||||
return 2
|
||||
project_root = project_root.resolve()
|
||||
else:
|
||||
project_root = infer_project_root(knowledge_path)
|
||||
|
||||
data = load_yaml(knowledge_path, "知识库")
|
||||
errors = validate_builtin_structure(data)
|
||||
errors.extend(validate_semantics(data, project_root=project_root))
|
||||
registry = data.get("verificationRegistry")
|
||||
if project_root is None and isinstance(registry, dict) and registry:
|
||||
errors.append(
|
||||
"verificationRegistry 非空但无法确定项目根目录;请传入 --project-root"
|
||||
)
|
||||
errors = list(dict.fromkeys(errors))
|
||||
if errors:
|
||||
sys.stderr.write(f"知识库无效,拒绝选择,共 {len(errors)} 项:\n")
|
||||
for error in errors:
|
||||
sys.stderr.write(f" - {error}\n")
|
||||
return 1
|
||||
|
||||
context = {
|
||||
"components": args.component,
|
||||
"paths": args.path,
|
||||
"dependencies": args.dependency,
|
||||
"versions": args.version,
|
||||
"tags": args.tag,
|
||||
"symbols": args.symbol,
|
||||
"errorSignatures": args.error_signature,
|
||||
}
|
||||
try:
|
||||
selected = select_entries(data, context, limit=args.limit)
|
||||
except ValueError as exc:
|
||||
sys.stderr.write(f"知识选择失败: {exc}\n")
|
||||
return 1
|
||||
refs = [stable_ref(entry) for entry in selected]
|
||||
if args.output_format == "refs":
|
||||
if refs:
|
||||
sys.stdout.write("\n".join(ref for ref in refs if ref) + "\n")
|
||||
return 0
|
||||
|
||||
payload = {
|
||||
"count": len(selected),
|
||||
"limit": args.limit,
|
||||
"refs": refs,
|
||||
"entries": [
|
||||
{
|
||||
"ref": stable_ref(entry),
|
||||
**entry,
|
||||
"verificationTarget": (
|
||||
data.get("verificationRegistry", {}).get(
|
||||
entry.get("verification", {}).get("ref")
|
||||
)
|
||||
if isinstance(data.get("verificationRegistry"), dict)
|
||||
and isinstance(entry.get("verification"), dict)
|
||||
else None
|
||||
),
|
||||
}
|
||||
for entry in selected
|
||||
],
|
||||
}
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 ACK Coordinator 输出有预算的任务板上下文。
|
||||
|
||||
脚本会解析并校验完整 tasks.yaml,但只输出项目配置、摘要、可工作任务或显式任务,
|
||||
以及这些任务引用的 worker receipt 和 delivery run。它不会修改任务板,也不会静默
|
||||
截断超过预算的任务集合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from validate_tasks import STATUS_ENUM, load_document, validate_builtin # type: ignore
|
||||
|
||||
|
||||
ACTIONABLE_STATUSES = (
|
||||
"open",
|
||||
"dispatched",
|
||||
"fixed_by_dev",
|
||||
"retesting",
|
||||
"failed_retest",
|
||||
)
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
MAX_OUTPUT_BYTES = 512 * 1024
|
||||
BOARD_METADATA_FIELDS = (
|
||||
"version",
|
||||
"updatedAt",
|
||||
"source",
|
||||
"ackVersion",
|
||||
"kitVersion",
|
||||
"testRecord",
|
||||
"statusReference",
|
||||
)
|
||||
|
||||
|
||||
class SelectionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _unique(values: list[str]) -> list[str]:
|
||||
return list(dict.fromkeys(values))
|
||||
|
||||
|
||||
def select_tasks(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
task_ids: list[str] | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
|
||||
raise SelectionError(f"limit 必须在 1..{MAX_LIMIT} 之间")
|
||||
tasks = data.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
raise SelectionError("tasks 必须是列表")
|
||||
|
||||
requested_ids = _unique(task_ids or [])
|
||||
requested_statuses = _unique(statuses or [])
|
||||
if requested_ids and requested_statuses:
|
||||
raise SelectionError("--task-id 与 --status 不能同时使用")
|
||||
|
||||
if requested_ids:
|
||||
by_id = {
|
||||
task.get("id"): task
|
||||
for task in tasks
|
||||
if isinstance(task, dict) and isinstance(task.get("id"), str)
|
||||
}
|
||||
missing = [task_id for task_id in requested_ids if task_id not in by_id]
|
||||
if missing:
|
||||
raise SelectionError("找不到任务: " + ", ".join(missing))
|
||||
selected = [by_id[task_id] for task_id in requested_ids]
|
||||
mode = "task_ids"
|
||||
criteria = requested_ids
|
||||
else:
|
||||
effective_statuses = requested_statuses or list(ACTIONABLE_STATUSES)
|
||||
unknown = [status for status in effective_statuses if status not in STATUS_ENUM]
|
||||
if unknown:
|
||||
raise SelectionError("未知状态: " + ", ".join(unknown))
|
||||
wanted = set(effective_statuses)
|
||||
selected = [
|
||||
task
|
||||
for task in tasks
|
||||
if isinstance(task, dict) and task.get("status") in wanted
|
||||
]
|
||||
mode = "statuses"
|
||||
criteria = effective_statuses
|
||||
|
||||
if len(selected) > limit:
|
||||
raise SelectionError(
|
||||
f"命中 {len(selected)} 条任务,超过 --limit={limit};"
|
||||
"请用 --task-id/--status 缩小范围或显式提高 limit"
|
||||
)
|
||||
return mode, criteria, selected
|
||||
|
||||
|
||||
def select_referenced_receipts(
|
||||
data: dict[str, Any], selected: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
receipt_ids: list[str] = []
|
||||
for task in selected:
|
||||
dispatch = task.get("dispatch")
|
||||
if not isinstance(dispatch, dict):
|
||||
continue
|
||||
for role in ("developer", "test"):
|
||||
role_dispatch = dispatch.get(role)
|
||||
if not isinstance(role_dispatch, dict):
|
||||
continue
|
||||
receipt_id = role_dispatch.get("receiptId")
|
||||
if isinstance(receipt_id, str) and receipt_id not in receipt_ids:
|
||||
receipt_ids.append(receipt_id)
|
||||
if not receipt_ids:
|
||||
return []
|
||||
|
||||
receipts = data.get("workerReceipts")
|
||||
if not isinstance(receipts, list):
|
||||
raise SelectionError("选中任务引用了 receipt,但 workerReceipts 不是列表")
|
||||
by_id = {
|
||||
receipt.get("id"): receipt
|
||||
for receipt in receipts
|
||||
if isinstance(receipt, dict) and isinstance(receipt.get("id"), str)
|
||||
}
|
||||
missing = [receipt_id for receipt_id in receipt_ids if receipt_id not in by_id]
|
||||
if missing:
|
||||
raise SelectionError("选中任务引用了未知 receipt: " + ", ".join(missing))
|
||||
return [by_id[receipt_id] for receipt_id in receipt_ids]
|
||||
|
||||
|
||||
def select_delivery_runs(
|
||||
data: dict[str, Any], selected: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
selected_ids = {
|
||||
task.get("id")
|
||||
for task in selected
|
||||
if isinstance(task.get("id"), str)
|
||||
}
|
||||
runs = data.get("deliveryRuns")
|
||||
if not selected_ids or not isinstance(runs, list):
|
||||
return []
|
||||
return [
|
||||
run
|
||||
for run in runs
|
||||
if isinstance(run, dict)
|
||||
and isinstance(run.get("taskIds"), list)
|
||||
and any(task_id in selected_ids for task_id in run["taskIds"])
|
||||
]
|
||||
|
||||
|
||||
def build_payload(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
mode: str,
|
||||
criteria: list[str],
|
||||
selected: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
receipts = select_referenced_receipts(data, selected)
|
||||
delivery_runs = select_delivery_runs(data, selected)
|
||||
tasks = data.get("tasks")
|
||||
metadata = {
|
||||
field: data[field]
|
||||
for field in BOARD_METADATA_FIELDS
|
||||
if field in data
|
||||
}
|
||||
selected_ids = [
|
||||
task["id"]
|
||||
for task in selected
|
||||
if isinstance(task.get("id"), str)
|
||||
]
|
||||
payload: dict[str, Any] = {
|
||||
"board": metadata,
|
||||
"project": data.get("project"),
|
||||
"summary": data.get("summary"),
|
||||
"selection": {
|
||||
"mode": mode,
|
||||
"criteria": criteria,
|
||||
"count": len(selected),
|
||||
"totalTasks": len(tasks) if isinstance(tasks, list) else 0,
|
||||
"selectedTaskIds": selected_ids,
|
||||
"referencedReceiptIds": [receipt.get("id") for receipt in receipts],
|
||||
"deliveryRunIds": [run.get("id") for run in delivery_runs],
|
||||
},
|
||||
"tasks": selected,
|
||||
"workerReceipts": receipts,
|
||||
"deliveryRuns": delivery_runs,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="选择 ACK 任务板的有预算 Coordinator 上下文"
|
||||
)
|
||||
parser.add_argument(
|
||||
"tasks",
|
||||
nargs="?",
|
||||
default=".pouch/ack/tasks.yaml",
|
||||
help="任务板路径",
|
||||
)
|
||||
parser.add_argument("--task-id", action="append", default=[])
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="append",
|
||||
default=[],
|
||||
choices=sorted(STATUS_ENUM),
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
tasks_path = Path(args.tasks)
|
||||
if not tasks_path.is_file():
|
||||
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
|
||||
return 2
|
||||
|
||||
data = load_document(tasks_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:
|
||||
mode, criteria, selected = select_tasks(
|
||||
data,
|
||||
task_ids=args.task_id,
|
||||
statuses=args.status,
|
||||
limit=args.limit,
|
||||
)
|
||||
payload = build_payload(
|
||||
data,
|
||||
mode=mode,
|
||||
criteria=criteria,
|
||||
selected=selected,
|
||||
)
|
||||
except SelectionError as exc:
|
||||
sys.stderr.write(f"任务选择失败: {exc}\n")
|
||||
return 1
|
||||
|
||||
if args.compact:
|
||||
output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
else:
|
||||
output = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if len(output.encode("utf-8")) > MAX_OUTPUT_BYTES:
|
||||
sys.stderr.write(
|
||||
f"任务选择输出超过 {MAX_OUTPUT_BYTES} bytes;请进一步缩小任务范围\n"
|
||||
)
|
||||
return 1
|
||||
sys.stdout.write(output + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
Executable
+871
@@ -0,0 +1,871 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验 ACK 项目交付契约。
|
||||
|
||||
权威结构位于 templates/delivery.schema.json。jsonschema 是可选依赖;内置规则始终
|
||||
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
|
||||
|
||||
用法:
|
||||
python3 validate_delivery.py .pouch/ack/delivery.yaml
|
||||
python3 validate_delivery.py .pouch/ack/delivery.yaml \
|
||||
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
|
||||
|
||||
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
|
||||
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
|
||||
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
|
||||
SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$")
|
||||
REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
TOP_LEVEL_FIELDS = {
|
||||
"version",
|
||||
"updatedAt",
|
||||
"project",
|
||||
"enabled",
|
||||
"defaultProfile",
|
||||
"intents",
|
||||
"entrypoints",
|
||||
"artifacts",
|
||||
"destinations",
|
||||
"environments",
|
||||
"profiles",
|
||||
}
|
||||
ENTRYPOINT_FIELDS = {
|
||||
"kind",
|
||||
"target",
|
||||
"function",
|
||||
"path",
|
||||
"args",
|
||||
"requiredSecrets",
|
||||
"workingDirectory",
|
||||
"timeoutSeconds",
|
||||
}
|
||||
ARTIFACT_FIELDS = {"type", "build", "outputs", "image", "platforms"}
|
||||
DESTINATION_FIELDS = {
|
||||
"type",
|
||||
"channel",
|
||||
"registry",
|
||||
"repository",
|
||||
"endpoint",
|
||||
"artifactName",
|
||||
"upload",
|
||||
}
|
||||
ENVIRONMENT_FIELDS = {
|
||||
"type",
|
||||
"classification",
|
||||
"target",
|
||||
"deploy",
|
||||
"healthCheck",
|
||||
"rollback",
|
||||
"mutex",
|
||||
}
|
||||
PROFILE_FIELDS = {"stopAt", "steps"}
|
||||
STEP_FIELDS = {
|
||||
"id",
|
||||
"action",
|
||||
"entrypoint",
|
||||
"artifact",
|
||||
"destination",
|
||||
"environment",
|
||||
"gate",
|
||||
"draft",
|
||||
"remote",
|
||||
"baseBranch",
|
||||
}
|
||||
|
||||
ENTRYPOINT_KINDS = {"make", "just", "task", "dagger", "script"}
|
||||
ARTIFACT_TYPES = {"deb", "oci-image", "file"}
|
||||
DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
|
||||
CHANNELS = {"preview", "staging", "stable"}
|
||||
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
|
||||
CLASSIFICATIONS = {"development", "staging", "production"}
|
||||
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||
INTENT_FIELDS = {"testEnvironment", "release"}
|
||||
INTENT_STOP_AT = {
|
||||
"release": "released",
|
||||
}
|
||||
TEST_ENVIRONMENT_FIELDS = {"via", "env"}
|
||||
ACTIONS = {
|
||||
"verify",
|
||||
"pull-request",
|
||||
"build",
|
||||
"publish",
|
||||
"deploy",
|
||||
"health-check",
|
||||
"approval",
|
||||
"mark-ready",
|
||||
}
|
||||
ACTION_FIELDS = {
|
||||
"verify": {"entrypoint"},
|
||||
"pull-request": {"draft", "remote", "baseBranch"},
|
||||
"build": {"artifact"},
|
||||
"publish": {"artifact", "destination"},
|
||||
"deploy": {"artifact", "environment"},
|
||||
"health-check": {"environment"},
|
||||
"approval": {"gate"},
|
||||
"mark-ready": set(),
|
||||
}
|
||||
|
||||
SECRET_PATTERNS = (
|
||||
("private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
|
||||
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
|
||||
("OpenAI-style token", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
|
||||
("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
|
||||
("URL credentials", re.compile(r"https?://[^/\s:@]+:[^/\s@]+@")),
|
||||
(
|
||||
"inline secret assignment",
|
||||
re.compile(
|
||||
r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)"
|
||||
r"\s*[:=]\s*[\"']?[^\s,\"']{8,}"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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_document(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 _safe_relative_path(value: Any, *, allow_glob: bool = False) -> bool:
|
||||
if not _nonempty(value) or value.startswith("/") or "\\" in value:
|
||||
return False
|
||||
if not RELATIVE_PATH_RE.fullmatch(value):
|
||||
return False
|
||||
if not allow_glob and any(marker in value for marker in "*?"):
|
||||
return False
|
||||
parts = PurePosixPath(value).parts
|
||||
return ".." not in parts and all(part not in {"", "/"} for part in parts)
|
||||
|
||||
|
||||
def _safe_branch_name(value: Any) -> bool:
|
||||
if not _nonempty(value) or len(value) > 255:
|
||||
return False
|
||||
if value == "@" or value.startswith(("/", ".", "-")):
|
||||
return False
|
||||
if value.endswith(("/", ".", ".lock")):
|
||||
return False
|
||||
if "@{" in value or ".." in value or "//" in value:
|
||||
return False
|
||||
return re.search(r"[\x00-\x20\x7f~^:?*\[\\]", value) is None
|
||||
|
||||
|
||||
def _validate_path_binding(
|
||||
project_root: Path,
|
||||
relative_path: str,
|
||||
where: str,
|
||||
*,
|
||||
expected: str,
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
current = project_root
|
||||
parts = PurePosixPath(relative_path).parts
|
||||
if relative_path == ".":
|
||||
parts = ()
|
||||
for index, part in enumerate(parts):
|
||||
current = current / part
|
||||
try:
|
||||
metadata = current.lstat()
|
||||
except FileNotFoundError:
|
||||
return [f"{where}: 路径不存在: {relative_path!r}"]
|
||||
except OSError as exc:
|
||||
return [f"{where}: 路径不可访问: {relative_path!r}: {exc}"]
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
return [f"{where}: 路径不能包含 symlink: {relative_path!r}"]
|
||||
if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode):
|
||||
return [f"{where}: 中间路径不是目录: {relative_path!r}"]
|
||||
|
||||
try:
|
||||
metadata
|
||||
except UnboundLocalError:
|
||||
metadata = project_root.lstat()
|
||||
if expected == "directory" and not stat.S_ISDIR(metadata.st_mode):
|
||||
errors.append(f"{where}: 必须指向目录: {relative_path!r}")
|
||||
if expected == "executable":
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
errors.append(f"{where}: 必须指向普通文件: {relative_path!r}")
|
||||
elif metadata.st_mode & 0o111 == 0:
|
||||
errors.append(f"{where}: 脚本不可执行: {relative_path!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def _scan_secrets(value: Any, where: str, errors: list[str]) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
_scan_secrets(item, f"{where}.{key}", errors)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
_scan_secrets(item, f"{where}[{index}]", errors)
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
for label, pattern in SECRET_PATTERNS:
|
||||
if pattern.search(value):
|
||||
errors.append(f"{where}: 疑似包含敏感信息({label})")
|
||||
|
||||
|
||||
def _validate_ids(values: Any, where: str, errors: list[str]) -> dict[str, Any]:
|
||||
if not isinstance(values, dict):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
return {}
|
||||
for key in values:
|
||||
if not isinstance(key, str) or ID_RE.fullmatch(key) is None:
|
||||
errors.append(f"{where}: ID {key!r} 必须使用小写连字符格式")
|
||||
return values
|
||||
|
||||
|
||||
def _validate_entrypoints(
|
||||
values: dict[str, Any],
|
||||
errors: list[str],
|
||||
project_root: Path | None,
|
||||
) -> None:
|
||||
for entrypoint_id, value in values.items():
|
||||
where = f"entrypoints.{entrypoint_id}"
|
||||
if not _mapping(value):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
_reject_unknown(value, ENTRYPOINT_FIELDS, where, errors)
|
||||
kind = value.get("kind")
|
||||
if kind not in ENTRYPOINT_KINDS:
|
||||
errors.append(f"{where}.kind: 必须是 {sorted(ENTRYPOINT_KINDS)}")
|
||||
required_selector = {
|
||||
"make": "target",
|
||||
"just": "target",
|
||||
"task": "target",
|
||||
"dagger": "function",
|
||||
"script": "path",
|
||||
}.get(kind)
|
||||
for selector in ("target", "function", "path"):
|
||||
if selector == required_selector:
|
||||
if not _nonempty(value.get(selector)):
|
||||
errors.append(f"{where}.{selector}: {kind} 入口必须填写非空值")
|
||||
elif selector in value:
|
||||
errors.append(f"{where}.{selector}: kind={kind!r} 不允许此字段")
|
||||
|
||||
args = value.get("args")
|
||||
if not isinstance(args, list) or any(not isinstance(item, str) for item in args):
|
||||
errors.append(f"{where}.args: 必须是字符串列表")
|
||||
required_secrets = value.get("requiredSecrets")
|
||||
if (
|
||||
not isinstance(required_secrets, list)
|
||||
or any(
|
||||
not isinstance(item, str) or SECRET_NAME_RE.fullmatch(item) is None
|
||||
for item in required_secrets
|
||||
)
|
||||
or (
|
||||
isinstance(required_secrets, list)
|
||||
and len(required_secrets) != len(set(required_secrets))
|
||||
)
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.requiredSecrets: 必须是唯一的大写 secret 名称列表"
|
||||
)
|
||||
working_directory = value.get("workingDirectory")
|
||||
if not _safe_relative_path(working_directory):
|
||||
errors.append(f"{where}.workingDirectory: 必须是安全的仓库内相对路径")
|
||||
timeout = value.get("timeoutSeconds")
|
||||
if (
|
||||
not isinstance(timeout, int)
|
||||
or isinstance(timeout, bool)
|
||||
or not 1 <= timeout <= 86400
|
||||
):
|
||||
errors.append(f"{where}.timeoutSeconds: 必须是 1..86400 的整数")
|
||||
|
||||
if kind == "script" and not _safe_relative_path(value.get("path")):
|
||||
errors.append(f"{where}.path: 必须是安全的仓库内相对路径")
|
||||
if project_root is not None:
|
||||
if _safe_relative_path(working_directory):
|
||||
errors.extend(
|
||||
_validate_path_binding(
|
||||
project_root,
|
||||
working_directory,
|
||||
f"{where}.workingDirectory",
|
||||
expected="directory",
|
||||
)
|
||||
)
|
||||
if kind == "script" and _safe_relative_path(value.get("path")):
|
||||
errors.extend(
|
||||
_validate_path_binding(
|
||||
project_root,
|
||||
value["path"],
|
||||
f"{where}.path",
|
||||
expected="executable",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_artifacts(
|
||||
values: dict[str, Any],
|
||||
entrypoints: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
for artifact_id, value in values.items():
|
||||
where = f"artifacts.{artifact_id}"
|
||||
if not _mapping(value):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
_reject_unknown(value, ARTIFACT_FIELDS, where, errors)
|
||||
artifact_type = value.get("type")
|
||||
if artifact_type not in ARTIFACT_TYPES:
|
||||
errors.append(f"{where}.type: 必须是 {sorted(ARTIFACT_TYPES)}")
|
||||
build = value.get("build")
|
||||
if build not in entrypoints:
|
||||
errors.append(f"{where}.build: 未定义 entrypoint {build!r}")
|
||||
outputs = value.get("outputs")
|
||||
if artifact_type in {"deb", "file"}:
|
||||
if (
|
||||
not isinstance(outputs, list)
|
||||
or not outputs
|
||||
or any(not _safe_relative_path(item, allow_glob=True) for item in outputs)
|
||||
):
|
||||
errors.append(f"{where}.outputs: deb/file 必须填写安全的产物路径列表")
|
||||
if "image" in value or "platforms" in value:
|
||||
errors.append(f"{where}: deb/file 不允许 image 或 platforms")
|
||||
if artifact_type == "oci-image":
|
||||
if not _nonempty(value.get("image")):
|
||||
errors.append(f"{where}.image: oci-image 必须填写镜像名")
|
||||
platforms = value.get("platforms")
|
||||
if (
|
||||
not isinstance(platforms, list)
|
||||
or not platforms
|
||||
or any(not isinstance(item, str) or PLATFORM_RE.fullmatch(item) is None for item in platforms)
|
||||
or len(platforms) != len(set(platforms))
|
||||
):
|
||||
errors.append(f"{where}.platforms: 必须是唯一的 os/arch 列表")
|
||||
if "outputs" in value:
|
||||
errors.append(f"{where}: oci-image 不允许 outputs")
|
||||
|
||||
|
||||
def _validate_destinations(
|
||||
values: dict[str, Any],
|
||||
entrypoints: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
for destination_id, value in values.items():
|
||||
where = f"destinations.{destination_id}"
|
||||
if not _mapping(value):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
_reject_unknown(value, DESTINATION_FIELDS, where, errors)
|
||||
destination_type = value.get("type")
|
||||
if destination_type not in DESTINATION_TYPES:
|
||||
errors.append(f"{where}.type: 必须是 {sorted(DESTINATION_TYPES)}")
|
||||
type_fields = {
|
||||
"apt-repository": {"endpoint", "repository"},
|
||||
"oci-registry": {"registry", "repository"},
|
||||
"ci-artifact": {"artifactName"},
|
||||
}.get(destination_type, set())
|
||||
allowed_fields = {"type", "channel", "upload"} | type_fields
|
||||
for field in sorted(set(value) - allowed_fields):
|
||||
errors.append(f"{where}.{field}: type={destination_type!r} 不允许此字段")
|
||||
if value.get("channel") not in CHANNELS:
|
||||
errors.append(f"{where}.channel: 必须是 {sorted(CHANNELS)}")
|
||||
upload = value.get("upload")
|
||||
if upload is not None and upload not in entrypoints:
|
||||
errors.append(f"{where}.upload: 未定义 entrypoint {upload!r}")
|
||||
if destination_type == "apt-repository":
|
||||
if not _nonempty(value.get("endpoint")):
|
||||
errors.append(f"{where}.endpoint: APT 目标必须填写服务地址")
|
||||
if not _nonempty(value.get("repository")):
|
||||
errors.append(f"{where}.repository: APT 目标必须填写仓库名")
|
||||
if destination_type == "oci-registry":
|
||||
for field in ("registry", "repository"):
|
||||
if not _nonempty(value.get(field)):
|
||||
errors.append(f"{where}.{field}: OCI 目标必须填写非空值")
|
||||
if destination_type == "ci-artifact" and not _nonempty(value.get("artifactName")):
|
||||
errors.append(f"{where}.artifactName: CI artifact 必须填写名称")
|
||||
|
||||
|
||||
def _validate_environments(
|
||||
values: dict[str, Any],
|
||||
entrypoints: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
for environment_id, value in values.items():
|
||||
where = f"environments.{environment_id}"
|
||||
if not _mapping(value):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
_reject_unknown(value, ENVIRONMENT_FIELDS, where, errors)
|
||||
if value.get("type") not in ENVIRONMENT_TYPES:
|
||||
errors.append(f"{where}.type: 必须是 {sorted(ENVIRONMENT_TYPES)}")
|
||||
classification = value.get("classification")
|
||||
if classification not in CLASSIFICATIONS:
|
||||
errors.append(f"{where}.classification: 必须是 {sorted(CLASSIFICATIONS)}")
|
||||
if not _nonempty(value.get("target")):
|
||||
errors.append(f"{where}.target: 必须是非空目标别名")
|
||||
for field in ("deploy", "healthCheck"):
|
||||
reference = value.get(field)
|
||||
if reference not in entrypoints:
|
||||
errors.append(f"{where}.{field}: 未定义 entrypoint {reference!r}")
|
||||
rollback = value.get("rollback")
|
||||
if rollback is not None and rollback not in entrypoints:
|
||||
errors.append(f"{where}.rollback: 未定义 entrypoint {rollback!r}")
|
||||
if classification == "production" and rollback is None:
|
||||
errors.append(f"{where}.rollback: production 环境必须提供回滚入口")
|
||||
if not _nonempty(value.get("mutex")):
|
||||
errors.append(f"{where}.mutex: 必须填写部署互斥锁 ID")
|
||||
|
||||
|
||||
def _artifact_destination_compatible(artifact_type: str, destination_type: str) -> bool:
|
||||
return destination_type in {
|
||||
"deb": {"apt-repository", "ci-artifact"},
|
||||
"oci-image": {"oci-registry", "ci-artifact"},
|
||||
"file": {"ci-artifact"},
|
||||
}.get(artifact_type, set())
|
||||
|
||||
|
||||
def _validate_profiles(
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
default_profile: Any,
|
||||
entrypoints: dict[str, Any],
|
||||
artifacts: dict[str, Any],
|
||||
destinations: dict[str, Any],
|
||||
environments: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
for profile_id, value in values.items():
|
||||
where = f"profiles.{profile_id}"
|
||||
if not _mapping(value):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
_reject_unknown(value, PROFILE_FIELDS, where, errors)
|
||||
stop_at = value.get("stopAt")
|
||||
if stop_at not in STOP_POINTS:
|
||||
errors.append(f"{where}.stopAt: 必须是 {sorted(STOP_POINTS)}")
|
||||
steps = value.get("steps")
|
||||
if not isinstance(steps, list):
|
||||
errors.append(f"{where}.steps: 必须是列表")
|
||||
continue
|
||||
|
||||
seen_step_ids: set[str] = set()
|
||||
built_artifacts: set[str] = set()
|
||||
published_artifacts: set[str] = set()
|
||||
deployed_environments: set[str] = set()
|
||||
checked_environments: set[str] = set()
|
||||
approvals: set[str] = set()
|
||||
has_pull_request = False
|
||||
has_mark_ready = False
|
||||
|
||||
for index, step in enumerate(steps):
|
||||
step_where = f"{where}.steps[{index}]"
|
||||
if not _mapping(step):
|
||||
errors.append(f"{step_where}: 必须是对象")
|
||||
continue
|
||||
_reject_unknown(step, STEP_FIELDS, step_where, errors)
|
||||
step_id = step.get("id")
|
||||
if not isinstance(step_id, str) or ID_RE.fullmatch(step_id) is None:
|
||||
errors.append(f"{step_where}.id: 必须使用小写连字符格式")
|
||||
elif step_id in seen_step_ids:
|
||||
errors.append(f"{step_where}.id: 不能重复 {step_id!r}")
|
||||
else:
|
||||
seen_step_ids.add(step_id)
|
||||
|
||||
action = step.get("action")
|
||||
if action not in ACTIONS:
|
||||
errors.append(f"{step_where}.action: 必须是 {sorted(ACTIONS)}")
|
||||
continue
|
||||
required_fields = ACTION_FIELDS[action]
|
||||
for field in sorted(required_fields):
|
||||
if field not in step:
|
||||
errors.append(f"{step_where}.{field}: action={action!r} 时必填")
|
||||
allowed_fields = {"id", "action"} | required_fields
|
||||
for field in sorted(set(step) - allowed_fields):
|
||||
errors.append(f"{step_where}.{field}: action={action!r} 不允许此字段")
|
||||
|
||||
if action == "verify" and step.get("entrypoint") not in entrypoints:
|
||||
errors.append(
|
||||
f"{step_where}.entrypoint: 未定义 entrypoint {step.get('entrypoint')!r}"
|
||||
)
|
||||
if action == "pull-request":
|
||||
if not isinstance(step.get("draft"), bool):
|
||||
errors.append(f"{step_where}.draft: 必须是布尔值")
|
||||
if (
|
||||
not isinstance(step.get("remote"), str)
|
||||
or REMOTE_RE.fullmatch(step["remote"]) is None
|
||||
):
|
||||
errors.append(f"{step_where}.remote: 必须是安全的 Git remote 名称")
|
||||
if not _safe_branch_name(step.get("baseBranch")):
|
||||
errors.append(f"{step_where}.baseBranch: 必须是安全的 Git 分支名")
|
||||
has_pull_request = True
|
||||
if action == "build":
|
||||
artifact_id = step.get("artifact")
|
||||
if artifact_id not in artifacts:
|
||||
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
|
||||
else:
|
||||
built_artifacts.add(artifact_id)
|
||||
if action == "publish":
|
||||
artifact_id = step.get("artifact")
|
||||
destination_id = step.get("destination")
|
||||
if artifact_id not in artifacts:
|
||||
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
|
||||
elif artifact_id not in built_artifacts:
|
||||
errors.append(f"{step_where}: publish 前必须先 build {artifact_id!r}")
|
||||
if destination_id not in destinations:
|
||||
errors.append(
|
||||
f"{step_where}.destination: 未定义 destination {destination_id!r}"
|
||||
)
|
||||
elif artifact_id in artifacts:
|
||||
artifact_type = artifacts[artifact_id].get("type")
|
||||
destination_type = destinations[destination_id].get("type")
|
||||
if not _artifact_destination_compatible(artifact_type, destination_type):
|
||||
errors.append(
|
||||
f"{step_where}: artifact {artifact_type!r} 不能发布到 "
|
||||
f"{destination_type!r}"
|
||||
)
|
||||
if destinations[destination_id].get("channel") == "stable" and "release" not in approvals:
|
||||
errors.append(f"{step_where}: stable 发布前必须有 release approval")
|
||||
published_artifacts.add(artifact_id)
|
||||
if action == "deploy":
|
||||
artifact_id = step.get("artifact")
|
||||
environment_id = step.get("environment")
|
||||
if artifact_id not in artifacts:
|
||||
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
|
||||
elif artifact_id not in built_artifacts:
|
||||
errors.append(f"{step_where}: deploy 前必须先 build {artifact_id!r}")
|
||||
if environment_id not in environments:
|
||||
errors.append(
|
||||
f"{step_where}.environment: 未定义 environment {environment_id!r}"
|
||||
)
|
||||
else:
|
||||
classification = environments[environment_id].get("classification")
|
||||
if classification == "production" and "production" not in approvals:
|
||||
errors.append(f"{step_where}: production 部署前必须有 production approval")
|
||||
if (
|
||||
classification == "production"
|
||||
and artifact_id not in published_artifacts
|
||||
):
|
||||
errors.append(f"{step_where}: production 部署前必须先 publish 同一产物")
|
||||
deployed_environments.add(environment_id)
|
||||
if action == "health-check":
|
||||
environment_id = step.get("environment")
|
||||
if environment_id not in environments:
|
||||
errors.append(
|
||||
f"{step_where}.environment: 未定义 environment {environment_id!r}"
|
||||
)
|
||||
elif environment_id not in deployed_environments:
|
||||
errors.append(
|
||||
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
|
||||
)
|
||||
else:
|
||||
checked_environments.add(environment_id)
|
||||
if action == "approval":
|
||||
gate = step.get("gate")
|
||||
if gate not in {"release", "production"}:
|
||||
errors.append(f"{step_where}.gate: 必须是 release/production")
|
||||
else:
|
||||
if gate == "release" and not built_artifacts:
|
||||
errors.append(f"{step_where}: release approval 前必须先 build 产物")
|
||||
if gate == "production" and not published_artifacts:
|
||||
errors.append(f"{step_where}: production approval 前必须先 publish 产物")
|
||||
approvals.add(gate)
|
||||
if action == "mark-ready":
|
||||
if not has_pull_request:
|
||||
errors.append(f"{step_where}: mark-ready 前必须先创建 pull-request")
|
||||
has_mark_ready = True
|
||||
if index != len(steps) - 1:
|
||||
errors.append(f"{step_where}: mark-ready 必须是 profile 最后一步")
|
||||
|
||||
if stop_at in {"review_ready", "released"} and (
|
||||
not has_pull_request or not has_mark_ready
|
||||
):
|
||||
errors.append(
|
||||
f"{where}: {stop_at} 必须包含 pull-request 和末尾 mark-ready"
|
||||
)
|
||||
if stop_at == "released" and not ({"release", "production"} & approvals):
|
||||
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
|
||||
if stop_at == "validation_ready":
|
||||
if not deployed_environments:
|
||||
errors.append(f"{where}: validation_ready 必须至少部署一个环境")
|
||||
missing_health = deployed_environments - checked_environments
|
||||
if missing_health:
|
||||
errors.append(
|
||||
f"{where}: validation_ready 的部署环境必须全部完成 health-check: "
|
||||
f"{sorted(missing_health)}"
|
||||
)
|
||||
|
||||
if profile_id == default_profile:
|
||||
if stop_at not in {"validation_ready", "review_ready"}:
|
||||
errors.append(
|
||||
f"{where}: defaultProfile 必须停在 validation_ready 或 review_ready"
|
||||
)
|
||||
used_destinations = {
|
||||
step.get("destination")
|
||||
for step in steps
|
||||
if isinstance(step, dict) and step.get("action") == "publish"
|
||||
}
|
||||
used_environments = {
|
||||
step.get("environment")
|
||||
for step in steps
|
||||
if isinstance(step, dict) and step.get("action") == "deploy"
|
||||
}
|
||||
if any(
|
||||
destinations.get(item, {}).get("channel") == "stable"
|
||||
for item in used_destinations
|
||||
):
|
||||
errors.append(f"{where}: defaultProfile 不能发布 stable 目标")
|
||||
if any(
|
||||
environments.get(item, {}).get("classification") == "production"
|
||||
for item in used_environments
|
||||
):
|
||||
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(
|
||||
values: Any,
|
||||
profiles: dict[str, Any],
|
||||
errors: list[str],
|
||||
project_root: Path | None = None,
|
||||
) -> None:
|
||||
if values is None:
|
||||
return
|
||||
if not _mapping(values):
|
||||
errors.append("intents: 必须是对象")
|
||||
return
|
||||
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
|
||||
if "testEnvironment" not in values:
|
||||
errors.append("intents.testEnvironment: 必填")
|
||||
else:
|
||||
_validate_test_environment_intent(
|
||||
values.get("testEnvironment"), errors, project_root
|
||||
)
|
||||
if "release" not in values:
|
||||
errors.append("intents.release: 必填")
|
||||
return
|
||||
profile_id = values["release"]
|
||||
if profile_id is None:
|
||||
return
|
||||
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
||||
errors.append("intents.release: 必须是 null 或小写连字符 profile ID")
|
||||
return
|
||||
profile = profiles.get(profile_id)
|
||||
if profile is None:
|
||||
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]:
|
||||
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 "updatedAt" in data and not _nonempty(data.get("updatedAt")):
|
||||
errors.append("updatedAt 必须是非空字符串")
|
||||
project = data.get("project")
|
||||
if not _mapping(project):
|
||||
errors.append("project 必须是对象")
|
||||
project = {}
|
||||
else:
|
||||
_reject_unknown(project, {"name"}, "project", errors)
|
||||
if not _nonempty(project.get("name")):
|
||||
errors.append("project.name 必须是非空字符串")
|
||||
|
||||
enabled = data.get("enabled")
|
||||
if not isinstance(enabled, bool):
|
||||
errors.append("enabled 必须是布尔值")
|
||||
default_profile = data.get("defaultProfile")
|
||||
if default_profile is not None and (
|
||||
not isinstance(default_profile, str) or ID_RE.fullmatch(default_profile) is None
|
||||
):
|
||||
errors.append("defaultProfile 必须是 null 或小写连字符 ID")
|
||||
|
||||
entrypoints = _validate_ids(data.get("entrypoints"), "entrypoints", errors)
|
||||
artifacts = _validate_ids(data.get("artifacts"), "artifacts", errors)
|
||||
destinations = _validate_ids(data.get("destinations"), "destinations", errors)
|
||||
environments = _validate_ids(data.get("environments"), "environments", errors)
|
||||
profiles = _validate_ids(data.get("profiles"), "profiles", errors)
|
||||
|
||||
_validate_entrypoints(entrypoints, errors, project_root)
|
||||
_validate_artifacts(artifacts, entrypoints, errors)
|
||||
_validate_destinations(destinations, entrypoints, errors)
|
||||
_validate_environments(environments, entrypoints, errors)
|
||||
_validate_profiles(
|
||||
profiles,
|
||||
default_profile=default_profile,
|
||||
entrypoints=entrypoints,
|
||||
artifacts=artifacts,
|
||||
destinations=destinations,
|
||||
environments=environments,
|
||||
errors=errors,
|
||||
)
|
||||
_validate_intents(data.get("intents"), profiles, errors, project_root)
|
||||
|
||||
if enabled:
|
||||
if default_profile not in profiles:
|
||||
errors.append("enabled=true 时 defaultProfile 必须引用已定义 profile")
|
||||
elif not profiles[default_profile].get("steps"):
|
||||
errors.append("enabled=true 时 defaultProfile.steps 不能为空")
|
||||
elif default_profile is not None and default_profile not in profiles:
|
||||
errors.append("defaultProfile 必须引用已定义 profile")
|
||||
|
||||
_scan_secrets(data, "<root>", errors)
|
||||
return errors
|
||||
|
||||
|
||||
def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
project = tasks.get("project")
|
||||
if not isinstance(project, dict):
|
||||
return ["tasks.project 必须是对象"]
|
||||
if project.get("deliveryFile") != ".pouch/ack/delivery.yaml":
|
||||
errors.append("tasks.project.deliveryFile 必须固定为 .pouch/ack/delivery.yaml")
|
||||
delivery_project = delivery.get("project")
|
||||
if (
|
||||
isinstance(delivery_project, dict)
|
||||
and _nonempty(delivery_project.get("name"))
|
||||
and _nonempty(project.get("name"))
|
||||
and delivery_project["name"] != project["name"]
|
||||
):
|
||||
errors.append("delivery.project.name 必须与 tasks.project.name 一致")
|
||||
if not isinstance(tasks.get("deliveryRuns"), list):
|
||||
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||
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 main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
|
||||
parser.add_argument("delivery", nargs="?", default=".pouch/ack/delivery.yaml")
|
||||
parser.add_argument("--tasks", help="关联的 .pouch/ack/tasks.yaml")
|
||||
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
|
||||
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
delivery_path = Path(args.delivery)
|
||||
if not delivery_path.is_file():
|
||||
sys.stderr.write(f"找不到交付契约: {delivery_path}\n")
|
||||
return 2
|
||||
project_root = Path(args.project_root).resolve() if args.project_root else None
|
||||
if project_root is not None and not project_root.is_dir():
|
||||
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
|
||||
return 2
|
||||
|
||||
delivery = _load_document(delivery_path, "交付契约")
|
||||
errors = validate_builtin(delivery, project_root)
|
||||
|
||||
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_document(tasks_path, "任务板")
|
||||
errors.extend(validate_tasks_link(delivery, tasks))
|
||||
|
||||
schema_path = (
|
||||
Path(args.schema)
|
||||
if args.schema
|
||||
else Path(__file__).resolve().parent.parent / "templates" / "delivery.schema.json"
|
||||
)
|
||||
if args.schema and not schema_path.is_file():
|
||||
sys.stderr.write(f"找不到 schema: {schema_path}\n")
|
||||
return 2
|
||||
if schema_path.is_file():
|
||||
try:
|
||||
errors.extend(validate_with_schema(delivery, schema_path))
|
||||
except ImportError:
|
||||
sys.stderr.write("提示: 未安装 jsonschema,仅执行内置语义规则\n")
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
sys.stderr.write(f"schema 读取失败: {exc}\n")
|
||||
return 2
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
sys.stderr.write(f"- {error}\n")
|
||||
return 1
|
||||
|
||||
sys.stdout.write("交付契约校验通过\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+1156
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
Executable
+1447
File diff suppressed because it is too large
Load Diff
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed migration shim for ACK's removed free-form command validator.
|
||||
|
||||
ACK v0.10 no longer accepts a shell command as worker configuration. Keep this
|
||||
file only so older documentation or automation fails with a precise migration
|
||||
message instead of silently retaining the unsafe path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
MIGRATION_MESSAGE = (
|
||||
"ACK v0.10 已停用自由 worker command 校验器;请在 "
|
||||
".pouch/ack/agents.yaml(优先)或 .pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
|
||||
"scripts/launch_worker.py profile-hash|plan|launch。"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if arguments == ["--self-test"]:
|
||||
print("legacy worker command 路径已 fail-closed")
|
||||
return 0
|
||||
sys.stderr.write(MIGRATION_MESSAGE + "\n")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe one dispatched ACK worker's liveness and emit a single JSON status.
|
||||
|
||||
Read-only supervision helper for the coordinator's wait loop. It never sends
|
||||
input, never mutates dispatch or terminal state, and never marks a task
|
||||
outcome. The coordinator runs it between rolling ``check --wait`` windows to
|
||||
detect workers that never started, stalled on an approval/choice prompt, hit a
|
||||
usage limit, or lost heartbeat.
|
||||
|
||||
Output (single JSON document on stdout):
|
||||
{
|
||||
"probedAt": "<RFC3339>",
|
||||
"taskId": "<task-id>",
|
||||
"dispatchId": "<dispatch-id>",
|
||||
"terminal": "<handle>",
|
||||
"status": "running | progress | stall | not-started | unknown",
|
||||
"stallReason": "<label> | null",
|
||||
"heartbeatAt": "<value> | null",
|
||||
"heartbeatAgeSeconds": "<number> | null",
|
||||
"evidence": "<bounded terminal tail>"
|
||||
}
|
||||
|
||||
Exit code is always 0 for a probe attempt: a failed probe is ``unknown`` for
|
||||
the coordinator to reconcile, never an automatic retry trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from launch_worker import ( # noqa: E402
|
||||
LaunchError,
|
||||
parse_timestamp,
|
||||
resolve_executable,
|
||||
run_json,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
# Conservative stall patterns: an interactive prompt the worker is waiting on.
|
||||
# Matching only means "evidence of a stall to inspect", never a verdict alone.
|
||||
STALL_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("approval", re.compile(r"(?i)approv(e|al)|allow tool|permission|批准|允许")),
|
||||
("usage-limit", re.compile(r"(?i)usage limit|rate limit|额度|quota")),
|
||||
("model-switch", re.compile(r"(?i)switch to|keep current model|choose an action|切换")),
|
||||
("press-enter", re.compile(r"(?i)press enter|回车|按回车")),
|
||||
)
|
||||
WORKING_PATTERN = re.compile(r"(?i)working|•working|running|执行中|正在")
|
||||
IDLE_TAIL_PATTERN = re.compile(r"(?i)welcome to|type help|fish, the friendly|>\\s*$")
|
||||
|
||||
MAX_EVIDENCE_CHARS = 500
|
||||
HEARTBEAT_INTERVAL_SECONDS = 5 * 60
|
||||
HEARTBEAT_MAX_AGE_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 3
|
||||
|
||||
|
||||
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):
|
||||
tail = "\n".join(tail)
|
||||
heartbeat_age, heartbeat_error = heartbeat_age_seconds(heartbeat, now)
|
||||
for label, pattern in STALL_PATTERNS:
|
||||
if pattern.search(tail):
|
||||
return _result(
|
||||
status="stall",
|
||||
stall_reason=label,
|
||||
heartbeat=heartbeat,
|
||||
heartbeat_age=heartbeat_age,
|
||||
evidence=tail,
|
||||
)
|
||||
if heartbeat_error:
|
||||
return _result(
|
||||
status="unknown",
|
||||
stall_reason=heartbeat_error,
|
||||
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):
|
||||
return _result(
|
||||
status="running",
|
||||
stall_reason=None,
|
||||
heartbeat=heartbeat,
|
||||
heartbeat_age=heartbeat_age,
|
||||
evidence=tail,
|
||||
)
|
||||
# No heartbeat and no working marker: the terminal may still be sitting at
|
||||
# a welcome/idle prompt (task never started) or have unclassified output.
|
||||
if IDLE_TAIL_PATTERN.search(tail) or not tail.strip():
|
||||
return _result(
|
||||
status="not-started",
|
||||
stall_reason=None,
|
||||
heartbeat=heartbeat,
|
||||
heartbeat_age=heartbeat_age,
|
||||
evidence=tail,
|
||||
)
|
||||
return _result(
|
||||
status="unknown",
|
||||
stall_reason=None,
|
||||
heartbeat=heartbeat,
|
||||
heartbeat_age=heartbeat_age,
|
||||
evidence=tail,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--task-id", required=True, help="Orca orchestration task ID")
|
||||
parser.add_argument("--terminal", required=True, help="worker terminal handle")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
orca = resolve_executable("orca")
|
||||
try:
|
||||
show = run_json(
|
||||
[str(orca), "orchestration", "dispatch-show", "--task", args.task_id, "--json"],
|
||||
"dispatch-show",
|
||||
)
|
||||
dispatch = show["result"]["dispatch"]
|
||||
read_response = run_json(
|
||||
[str(orca), "terminal", "read", "--terminal", args.terminal, "--json"],
|
||||
"terminal read",
|
||||
)
|
||||
terminal = read_response["result"]["terminal"]
|
||||
except (LaunchError, KeyError, TypeError, IndexError) as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"probedAt": utc_now().isoformat().replace("+00:00", "Z"),
|
||||
"taskId": args.task_id,
|
||||
"dispatchId": None,
|
||||
"terminal": args.terminal,
|
||||
"status": "unknown",
|
||||
"stallReason": None,
|
||||
"heartbeatAt": None,
|
||||
"heartbeatAgeSeconds": None,
|
||||
"evidence": f"probe failed: {type(exc).__name__}: {exc}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
dispatch_id = dispatch.get("id")
|
||||
heartbeat = dispatch.get("last_heartbeat_at")
|
||||
tail = "\n".join(terminal.get("tail") or [])
|
||||
result = classify(tail, heartbeat)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"probedAt": utc_now().isoformat().replace("+00:00", "Z"),
|
||||
"taskId": args.task_id,
|
||||
"dispatchId": dispatch_id,
|
||||
"terminal": args.terminal,
|
||||
**result,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ACK YAML 的零依赖、fail-closed 子集解析器。
|
||||
|
||||
这不是通用 YAML 实现。它只覆盖 ACK 状态文件所需的 mapping、
|
||||
sequence、flow collection、标量和 ``>`` / ``|`` block scalar。锚点、
|
||||
alias、tag、多文档和其它未实现语法会显式失败,不会猜测或静默误解析。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
class YamlSubsetError(ValueError):
|
||||
"""YAML 超出 ACK 子集或语法无效。"""
|
||||
|
||||
|
||||
class DuplicateKeyError(ValueError):
|
||||
"""JSON/YAML mapping 包含重复键。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Line:
|
||||
number: int
|
||||
indent: int
|
||||
content: str
|
||||
|
||||
|
||||
_DECIMAL_INT_RE = re.compile(r"[-+]?(?:0|[1-9][0-9]*)\Z")
|
||||
_AMBIGUOUS_NUMBER_RE = re.compile(
|
||||
r"[-+]?(?:"
|
||||
r"[0-9][0-9_]*\.[0-9_]*(?:[eE][-+]?[0-9]+)?|"
|
||||
r"[0-9][0-9_]*(?:[eE][-+]?[0-9]+)|"
|
||||
r"0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|"
|
||||
r"0[0-9_]+|[0-9][0-9_]*:[0-9_:]+"
|
||||
r")\Z"
|
||||
)
|
||||
_PLAIN_KEY_FORBIDDEN_RE = re.compile(r"[\[\]{},#]")
|
||||
_ANCHOR_OR_ALIAS_RE = re.compile(
|
||||
r"(?:^|\s)[&*][A-Za-z0-9_-]+(?:\s|$)"
|
||||
)
|
||||
|
||||
|
||||
def load_json_unique(content: str) -> Any:
|
||||
"""Parse JSON while rejecting duplicate object keys at every depth."""
|
||||
|
||||
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise DuplicateKeyError(f"JSON 存在重复键 {key!r}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
return json.loads(content, object_pairs_hook=unique_object)
|
||||
|
||||
|
||||
def make_unique_pyyaml_loader(yaml_module: Any) -> type:
|
||||
"""Build a SafeLoader that rejects duplicate keys and graph features.
|
||||
|
||||
ACK documents are trees. Anchors/aliases can introduce shared identity or
|
||||
cycles, which are unnecessary here and make recursive validation unsafe.
|
||||
Explicit tags are also outside the fallback grammar, so both code paths
|
||||
reject them consistently.
|
||||
"""
|
||||
|
||||
class UniqueKeySafeLoader(yaml_module.SafeLoader): # type: ignore[misc]
|
||||
def compose_node(self, parent: Any, index: Any) -> Any:
|
||||
if self.check_event(yaml_module.events.AliasEvent):
|
||||
event = self.peek_event()
|
||||
raise yaml_module.YAMLError(
|
||||
f"ACK YAML 不支持 alias: *{event.anchor}"
|
||||
)
|
||||
event = self.peek_event()
|
||||
if getattr(event, "anchor", None) is not None:
|
||||
raise yaml_module.YAMLError(
|
||||
f"ACK YAML 不支持 anchor: &{event.anchor}"
|
||||
)
|
||||
if getattr(event, "tag", None) is not None:
|
||||
raise yaml_module.YAMLError(
|
||||
f"ACK YAML 不支持显式 tag: {event.tag}"
|
||||
)
|
||||
return super().compose_node(parent, index)
|
||||
|
||||
def construct_unique_mapping(
|
||||
loader: Any,
|
||||
node: Any,
|
||||
deep: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
for key_node, _ in node.value:
|
||||
if (
|
||||
getattr(key_node, "tag", None) == "tag:yaml.org,2002:merge"
|
||||
or getattr(key_node, "value", None) == "<<"
|
||||
):
|
||||
raise yaml_module.constructor.ConstructorError(
|
||||
"while constructing an ACK mapping",
|
||||
node.start_mark,
|
||||
"merge keys are not supported",
|
||||
key_node.start_mark,
|
||||
)
|
||||
loader.flatten_mapping(node)
|
||||
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 yaml_module.constructor.ConstructorError(
|
||||
"while constructing an ACK mapping",
|
||||
node.start_mark,
|
||||
"mapping key must be a string",
|
||||
key_node.start_mark,
|
||||
)
|
||||
if key == "<<":
|
||||
raise yaml_module.constructor.ConstructorError(
|
||||
"while constructing an ACK mapping",
|
||||
node.start_mark,
|
||||
"merge keys are not supported",
|
||||
key_node.start_mark,
|
||||
)
|
||||
if key in mapping:
|
||||
raise yaml_module.constructor.ConstructorError(
|
||||
"while constructing an ACK mapping",
|
||||
node.start_mark,
|
||||
f"found duplicate key {key!r}",
|
||||
key_node.start_mark,
|
||||
)
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
UniqueKeySafeLoader.add_constructor(
|
||||
yaml_module.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
construct_unique_mapping,
|
||||
)
|
||||
return UniqueKeySafeLoader
|
||||
|
||||
|
||||
def load_yaml_subset(content: str) -> Any:
|
||||
"""Parse the deliberately small YAML subset used by ACK files."""
|
||||
|
||||
return _SubsetParser(content).parse()
|
||||
|
||||
|
||||
class _SubsetParser:
|
||||
def __init__(self, content: str) -> None:
|
||||
if content.startswith("\ufeff"):
|
||||
content = content[1:]
|
||||
if "\t" in content:
|
||||
raise YamlSubsetError("ACK YAML 子集不支持 Tab,请使用空格")
|
||||
self.lines = [
|
||||
_Line(number, len(raw) - len(raw.lstrip(" ")), raw.lstrip(" "))
|
||||
for number, raw in enumerate(content.splitlines(), start=1)
|
||||
]
|
||||
self.index = 0
|
||||
|
||||
def parse(self) -> Any:
|
||||
self._skip_insignificant()
|
||||
if self.index >= len(self.lines):
|
||||
return None
|
||||
first = self.lines[self.index]
|
||||
if first.indent != 0:
|
||||
self._error(first, "顶层不能缩进")
|
||||
value = self._parse_node(0)
|
||||
self._skip_insignificant()
|
||||
if self.index != len(self.lines):
|
||||
line = self.lines[self.index]
|
||||
self._error(line, "文档尾部存在无法解析的内容")
|
||||
return value
|
||||
|
||||
def _parse_node(self, indent: int) -> Any:
|
||||
self._skip_insignificant()
|
||||
if self.index >= len(self.lines):
|
||||
return None
|
||||
line = self.lines[self.index]
|
||||
if line.indent != indent:
|
||||
self._error(line, f"期望 {indent} 个空格的缩进")
|
||||
content = self._without_comment(line.content).rstrip()
|
||||
self._reject_document_syntax(content, line)
|
||||
if self._is_sequence_marker(content):
|
||||
return self._parse_sequence(indent)
|
||||
return self._parse_mapping(indent)
|
||||
|
||||
def _parse_mapping(self, indent: int) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
while True:
|
||||
self._skip_insignificant()
|
||||
if self.index >= len(self.lines):
|
||||
break
|
||||
line = self.lines[self.index]
|
||||
if line.indent < indent:
|
||||
break
|
||||
if line.indent > indent:
|
||||
self._error(line, "mapping 存在意外缩进")
|
||||
content = self._without_comment(line.content).rstrip()
|
||||
self._reject_document_syntax(content, line)
|
||||
if self._is_sequence_marker(content):
|
||||
break
|
||||
self.index += 1
|
||||
self._consume_mapping_entry(
|
||||
result,
|
||||
content,
|
||||
mapping_indent=indent,
|
||||
line=line,
|
||||
)
|
||||
return result
|
||||
|
||||
def _parse_sequence(self, indent: int) -> list[Any]:
|
||||
result: list[Any] = []
|
||||
while True:
|
||||
self._skip_insignificant()
|
||||
if self.index >= len(self.lines):
|
||||
break
|
||||
line = self.lines[self.index]
|
||||
if line.indent < indent:
|
||||
break
|
||||
if line.indent > indent:
|
||||
self._error(line, "sequence 存在意外缩进")
|
||||
content = self._without_comment(line.content).rstrip()
|
||||
self._reject_document_syntax(content, line)
|
||||
if not self._is_sequence_marker(content):
|
||||
break
|
||||
rest = content[1:].lstrip(" ")
|
||||
self.index += 1
|
||||
if not rest:
|
||||
next_line = self._peek_significant()
|
||||
if next_line is not None and next_line.indent > indent:
|
||||
result.append(self._parse_node(next_line.indent))
|
||||
else:
|
||||
result.append(None)
|
||||
continue
|
||||
if self._is_sequence_marker(rest):
|
||||
self._error(
|
||||
line,
|
||||
"不支持紧凑嵌套 sequence,请把内层 '-' 放到下一行",
|
||||
)
|
||||
if self._looks_like_mapping_entry(rest):
|
||||
mapping_indent = indent + 2
|
||||
item: dict[str, Any] = {}
|
||||
self._consume_mapping_entry(
|
||||
item,
|
||||
rest,
|
||||
mapping_indent=mapping_indent,
|
||||
line=line,
|
||||
)
|
||||
while True:
|
||||
self._skip_insignificant()
|
||||
continuation = self._peek_significant()
|
||||
if continuation is None or continuation.indent < mapping_indent:
|
||||
break
|
||||
if continuation.indent > mapping_indent:
|
||||
self._error(
|
||||
continuation,
|
||||
"sequence mapping 存在意外缩进",
|
||||
)
|
||||
continuation_content = self._without_comment(
|
||||
continuation.content
|
||||
).rstrip()
|
||||
if self._is_sequence_marker(continuation_content):
|
||||
self._error(
|
||||
continuation,
|
||||
"sequence mapping 中需要 key: value",
|
||||
)
|
||||
self.index += 1
|
||||
self._consume_mapping_entry(
|
||||
item,
|
||||
continuation_content,
|
||||
mapping_indent=mapping_indent,
|
||||
line=continuation,
|
||||
)
|
||||
result.append(item)
|
||||
continue
|
||||
if rest in {">", "|"}:
|
||||
result.append(self._parse_block_scalar(indent, rest, line))
|
||||
else:
|
||||
result.append(self._parse_inline_value(rest, line))
|
||||
next_line = self._peek_significant()
|
||||
if next_line is not None and next_line.indent > indent:
|
||||
self._error(next_line, "scalar sequence 项后存在意外缩进")
|
||||
return result
|
||||
|
||||
def _consume_mapping_entry(
|
||||
self,
|
||||
result: dict[str, Any],
|
||||
content: str,
|
||||
*,
|
||||
mapping_indent: int,
|
||||
line: _Line,
|
||||
) -> None:
|
||||
key_text, value_text = self._split_mapping_entry(content, line)
|
||||
key = self._parse_key(key_text, line)
|
||||
if key in result:
|
||||
self._error(line, f"mapping 存在重复键 {key!r}")
|
||||
|
||||
value_text = value_text.strip()
|
||||
if value_text in {">", "|"}:
|
||||
value = self._parse_block_scalar(mapping_indent, value_text, line)
|
||||
elif value_text:
|
||||
if value_text.startswith((">", "|")):
|
||||
self._error(
|
||||
line,
|
||||
"block scalar 只支持 '>' 或 '|',不支持 chomping/indent 指示符",
|
||||
)
|
||||
value = self._parse_inline_value(value_text, line)
|
||||
next_line = self._peek_significant()
|
||||
if next_line is not None and next_line.indent > mapping_indent:
|
||||
self._error(next_line, f"{key!r} 的 scalar 后存在意外缩进")
|
||||
else:
|
||||
next_line = self._peek_significant()
|
||||
if next_line is not None and next_line.indent > mapping_indent:
|
||||
value = self._parse_node(next_line.indent)
|
||||
else:
|
||||
value = None
|
||||
result[key] = value
|
||||
|
||||
def _parse_block_scalar(
|
||||
self,
|
||||
parent_indent: int,
|
||||
style: str,
|
||||
header: _Line,
|
||||
) -> str:
|
||||
probe = self.index
|
||||
while probe < len(self.lines) and not self.lines[probe].content.strip():
|
||||
probe += 1
|
||||
if probe >= len(self.lines) or self.lines[probe].indent <= parent_indent:
|
||||
return ""
|
||||
block_indent = self.lines[probe].indent
|
||||
if block_indent <= parent_indent:
|
||||
self._error(header, "block scalar 内容必须比键更深缩进")
|
||||
|
||||
values: list[str] = []
|
||||
while self.index < len(self.lines):
|
||||
line = self.lines[self.index]
|
||||
if not line.content.strip():
|
||||
values.append("")
|
||||
self.index += 1
|
||||
continue
|
||||
if line.indent < block_indent:
|
||||
break
|
||||
values.append(" " * (line.indent - block_indent) + line.content)
|
||||
self.index += 1
|
||||
|
||||
while values and values[-1] == "":
|
||||
values.pop()
|
||||
if not values:
|
||||
return ""
|
||||
if style == "|":
|
||||
return "\n".join(values) + "\n"
|
||||
|
||||
output = ""
|
||||
previous: str | None = None
|
||||
blank_count = 0
|
||||
for value in values:
|
||||
if value == "":
|
||||
blank_count += 1
|
||||
continue
|
||||
if previous is None:
|
||||
output = "\n" * blank_count + value
|
||||
elif blank_count:
|
||||
output += "\n" * blank_count + value
|
||||
elif previous.startswith(" ") or value.startswith(" "):
|
||||
output += "\n" + value
|
||||
else:
|
||||
output += " " + value
|
||||
previous = value
|
||||
blank_count = 0
|
||||
return output + "\n"
|
||||
|
||||
def _parse_key(self, text: str, line: _Line) -> str:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
self._error(line, "mapping key 不能为空")
|
||||
if text[0] in {'"', "'"}:
|
||||
parsed = _FlowParser(text, line.number).parse_complete_value()
|
||||
if not isinstance(parsed, str):
|
||||
self._error(line, "mapping key 必须是字符串")
|
||||
return parsed
|
||||
if text == "<<":
|
||||
self._error(line, "ACK YAML 子集不支持 merge key '<<'")
|
||||
if text[0] in "-?:!&*%@`" or _PLAIN_KEY_FORBIDDEN_RE.search(text):
|
||||
self._error(line, f"不支持的 plain mapping key {text!r}")
|
||||
parsed = _plain_scalar(text, line.number)
|
||||
if not isinstance(parsed, str):
|
||||
self._error(line, "mapping key 必须是字符串,特殊标量请加引号")
|
||||
return text
|
||||
|
||||
def _parse_inline_value(self, text: str, line: _Line) -> Any:
|
||||
parser = _FlowParser(text, line.number)
|
||||
return parser.parse_complete_value()
|
||||
|
||||
def _split_mapping_entry(self, content: str, line: _Line) -> tuple[str, str]:
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
depth = 0
|
||||
index = 0
|
||||
while index < len(content):
|
||||
char = content[index]
|
||||
if quote == '"':
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
if quote == "'":
|
||||
if char == "'":
|
||||
if index + 1 < len(content) and content[index + 1] == "'":
|
||||
index += 2
|
||||
continue
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
if char in {'"', "'"}:
|
||||
quote = char
|
||||
elif char in "[{":
|
||||
depth += 1
|
||||
elif char in "]}":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
self._error(line, "flow collection 括号不匹配")
|
||||
elif (
|
||||
char == ":"
|
||||
and depth == 0
|
||||
and (index + 1 == len(content) or content[index + 1].isspace())
|
||||
):
|
||||
return content[:index], content[index + 1 :]
|
||||
index += 1
|
||||
self._error(line, "mapping 项必须使用 'key: value'")
|
||||
|
||||
def _looks_like_mapping_entry(self, content: str) -> bool:
|
||||
try:
|
||||
self._split_mapping_entry(content, self.lines[self.index - 1])
|
||||
except YamlSubsetError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_sequence_marker(content: str) -> bool:
|
||||
return content == "-" or content.startswith("- ")
|
||||
|
||||
def _peek_significant(self) -> _Line | None:
|
||||
probe = self.index
|
||||
while probe < len(self.lines):
|
||||
line = self.lines[probe]
|
||||
if line.content.strip() and not line.content.lstrip().startswith("#"):
|
||||
return line
|
||||
probe += 1
|
||||
return None
|
||||
|
||||
def _skip_insignificant(self) -> None:
|
||||
while self.index < len(self.lines):
|
||||
content = self.lines[self.index].content
|
||||
if content.strip() and not content.lstrip().startswith("#"):
|
||||
break
|
||||
self.index += 1
|
||||
|
||||
def _without_comment(self, content: str) -> str:
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
index = 0
|
||||
while index < len(content):
|
||||
char = content[index]
|
||||
if quote == '"':
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
elif quote == "'":
|
||||
if char == "'":
|
||||
if index + 1 < len(content) and content[index + 1] == "'":
|
||||
index += 1
|
||||
else:
|
||||
quote = None
|
||||
elif char in {'"', "'"}:
|
||||
quote = char
|
||||
elif char == "#" and (index == 0 or content[index - 1].isspace()):
|
||||
return content[:index]
|
||||
index += 1
|
||||
if quote is not None:
|
||||
raise YamlSubsetError("未结束的引号标量")
|
||||
return content
|
||||
|
||||
def _reject_document_syntax(self, content: str, line: _Line) -> None:
|
||||
if content in {"---", "..."} or content.startswith("%"):
|
||||
self._error(line, "ACK YAML 子集只支持单文档,不支持 directive/marker")
|
||||
if content.startswith(("!", "&", "*")):
|
||||
self._error(line, "ACK YAML 子集不支持 tag/anchor/alias")
|
||||
|
||||
@staticmethod
|
||||
def _error(line: _Line, message: str) -> None:
|
||||
raise YamlSubsetError(f"第 {line.number} 行: {message}")
|
||||
|
||||
|
||||
class _FlowParser:
|
||||
def __init__(self, text: str, line_number: int) -> None:
|
||||
self.text = text
|
||||
self.line_number = line_number
|
||||
self.index = 0
|
||||
|
||||
def parse_complete_value(self) -> Any:
|
||||
value = self._parse_value()
|
||||
self._skip_space()
|
||||
if self.index != len(self.text):
|
||||
self._error(f"标量后存在未支持内容: {self.text[self.index:]!r}")
|
||||
return value
|
||||
|
||||
def _parse_value(self) -> Any:
|
||||
self._skip_space()
|
||||
if self.index >= len(self.text):
|
||||
self._error("缺少标量")
|
||||
char = self.text[self.index]
|
||||
if char == "[":
|
||||
return self._parse_list()
|
||||
if char == "{":
|
||||
return self._parse_map()
|
||||
if char in {'"', "'"}:
|
||||
return self._parse_quoted()
|
||||
if char in "]},":
|
||||
self._error(f"意外字符 {char!r}")
|
||||
return self._parse_plain({",", "]", "}"})
|
||||
|
||||
def _parse_list(self) -> list[Any]:
|
||||
self.index += 1
|
||||
result: list[Any] = []
|
||||
self._skip_space()
|
||||
if self._consume("]"):
|
||||
return result
|
||||
while True:
|
||||
result.append(self._parse_value())
|
||||
self._skip_space()
|
||||
if self._consume("]"):
|
||||
return result
|
||||
if not self._consume(","):
|
||||
self._error("flow list 项之间必须用 ',' 分隔")
|
||||
self._skip_space()
|
||||
if self.index >= len(self.text) or self.text[self.index] == "]":
|
||||
self._error("flow list 不支持尾随逗号")
|
||||
|
||||
def _parse_map(self) -> dict[str, Any]:
|
||||
self.index += 1
|
||||
result: dict[str, Any] = {}
|
||||
self._skip_space()
|
||||
if self._consume("}"):
|
||||
return result
|
||||
while True:
|
||||
self._skip_space()
|
||||
if self.index >= len(self.text):
|
||||
self._error("flow mapping 未结束")
|
||||
if self.text[self.index] in {'"', "'"}:
|
||||
key = self._parse_quoted()
|
||||
else:
|
||||
key = self._parse_plain({":"}, convert=False)
|
||||
if not isinstance(key, str) or not key:
|
||||
self._error("flow mapping key 必须是非空字符串")
|
||||
if key == "<<":
|
||||
self._error("ACK YAML 子集不支持 merge key '<<'")
|
||||
self._skip_space()
|
||||
if not self._consume(":"):
|
||||
self._error("flow mapping key 后必须是 ':'")
|
||||
value = self._parse_value()
|
||||
if key in result:
|
||||
self._error(f"flow mapping 存在重复键 {key!r}")
|
||||
result[key] = value
|
||||
self._skip_space()
|
||||
if self._consume("}"):
|
||||
return result
|
||||
if not self._consume(","):
|
||||
self._error("flow mapping 项之间必须用 ',' 分隔")
|
||||
self._skip_space()
|
||||
if self.index >= len(self.text) or self.text[self.index] == "}":
|
||||
self._error("flow mapping 不支持尾随逗号")
|
||||
|
||||
def _parse_quoted(self) -> str:
|
||||
quote = self.text[self.index]
|
||||
self.index += 1
|
||||
result: list[str] = []
|
||||
while self.index < len(self.text):
|
||||
char = self.text[self.index]
|
||||
self.index += 1
|
||||
if char == quote:
|
||||
if quote == "'" and self.index < len(self.text) and self.text[
|
||||
self.index
|
||||
] == "'":
|
||||
result.append("'")
|
||||
self.index += 1
|
||||
continue
|
||||
return "".join(result)
|
||||
if quote == "'" or char != "\\":
|
||||
result.append(char)
|
||||
continue
|
||||
if self.index >= len(self.text):
|
||||
self._error("双引号标量以转义符结尾")
|
||||
escape = self.text[self.index]
|
||||
self.index += 1
|
||||
simple = {
|
||||
"0": "\0",
|
||||
"a": "\a",
|
||||
"b": "\b",
|
||||
"t": "\t",
|
||||
"n": "\n",
|
||||
"v": "\v",
|
||||
"f": "\f",
|
||||
"r": "\r",
|
||||
"e": "\x1b",
|
||||
" ": " ",
|
||||
'"': '"',
|
||||
"/": "/",
|
||||
"\\": "\\",
|
||||
}
|
||||
if escape in simple:
|
||||
result.append(simple[escape])
|
||||
continue
|
||||
widths = {"x": 2, "u": 4, "U": 8}
|
||||
if escape in widths:
|
||||
width = widths[escape]
|
||||
digits = self.text[self.index : self.index + width]
|
||||
if len(digits) != width or not re.fullmatch(
|
||||
rf"[0-9a-fA-F]{{{width}}}", digits
|
||||
):
|
||||
self._error(f"无效 Unicode 转义 \\{escape}{digits}")
|
||||
codepoint = int(digits, 16)
|
||||
try:
|
||||
result.append(chr(codepoint))
|
||||
except ValueError as exc:
|
||||
raise YamlSubsetError(
|
||||
f"第 {self.line_number} 行: 无效 Unicode 码点"
|
||||
) from exc
|
||||
self.index += width
|
||||
continue
|
||||
self._error(f"不支持的双引号转义 \\{escape}")
|
||||
self._error("引号标量未结束")
|
||||
|
||||
def _parse_plain(
|
||||
self,
|
||||
delimiters: set[str],
|
||||
*,
|
||||
convert: bool = True,
|
||||
) -> Any:
|
||||
start = self.index
|
||||
while self.index < len(self.text) and self.text[self.index] not in delimiters:
|
||||
self.index += 1
|
||||
token = self.text[start : self.index].strip()
|
||||
if not token:
|
||||
self._error("空 plain scalar")
|
||||
if "#" in token:
|
||||
self._error("flow collection 内的注释不受支持")
|
||||
if ": " in token:
|
||||
self._error("plain scalar 中的 ': ' 必须加引号")
|
||||
if token[0] in "!&*%@`?" or _ANCHOR_OR_ALIAS_RE.search(token):
|
||||
self._error("ACK YAML 子集不支持 tag/anchor/alias/directive")
|
||||
return _plain_scalar(token, self.line_number) if convert else token
|
||||
|
||||
def _skip_space(self) -> None:
|
||||
while self.index < len(self.text) and self.text[self.index] == " ":
|
||||
self.index += 1
|
||||
|
||||
def _consume(self, expected: str) -> bool:
|
||||
if self.index < len(self.text) and self.text[self.index] == expected:
|
||||
self.index += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def _error(self, message: str) -> None:
|
||||
raise YamlSubsetError(f"第 {self.line_number} 行: {message}")
|
||||
|
||||
|
||||
def _plain_scalar(token: str, line_number: int) -> Any:
|
||||
lowered = token.lower()
|
||||
if lowered in {"null", "~"}:
|
||||
return None
|
||||
if lowered in {"true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"false", "no", "off"}:
|
||||
return False
|
||||
if _DECIMAL_INT_RE.fullmatch(token):
|
||||
return int(token, 10)
|
||||
if _AMBIGUOUS_NUMBER_RE.fullmatch(token):
|
||||
raise YamlSubsetError(
|
||||
f"第 {line_number} 行: ACK YAML 子集不支持该数字格式 {token!r},"
|
||||
"如需字符串请加引号"
|
||||
)
|
||||
return token
|
||||
@@ -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"
|
||||
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/delivery.schema.json",
|
||||
"title": "ACK project delivery contract",
|
||||
"description": ".pouch/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"project",
|
||||
"enabled",
|
||||
"defaultProfile",
|
||||
"entrypoints",
|
||||
"artifacts",
|
||||
"destinations",
|
||||
"environments",
|
||||
"profiles"
|
||||
],
|
||||
"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" }
|
||||
}
|
||||
},
|
||||
"enabled": { "type": "boolean" },
|
||||
"defaultProfile": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
},
|
||||
"intents": { "$ref": "#/definitions/intents" },
|
||||
"entrypoints": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/definitions/id" },
|
||||
"additionalProperties": { "$ref": "#/definitions/entrypoint" }
|
||||
},
|
||||
"artifacts": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/definitions/id" },
|
||||
"additionalProperties": { "$ref": "#/definitions/artifact" }
|
||||
},
|
||||
"destinations": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/definitions/id" },
|
||||
"additionalProperties": { "$ref": "#/definitions/destination" }
|
||||
},
|
||||
"environments": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/definitions/id" },
|
||||
"additionalProperties": { "$ref": "#/definitions/environment" }
|
||||
},
|
||||
"profiles": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/definitions/id" },
|
||||
"additionalProperties": { "$ref": "#/definitions/profile" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
},
|
||||
"intents": {
|
||||
"type": "object",
|
||||
"required": ["testEnvironment", "release"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"testEnvironment": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["via", "env"],
|
||||
"properties": {
|
||||
"via": { "const": "deployer" },
|
||||
"env": { "$ref": "#/definitions/id" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relativePath": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/*?+-]+$"
|
||||
},
|
||||
"stringList": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"secretName": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{0,127}$"
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"args",
|
||||
"requiredSecrets",
|
||||
"workingDirectory",
|
||||
"timeoutSeconds"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["make", "just", "task", "dagger", "script"]
|
||||
},
|
||||
"target": { "type": "string", "minLength": 1 },
|
||||
"function": { "type": "string", "minLength": 1 },
|
||||
"path": { "$ref": "#/definitions/relativePath" },
|
||||
"args": { "$ref": "#/definitions/stringList" },
|
||||
"requiredSecrets": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/definitions/secretName" }
|
||||
},
|
||||
"workingDirectory": { "$ref": "#/definitions/relativePath" },
|
||||
"timeoutSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 86400
|
||||
}
|
||||
}
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"required": ["type", "build"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["deb", "oci-image", "file"] },
|
||||
"build": { "$ref": "#/definitions/id" },
|
||||
"outputs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/definitions/relativePath" }
|
||||
},
|
||||
"image": { "type": "string", "minLength": 1 },
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z0-9]+/[A-Za-z0-9._-]+$" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"destination": {
|
||||
"type": "object",
|
||||
"required": ["type", "channel"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["apt-repository", "oci-registry", "ci-artifact"]
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"enum": ["preview", "staging", "stable"]
|
||||
},
|
||||
"registry": { "type": "string", "minLength": 1 },
|
||||
"repository": { "type": "string", "minLength": 1 },
|
||||
"endpoint": { "type": "string", "minLength": 1 },
|
||||
"artifactName": { "type": "string", "minLength": 1 },
|
||||
"upload": { "$ref": "#/definitions/id" }
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"classification",
|
||||
"target",
|
||||
"deploy",
|
||||
"healthCheck",
|
||||
"mutex"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["ssh-host", "docker-compose", "kubernetes", "custom"]
|
||||
},
|
||||
"classification": {
|
||||
"type": "string",
|
||||
"enum": ["development", "staging", "production"]
|
||||
},
|
||||
"target": { "type": "string", "minLength": 1 },
|
||||
"deploy": { "$ref": "#/definitions/id" },
|
||||
"healthCheck": { "$ref": "#/definitions/id" },
|
||||
"rollback": { "$ref": "#/definitions/id" },
|
||||
"mutex": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"step": {
|
||||
"type": "object",
|
||||
"required": ["id", "action"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": { "$ref": "#/definitions/id" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"verify",
|
||||
"pull-request",
|
||||
"build",
|
||||
"publish",
|
||||
"deploy",
|
||||
"health-check",
|
||||
"approval",
|
||||
"mark-ready"
|
||||
]
|
||||
},
|
||||
"entrypoint": { "$ref": "#/definitions/id" },
|
||||
"artifact": { "$ref": "#/definitions/id" },
|
||||
"destination": { "$ref": "#/definitions/id" },
|
||||
"environment": { "$ref": "#/definitions/id" },
|
||||
"gate": { "type": "string", "enum": ["release", "production"] },
|
||||
"draft": { "type": "boolean" },
|
||||
"remote": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
|
||||
},
|
||||
"baseBranch": { "type": "string", "minLength": 1, "maxLength": 255 }
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"required": ["stopAt", "steps"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"stopAt": {
|
||||
"type": "string",
|
||||
"enum": ["verified", "validation_ready", "review_ready", "released"]
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/step" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# 复制为 .pouch/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
|
||||
version: 1
|
||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||
project:
|
||||
name: "<project_name>"
|
||||
|
||||
enabled: false
|
||||
defaultProfile: null
|
||||
|
||||
# 测试环境由 ACK 内部调用 deployer;发版仍指向本文件的 profile。
|
||||
# null 表示用户尚未说明该操作。
|
||||
intents:
|
||||
testEnvironment: null
|
||||
release: null
|
||||
|
||||
# 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。
|
||||
# 不在此保存 shell、env 或凭据正文。
|
||||
entrypoints: {}
|
||||
|
||||
# 支持 deb、oci-image 和 file。复杂构建逻辑留在项目已有入口中。
|
||||
artifacts: {}
|
||||
|
||||
# 支持 apt-repository、oci-registry 和 ci-artifact。
|
||||
destinations: {}
|
||||
|
||||
# 支持 ssh-host、docker-compose、kubernetes 和 custom。
|
||||
environments: {}
|
||||
|
||||
# enabled=true 时,defaultProfile 必须指向一个非空 profile。
|
||||
profiles: {}
|
||||
@@ -0,0 +1,367 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/knowledge.schema.json",
|
||||
"title": "ACK project knowledge guardrails",
|
||||
"description": ".pouch/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
|
||||
"type": "object",
|
||||
"required": ["version", "updatedAt", "project", "verificationRegistry", "entries"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"project": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verificationRegistry": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/verificationTarget"
|
||||
}
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"stableRef": {
|
||||
"type": "string",
|
||||
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
|
||||
},
|
||||
"stringSet": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"all",
|
||||
"components",
|
||||
"paths",
|
||||
"dependencies",
|
||||
"versions",
|
||||
"tags",
|
||||
"symbols",
|
||||
"errorSignatures"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"all": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"components": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
},
|
||||
"paths": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
},
|
||||
"dependencies": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
},
|
||||
"versions": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
},
|
||||
"symbols": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
},
|
||||
"errorSignatures": {
|
||||
"$ref": "#/definitions/stringSet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"type": "object",
|
||||
"required": ["ref", "expected"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
||||
},
|
||||
"expected": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verificationTarget": {
|
||||
"type": "object",
|
||||
"required": ["path", "args"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?=.*\\S)[A-Za-z0-9][A-Za-z0-9._/-]*$",
|
||||
"description": "项目内可执行文件;运行时 cwd 为项目根,脚本不得依赖自身文件路径定位资源"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"required": ["taskId", "attemptId", "codeRef", "evidenceRef"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"attemptId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"codeRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"evidenceRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"revision",
|
||||
"kind",
|
||||
"status",
|
||||
"title",
|
||||
"subject",
|
||||
"scope",
|
||||
"appliesWhen",
|
||||
"directive",
|
||||
"rationale",
|
||||
"verification",
|
||||
"provenance",
|
||||
"owner",
|
||||
"author",
|
||||
"reviewer",
|
||||
"approval",
|
||||
"createdAt",
|
||||
"lastValidatedAt",
|
||||
"reviewAfter",
|
||||
"temporary",
|
||||
"removalCondition",
|
||||
"statusReason",
|
||||
"supersedes",
|
||||
"conflictsWith"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^K-[A-Z0-9][A-Z0-9-]*$"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["guardrail", "pitfall", "verification"]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "stale", "superseded", "archived"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z0-9][a-z0-9-]*$"
|
||||
},
|
||||
"scope": {
|
||||
"$ref": "#/definitions/scope"
|
||||
},
|
||||
"appliesWhen": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"directive": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"verification": {
|
||||
"$ref": "#/definitions/verification"
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/definitions/provenance"
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"reviewer": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"approval": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{ "$ref": "#/definitions/approval" }
|
||||
]
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"lastValidatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"reviewAfter": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time"
|
||||
},
|
||||
"temporary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"removalCondition": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"statusReason": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"supersedes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/definitions/stableRef"
|
||||
}
|
||||
},
|
||||
"conflictsWith": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/definitions/stableRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"temporary": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"reviewAfter": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"removalCondition": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"enum": ["stale", "superseded", "archived"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"statusReason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"approval": {
|
||||
"type": "object",
|
||||
"required": ["approvedBy", "approvedAt", "evidenceRef"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"approvedBy": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"approvedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"evidenceRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
||||
version: 1
|
||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||
project:
|
||||
name: "<project_name>"
|
||||
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
|
||||
verificationRegistry: {}
|
||||
entries: []
|
||||
@@ -0,0 +1,136 @@
|
||||
# <项目名> Agent 协作协议(项目覆盖层)
|
||||
|
||||
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
|
||||
> `references/` 读取;本文件只保存项目差异。
|
||||
>
|
||||
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `.pouch/ack/project.md`,
|
||||
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
|
||||
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
||||
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、默认关闭的
|
||||
> `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖,
|
||||
> 不复制或链接 Skill。
|
||||
|
||||
## 项目概览
|
||||
|
||||
- 项目:`<project_name>`
|
||||
- 技术栈:`<tech_stack>`
|
||||
- 运行命令:`<run_command>`
|
||||
- Base URL:`<base_url>`
|
||||
- 任务板配置:`.pouch/ack/tasks.yaml`
|
||||
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`(追加式)
|
||||
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
|
||||
- 回归目录:`.pouch/ack/regression.yaml`
|
||||
- 角色启动覆盖(可选):`.pouch/ack/agents.yaml`
|
||||
- 覆盖层文件:`<overlay_file_path>`(默认 `.pouch/ack/project.md`)
|
||||
|
||||
## 通用规范(由 ACK Skill 按需读取)
|
||||
|
||||
- 开始需求:`references/kickoff.md`
|
||||
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
|
||||
- 模型档位与升级规则:`references/model-routing.md`
|
||||
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
|
||||
- 验收信号与三轮策略:`references/optimization-method.md`
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||
- 回归目录与运行(可选):`references/regression.md`
|
||||
|
||||
## Worker 路由
|
||||
|
||||
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
|
||||
receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml` 的
|
||||
`project.orchestration` 与顶层 `workerReceipts` 为准。本文件不保存可执行 worker 命令。
|
||||
|
||||
默认 profile:
|
||||
|
||||
| 角色 | profile ID | 档位 |
|
||||
|------|------------|------|
|
||||
| Developer | `codex-dev-standard` | standard |
|
||||
| Test | `codex-test-standard` | standard |
|
||||
| Developer 升级 | `codex-dev-strong` | strong |
|
||||
|
||||
项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
|
||||
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
|
||||
`--expected-launch-fingerprint` 启动 fresh worker。`receiptHash` 只作审计 checksum,
|
||||
不授权自动复用旧终端;只有同轮空闲、身份匹配且历史消息可信清理后才可复用。
|
||||
|
||||
## 路径权限(项目覆盖层,必须填实际路径)
|
||||
|
||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||
|------|:-----------:|:----:|:---------:|------|
|
||||
| `<spec_paths>` | R/W | Read-only | Read-only | 产品规格、API 文档、计划(PM 拥有) |
|
||||
| `<integration_test_paths>` | Read-only | R/W | Read-only | 浏览器/API 回归(Test 拥有) |
|
||||
| `<test_records_path>` | Read-only | R/W | Read-only | 复测记录 |
|
||||
| `<source_paths>` | Read-only | Read-only | R/W | 应用源码 |
|
||||
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
|
||||
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
|
||||
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
|
||||
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
|
||||
| `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 | 项目交付能力,不是执行授权 |
|
||||
|
||||
## 命令(项目覆盖层)
|
||||
|
||||
Developer 白盒验证:
|
||||
|
||||
```bash
|
||||
<unit_test_command>
|
||||
<build_command>
|
||||
<local_run_command>
|
||||
```
|
||||
|
||||
Test 黑盒复测(服务启动以 deployer 绑定的 `intents.testEnvironment` 为准):
|
||||
|
||||
```bash
|
||||
<preflight_command>
|
||||
<api_smoke_command>
|
||||
<browser_regression_command>
|
||||
```
|
||||
|
||||
知识项的 `verification.ref` 只能引用 `knowledge.yaml.verificationRegistry` 中已
|
||||
审查的检查入口。Registry 只保存仓库内相对 path 和结构化 args,不保存或执行自由
|
||||
shell 命令;新项目没有知识时保持空对象。Developer/Test 只能把 registry ID 交给
|
||||
Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检查脚本从项目
|
||||
根 cwd 或 `ACK_PROJECT_ROOT` 定位资源,不能依赖自身文件路径。
|
||||
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
|
||||
`ACK_PROJECT_ROOT_DISPLAY`。
|
||||
|
||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||
`scripts/validate_regression.py` 执行。
|
||||
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
|
||||
测试环境由 ACK 内部调用 deployer。本文件不维护第二套交付命令。
|
||||
|
||||
## 硬规则(其余见 references/)
|
||||
|
||||
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
||||
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
|
||||
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
|
||||
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
|
||||
等第二配置面。
|
||||
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
|
||||
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
|
||||
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
||||
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`;
|
||||
Developer 与 Test 都只读,通过消息回报。
|
||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||
`candidate` 不派发,知识库不全量注入。
|
||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境
|
||||
绑定 deployer,发版写在 `intents.release`;用户明确要求重新部署测试环境或发布
|
||||
版本时才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后
|
||||
运行。回归目录在 `.pouch/ack/regression.yaml`,由用户明确要求「回归」时运行。
|
||||
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
||||
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||
@@ -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: []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
# 复制为 .pouch/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
|
||||
version: 1
|
||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "<接入时的 ack skill 版本>"
|
||||
# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。
|
||||
taskStore:
|
||||
version: 1
|
||||
directory: "task-records"
|
||||
project:
|
||||
name: "<project_name>"
|
||||
baseUrl: "<base_url>"
|
||||
overlayFile: ".pouch/ack/project.md"
|
||||
knowledgeFile: ".pouch/ack/knowledge.yaml"
|
||||
deliveryFile: ".pouch/ack/delivery.yaml"
|
||||
regressionFile: ".pouch/ack/regression.yaml"
|
||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||
# bugIntake:
|
||||
# provider: "feishu-base"
|
||||
# workflow: "clarified-writeback-v1"
|
||||
# profile: "project-feishu"
|
||||
# baseToken: "<base_token>"
|
||||
# tableId: "<table_id>"
|
||||
# viewId: "<ack_ready_view_id>"
|
||||
# fields:
|
||||
# title: "<field_id>"
|
||||
# details: "<field_id>"
|
||||
# problemStatement: "<field_id>"
|
||||
# expectedOutcome: "<field_id>"
|
||||
# acceptance: "<field_id>"
|
||||
# intakeStatus: "<field_id>"
|
||||
# ackTaskId: "<field_id>"
|
||||
# attachments: "<field_id>"
|
||||
# updatedAt: "<field_id>"
|
||||
orchestration:
|
||||
# 可选:临时角色路由写到 .pouch/ack/agents.yaml;存在时由 launcher 优先读取。
|
||||
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
|
||||
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
standard: ["gpt-5.6-terra"]
|
||||
strong: ["gpt-5.6-sol"]
|
||||
test:
|
||||
standard: ["gpt-5.6-luna"]
|
||||
cursor-agent:
|
||||
developer:
|
||||
standard: ["auto"]
|
||||
test:
|
||||
standard: ["auto"]
|
||||
grok:
|
||||
developer:
|
||||
standard: ["grok-4.5"]
|
||||
strong: ["grok-4.6"]
|
||||
test:
|
||||
standard: ["grok-4.5"]
|
||||
profiles:
|
||||
codex-dev-standard:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-terra"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
codex-test-standard:
|
||||
role: "test"
|
||||
cli: "codex"
|
||||
tier: "standard"
|
||||
model: "gpt-5.6-luna"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
codex-dev-strong:
|
||||
role: "developer"
|
||||
cli: "codex"
|
||||
tier: "strong"
|
||||
model: "gpt-5.6-sol"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
cursor-dev-standard:
|
||||
role: "developer"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
cursor-test-standard:
|
||||
role: "test"
|
||||
cli: "cursor-agent"
|
||||
tier: "standard"
|
||||
model: "auto"
|
||||
reasoningEffort: null
|
||||
permissionMode: "workspace-write"
|
||||
grok-dev-standard:
|
||||
role: "developer"
|
||||
cli: "grok"
|
||||
tier: "standard"
|
||||
model: "grok-4.5"
|
||||
reasoningEffort: "medium"
|
||||
permissionMode: "workspace-write"
|
||||
grok-test-standard:
|
||||
role: "test"
|
||||
cli: "grok"
|
||||
tier: "standard"
|
||||
model: "grok-4.5"
|
||||
reasoningEffort: "low"
|
||||
permissionMode: "workspace-write"
|
||||
grok-dev-strong:
|
||||
role: "developer"
|
||||
cli: "grok"
|
||||
tier: "strong"
|
||||
model: "grok-4.6"
|
||||
reasoningEffort: "high"
|
||||
permissionMode: "workspace-write"
|
||||
defaults:
|
||||
developer: "codex-dev-standard"
|
||||
test: "codex-test-standard"
|
||||
developerUpgraded: "codex-dev-strong"
|
||||
|
||||
workerReceipts: []
|
||||
deliveryRuns: []
|
||||
regressionRuns: []
|
||||
|
||||
summary:
|
||||
verified: []
|
||||
open: []
|
||||
failedRetest: []
|
||||
leftovers: []
|
||||
|
||||
tasks:
|
||||
- id: "BUG-001"
|
||||
type: "bug"
|
||||
title: "<short title>"
|
||||
priority: "P0"
|
||||
status: "open"
|
||||
assignee: "developer"
|
||||
component: "<source file or module>"
|
||||
specRefs:
|
||||
- "<docs/spec.md#section>"
|
||||
testRefs:
|
||||
- "<tests/browser/cases/01-case.md>"
|
||||
knowledgeRefs: []
|
||||
knowledgeApplied: []
|
||||
knowledgeCandidates: []
|
||||
knowledgeChecks: []
|
||||
regressionRefs: []
|
||||
regressionCandidates: []
|
||||
|
||||
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||
# source:
|
||||
# kind: "feishu-base"
|
||||
# workflow: "reviewed-writeback-v1"
|
||||
# ref: "feishu-base:sha256:<64-lowercase-hex>"
|
||||
# recordId: "<record-id>"
|
||||
# updatedAt: "<source-updated-at>"
|
||||
# approvedRevision: "sha256:<approved-draft-digest>"
|
||||
# approvedPayloadHash: "sha256:<canonical-task-payload-digest>"
|
||||
|
||||
description: >
|
||||
<What is wrong, in user-visible terms.>
|
||||
|
||||
# 飞书审核通过后写入批准版本;审核前不创建这条 ACK 任务。
|
||||
fixLogic: >
|
||||
<Approved repair logic, affected behavior, invariants, and regression risks.>
|
||||
|
||||
acceptanceCriteria:
|
||||
- "<observable acceptance signal 1>"
|
||||
- "<observable acceptance signal 2>"
|
||||
|
||||
stepsToReproduce:
|
||||
- "<step 1>"
|
||||
- "<step 2>"
|
||||
|
||||
expected: >
|
||||
<Expected behavior.>
|
||||
|
||||
actual: >
|
||||
<Actual behavior observed by Test.>
|
||||
|
||||
evidence:
|
||||
browser: "<snapshot text, screenshot path, or UI finding>"
|
||||
api: "<curl result or API mismatch>"
|
||||
logs: "<relevant log line if any>"
|
||||
|
||||
verification:
|
||||
commands:
|
||||
- "<unit/build command>"
|
||||
- "<browser/API smoke command>"
|
||||
browser:
|
||||
page: "<page path>"
|
||||
checks:
|
||||
- "<visible text or interaction expected>"
|
||||
|
||||
dispatch:
|
||||
developer:
|
||||
profileId: "codex-dev-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
test:
|
||||
profileId: "codex-test-standard"
|
||||
receiptId: null
|
||||
attemptId: null
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds: []
|
||||
# 环境失败不占产品复验轮次;按 BUG-001-ENV-1... 连续编号,并写清 userAction。
|
||||
environmentIncidents: []
|
||||
|
||||
resolution:
|
||||
fixedBy: null
|
||||
verifiedBy: null
|
||||
verifiedAt: null
|
||||
leftoverReason: null
|
||||
@@ -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 清理语义。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user