Compare commits
38 Commits
v0.1.0
...
e9d2b5fde6
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Python(skiff CLI 运行时产物)
|
||||
# Python(pouch CLI 运行时产物)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
@@ -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,12 +1,11 @@
|
||||
# Agent Skills 仓库
|
||||
# pouch
|
||||
|
||||
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。Skill 内容、规范包与可运行的 skiff CLI 在本仓库一并维护。
|
||||
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。仓库名、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。旧名是 `skills` / `pouch`。
|
||||
|
||||
|
||||
| 仓库 | 地址 | 职责 |
|
||||
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
|
||||
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill、规范包、skiff CLI 的当前 SSOT |
|
||||
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | skiff CLI 的独立来源 / 上游同步参考 |
|
||||
| **pouch**(本仓库) | [https://git.yumee.top/laily/pouch](https://git.yumee.top/laily/pouch) | skill、规范包、pouch CLI 的当前 SSOT |
|
||||
|
||||
|
||||
---
|
||||
@@ -15,16 +14,16 @@
|
||||
|
||||
```bash
|
||||
# 1. 克隆并关联
|
||||
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
||||
cd ~/.skills && ./install.sh
|
||||
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||
cd ~/.pouch && ./install.sh
|
||||
|
||||
# install.sh 会自动把 skiff 项目 skill 安装到所有 Agent
|
||||
# install.sh 会自动把 pouch 项目 skill 安装到所有 Agent
|
||||
# 2. 安装其他 skill
|
||||
skiff add declarative-openspec-loop -g
|
||||
pouch add declarative-openspec-loop -g
|
||||
|
||||
# 3. 查看状态
|
||||
skiff list
|
||||
skiff status
|
||||
pouch list
|
||||
pouch status
|
||||
```
|
||||
|
||||
---
|
||||
@@ -41,13 +40,13 @@ skills/
|
||||
├── discussion-notes/ # 讨论沉淀笔记
|
||||
│ ├── SKILL.md
|
||||
│ └── reference.md
|
||||
skiff/ # CLI 源码(Python 3)
|
||||
bin/skiff # CLI 入口
|
||||
catalog.yaml # skiff 预置 Skill 来源目录
|
||||
pouch/ # CLI 源码(Python 3)
|
||||
bin/pouch # CLI 入口
|
||||
catalog.yaml # pouch 预置 Skill 来源目录
|
||||
AGENTS.md # 本文档
|
||||
```
|
||||
|
||||
**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`catalog.yaml`、`AGENTS.md`
|
||||
**本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
|
||||
**本仓库不包含**:各项目的 skill 启用清单
|
||||
|
||||
---
|
||||
@@ -59,8 +58,9 @@ AGENTS.md # 本文档
|
||||
|
||||
| Skill | 说明 |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
|
||||
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
||||
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
|
||||
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行三角色闭环、测试环境、发版与回归 |
|
||||
| [pouch](skills/pouch/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
||||
|
||||
@@ -72,12 +72,12 @@ Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目
|
||||
项目状态,不复制或链接 Skill 内容:
|
||||
|
||||
```bash
|
||||
skiff init ack
|
||||
pouch init ack
|
||||
```
|
||||
|
||||
### 预置目录(Catalog)
|
||||
|
||||
在 `catalog.yaml` 中预置,通过 skiff 拉取安装:
|
||||
在 `catalog.yaml` 中预置,通过 pouch 拉取安装:
|
||||
|
||||
|
||||
| Source | 来源 |
|
||||
@@ -86,8 +86,8 @@ skiff init ack
|
||||
|
||||
|
||||
```bash
|
||||
skiff fetch waza
|
||||
skiff add waza/think -g
|
||||
pouch fetch waza
|
||||
pouch add waza/think -g
|
||||
```
|
||||
|
||||
### 自定义仓库(Custom Sources)
|
||||
@@ -95,21 +95,21 @@ skiff add waza/think -g
|
||||
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
|
||||
|
||||
```bash
|
||||
skiff source add company \
|
||||
pouch source add company \
|
||||
git@git.company.com:platform/agent-skills.git \
|
||||
--skills-path skills
|
||||
skiff list --source company
|
||||
skiff add company/internal-review -g
|
||||
pouch list --source company
|
||||
pouch add company/internal-review -g
|
||||
```
|
||||
|
||||
也可以接入已有本地 checkout:
|
||||
|
||||
```bash
|
||||
skiff source add company --local ~/code/company-skills --skills-path skills
|
||||
pouch source add company --local ~/code/company-skills --skills-path skills
|
||||
```
|
||||
|
||||
配置保存在 `~/.config/skiff/config.yaml`,Git source 默认 clone 到
|
||||
`~/.local/share/skiff/sources/<source>/`。项目 `.skills.yaml` 只记录逻辑
|
||||
配置保存在 `~/.config/pouch/config.yaml`,Git source 默认 clone 到
|
||||
`~/.local/share/pouch/sources/<source>/`。项目 `.pouch.yaml` 只记录逻辑
|
||||
source 名称,每台机器独立配置实际仓库地址。
|
||||
|
||||
### 社区(External NPM / GitHub)
|
||||
@@ -126,28 +126,29 @@ npx skills find typescript
|
||||
## 设计原则
|
||||
|
||||
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
|
||||
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单
|
||||
2. **项目自治** — 每个项目自己维护 `.pouch.yaml`,本仓库不维护项目清单
|
||||
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
|
||||
4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
|
||||
5. **一体维护** — skill 与 CLI 同仓库维护;需要时再与独立 skiff 仓库同步
|
||||
5. **一体维护** — skill 与 CLI 同仓库维护
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
skills 仓库(本仓库) skiff CLI
|
||||
skills/<name>/ ←── skiff install / enable
|
||||
skiff/ ←── python3 -m skiff
|
||||
catalog.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 三层分类
|
||||
@@ -156,8 +157,8 @@ catalog.yaml ←── skiff add / fetch
|
||||
| 层级 | 位置 | 维护方式 |
|
||||
| ---------------- | ---------------------------------- | ------------------------------- |
|
||||
| **Builtin** | `skills/<name>/` | 本仓库 commit |
|
||||
| **Catalog** | `catalog.yaml` + checkout 缓存 | `skiff catalog add / fetch` |
|
||||
| **Custom Source** | `~/.local/share/skiff/sources/` 或本地路径 | `skiff source add/fetch` |
|
||||
| **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` |
|
||||
|
||||
### 非 Skill 资料分类
|
||||
@@ -175,16 +176,17 @@ catalog.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:
|
||||
- name: declarative-openspec-loop
|
||||
source: builtin
|
||||
@@ -202,38 +204,38 @@ 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` |
|
||||
|
||||
|
||||
项目级命令:
|
||||
|
||||
```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 bootstrap` | 将 skiff 项目 skill 全局安装到所有 Agent |
|
||||
| `skiff list` | 列出所有 skill |
|
||||
| `skiff status` | 安装状态总览 |
|
||||
| `skiff add <name> [-g]` | 项目或全局安装(symlink) |
|
||||
| `skiff remove <name> [-g]` | 移除 symlink |
|
||||
| `skiff catalog add` / `skiff fetch` | 管理和拉取 catalog source |
|
||||
| `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 |
|
||||
|
||||
|
||||
### 草稿与健康检查
|
||||
@@ -241,10 +243,10 @@ skiff sync
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `skiff create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
|
||||
| `skiff check <name>` | 校验草稿或正式 skill |
|
||||
| `skiff finalize <name>` | 校验并将草稿转为正式 skill |
|
||||
| `skiff doctor [--fix]` | symlink 健康检查与修复 |
|
||||
| `pouch create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
|
||||
| `pouch check <name>` | 校验草稿或正式 skill |
|
||||
| `pouch finalize <name>` | 校验并将草稿转为正式 skill |
|
||||
| `pouch doctor [--fix]` | symlink 健康检查与修复 |
|
||||
|
||||
|
||||
---
|
||||
@@ -279,12 +281,12 @@ description: >-
|
||||
|
||||
### 新建流程
|
||||
|
||||
1. `skiff create my-skill --idea "..." --from-project .`
|
||||
2. Agent 编辑 `~/.skills/.drafts/my-skill/SKILL.md`
|
||||
3. `skiff check my-skill`
|
||||
4. 用户确认后执行 `skiff finalize my-skill`
|
||||
5. `skiff add my-skill -a cursor -g -y` 验证
|
||||
6. 在本仓库 commit;需要的项目再用 `skiff add my-skill` 启用
|
||||
1. `pouch create my-skill --idea "..." --from-project .`
|
||||
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.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。
|
||||
|
||||
@@ -296,7 +298,7 @@ symlink 正确时,Agent 在项目里改 skill 文件 = 直接改 SSOT:
|
||||
|
||||
```
|
||||
project/.agents/skills/foo/SKILL.md
|
||||
→ ~/.skills/skills/foo/SKILL.md
|
||||
→ ~/.pouch/skills/foo/SKILL.md
|
||||
→ 在本仓库 commit
|
||||
```
|
||||
|
||||
@@ -311,7 +313,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| Cursor / Codex | symlink,正常 |
|
||||
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
|
||||
| symlink 被替换 | `skiff doctor --fix` → 重建 symlink |
|
||||
| symlink 被替换 | `pouch doctor --fix` → 重建 symlink |
|
||||
|
||||
|
||||
---
|
||||
@@ -321,7 +323,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) |
|
||||
@@ -334,13 +336,13 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
|
||||
|
||||
| 我要… | 命令 | 在哪 |
|
||||
| ---------- | --------------------------------- | ---- |
|
||||
| 首次安装 | `git clone <repo> ~/.skills && ~/.skills/install.sh` | 任意 |
|
||||
| 新建 skill | `skiff create` → Agent 完善 → `check/finalize` | 任意项目 |
|
||||
| 全局启用 | `skiff install <name>` | 任意 |
|
||||
| 项目启用 | `skiff enable <name>` | 项目目录 |
|
||||
| 看状态 | `skiff status` | 任意 |
|
||||
| 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
|
||||
| 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
|
||||
| 全局启用 | `pouch install <name>` | 任意 |
|
||||
| 项目启用 | `pouch enable <name>` | 项目目录 |
|
||||
| 看状态 | `pouch status` | 任意 |
|
||||
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
|
||||
| 更新外部 skill | `skiff fetch <name>` | 任意 |
|
||||
| 更新外部 skill | `pouch fetch <name>` | 任意 |
|
||||
|
||||
|
||||
---
|
||||
@@ -348,7 +350,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,80 +1,91 @@
|
||||
# Agent Skills
|
||||
# pouch
|
||||
|
||||
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。Skill 内容与 **skiff** CLI 在本仓库一并维护。
|
||||
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
||||
cd ~/.skills
|
||||
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
|
||||
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
|
||||
cd ~/.pouch
|
||||
./install.sh # 安装 CLI,并将 pouch 项目 skill 安装到所有 Agent
|
||||
|
||||
skiff add declarative-openspec-loop -g
|
||||
skiff select # 交互式选择并批量安装
|
||||
skiff list
|
||||
skiff status
|
||||
pouch add declarative-openspec-loop -g
|
||||
pouch select # 交互式选择并批量安装
|
||||
pouch list
|
||||
pouch status
|
||||
```
|
||||
|
||||
从旧的 `~/.skills` / `pouch` 迁移:
|
||||
|
||||
```bash
|
||||
mv ~/.skills ~/.pouch
|
||||
~/.pouch/install.sh
|
||||
```
|
||||
|
||||
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
```
|
||||
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
|
||||
skiff/ # CLI 源码(Python 3)
|
||||
bin/skiff # CLI 入口
|
||||
catalog.yaml # skiff 预置 Skill 来源目录
|
||||
pouch/ # CLI 源码(Python 3)
|
||||
bin/pouch # CLI 入口
|
||||
catalog.yaml # pouch 预置 Skill 来源目录
|
||||
AGENTS.md # 详细规范与架构说明
|
||||
```
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
|
||||
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
|
||||
| [catalog.yaml](catalog.yaml) | skiff 预置 Skill 来源目录 |
|
||||
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
|
||||
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
|
||||
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
|
||||
|
||||
## 自研 Skill
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
|
||||
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
|
||||
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
||||
| [pouch](skills/pouch/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
||||
|
||||
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
|
||||
|
||||
```bash
|
||||
skiff init ack
|
||||
skiff init ack --project ~/app
|
||||
pouch init ack
|
||||
pouch init ack --project ~/app
|
||||
```
|
||||
|
||||
初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护
|
||||
DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。
|
||||
初始化会生成默认关闭的 `.pouch/ack/delivery.yaml` 和空的 `.pouch/ack/regression.yaml`。
|
||||
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
|
||||
验证通过后收获回归用例。
|
||||
|
||||
新建 skill:
|
||||
|
||||
```bash
|
||||
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||
# 由 Agent 完善 ~/.skills/.drafts/my-skill/SKILL.md
|
||||
skiff check my-skill
|
||||
skiff finalize my-skill
|
||||
skiff add my-skill -g # 全局安装验证
|
||||
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md
|
||||
pouch check my-skill
|
||||
pouch finalize my-skill
|
||||
pouch add my-skill -g # 全局安装验证
|
||||
```
|
||||
|
||||
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `skiff` skill 收集实际结果与期望结果,修改 `~/.skills/skills/<name>/` 的 SSOT,并执行 `skiff check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
|
||||
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `pouch` skill 收集实际结果与期望结果,修改 `~/.pouch/skills/<name>/` 的 SSOT,并执行 `pouch check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
|
||||
|
||||
## 安装方式
|
||||
|
||||
### 全局(用户级)
|
||||
|
||||
```bash
|
||||
skiff add <name> -g # 安装到 ~/.cursor/skills/ 等
|
||||
skiff add <name> -g -a cursor
|
||||
pouch add <name> -g # 安装到 ~/.cursor/skills/ 等
|
||||
pouch add <name> -g -a cursor
|
||||
```
|
||||
|
||||
### 项目级
|
||||
|
||||
在项目根目录维护 `.skills.yaml`:
|
||||
在项目根目录维护 `.pouch.yaml`:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
@@ -90,9 +101,9 @@ targets: # 可选,默认 all
|
||||
```
|
||||
|
||||
```bash
|
||||
skiff add declarative-openspec-loop
|
||||
skiff sync
|
||||
skiff remove declarative-openspec-loop
|
||||
pouch add declarative-openspec-loop
|
||||
pouch sync
|
||||
pouch remove declarative-openspec-loop
|
||||
```
|
||||
|
||||
## Catalog 与 Custom Source
|
||||
@@ -100,15 +111,15 @@ skiff remove declarative-openspec-loop
|
||||
安装 catalog 中预置的来源:
|
||||
|
||||
```bash
|
||||
skiff fetch waza
|
||||
skiff add waza/think -g
|
||||
pouch fetch waza
|
||||
pouch add waza/think -g
|
||||
```
|
||||
|
||||
接入团队自己的本地目录或 Git 仓库:
|
||||
|
||||
```bash
|
||||
skiff source add company --local ~/code/company-skills --skills-path skills
|
||||
skiff add company/internal-review -g
|
||||
pouch source add company --local ~/code/company-skills --skills-path skills
|
||||
pouch add company/internal-review -g
|
||||
```
|
||||
|
||||
**社区来源**(Vercel CLI):
|
||||
@@ -122,23 +133,24 @@ npx skills find typescript
|
||||
|
||||
```
|
||||
本仓库
|
||||
├── skills/<name>/ ←── skiff install / enable
|
||||
├── skills/<name>/ ←── pouch install / enable
|
||||
├── catalog.yaml ←── 预置来源发现与 fetch
|
||||
└── skiff/ ←── python3 -m skiff
|
||||
└── 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 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
|
||||
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
|
||||
@@ -146,7 +158,7 @@ npx skills find typescript
|
||||
## 文档
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
|
||||
- [skiff/README.md](skiff/README.md) — CLI 命令参考与开发说明
|
||||
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 跟随软链,确保从 ~/.local/bin/skiff 调用时仍能找到仓库根目录
|
||||
# 跟随软链,确保从 ~/.local/bin/pouch 调用时仍能找到仓库根目录
|
||||
SCRIPT="${BASH_SOURCE[0]}"
|
||||
while [ -L "$SCRIPT" ]; do
|
||||
link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||
@@ -11,4 +11,4 @@ done
|
||||
REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
|
||||
|
||||
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||
exec python3 -m skiff "$@"
|
||||
exec python3 -m pouch "$@"
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# repo: <git-url>
|
||||
# ref: <branch|tag> (default: main)
|
||||
# path: <subpath> (default: .)
|
||||
# description: <text> (optional, shown by `skiff select`)
|
||||
# description: <text> (optional, shown by `pouch select`)
|
||||
# tags: (optional)
|
||||
# - <tag>
|
||||
#
|
||||
|
||||
@@ -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 黑盒复测并记录证据。
|
||||
+11
-11
@@ -17,7 +17,7 @@ updated: "2026-07-31T23:31:48+08:00"
|
||||
## 背景
|
||||
|
||||
本轮评审覆盖 `skills/ack/` 的入口、角色规范、闭环流程、项目模板、任务板
|
||||
schema、校验脚本、Orca 适配器,以及 `skiff init ack` 的真实运行路径。
|
||||
schema、校验脚本、Orca 适配器,以及 `pouch init ack` 的真实运行路径。
|
||||
|
||||
评审主要回答两个问题:
|
||||
|
||||
@@ -216,15 +216,15 @@ Deferred,当前 launcher 必须 fail closed。
|
||||
|
||||
## P0:初始化需要原子化
|
||||
|
||||
`skiff init ack` 会先写 `project.md` 和 `tasks.yaml`,再运行任务板校验。缺少 PyYAML
|
||||
`pouch init ack` 会先写 `project.md` 和 `tasks.yaml`,再运行任务板校验。缺少 PyYAML
|
||||
时,命令会失败但保留两个文件。再次运行又会因为拒绝覆盖而失败。
|
||||
|
||||
相关位置:
|
||||
|
||||
- `requirements.txt:1`
|
||||
- `skills/ack/scripts/validate_tasks.py:37`
|
||||
- `skiff/cli.py:1144`
|
||||
- `skiff/cli.py:1156`
|
||||
- `pouch/cli.py:1144`
|
||||
- `pouch/cli.py:1156`
|
||||
|
||||
本轮在不含第三方包的隔离 Python 环境中复现了这个状态。
|
||||
|
||||
@@ -232,7 +232,7 @@ Deferred,当前 launcher 必须 fail closed。
|
||||
|
||||
- 在临时目录渲染和校验,全部通过后再原子 rename。
|
||||
- 失败时只清理由本次调用创建的临时文件。
|
||||
- 提供 `skiff init ack --repair` 或等价恢复路径。
|
||||
- 提供 `pouch init ack --repair` 或等价恢复路径。
|
||||
- 默认模板使用 `tasks: []`,完整示例继续放在 `examples/`。
|
||||
- CLI 输出“脚手架已创建,待配置”,检查通过后再称为“初始化完成”。
|
||||
|
||||
@@ -332,7 +332,7 @@ ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过
|
||||
2. 把 Orca adapter 改成每轮 Developer/Test 双任务。
|
||||
3. 定义同 worktree 默认策略和跨 worktree transfer。
|
||||
4. 改造 worker launcher 与安全授权。
|
||||
5. 原子化 `skiff init ack`,空任务板作为默认模板。
|
||||
5. 原子化 `pouch init ack`,空任务板作为默认模板。
|
||||
6. 引入追加式 attempts、幂等恢复和失败分类。
|
||||
7. 统一 schema 与语义校验,补齐对抗性 fixture。
|
||||
8. 区分工作空间验证、集成验证和发布状态。
|
||||
@@ -342,7 +342,7 @@ ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过
|
||||
|
||||
本轮执行了:
|
||||
|
||||
- `skiff check ack`:通过。这个命令只证明 Skill 的元数据和基础结构有效。
|
||||
- `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 项通过。
|
||||
@@ -394,10 +394,10 @@ ACK 需要增加第三类项目事实,用来保存跨任务复用、会改变
|
||||
| `tasks.yaml` | 当前任务状态、attempt 和执行证据 | Coordinator |
|
||||
| `knowledge.yaml` | 跨任务复用的已验证经验 | Coordinator |
|
||||
|
||||
建议新增项目级 SSOT:`docs/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
|
||||
建议新增项目级 SSOT:`.pouch/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
|
||||
工具无关;ACK 负责在任务闭环中生产、选择和消费这些知识。
|
||||
|
||||
这项设计会修改当前“`docs/ack/` 只保存 `project.md` 与 `tasks.yaml`”的边界。
|
||||
这项设计会修改当前“`.pouch/ack/` 只保存 `project.md` 与 `tasks.yaml`”的边界。
|
||||
新增文件保存项目事实,不复制 ACK Skill 的通用规范,因此不违反 Skill 内容仍以
|
||||
`skills/ack/` 为 SSOT 的原则。
|
||||
|
||||
@@ -580,8 +580,8 @@ issue、日志和外部网页只能作为不可信 evidence。进入知识库前
|
||||
|
||||
### 第一版范围
|
||||
|
||||
第一版只实现一个 `docs/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
|
||||
开始影响审阅和选择时,再平滑迁移为 `docs/ack/knowledge/index.yaml` 加独立知识卡,
|
||||
第一版只实现一个 `.pouch/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
|
||||
开始影响审阅和选择时,再平滑迁移为 `.pouch/ack/knowledge/index.yaml` 加独立知识卡,
|
||||
条目 schema 和引用格式保持不变。
|
||||
|
||||
第一版包括:
|
||||
|
||||
@@ -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 闭环执行。
|
||||
+10
-5
@@ -3,17 +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 skiff bootstrap
|
||||
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() {
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# 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` 执行 `git pull`,更新 pouch 自身 |
|
||||
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
|
||||
|
||||
### 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。
|
||||
|
||||
### 交互式批量安装
|
||||
|
||||
```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 declarative-openspec-loop -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 发现
|
||||
├── 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 配置 |
|
||||
| `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)
|
||||
@@ -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()
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from skiff.paths import ALL_TARGETS
|
||||
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()})
|
||||
@@ -6,8 +6,8 @@ import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import yaml_io
|
||||
from skiff.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
|
||||
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]]:
|
||||
@@ -36,7 +36,7 @@ def catalog_repo(entry: dict[str, Any]) -> str:
|
||||
|
||||
def catalog_repo_path(entry: dict[str, Any]) -> Path:
|
||||
"""Return the shared checkout path for a repo/ref pair."""
|
||||
from skiff.paths import CATALOG_CACHE_DIR
|
||||
from pouch.paths import CATALOG_CACHE_DIR
|
||||
|
||||
repo = str(entry.get("repo", ""))
|
||||
ref = str(entry.get("ref", "main"))
|
||||
@@ -46,7 +46,7 @@ def catalog_repo_path(entry: dict[str, Any]) -> Path:
|
||||
|
||||
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
|
||||
"""Use a local repo directly, otherwise return its external checkout."""
|
||||
from skiff.paths import CATALOG_CACHE_DIR
|
||||
from pouch.paths import CATALOG_CACHE_DIR
|
||||
|
||||
configured_repo = str(entry.get("repo", ""))
|
||||
local_repo = Path(catalog_repo(entry))
|
||||
+127
-99
@@ -1,4 +1,4 @@
|
||||
"""skiff CLI 入口。"""
|
||||
"""pouch CLI 入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,28 +15,29 @@ import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from skiff import __version__
|
||||
from skiff.agents import flatten_agent_args, resolve_agent_args
|
||||
from skiff.gitops import publish as git_publish
|
||||
from skiff.paths import (
|
||||
from pouch import __version__
|
||||
from pouch.agents import flatten_agent_args, resolve_agent_args
|
||||
from pouch.gitops import publish as git_publish
|
||||
from pouch.paths import (
|
||||
ALL_TARGETS,
|
||||
DRAFTS_DIR,
|
||||
CATALOG_CACHE_DIR,
|
||||
CONFIG_FILE,
|
||||
SKILLS_DIR,
|
||||
SKILLS_HOME,
|
||||
POUCH_HOME,
|
||||
TEMPLATE_DIR,
|
||||
agent_skill_dir,
|
||||
ensure_skills_home,
|
||||
ensure_pouch_home,
|
||||
project_manifest,
|
||||
)
|
||||
from skiff.project import (
|
||||
from pouch.project import (
|
||||
add_skill_to_manifest,
|
||||
iter_manifest_skills,
|
||||
load_manifest,
|
||||
remove_skill_from_manifest,
|
||||
resolve_manifest_skill,
|
||||
)
|
||||
from skiff.catalog import (
|
||||
from pouch.catalog import (
|
||||
discover_catalog_skills,
|
||||
catalog_checkout_path,
|
||||
catalog_skill_path,
|
||||
@@ -44,8 +45,8 @@ from skiff.catalog import (
|
||||
catalog_repo,
|
||||
save_catalog,
|
||||
)
|
||||
from skiff.selector import SkillChoice, select_skills
|
||||
from skiff.skills import (
|
||||
from pouch.selector import SkillChoice, select_skills
|
||||
from pouch.skills import (
|
||||
list_custom_skills,
|
||||
list_builtin_skills,
|
||||
builtin_skill_path,
|
||||
@@ -57,7 +58,7 @@ from skiff.skills import (
|
||||
validate_skill_dir,
|
||||
validate_skill_name,
|
||||
)
|
||||
from skiff.sources import (
|
||||
from pouch.sources import (
|
||||
discover_source_skills,
|
||||
fetch_source,
|
||||
load_sources,
|
||||
@@ -66,8 +67,8 @@ from skiff.sources import (
|
||||
source_skills_root,
|
||||
validate_source_name,
|
||||
)
|
||||
from skiff.yaml_io import safe_dump
|
||||
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||
from pouch.yaml_io import safe_dump
|
||||
from pouch.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||
|
||||
_RENAME_NOREPLACE = 1
|
||||
|
||||
@@ -485,7 +486,7 @@ def _remove_skill(
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
source_filter = normalize_source(args.source)
|
||||
builtin = list_builtin_skills()
|
||||
catalog = load_catalog()
|
||||
@@ -528,18 +529,18 @@ def cmd_list(args: argparse.Namespace) -> None:
|
||||
|
||||
def cmd_bootstrap(args: argparse.Namespace) -> None:
|
||||
del args
|
||||
ensure_skills_home()
|
||||
project_skill = "skiff"
|
||||
ensure_pouch_home()
|
||||
project_skill = "pouch"
|
||||
builtin_skill_path(project_skill)
|
||||
_install_skill(project_skill, list(ALL_TARGETS), project_root=None)
|
||||
_print("已全局安装 builtin skiff skill 到所有 agent")
|
||||
_print("已全局安装 builtin pouch skill 到所有 agent")
|
||||
|
||||
|
||||
def cmd_update(args: argparse.Namespace) -> None:
|
||||
del args
|
||||
ensure_skills_home()
|
||||
_print(f"更新 skiff: {SKILLS_HOME}")
|
||||
subprocess.run(["git", "-C", str(SKILLS_HOME), "pull"], check=True)
|
||||
ensure_pouch_home()
|
||||
_print(f"更新 pouch: {POUCH_HOME}")
|
||||
subprocess.run(["git", "-C", str(POUCH_HOME), "pull"], check=True)
|
||||
|
||||
|
||||
def _installed_links(
|
||||
@@ -558,7 +559,7 @@ def _installed_links(
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
builtin = list_builtin_skills()
|
||||
catalog = load_catalog()
|
||||
@@ -578,7 +579,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
||||
)
|
||||
entries.extend((source, name) for source, names in custom.items() for name in names)
|
||||
|
||||
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
|
||||
_print(f"pouch 仓库: {POUCH_HOME.resolve()}")
|
||||
_print(f"agents: {', '.join(targets)}\n")
|
||||
|
||||
for package in unfetched_catalog:
|
||||
@@ -604,10 +605,10 @@ def cmd_status(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def _print_available_skills() -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
builtin = list_builtin_skills()
|
||||
if not builtin:
|
||||
_print("~/.skills/skills/ 中没有自研 skill")
|
||||
_print("~/.pouch/skills/ 中没有自研 skill")
|
||||
return
|
||||
|
||||
_print(f"来源: {SKILLS_DIR}\n")
|
||||
@@ -621,14 +622,14 @@ def _print_available_skills() -> None:
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
|
||||
if args.list_available:
|
||||
if args.source:
|
||||
cmd_list(argparse.Namespace(source=args.source))
|
||||
else:
|
||||
_print_available_skills()
|
||||
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
|
||||
_print("使用 pouch add <name> 安装,或 pouch add <name> -g 全局安装")
|
||||
return
|
||||
|
||||
if args.all:
|
||||
@@ -647,7 +648,7 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
else:
|
||||
names = _collect_skill_names(args.skills, args.skills_flag)
|
||||
if not names:
|
||||
raise SystemExit("请指定 skill 名称,或使用 skiff add --list 查看可用 skill")
|
||||
raise SystemExit("请指定 skill 名称,或使用 pouch add --list 查看可用 skill")
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
|
||||
project_root = None if args.global_scope else _project_root(args.project)
|
||||
@@ -671,7 +672,7 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
(link, str(link.readlink()) if link.is_symlink() else None)
|
||||
)
|
||||
|
||||
manifest_path = project_root / ".skills.yaml" if project_root else None
|
||||
manifest_path = project_manifest(project_root) if project_root else None
|
||||
manifest_before = (
|
||||
manifest_path.read_bytes()
|
||||
if manifest_path and manifest_path.is_file()
|
||||
@@ -709,10 +710,10 @@ def cmd_add(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_select(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
raise SystemExit(
|
||||
"`skiff select` 需要交互式终端;非交互环境请使用 `skiff add <name>...`"
|
||||
"`pouch select` 需要交互式终端;非交互环境请使用 `pouch add <name>...`"
|
||||
)
|
||||
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
@@ -877,7 +878,7 @@ def cmd_select(args: argparse.Namespace) -> None:
|
||||
names = sorted(selected - selected_installed_keys)
|
||||
|
||||
failures: list[tuple[str, str]] = []
|
||||
manifest_path = project_root / ".skills.yaml" if project_root else None
|
||||
manifest_path = project_manifest(project_root) if project_root else None
|
||||
successful = set(selected & selected_installed_keys)
|
||||
for name in names:
|
||||
skill_name, source = choice_requests[name]
|
||||
@@ -915,7 +916,7 @@ def cmd_select(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_remove(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
project_root = None if args.global_scope else _project_root(args.project)
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
|
||||
@@ -925,7 +926,7 @@ def cmd_remove(args: argparse.Namespace) -> None:
|
||||
names = _collect_skill_names(args.skills, args.skills_flag)
|
||||
|
||||
if not names:
|
||||
raise SystemExit("请指定 skill 名称,或使用 skiff remove --all")
|
||||
raise SystemExit("请指定 skill 名称,或使用 pouch remove --all")
|
||||
|
||||
catalog = load_catalog()
|
||||
sources = load_sources()
|
||||
@@ -958,14 +959,14 @@ def cmd_remove(args: argparse.Namespace) -> None:
|
||||
for name in dict.fromkeys(expanded):
|
||||
total += _remove_skill(name, targets, project_root=project_root)
|
||||
if project_root is not None:
|
||||
remove_skill_from_manifest(project_root / ".skills.yaml", name)
|
||||
remove_skill_from_manifest(project_manifest(project_root), name)
|
||||
|
||||
if total == 0:
|
||||
_print("没有移除任何 skill")
|
||||
|
||||
|
||||
def cmd_publish(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
paths = args.paths or ["."]
|
||||
git_publish(
|
||||
paths=paths,
|
||||
@@ -976,7 +977,7 @@ def cmd_publish(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_catalog_add(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
validate_skill_name(args.name)
|
||||
catalog = load_catalog()
|
||||
if args.name in catalog:
|
||||
@@ -992,7 +993,7 @@ def cmd_catalog_add(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_fetch(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
catalog = load_catalog()
|
||||
if args.name not in catalog:
|
||||
raise SystemExit(f"catalog 中不存在: {args.name}")
|
||||
@@ -1106,9 +1107,9 @@ def cmd_source_remove(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
root = _project_root(args.project)
|
||||
manifest_path = root / ".skills.yaml"
|
||||
manifest_path = project_manifest(root)
|
||||
if not manifest_path.is_file():
|
||||
raise SystemExit(f"未找到 {manifest_path}")
|
||||
|
||||
@@ -1133,7 +1134,7 @@ def cmd_sync(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_create(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
validate_skill_name(args.name)
|
||||
if not TEMPLATE_DIR.is_dir():
|
||||
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
|
||||
@@ -1170,8 +1171,8 @@ def cmd_create(args: argparse.Namespace) -> None:
|
||||
}
|
||||
(dst / "brief.yaml").write_text(safe_dump(brief), encoding="utf-8")
|
||||
_print(f"草稿已创建: {dst}")
|
||||
_print(f"下一步: 请完善 skiff 草稿 {args.name}")
|
||||
_print(f"完成后运行: skiff check {args.name} && skiff finalize {args.name}")
|
||||
_print(f"下一步: 请完善 pouch 草稿 {args.name}")
|
||||
_print(f"完成后运行: pouch check {args.name} && pouch finalize {args.name}")
|
||||
|
||||
|
||||
def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
|
||||
@@ -1185,7 +1186,7 @@ def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
validate_skill_name(args.name)
|
||||
path, kind = _draft_or_builtin_path(args.name)
|
||||
issues = validate_skill_dir(path, args.name)
|
||||
@@ -1199,7 +1200,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_finalize(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
validate_skill_name(args.name)
|
||||
draft = DRAFTS_DIR / args.name
|
||||
if not draft.is_dir():
|
||||
@@ -1221,25 +1222,25 @@ def cmd_finalize(args: argparse.Namespace) -> None:
|
||||
if brief.exists():
|
||||
brief.unlink()
|
||||
_print(f"已完成 skill: {final}")
|
||||
_print(f"下一步: skiff publish skills/{args.name} -m \"add {args.name}\" --push")
|
||||
_print(f"下一步: pouch publish skills/{args.name} -m \"add {args.name}\" --push")
|
||||
|
||||
|
||||
def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||||
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()}")
|
||||
_print(f"pouch 仓库: {POUCH_HOME.resolve()}")
|
||||
if POUCH_HOME.is_symlink():
|
||||
if not POUCH_HOME.resolve().is_dir():
|
||||
_err(f"✗ ~/.pouch 指向无效路径: {POUCH_HOME.resolve()}")
|
||||
issues += 1
|
||||
else:
|
||||
_print("✓ ~/.skills 软链正常")
|
||||
elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir():
|
||||
_print("✓ ~/.skills 为本地仓库目录")
|
||||
_print("✓ ~/.pouch 软链正常")
|
||||
elif POUCH_HOME.is_dir() and (POUCH_HOME / "skills").is_dir():
|
||||
_print("✓ ~/.pouch 为本地仓库目录")
|
||||
else:
|
||||
_err("✗ ~/.skills 未正确配置")
|
||||
_err("✗ ~/.pouch 未正确配置")
|
||||
issues += 1
|
||||
|
||||
for name in list_builtin_skills():
|
||||
@@ -1278,13 +1279,13 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
else:
|
||||
_print(f"\n发现 {issues} 个问题")
|
||||
if not args.fix:
|
||||
_print("提示: 使用 skiff doctor --fix 尝试自动修复软链")
|
||||
_print("提示: 使用 pouch doctor --fix 尝试自动修复软链")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
validate_skill_name(args.name)
|
||||
skills_root = SKILLS_DIR.resolve()
|
||||
skill_source = (SKILLS_DIR / args.name).resolve()
|
||||
@@ -1307,14 +1308,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
initial_project_stat.st_ino,
|
||||
stat.S_IFMT(initial_project_stat.st_mode),
|
||||
)
|
||||
destination = project / "docs" / args.name
|
||||
destination = project / ".pouch" / args.name
|
||||
project_file = destination / "project.md"
|
||||
tasks_file = destination / "tasks.yaml"
|
||||
knowledge_file = destination / "knowledge.yaml"
|
||||
delivery_file = destination / "delivery.yaml"
|
||||
regression_file = destination / "regression.yaml"
|
||||
managed_targets = [project_file, tasks_file]
|
||||
if args.name == "ack":
|
||||
managed_targets.extend((knowledge_file, delivery_file))
|
||||
managed_targets.extend((knowledge_file, delivery_file, regression_file))
|
||||
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||
if existing:
|
||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||
@@ -1331,19 +1333,29 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
(
|
||||
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
|
||||
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
|
||||
(
|
||||
skill_source / "templates" / "regression.template.yaml",
|
||||
regression_file,
|
||||
),
|
||||
)
|
||||
)
|
||||
missing = [path for path, _ in template_targets if not path.is_file()]
|
||||
if missing:
|
||||
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
|
||||
paths = ", ".join(str(path.relative_to(POUCH_HOME)) for path in missing)
|
||||
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
||||
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
||||
regression_validator = skill_source / "scripts" / "validate_regression.py"
|
||||
if args.name == "ack":
|
||||
missing_validators = [
|
||||
path
|
||||
for path in (validator, knowledge_validator, delivery_validator)
|
||||
for path in (
|
||||
validator,
|
||||
knowledge_validator,
|
||||
delivery_validator,
|
||||
regression_validator,
|
||||
)
|
||||
if not path.is_file()
|
||||
]
|
||||
if missing_validators:
|
||||
@@ -1357,13 +1369,13 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
"<project_name>": project.name,
|
||||
"<repo_path>": str(project),
|
||||
"<dev_worktree>": str(project),
|
||||
"<overlay_file_path>": f"docs/{args.name}/project.md",
|
||||
"<overlay_file_path>": f".pouch/{args.name}/project.md",
|
||||
"<ack_version>": ack_version,
|
||||
"<接入时的 ack skill 版本>": ack_version,
|
||||
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"skiff-{args.name}-init-") as temp_dir:
|
||||
with tempfile.TemporaryDirectory(prefix=f"pouch-{args.name}-init-") as temp_dir:
|
||||
staging = Path(temp_dir)
|
||||
staged_files: dict[Path, Path] = {}
|
||||
rendered_files: dict[Path, str] = {}
|
||||
@@ -1416,6 +1428,21 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
raise SystemExit(
|
||||
f"初始化交付契约校验失败(exit {completed.returncode})"
|
||||
)
|
||||
if args.name == "ack" and regression_validator.is_file():
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(regression_validator),
|
||||
str(staged_files[regression_file]),
|
||||
"--tasks",
|
||||
str(staged_files[tasks_file]),
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise SystemExit(
|
||||
f"初始化回归目录校验失败(exit {completed.returncode})"
|
||||
)
|
||||
|
||||
for target, staged in staged_files.items():
|
||||
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
||||
@@ -1430,10 +1457,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
|
||||
|
||||
project_fd: int | None = None
|
||||
docs_fd: int | None = None
|
||||
pouch_fd: int | None = None
|
||||
transaction_fd: int | None = None
|
||||
staging_fd: int | None = None
|
||||
docs_created = False
|
||||
pouch_created = False
|
||||
transaction_name: str | None = None
|
||||
staged_names: list[str] = []
|
||||
published = False
|
||||
@@ -1461,8 +1488,8 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
project,
|
||||
phase="初始化",
|
||||
)
|
||||
docs_fd, docs_created = _open_or_create_directory_at(project_fd, "docs")
|
||||
if docs_created:
|
||||
pouch_fd, pouch_created = _open_or_create_directory_at(project_fd, ".pouch")
|
||||
if pouch_created:
|
||||
os.fsync(project_fd)
|
||||
_assert_open_directory_path(
|
||||
project_fd,
|
||||
@@ -1470,15 +1497,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
phase="初始化",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
docs_fd,
|
||||
project / "docs",
|
||||
pouch_fd,
|
||||
project / ".pouch",
|
||||
phase="初始化",
|
||||
label="docs 目录",
|
||||
label=".pouch 目录",
|
||||
)
|
||||
try:
|
||||
destination_stat = os.stat(
|
||||
args.name,
|
||||
dir_fd=docs_fd,
|
||||
dir_fd=pouch_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
@@ -1487,14 +1514,14 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
if stat.S_ISLNK(destination_stat.st_mode):
|
||||
raise SystemExit(
|
||||
"初始化路径必须是普通目录且不能是软链接: "
|
||||
f"docs/{args.name}"
|
||||
f".pouch/{args.name}"
|
||||
)
|
||||
raise SystemExit(f"拒绝覆盖已有路径: docs/{args.name}")
|
||||
raise SystemExit(f"拒绝覆盖已有路径: .pouch/{args.name}")
|
||||
|
||||
for _ in range(32):
|
||||
candidate = f".{args.name}-init-{secrets.token_hex(8)}"
|
||||
try:
|
||||
os.mkdir(candidate, mode=0o700, dir_fd=docs_fd)
|
||||
os.mkdir(candidate, mode=0o700, dir_fd=pouch_fd)
|
||||
except FileExistsError:
|
||||
continue
|
||||
transaction_name = candidate
|
||||
@@ -1505,7 +1532,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
transaction_fd = os.open(
|
||||
transaction_name,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
dir_fd=docs_fd,
|
||||
dir_fd=pouch_fd,
|
||||
)
|
||||
os.mkdir("payload", mode=0o755, dir_fd=transaction_fd)
|
||||
staging_fd = os.open(
|
||||
@@ -1534,21 +1561,21 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
phase="发布",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
docs_fd,
|
||||
project / "docs",
|
||||
pouch_fd,
|
||||
project / ".pouch",
|
||||
phase="发布",
|
||||
label="docs 目录",
|
||||
label=".pouch 目录",
|
||||
)
|
||||
try:
|
||||
_rename_directory_noreplace(
|
||||
transaction_fd,
|
||||
"payload",
|
||||
docs_fd,
|
||||
pouch_fd,
|
||||
args.name,
|
||||
)
|
||||
except FileExistsError as exc:
|
||||
raise SystemExit(
|
||||
f"拒绝覆盖已有路径: docs/{args.name}"
|
||||
f"拒绝覆盖已有路径: .pouch/{args.name}"
|
||||
) from exc
|
||||
published = True
|
||||
_assert_open_directory_path(
|
||||
@@ -1560,23 +1587,23 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
if (
|
||||
transaction_name is not None
|
||||
and _directory_entry_matches_open_fd(
|
||||
docs_fd,
|
||||
pouch_fd,
|
||||
transaction_name,
|
||||
transaction_fd,
|
||||
)
|
||||
):
|
||||
try:
|
||||
os.rmdir(transaction_name, dir_fd=docs_fd)
|
||||
os.rmdir(transaction_name, dir_fd=pouch_fd)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
transaction_name = None
|
||||
try:
|
||||
os.fsync(docs_fd)
|
||||
os.fsync(pouch_fd)
|
||||
except OSError as exc:
|
||||
raise SystemExit(
|
||||
"初始化目录已完整发布,但无法确认目录项持久化;"
|
||||
f"请检查 docs/{args.name} 后再重试"
|
||||
f"请检查 .pouch/{args.name} 后再重试"
|
||||
) from exc
|
||||
_assert_open_directory_path(
|
||||
project_fd,
|
||||
@@ -1584,10 +1611,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
phase="完成初始化",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
docs_fd,
|
||||
project / "docs",
|
||||
pouch_fd,
|
||||
project / ".pouch",
|
||||
phase="完成初始化",
|
||||
label="docs 目录",
|
||||
label=".pouch 目录",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
staging_fd,
|
||||
@@ -1612,15 +1639,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
if (
|
||||
transaction_name is not None
|
||||
and transaction_fd is not None
|
||||
and docs_fd is not None
|
||||
and pouch_fd is not None
|
||||
and _directory_entry_matches_open_fd(
|
||||
docs_fd,
|
||||
pouch_fd,
|
||||
transaction_name,
|
||||
transaction_fd,
|
||||
)
|
||||
):
|
||||
try:
|
||||
os.rmdir(transaction_name, dir_fd=docs_fd)
|
||||
os.rmdir(transaction_name, dir_fd=pouch_fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -1628,7 +1655,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
for directory_fd in (
|
||||
staging_fd,
|
||||
transaction_fd,
|
||||
docs_fd,
|
||||
pouch_fd,
|
||||
project_fd,
|
||||
):
|
||||
if directory_fd is not None:
|
||||
@@ -1644,6 +1671,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
if args.name == "ack":
|
||||
_print(f" 知识库: {knowledge_file}")
|
||||
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
||||
_print(f" 回归目录: {regression_file}")
|
||||
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
||||
|
||||
|
||||
@@ -1655,7 +1683,7 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
||||
nargs="+",
|
||||
action="append",
|
||||
metavar="AGENT",
|
||||
help="目标 agent(cursor、claude、claude-code、codex、*)",
|
||||
help="目标 agent(cursor、claude、claude-code、codex、agents、*)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-g",
|
||||
@@ -1670,20 +1698,20 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="skiff",
|
||||
prog="pouch",
|
||||
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills)",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"skiff {__version__}")
|
||||
parser.add_argument("--version", action="version", version=f"pouch {__version__}")
|
||||
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_bootstrap = sub.add_parser(
|
||||
"bootstrap",
|
||||
help="将本项目 skiff skill 全局安装到所有 agent",
|
||||
help="将本项目 pouch skill 全局安装到所有 agent",
|
||||
)
|
||||
p_bootstrap.set_defaults(func=cmd_bootstrap)
|
||||
|
||||
p_update = sub.add_parser("update", help="通过 git pull 更新 skiff 自身")
|
||||
p_update = sub.add_parser("update", help="通过 git pull 更新 pouch 自身")
|
||||
p_update.set_defaults(func=cmd_update)
|
||||
|
||||
p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
|
||||
@@ -1727,7 +1755,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
p_publish = sub.add_parser(
|
||||
"publish",
|
||||
help="在 ~/.skills 内 git add / commit / push",
|
||||
help="在 ~/.pouch 内 git add / commit / push",
|
||||
)
|
||||
p_publish.add_argument("paths", nargs="*", help="要提交的路径(默认 .)")
|
||||
p_publish.add_argument("-m", "--message", help="commit 说明")
|
||||
@@ -1778,11 +1806,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_source_remove.add_argument(
|
||||
"--delete-checkout",
|
||||
action="store_true",
|
||||
help="同时永久删除 skiff 管理的 checkout",
|
||||
help="同时永久删除 pouch 管理的 checkout",
|
||||
)
|
||||
p_source_remove.set_defaults(func=cmd_source_remove)
|
||||
|
||||
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
|
||||
p_sync = sub.add_parser("sync", help="按 .pouch.yaml 重建项目软链")
|
||||
p_sync.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
||||
p_sync.add_argument("--project")
|
||||
p_sync.set_defaults(func=cmd_sync)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""~/.skills 仓库内的 git 操作。"""
|
||||
"""~/.pouch 仓库内的 git 操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from skiff.paths import SKILLS_HOME
|
||||
from pouch.paths import POUCH_HOME
|
||||
|
||||
|
||||
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
@@ -35,7 +35,7 @@ def publish(
|
||||
push: bool,
|
||||
no_commit: bool,
|
||||
) -> None:
|
||||
repo = SKILLS_HOME.resolve()
|
||||
repo = POUCH_HOME.resolve()
|
||||
ensure_git_repo(repo)
|
||||
|
||||
_run_git(repo, "add", "--", *paths)
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
"""路径与 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",
|
||||
)
|
||||
SOURCES_DIR = _first_existing_dir(
|
||||
HOME / ".local" / "share" / "pouch" / "sources",
|
||||
HOME / ".local" / "share" / "skiff" / "sources",
|
||||
default=HOME / ".local" / "share" / "pouch" / "sources",
|
||||
)
|
||||
|
||||
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 normalize_source, 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
|
||||
@@ -34,7 +34,7 @@ def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
|
||||
return {"name": entry, "source": "builtin"}
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
raise SystemExit(f".skills.yaml 条目缺少 name: {entry}")
|
||||
raise SystemExit(f".pouch.yaml 条目缺少 name: {entry}")
|
||||
source = normalize_source(str(entry.get("source", "builtin")))
|
||||
if source == "catalog" and entry.get("registry"):
|
||||
source = f"catalog:{entry['registry']}"
|
||||
@@ -58,7 +58,7 @@ def add_skill_to_manifest(
|
||||
) -> 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():
|
||||
@@ -82,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]
|
||||
@@ -165,7 +165,7 @@ def select_skills(
|
||||
)
|
||||
|
||||
footer = (
|
||||
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
|
||||
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
|
||||
)
|
||||
stdscr.addnstr(
|
||||
height - 1,
|
||||
@@ -5,13 +5,13 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
|
||||
from skiff.catalog import (
|
||||
from pouch.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_pouch_home
|
||||
from pouch.catalog import (
|
||||
catalog_skill_path,
|
||||
discover_catalog_skills,
|
||||
load_catalog,
|
||||
)
|
||||
from skiff.sources import (
|
||||
from pouch.sources import (
|
||||
discover_source_skills,
|
||||
list_source_skills,
|
||||
load_sources,
|
||||
@@ -19,7 +19,7 @@ from skiff.sources import (
|
||||
|
||||
|
||||
def list_builtin_skills() -> list[str]:
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
if not SKILLS_DIR.is_dir():
|
||||
return []
|
||||
names: list[str] = []
|
||||
@@ -76,7 +76,7 @@ def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
|
||||
|
||||
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
|
||||
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
|
||||
ensure_skills_home()
|
||||
ensure_pouch_home()
|
||||
name, source = split_skill_spec(name, source)
|
||||
|
||||
builtin = SKILLS_DIR / name
|
||||
@@ -106,7 +106,7 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
||||
if not (path / "SKILL.md").is_file():
|
||||
raise SystemExit(
|
||||
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
|
||||
f"请运行: skiff fetch {name}"
|
||||
f"请运行: pouch fetch {name}"
|
||||
)
|
||||
return path, f"catalog:{name}"
|
||||
|
||||
@@ -114,7 +114,7 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
||||
if source not in sources:
|
||||
raise SystemExit(
|
||||
f"项目依赖 source {source!r},但本机尚未配置。"
|
||||
f"请运行: skiff source add {source} <repo>"
|
||||
f"请运行: pouch source add {source} <repo>"
|
||||
)
|
||||
skills = discover_source_skills(source, sources[source])
|
||||
if name not in skills:
|
||||
@@ -156,7 +156,7 @@ def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path,
|
||||
path, resolved_source = candidates[0]
|
||||
if resolved_source.startswith("catalog:") and not path.exists():
|
||||
provider = resolved_source.split(":", 1)[1]
|
||||
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: skiff fetch {provider}")
|
||||
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: pouch fetch {provider}")
|
||||
return path, resolved_source
|
||||
|
||||
raise SystemExit(f"找不到 skill: {name}")
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
|
||||
pouch 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
|
||||
`registry` 外部仓库和用户配置的 custom source。
|
||||
|
||||
这些名称混合了不同维度:
|
||||
@@ -17,8 +17,8 @@ skiff 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时
|
||||
|
||||
本文建议将用户可见的来源注册方式统一为三类:
|
||||
|
||||
- `builtin`:随当前 skiff 仓库提供的 Skill。
|
||||
- `catalog:<name>`:由 skiff 自带目录预先登记的来源。
|
||||
- `builtin`:随当前 pouch 仓库提供的 Skill。
|
||||
- `catalog:<name>`:由 pouch 自带目录预先登记的来源。
|
||||
- `custom:<name>`:用户在本机显式配置的命名来源。
|
||||
|
||||
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
|
||||
@@ -29,20 +29,20 @@ Git 仓库或本地目录。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S[skiff 可发现的 Skills]
|
||||
S[pouch 可发现的 Skills]
|
||||
|
||||
S --> O["owned<br/>~/.skills/skills/*"]
|
||||
S --> C["custom source<br/>~/.config/skiff/config.yaml"]
|
||||
S --> O["owned<br/>~/.pouch/skills/*"]
|
||||
S --> C["custom source<br/>~/.config/pouch/config.yaml"]
|
||||
S --> R["registry<br/>registry.yaml"]
|
||||
|
||||
O --> O1["本仓库维护<br/>随 skiff 一起分发"]
|
||||
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/skills/externals/"]
|
||||
R1 --> E["~/.local/share/pouch/externals/"]
|
||||
R2 --> E
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ flowchart TD
|
||||
- `list` 和 `status` 支持 owned、registry 和 custom source。
|
||||
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
|
||||
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
|
||||
- `.skills.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
|
||||
- `.pouch.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
|
||||
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
|
||||
|
||||
## 现行模型
|
||||
@@ -61,12 +61,12 @@ flowchart TD
|
||||
A[Skill Provider]
|
||||
|
||||
A --> B["builtin<br/>仓库内隐式注册"]
|
||||
A --> C["catalog:waza<br/>skiff 预置目录"]
|
||||
A --> C["catalog:waza<br/>pouch 预置目录"]
|
||||
A --> D["custom:company<br/>用户本机配置"]
|
||||
|
||||
B --> B1["skills/ack"]
|
||||
B --> B2["skills/skiff"]
|
||||
B --> B3["skills/deb-publisher"]
|
||||
B --> B2["skills/pouch"]
|
||||
B --> B3["skills/builder"]
|
||||
|
||||
C --> C1["Git 或本地目录"]
|
||||
C1 --> C2["单 Skill"]
|
||||
@@ -85,14 +85,14 @@ flowchart TD
|
||||
|
||||
| 类型 | 含义 | 配置来源 | 用户界面展示 |
|
||||
| --- | --- | --- | --- |
|
||||
| `builtin` | 随当前 skiff 仓库提供 | `skills/` | `builtin` |
|
||||
| `catalog` | skiff 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
|
||||
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/skiff/config.yaml` | `custom:<name>` |
|
||||
| `builtin` | 随当前 pouch 仓库提供 | `skills/` | `builtin` |
|
||||
| `catalog` | pouch 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
|
||||
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/pouch/config.yaml` | `custom:<name>` |
|
||||
|
||||
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
|
||||
方式,而不是仓库的所有权关系。
|
||||
|
||||
`catalog` 比 `registry` 更准确:当前文件只是 skiff 随仓库维护的预置来源目录,
|
||||
`catalog` 比 `registry` 更准确:当前文件只是 pouch 随仓库维护的预置来源目录,
|
||||
并不是远程注册中心,也不是一种 Skill 来源协议。
|
||||
|
||||
### 三个正交维度
|
||||
@@ -128,11 +128,11 @@ layout: single | collection
|
||||
|
||||
## `select` 展示
|
||||
|
||||
`skiff select` 应同时展示 builtin、catalog 和 custom Skill:
|
||||
`pouch select` 应同时展示 builtin、catalog 和 custom Skill:
|
||||
|
||||
```text
|
||||
[ ] ack builtin
|
||||
[ ] deb-publisher builtin
|
||||
[ ] builder builtin
|
||||
[-] company custom source
|
||||
[ ] company/release custom:company
|
||||
[ ] company/security-review custom:company
|
||||
@@ -151,7 +151,7 @@ layout: single | collection
|
||||
|
||||
## 配置表示
|
||||
|
||||
新写入的 `.skills.yaml` 使用以下形式:
|
||||
新写入的 `.pouch.yaml` 使用以下形式:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
@@ -172,7 +172,7 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
|
||||
|
||||
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
|
||||
`custom:<name>`。
|
||||
2. 新生成的 `.skills.yaml` 对内置 Skill 写入 `source: builtin`。
|
||||
2. 新生成的 `.pouch.yaml` 对内置 Skill 写入 `source: builtin`。
|
||||
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
|
||||
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
|
||||
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
|
||||
@@ -188,14 +188,14 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
|
||||
|
||||
当前实现覆盖:
|
||||
|
||||
- `skiff/skills.py`:来源解析、归一化和 builtin 命名。
|
||||
- `skiff/project.py`:manifest 默认值、序列化与旧值兼容。
|
||||
- `skiff/sources.py`:来源保留字。
|
||||
- `skiff/catalog.py`:catalog 配置、checkout 与 Skill 发现。
|
||||
- `skiff/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
|
||||
- `skiff/selector.py`:统一 catalog/custom 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、`skiff/README.md`、`skills/skiff/SKILL.md` 和相关示例。
|
||||
- 根 README、`pouch/README.md`、`skills/pouch/SKILL.md` 和相关示例。
|
||||
|
||||
## 验证要求
|
||||
|
||||
@@ -212,6 +212,6 @@ custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。
|
||||
|
||||
## 设计前提
|
||||
|
||||
迁移前 `registry.yaml` 的真实职责只是维护 skiff 预置的来源目录,而不是提供远程
|
||||
迁移前 `registry.yaml` 的真实职责只是维护 pouch 预置的来源目录,而不是提供远程
|
||||
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
|
||||
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
|
||||
@@ -6,14 +6,14 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from skiff import yaml_io
|
||||
from skiff.paths import CONFIG_FILE, SOURCES_DIR
|
||||
from pouch import yaml_io
|
||||
from pouch.paths import CONFIG_FILE, SOURCES_DIR
|
||||
|
||||
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
|
||||
|
||||
|
||||
def validate_source_name(name: str) -> None:
|
||||
from skiff.skills import validate_skill_name
|
||||
from pouch.skills import validate_skill_name
|
||||
|
||||
validate_skill_name(name)
|
||||
if name in RESERVED_SOURCES:
|
||||
@@ -26,12 +26,12 @@ def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"skiff 配置格式错误: {path}")
|
||||
raise SystemExit(f"pouch 配置格式错误: {path}")
|
||||
raw = data.get("sources", {})
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise SystemExit(f"skiff 配置 sources 格式错误: {path}")
|
||||
raise SystemExit(f"pouch 配置 sources 格式错误: {path}")
|
||||
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ 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 / "catalog.yaml").is_file()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""轻量 YAML 读写(覆盖 skiff 使用的子集,无第三方依赖)。"""
|
||||
"""轻量 YAML 读写(覆盖 pouch 使用的子集,无第三方依赖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
# skiff 仅使用 Python 标准库,无第三方依赖
|
||||
# pouch 仅使用 Python 标准库,无第三方依赖
|
||||
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
# skiff
|
||||
|
||||
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/skills # 本仓库根目录
|
||||
./install.sh # 软链到 ~/.local/bin/skiff
|
||||
```
|
||||
|
||||
确保 `~/.local/bin` 在 `PATH` 中。
|
||||
|
||||
## 命令风格
|
||||
|
||||
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
|
||||
统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。
|
||||
|
||||
```bash
|
||||
# 浏览可用自研 skill
|
||||
skiff add --list
|
||||
|
||||
# 装到当前项目 / 全局
|
||||
skiff add discussion-notes -a cursor -y
|
||||
skiff add discussion-notes -a cursor -g -y
|
||||
|
||||
# 卸载
|
||||
skiff remove discussion-notes -a cursor -y
|
||||
skiff rm discussion-notes -g -y
|
||||
|
||||
# 改完 skill 后提交推送(在任意目录执行,操作 ~/.skills)
|
||||
skiff publish skills/discussion-notes -m "update discussion-notes" --push
|
||||
```
|
||||
|
||||
开发时也可直接运行:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/path/to/skills python3 -m skiff <command>
|
||||
```
|
||||
|
||||
## 首次安装
|
||||
|
||||
```bash
|
||||
git clone https://git.yumee.top/laily/skills.git ~/.skills
|
||||
~/.skills/install.sh
|
||||
```
|
||||
|
||||
`install.sh` 会安装 CLI,并自动执行 `skiff bootstrap`,将本仓库的 `skiff` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑:
|
||||
|
||||
```bash
|
||||
skiff bootstrap
|
||||
```
|
||||
|
||||
## 命令参考
|
||||
|
||||
### 查看
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
||||
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
||||
|
||||
### 项目初始化
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
|
||||
| `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
|
||||
| `skiff init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
|
||||
|
||||
### Skill 安装
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
|
||||
| `skiff select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
|
||||
| `skiff remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
|
||||
| `skiff add --list` | 列出可用 builtin skill |
|
||||
| `skiff publish [paths] -m MSG [--push]` | 在 ~/.skills 内 git add/commit/push |
|
||||
|
||||
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
|
||||
|
||||
全局目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `~/.cursor/skills/` |
|
||||
| claude | `~/.claude/skills/` |
|
||||
| codex | `~/.codex/skills/` |
|
||||
|
||||
### 预置 Catalog Source
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
|
||||
| `skiff fetch <name>` | 克隆或更新 catalog source checkout |
|
||||
| `skiff add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
|
||||
| `skiff add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
|
||||
|
||||
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
|
||||
会显示在 `skiff select` 的候选列表中。`path` 可以直接指向含
|
||||
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
|
||||
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`skiff add <name>`
|
||||
安装全部,`skiff select` 则展开为 `<name>/<skill>` 供分别勾选。同一
|
||||
`repo` 与 `ref` 共享一份 Git checkout。
|
||||
|
||||
### 交互式批量安装
|
||||
|
||||
```bash
|
||||
skiff select # 当前项目,全部 Agent
|
||||
skiff select -a codex # 当前项目,仅 Codex
|
||||
skiff select -g # 全局安装
|
||||
skiff select --project ~/code/app # 指定项目
|
||||
```
|
||||
|
||||
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
|
||||
取消。普通 `skiff select` 只安装到项目,并在每一项旁只读显示各 Agent 的
|
||||
全局安装状态;`skiff select -g` 只安装到全局。已经安装到目标范围的 skill
|
||||
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `skiff remove`。如果全局
|
||||
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
|
||||
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
|
||||
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
|
||||
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
|
||||
|
||||
### Custom source(多-skill 仓库)
|
||||
|
||||
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
|
||||
|
||||
```bash
|
||||
# Git 仓库,默认 clone 到 ~/.local/share/skiff/sources/company
|
||||
skiff source add company \
|
||||
git@git.company.com:platform/agent-skills.git \
|
||||
--ref main \
|
||||
--skills-path internal/skills
|
||||
|
||||
# 或接入已有本地仓库
|
||||
skiff source add company \
|
||||
--local ~/code/company-agent-skills \
|
||||
--skills-path skills
|
||||
|
||||
skiff source list
|
||||
skiff source fetch company
|
||||
skiff list --source company
|
||||
skiff add company/code-review -g -a codex
|
||||
```
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
|
||||
| `skiff source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
|
||||
| `skiff source list` / `show <name>` | 查看 source |
|
||||
| `skiff source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
|
||||
| `skiff source remove <name>` | 移除配置并保留 checkout |
|
||||
|
||||
配置保存在 `~/.config/skiff/config.yaml`。Git/SSH 认证复用本机 Git 配置,
|
||||
skiff 不保存 token。可以使用 `company/code-review`,也可以使用
|
||||
`skiff add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
|
||||
|
||||
### 项目级
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff enable <name> [--target all] [--project <dir>]` | 写入 `.skills.yaml` 并创建项目软链 |
|
||||
| `skiff disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
|
||||
| `skiff sync [--target all] [--project <dir>]` | 按 `.skills.yaml` 重建软链 |
|
||||
|
||||
项目目标路径:
|
||||
|
||||
| Agent | 路径 |
|
||||
|-------|------|
|
||||
| cursor | `<project>/.agents/skills/` |
|
||||
| claude | `<project>/.claude/skills/` |
|
||||
| codex | `<project>/.agents/skills/` |
|
||||
|
||||
### 脚手架与健康检查
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `skiff create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
|
||||
| `skiff check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
|
||||
| `skiff finalize <name>` | 校验草稿并移动到正式 `skills/` |
|
||||
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
|
||||
|
||||
## 常用工作流
|
||||
|
||||
### 新建并全局启用自研 skill
|
||||
|
||||
```bash
|
||||
skiff create my-skill --idea "描述要解决的重复问题" --from-project .
|
||||
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
|
||||
skiff check my-skill
|
||||
skiff finalize my-skill
|
||||
skiff publish skills/my-skill -m "add my-skill" --push
|
||||
skiff add my-skill -a cursor -g -y
|
||||
skiff doctor -a cursor
|
||||
```
|
||||
|
||||
### 在项目中启用 skill
|
||||
|
||||
```bash
|
||||
cd ~/code/my-app
|
||||
skiff add declarative-openspec-loop -a cursor -y
|
||||
```
|
||||
|
||||
### 添加 Catalog Source
|
||||
|
||||
```bash
|
||||
skiff catalog add my-ext https://github.com/org/repo --ref main
|
||||
skiff fetch my-ext
|
||||
skiff add my-ext -g
|
||||
```
|
||||
|
||||
## 源码结构
|
||||
|
||||
```
|
||||
skiff/
|
||||
├── __init__.py # 版本号
|
||||
├── __main__.py # python3 -m skiff 入口
|
||||
├── cli.py # 命令定义与调度
|
||||
├── paths.py # 路径常量与 Agent 目标
|
||||
├── skills.py # builtin/catalog/custom 统一解析
|
||||
├── catalog.py # catalog.yaml 读写与 Skill 发现
|
||||
├── sources.py # custom source 配置、发现与 Git 管理
|
||||
├── project.py # .skills.yaml 管理
|
||||
├── symlinks.py # 软链创建/检查/修复
|
||||
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
|
||||
```
|
||||
|
||||
入口脚本:[../bin/skiff](../bin/skiff)
|
||||
|
||||
## 路径约定
|
||||
|
||||
| 变量 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
|
||||
| `SKILLS_DIR` | `~/.skills/skills/` | builtin skill 目录 |
|
||||
| `CATALOG_FILE` | `~/.skills/catalog.yaml` | 预置 Skill 来源目录 |
|
||||
| `CATALOG_CACHE_DIR` | `~/.local/share/skills/externals/` | catalog checkout 兼容缓存;按 repo/ref 共享 |
|
||||
| `CONFIG_FILE` | `~/.config/skiff/config.yaml` | custom source 配置 |
|
||||
| `SOURCES_DIR` | `~/.local/share/skiff/sources/` | custom Git source 默认 checkout |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
|
||||
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
|
||||
- 若 Agent 将软链替换为普通目录,运行 `skiff doctor --fix` 重建
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [项目 README](../README.md)
|
||||
- [AGENTS.md](../AGENTS.md)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""skiff — Agent Skills 安装与管理 CLI。"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
@@ -1,52 +0,0 @@
|
||||
"""路径与 Agent 目标定义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
SKILLS_HOME = HOME / ".skills"
|
||||
SKILLS_DIR = SKILLS_HOME / "skills"
|
||||
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||
DRAFTS_DIR = SKILLS_HOME / ".drafts"
|
||||
CATALOG_FILE = SKILLS_HOME / "catalog.yaml"
|
||||
LEGACY_REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
||||
CATALOG_CACHE_DIR = HOME / ".local" / "share" / "skills" / "externals"
|
||||
CONFIG_FILE = HOME / ".config" / "skiff" / "config.yaml"
|
||||
SOURCES_DIR = HOME / ".local" / "share" / "skiff" / "sources"
|
||||
PROJECT_MANIFEST = ".skills.yaml"
|
||||
|
||||
AGENT_GLOBAL: dict[str, Path] = {
|
||||
"cursor": HOME / ".cursor" / "skills",
|
||||
"claude": HOME / ".claude" / "skills",
|
||||
"codex": HOME / ".codex" / "skills",
|
||||
}
|
||||
|
||||
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(
|
||||
"~/.skills 不存在。请将 skills 仓库克隆到 ~/.skills"
|
||||
)
|
||||
+148
-45
@@ -9,46 +9,66 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
|
||||
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
||||
`leftover`,然后继续处理其它任务。
|
||||
|
||||
项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile
|
||||
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
|
||||
稳定发布与生产部署始终保留人工批准点。
|
||||
测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份
|
||||
`.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进
|
||||
`.pouch/ack/regression.yaml`;之后可以单独跑回归。
|
||||
|
||||
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
||||
|
||||
## 使用场景
|
||||
|
||||
| 场景 | 怎么说 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/` |
|
||||
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件 |
|
||||
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 |
|
||||
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 |
|
||||
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
|
||||
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
|
||||
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
|
||||
| 回归 | `/ack 回归` | 先布测试环境,再按 `regression.yaml` 用浏览器或 API 跑 |
|
||||
|
||||
做需求和修 bug 在 `verified` 之后,都要更新回归目录。
|
||||
|
||||
## 安装
|
||||
|
||||
全局安装:
|
||||
|
||||
```bash
|
||||
skiff add ack -g
|
||||
pouch add ack -g
|
||||
```
|
||||
|
||||
或只安装到当前项目:
|
||||
|
||||
```bash
|
||||
skiff add ack
|
||||
pouch add ack
|
||||
```
|
||||
|
||||
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
||||
|
||||
## 初始化项目
|
||||
|
||||
```bash
|
||||
skiff init ack
|
||||
skiff init ack --project ~/code/my-app
|
||||
pouch init ack
|
||||
pouch init ack --project ~/code/my-app
|
||||
```
|
||||
|
||||
初始化后,项目只保存自己的 ACK 状态:
|
||||
|
||||
```text
|
||||
docs/ack/
|
||||
.pouch/ack/
|
||||
├── project.md
|
||||
├── tasks.yaml
|
||||
├── knowledge.yaml
|
||||
└── delivery.yaml # 默认 enabled: false
|
||||
├── delivery.yaml # 默认 enabled: false
|
||||
└── regression.yaml # 默认 cases: []
|
||||
```
|
||||
|
||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
||||
目录读取。
|
||||
|
||||
ACK 从当前命令指定的 `--project-root/.pouch/ack/` 定位项目状态,不要求在
|
||||
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
|
||||
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
|
||||
|
||||
## Skill 结构
|
||||
|
||||
```text
|
||||
@@ -57,37 +77,54 @@ skills/ack/
|
||||
├── README.md
|
||||
├── VERSION
|
||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
|
||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml、regression.yaml 模板和 schema
|
||||
├── examples/ # 完整示例
|
||||
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
||||
└── scripts/ # 状态校验、任务/知识/回归选择、安全验证执行与结构化 worker launcher
|
||||
```
|
||||
|
||||
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
|
||||
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
||||
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
||||
`.pouch/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
||||
`.pouch/ack/tasks.yaml` 保存当前任务状态;`.pouch/ack/knowledge.yaml` 保存跨任务复用、
|
||||
已经独立验证的项目知识护栏。
|
||||
`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在
|
||||
`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。
|
||||
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一契约;测试环境由 ACK
|
||||
内部调用 deployer,运行证据记在 `tasks.yaml.deliveryRuns`。
|
||||
`.pouch/ack/regression.yaml` 是黑盒回归用例目录,运行证据记在
|
||||
`tasks.yaml.regressionRuns`。
|
||||
|
||||
## 检查项目状态
|
||||
|
||||
Agent 会从当前 ACK Skill 目录解析校验脚本:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
||||
--tasks docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||
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
|
||||
```
|
||||
|
||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
|
||||
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 BUG-001
|
||||
|
||||
python3 <ack-skill-dir>/scripts/select_knowledge.py .pouch/ack/knowledge.yaml \
|
||||
--component web --path web/app.py --tag long-running-service --limit 10
|
||||
|
||||
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 --case-id REG-login-001
|
||||
```
|
||||
|
||||
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
|
||||
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
|
||||
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
|
||||
上下文。
|
||||
|
||||
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
|
||||
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
|
||||
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
|
||||
@@ -96,7 +133,7 @@ python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/run_verification.py \
|
||||
docs/ack/knowledge.yaml check-api-contract --project-root <project-root>
|
||||
.pouch/ack/knowledge.yaml check-api-contract --project-root <project-root>
|
||||
```
|
||||
|
||||
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
|
||||
@@ -104,7 +141,7 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
|
||||
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
|
||||
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
|
||||
项目内无 symlink 的权威
|
||||
`docs/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
|
||||
`.pouch/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
|
||||
和 `ACK_PROJECT_ROOT` 都固定到该根 fd;后者是只在检查进程存活期间有效的
|
||||
`/proc/self/fd/...` 或 `/dev/fd/...` 路径。原始可读路径另放在
|
||||
`ACK_PROJECT_ROOT_DISPLAY`,只能用于日志,不能用于资源访问。Runner 还提供
|
||||
@@ -118,24 +155,38 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
|
||||
|
||||
旧项目只有 `project.md` 和 `tasks.yaml` 时,不要重跑初始化。由 `/ack` 检查现有
|
||||
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
|
||||
同时只补 `project.knowledgeFile: docs/ack/knowledge.yaml`,再运行跨文件校验。
|
||||
同时只补 `project.knowledgeFile: .pouch/ack/knowledge.yaml`,再运行跨文件校验。
|
||||
|
||||
只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`。知识正文不能作为自由 shell
|
||||
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
||||
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`。知识正文
|
||||
不能作为自由 shell 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK
|
||||
不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
|
||||
## 配置与运行交付
|
||||
|
||||
用户可以直接向 `/ack` 描述项目差异,例如“这个项目用 `make deb` 构建 DEB,推到
|
||||
preview APT 源,再部署到开发机”。ACK 会把它维护成
|
||||
`docs/ack/delivery.yaml` 中的声明式 entrypoint、artifact、destination、environment
|
||||
和 profile,校验后展示 diff;首次配置保持关闭,确认后才启用。
|
||||
测试环境走 deployer 的项目内布局(通常是 `.pouch/deployer/test`),发版仍用
|
||||
delivery profile。可以直接说:
|
||||
|
||||
```text
|
||||
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你。
|
||||
```
|
||||
|
||||
ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成
|
||||
`intents.release` 指向的 profile。首次配置保持关闭,确认后才启用。之后可以说:
|
||||
|
||||
```text
|
||||
/ack 重新布一下测试环境,我要测试
|
||||
/ack 发布一个版本
|
||||
```
|
||||
|
||||
对应 intent 未配置时先问清楚并写回同一文件,不猜测。运行测试环境时 ACK 加载
|
||||
deployer skill,不复制 compose 命令。本地进程启动写在 `project.md`,不算这个
|
||||
模式。intent 运行不要求当前有 `verified` 任务;`deliveryRuns.intent` 记录是
|
||||
测试环境还是发版。
|
||||
|
||||
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
|
||||
凭据值。ACK 在任务进入 `verified` 后,按用户确认的 profile 执行,并把 revision、
|
||||
PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml.deliveryRuns`。默认
|
||||
profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。
|
||||
具体契约见 `references/delivery.md`。
|
||||
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
|
||||
profile 只能停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须经过
|
||||
对应 approval 步骤。具体契约见 `references/delivery.md`。
|
||||
|
||||
## 启动 Worker
|
||||
|
||||
@@ -178,19 +229,27 @@ identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 rec
|
||||
不能跨任务或跨轮次改挂。
|
||||
|
||||
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
|
||||
Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有可信平台
|
||||
审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化
|
||||
Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭
|
||||
sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件
|
||||
伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用
|
||||
停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数,workspace-write 默认 yolo。旧任务板没有结构化
|
||||
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
|
||||
|
||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。由于 Orca 当前
|
||||
不能证明旧终端的原始 argv/模型/权限,v0.10 不自动复用既有 worker;每次自动派发
|
||||
都重新 `plan` 并用 expected fingerprint 启动 fresh worker。
|
||||
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
|
||||
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
|
||||
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
|
||||
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
|
||||
启动 fresh worker。
|
||||
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
|
||||
重复启动,结果不确定时必须先 reconcile。
|
||||
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
|
||||
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
|
||||
直接重试。
|
||||
|
||||
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
|
||||
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
|
||||
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
|
||||
|
||||
## 开始一个需求
|
||||
|
||||
初始化完成后可以直接说:
|
||||
@@ -200,7 +259,42 @@ fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功
|
||||
```
|
||||
|
||||
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
||||
可观测验收信号;用户确认后才派发实现和复测。
|
||||
可观测验收信号;用户确认后才派发实现和复测。任务 `verified` 后会把本轮黑盒路径
|
||||
收进 `.pouch/ack/regression.yaml`,确认后才写入。
|
||||
|
||||
## 修复 bug
|
||||
|
||||
可以直接说:
|
||||
|
||||
```text
|
||||
/ack 修这个 bug:<现象、复现、期望>
|
||||
```
|
||||
|
||||
Coordinator 写短问题说明、复现步骤和可观测验收,不写大 PRD;你确认后再派发。
|
||||
Developer 先补会失败的用例再修,后续闭环与做需求相同。
|
||||
|
||||
配了飞书 `bugIntake` 时,飞书是审核前的唯一协作区。你只需维护标题、详细描述和
|
||||
附件;Coordinator 整理问题说明、期望效果和验收标准并写回飞书。你针对当前
|
||||
revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
|
||||
|
||||
```text
|
||||
/ack 处理飞书里待整理的 bug
|
||||
```
|
||||
|
||||
## 运行回归
|
||||
|
||||
平时做需求和修 bug 结束后,ACK 按本轮内容更新 `.pouch/ack/regression.yaml`。
|
||||
之后可以单独跑:
|
||||
|
||||
```text
|
||||
/ack 回归
|
||||
/ack 跑 full 回归
|
||||
/ack 回归 REG-login-001
|
||||
```
|
||||
|
||||
ACK 先按 deployer 布测试环境,再派独立 Test 按用例用浏览器或 API 执行。失败只
|
||||
报告,不会自动开修;要修再说 `/ack 修这些回归失败`。默认跑 smoke。细则见
|
||||
`references/regression.md`。
|
||||
|
||||
首次配置交付可以说:
|
||||
|
||||
@@ -223,5 +317,14 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、
|
||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
||||
`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的
|
||||
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
|
||||
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型;从 `0.16.0` 起,
|
||||
`delivery.yaml` 可用 `intents.testEnvironment` 与 `intents.release` 把测试环境部署和
|
||||
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
|
||||
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
|
||||
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
|
||||
`--always-approve`,sandbox 仍必开;从 `0.19.0` 起 `intents.testEnvironment` 改为
|
||||
deployer 绑定,ACK 内部调用 deployer skill 布测试环境,并增加
|
||||
`.pouch/ack/regression.yaml` 与「运行回归」模式。旧的测试环境 profile ID 字符串不再
|
||||
执行,需要迁到 `{via: deployer, env: <env>}`。旧项目可以不迁移回归目录而继续使用
|
||||
原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||
|
||||
+210
-72
@@ -2,16 +2,17 @@
|
||||
name: ack
|
||||
description: >-
|
||||
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
||||
工作,或配置并执行任务验证后的项目交付流程时使用。
|
||||
初始化 ACK、检查 .pouch/ack 配置、按 ACK 规划需求或修复 bug、指挥
|
||||
Coordinator/Developer/Test 工作,配置测试环境与发版方式,重新部署测试环境
|
||||
(内部调用 deployer),发布版本,或运行回归测试时使用。
|
||||
---
|
||||
|
||||
# ACK 项目协作入口
|
||||
|
||||
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
||||
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
||||
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
|
||||
内容。
|
||||
状态模板,`scripts/` 保存校验工具。目标项目只在 `.pouch/ack/` 保存 `project.md`、
|
||||
`tasks.yaml`、`knowledge.yaml`、默认关闭的 `delivery.yaml` 和空的
|
||||
`regression.yaml`,不要复制或链接 Skill 内容。
|
||||
|
||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
||||
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
||||
@@ -20,8 +21,16 @@ description: >-
|
||||
|
||||
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
||||
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
||||
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
||||
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
|
||||
- 用户要求用 ACK 做需求、修复 bug 或继续任务:执行“工作”。修 bug 不写大 PRD;
|
||||
飞书收件仍走本模式。
|
||||
- 用户用自然语言说明怎么部署测试环境、怎么发布版本,或要求增加、修改、关闭交付
|
||||
流程:执行“交付配置维护”。测试环境绑定 deployer,发版写在同一份
|
||||
`.pouch/ack/delivery.yaml`。
|
||||
- 用户要求部署、重新部署测试环境,或按已配置方式开始测试:执行“运行测试环境”。
|
||||
内部加载 deployer skill,不在 ACK 里复制 compose/rsync 命令。
|
||||
- 用户要求发布版本:执行“运行版本发布”。
|
||||
- 用户要求回归、跑回归测试:执行“运行回归”。先布测试环境,再派 Test 按
|
||||
`.pouch/ack/regression.yaml` 用浏览器或 API 执行。
|
||||
|
||||
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
||||
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
||||
@@ -29,134 +38,207 @@ description: >-
|
||||
|
||||
## 初始化
|
||||
|
||||
1. 确认 `skiff` 可执行,并检查 `<project>/docs/ack` 是否存在。
|
||||
1. 确认 `pouch` 可执行,并检查 `<project>/.pouch/ack` 是否存在。
|
||||
2. 不存在时执行:
|
||||
|
||||
```bash
|
||||
skiff init ack --project <project-root>
|
||||
pouch init ack --project <project-root>
|
||||
```
|
||||
|
||||
该命令从本 Skill 的 `templates/` 生成项目状态,不会在项目中创建 Skill
|
||||
软链接或资源副本。
|
||||
3. 如果 `docs/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”。旧项目只有
|
||||
3. 如果 `.pouch/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”。旧项目只有
|
||||
`project.md` 与 `tasks.yaml` 时,先报告缺少 `knowledge.yaml`。用户授权后,
|
||||
从 `templates/knowledge.template.yaml` 生成这个缺失文件并替换项目名和时间;若
|
||||
`tasks.yaml` 尚无 `project.knowledgeFile`,同时只补
|
||||
`docs/ack/knowledge.yaml` 这一项。不要重跑 `skiff init`,也不要改写其它已有
|
||||
`.pouch/ack/knowledge.yaml` 这一项。不要重跑 `pouch init`,也不要改写其它已有
|
||||
项目状态。
|
||||
4. 读取项目的公开配置和文档,例如 README、语言清单、包管理清单、测试配置与
|
||||
CI,确定项目名、技术栈、源码/规格/测试路径及真实可执行命令。
|
||||
5. 完善 `docs/ack/project.md`:
|
||||
5. 完善 `.pouch/ack/project.md`:
|
||||
- 用实际项目值替换全部占位符。
|
||||
- 无服务地址时把 Base URL 写为 `n/a`,不要虚构端口。
|
||||
- 无法从项目证据确定的命令写为 `n/a`,并在结果中列为待配置项。
|
||||
- 只写项目差异,不复制 `references/` 中的通用规范。
|
||||
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
|
||||
6. 完善 `.pouch/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
|
||||
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
|
||||
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
||||
项目状态固定从当前项目根的 `.pouch/ack/` 推导,不写入 `repoPath` 或 `devWorktree`;
|
||||
worker 默认在 `--project-root`(权威状态目录)工作,不再配置
|
||||
`allowedWorktrees` 白名单(v0.19 起废弃);需要隔离 worktree 时由 Coordinator 在
|
||||
派发时显式指定。旧任务板中的 `repoPath`、`devWorktree` 仅兼容读取。
|
||||
7. 检查 `.pouch/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
||||
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
||||
猜测并激活知识。
|
||||
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
||||
8. 检查 `.pouch/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
||||
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
|
||||
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
|
||||
9. 更新 `updatedAt`,并运行:
|
||||
9. 检查 `.pouch/ack/regression.yaml`。新项目保留 `cases: []`。旧项目没有该文件时仍
|
||||
可继续原闭环;用户授权后从 `templates/regression.template.yaml` 生成,并只补
|
||||
`project.regressionFile` 与顶层 `regressionRuns: []`。不要从聊天虚构用例。
|
||||
10. 更新 `updatedAt`,并运行:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
||||
--tasks docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||
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
|
||||
```
|
||||
|
||||
10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有
|
||||
`<...>` 占位符。
|
||||
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
||||
缺失值。
|
||||
11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
||||
不推送。
|
||||
11. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 与
|
||||
`regression.yaml` 是否仍有 `<...>` 占位符。
|
||||
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
||||
缺失值。
|
||||
12. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
||||
不推送。
|
||||
|
||||
## 检查
|
||||
|
||||
1. 检查以下路径:
|
||||
- `docs/ack/project.md`
|
||||
- `docs/ack/tasks.yaml`
|
||||
- `docs/ack/knowledge.yaml`
|
||||
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||
- `.pouch/ack/project.md`
|
||||
- `.pouch/ack/tasks.yaml`
|
||||
- `.pouch/ack/knowledge.yaml`
|
||||
- `.pouch/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||
- `.pouch/ack/regression.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
|
||||
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
|
||||
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
|
||||
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
||||
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
||||
存在。
|
||||
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
|
||||
3. 查找未替换占位符,并核对项目根、覆盖层路径、Developer 白盒命令、Test
|
||||
黑盒命令和 Base URL。
|
||||
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
||||
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
|
||||
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
|
||||
`<ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks
|
||||
.pouch/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
|
||||
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
|
||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
||||
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
||||
缺少可选交付配置而宣称失败。
|
||||
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
.pouch/ack/delivery.yaml --tasks .pouch/ack/tasks.yaml --project-root <project-root>`
|
||||
校验交付能力、顺序、安全边界和跨文件引用。如果存在回归目录或任务板声明了
|
||||
`project.regressionFile`,再使用 `<ack-skill-dir>/scripts/validate_regression.py
|
||||
.pouch/ack/regression.yaml --tasks .pouch/ack/tasks.yaml` 校验用例与跨文件引用。
|
||||
只报告证据明确的问题,不因旧项目缺少可选交付或回归配置而宣称失败。
|
||||
5. 若存在 `project.bugIntake`,运行
|
||||
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check .pouch/ack/tasks.yaml`。
|
||||
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
|
||||
`references/feishu-bug-intake.md`。
|
||||
6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
||||
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||
必须同时为空或同时填写。
|
||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
|
||||
## 工作
|
||||
|
||||
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
|
||||
1. 若 `.pouch/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
|
||||
2. 依次读取:
|
||||
- `docs/ack/project.md`
|
||||
- `docs/ack/tasks.yaml`
|
||||
- `.pouch/ack/project.md`
|
||||
- 运行 `python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml`,只读取
|
||||
`project`、`summary` 和默认可工作状态的任务;已知当前任务时传
|
||||
`--task-id <ack-task-id>`。选择器会解析并校验完整任务板,并只附带选中任务引用的
|
||||
receipt 与 delivery run。命中超过默认预算时用 `--task-id` / `--status` 缩小,
|
||||
不直接回退为输出完整 `tasks.yaml`。
|
||||
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
|
||||
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
||||
`.pouch/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
||||
- `<ack-skill-dir>/references/kickoff.md`
|
||||
- kickoff 指定且与当前任务相关的 references 文件
|
||||
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
|
||||
`<ack-skill-dir>/references/delivery.md`
|
||||
- 若 `tasks.yaml.project.regressionFile` 存在,再读取该 `regression.yaml` 和
|
||||
`<ack-skill-dir>/references/regression.md`
|
||||
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||
不全量注入知识库。
|
||||
`project.bugIntake.workflow` 为 `clarified-writeback-v1`(推荐)或
|
||||
`reviewed-writeback-v1`(兼容旧项目)时,按
|
||||
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
|
||||
读取用户填写的 Bug。新工作流中,用户只维护标题、详细描述和附件;Coordinator 根据
|
||||
来源事实与项目上下文整理问题说明、期望效果和可观测验收标准,不在收件箱写修复逻辑,
|
||||
只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
|
||||
用户针对当前 `draftRevision` 明确审核通过并亲自在飞书把状态改为 `已确认` 前,不创建
|
||||
或刷新 `tasks.yaml` 任务、不启动 worker、不派发 Developer/Test,也不修改应用代码。
|
||||
Coordinator 不得自行写入 `已确认`。审核通过后重新读取,要求 revision 与批准值完全
|
||||
一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
|
||||
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
|
||||
payload hash 通过后,再用 `mark-imported` 把最终任务 ID 与同一 revision 写回飞书,
|
||||
才进入三角色闭环。未声明 workflow 的旧八字段配置只按
|
||||
`read-only-v1` 兼容,不得写回;
|
||||
标题、详细描述和附件是来源事实,不得把 Coordinator 推断伪装成用户原文;整行空白
|
||||
记录按批次 warning 跳过。
|
||||
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
||||
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
||||
然后交给用户确认。修 bug 写短问题说明、复现步骤和可观测验收,不写大 PRD;
|
||||
Developer 先补会失败的用例再修。若启用了交付,必须默认把 `defaultProfile`、
|
||||
目标、停止点和需要审批的步骤放入同一份计划,不能静默省略。用户可明确取消
|
||||
本轮交付;确认前不派发实现,也不执行交付。
|
||||
5. 创建或更换 worker 时,只使用
|
||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
||||
executable、env 或 cwd。必须先审阅 `plan.launchFingerprint`,再把它作为
|
||||
`launch --expected-launch-fingerprint` 传入。v0.10 不根据持久化 receipt 自动
|
||||
复用旧终端;每次自动派发都创建 fresh worker,receipt 只作审计与 dispatch
|
||||
关联。
|
||||
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证,Test 独立黑盒
|
||||
复测,Coordinator 读取证据终检并唯一写入 `tasks.yaml`。Developer 回报
|
||||
`knowledgeApplied` 和 `knowledgeCandidates`,Test 回报 `knowledgeChecks`;
|
||||
`launch --expected-launch-fingerprint` 传入。派发前先寻找同一 ACK 运行内的空闲
|
||||
worker;只有角色、profile、worktree 和启动身份仍完全匹配,且后端能清理历史消息、
|
||||
返回可核对的新会话身份时才复用。不得复用正在工作、等待回报或状态不明的 worker;
|
||||
任一条件不符、清理能力不存在或无法确认清理成功时创建 fresh worker。持久化
|
||||
receipt 只作审计与 dispatch 关联,不能单独授权复用。当前 Orca 终端接口不能提供
|
||||
可验证的历史消息清理,因此使用 Orca 时仍走 fresh worker。
|
||||
6. 用户已确认的任务按 ACK 闭环执行:Developer 实现与白盒验证;若
|
||||
`intents.testEnvironment` 已启用,Coordinator 先按「运行测试环境」拉起服务,再
|
||||
派 Test 独立黑盒复测。派发后先确认 worker 真正开始执行(terminal read 确认任务
|
||||
注入;卡在审批提示、未回车或额度限制时按环境失败处理并报告),等待期间用
|
||||
`scripts/worker_probe.py` 滚动检查活性,不盲等 `worker_done`。Coordinator 读取
|
||||
证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和
|
||||
`knowledgeCandidates`,Test 回报 `knowledgeChecks` 和 `regressionCandidates`;
|
||||
`candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。
|
||||
任务进入 `verified` 且改了用户可见行为或 API 后,按 `references/regression.md`
|
||||
给出新增/更新/退役/无回归四选一,用户确认后写入 `.pouch/ack/regression.yaml`,
|
||||
并把 case id 记入 `regressionRefs`。Test 只提名,不写该文件。
|
||||
7. 执行知识项的 `verification.ref` 时,只调用
|
||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
||||
也不要给 runner 注入额外命令或参数。
|
||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
|
||||
`leftover` 并继续其它任务。
|
||||
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。三轮预算只计算 Test 已对齐正确
|
||||
服务、数据和工具后实际执行验收所得的产品失败;环境失败不占复验轮次,不写
|
||||
`failed_retest`,而写入 `dispatch.environmentIncidents`。Coordinator 先做一次有界、
|
||||
安全的恢复;事件未解决、需要用户动作或会阻断本轮时,立即向用户报告原因、影响、
|
||||
已尝试动作、下一恢复动作和明确的 `userAction`;即使已自动恢复,也要在最终报告汇总。
|
||||
每项最多三轮有效产品复验,仍失败才记录 `leftover` 并继续其它任务。细则见
|
||||
`references/optimization-method.md` §4。
|
||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
||||
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
|
||||
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
|
||||
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
|
||||
run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署
|
||||
必须在对应步骤再次取得明确批准。
|
||||
run,不回写成任务失败。开发或测试环境完成构建、部署和健康检查后写
|
||||
`validation_ready`,并把访问地址、验证范围和用户下一步交给用户;不能停在
|
||||
`verified` 却声称整轮 ACK 已结束。默认 profile 最多到 `validation_ready` 或
|
||||
`review_ready`,稳定发布和生产部署必须在对应步骤再次取得明确批准。
|
||||
11. Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先
|
||||
dry-run 审阅决策,再 `--apply` 回收所有只属于 `verified` 任务的 worker
|
||||
终端,并核对关闭回执;历史 receipt 和任务证据继续保留。任何还被 `open`、`dispatched`、`fixed_by_dev`、
|
||||
`retesting`、`blocked`、`failed_retest`、`leftover` 或未解决环境事件引用的终端
|
||||
都保留,不设置 TTL,也不能因为同一终端还关联过 `verified` 任务而误关。若关闭
|
||||
结果不确定,记录并报告,不重复关闭或伪报已回收。
|
||||
|
||||
## 交付配置维护
|
||||
|
||||
1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布
|
||||
入口和 CI;把用户自然语言描述转换为结构化 entrypoint、artifact、destination、
|
||||
environment 与 profile。配置只引用仓库内脚本或声明式工具 target,不保存 shell。
|
||||
2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
|
||||
`deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
|
||||
1. 读取 `references/delivery.md`、deployer skill、模板、schema、现有
|
||||
`delivery.yaml`、项目构建/发布入口和 CI。测试环境写成
|
||||
`intents.testEnvironment: {via: deployer, env: <env>}`,并按 deployer skill
|
||||
准备 `.pouch/deployer/<env>`;不要把 compose/rsync 命令写进 ACK。发版仍指向
|
||||
本文件的 profile。不要拆成第二份文档。配置只引用仓库内脚本或声明式工具
|
||||
target,不保存 shell。本地 `npm run dev` / `go run` 写在 `project.md` 的
|
||||
Developer 白盒命令里,不算测试环境部署。
|
||||
2. 若旧项目首次启用,生成 `.pouch/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
|
||||
`deliveryFile: .pouch/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
|
||||
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
|
||||
3. 运行 delivery、tasks 和跨文件校验;需要的脚本不存在、不可执行、引用不完整或
|
||||
涉及凭据正文时 fail closed。凭据只写 secret 名称,值由外部环境提供。
|
||||
@@ -164,24 +246,80 @@ description: >-
|
||||
执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改
|
||||
扩大权限。
|
||||
|
||||
## 运行测试环境
|
||||
|
||||
1. 读取 `.pouch/ack/delivery.yaml`、`references/delivery.md` 和 deployer skill 的
|
||||
`SKILL.md`。
|
||||
2. `enabled` 不为 true,或 `intents.testEnvironment` 为 null:停止,请用户说明如何
|
||||
部署测试环境,转入交付配置维护。不猜测编译或启动命令。
|
||||
3. `intents.testEnvironment` 必须是 `{via: deployer, env: <env>}`。若仍是旧的
|
||||
profile ID 字符串:停止,展示迁移说明,转入交付配置维护。不要执行 ACK
|
||||
delivery profile 来布测试环境。
|
||||
4. 不要求任务已 `verified`。按 deployer 的项目内环境布局操作
|
||||
`.pouch/deployer/<env>`:list 确认服务,再按服务 sync + up(或用户要求的
|
||||
recreate),并用 ps/logs/健康检查验证。不要复制 deployer 脚本,不要发明
|
||||
第二套 compose 命令。
|
||||
5. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`,
|
||||
`intent: testEnvironment`,`profile` 记 `deployer-<env>`,`taskIds` 可为空。
|
||||
6. 派发 Test 前若该 intent 已启用,必须先完成本步骤。deployer 未安装、环境目录
|
||||
不存在或健康检查失败:fail closed,报告 `userAction`,不把环境失败写成产品
|
||||
失败。
|
||||
|
||||
## 运行回归
|
||||
|
||||
1. 若 `.pouch/ack` 不存在,停止并建议先初始化。
|
||||
2. 若没有 `.pouch/ack/regression.yaml` 或 `project.regressionFile`:停止,用户授权后
|
||||
从模板生成空文件并只补任务板指针与 `regressionRuns: []`。
|
||||
3. 用 `scripts/select_regression.py` 读取 active 用例(默认 `--suite smoke`;用户
|
||||
指定 full 或 case id 时缩小范围)。没有命中用例时停止并说明先收获用例。
|
||||
不要把完整 `regression.yaml` 注入上下文。细则见 `references/regression.md`。
|
||||
4. 先执行「运行测试环境」。
|
||||
5. 当前会话担任 Coordinator:按 Test profile 启动独立 Test worker,派发回归清单、
|
||||
Base URL 和每条 case 的 surface/steps/expected。Coordinator 不亲自点浏览器或
|
||||
打 API。
|
||||
6. Test 按 `surface` 执行:`browser` 必须走真实交互,不得改成只打 API。逐条对照
|
||||
`expected` 回报。环境失败记环境事件,不记产品失败。
|
||||
7. Coordinator 终检后写入 `regressionRuns`。失败只报告,不自动派 Developer,不占
|
||||
任务三轮预算。用户明确要求修复时再按修 bug 为每条失败开任务。
|
||||
|
||||
## 运行版本发布
|
||||
|
||||
1. 读取同一份 `.pouch/ack/delivery.yaml` 与 `references/delivery.md`。
|
||||
2. `enabled` 不为 true,或 `intents.release` 为 null:停止,请用户说明如何发版,
|
||||
写入同一文件后再执行。
|
||||
3. 按该 profile 顺序执行。stable 发布和生产部署的 `approval` 不能用口头「发版」
|
||||
代替。
|
||||
4. 证据写入 `deliveryRuns`,`intent: release`;绑定了任务时 `taskIds` 仍只能引用
|
||||
`verified` 任务。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。
|
||||
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
|
||||
- 不猜测项目命令、服务地址、worker handle 或模型名称。
|
||||
- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成
|
||||
v0.10 自动 worker 的合法配置;当前一律 fail closed。
|
||||
- 不把 full-access、bypass、Grok `--yolo` / bypassPermissions、关闭 sandbox
|
||||
或项目内“授权”字段当成 v0.10 自动 worker 的合法配置;这些 CLI 绕过标志
|
||||
当前一律 fail closed。Grok worker 由 launcher 固定带 `--always-approve`,
|
||||
仍必须带 sandbox。
|
||||
- OMP worker 使用结构化 `--model`、`--thinking` 和 `--approval-mode` 参数。
|
||||
`--approval-mode yolo` 是 OMP worker 的审批模式,不是 CLI 绕过标志:规则层
|
||||
直接允许并默认启用(workspace-write → yolo、read-only → always-ask);
|
||||
仍禁止 `--auto-approve`,也不适用于 codex/cursor-agent/grok。
|
||||
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
|
||||
v0.10 不自动复用既有 worker。
|
||||
没有可信空闲状态、配置匹配和历史消息清理证明时不复用既有 worker。
|
||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||
- 不覆盖已有 `.pouch/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||
提交、推送、创建终端、新 worktree、发布产物或部署。
|
||||
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
||||
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
|
||||
维护中修改。
|
||||
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml`、`regression.yaml`、
|
||||
`deliveryRuns` 和 `regressionRuns`;Developer 与 Test 只读,只能通过回报提名
|
||||
或验证。`delivery.yaml` 只在显式的交付配置维护中修改。
|
||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
|
||||
ACK Skill 目录读取。
|
||||
- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务
|
||||
视图,写回前仍运行完整任务板校验。
|
||||
- 项目只保存 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
|
||||
`.pouch/ack/knowledge.yaml`、可选的 `.pouch/ack/delivery.yaml` 和可选的
|
||||
`.pouch/ack/regression.yaml`;通用资源始终从当前 ACK Skill 目录读取。
|
||||
- 不把完整 `regression.yaml` 注入上下文;使用 `select_regression.py` 获取有预算的
|
||||
用例视图,写回前仍运行完整校验。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.11.0
|
||||
0.19.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "ACK"
|
||||
short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环"
|
||||
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile."
|
||||
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
|
||||
|
||||
@@ -6,6 +6,12 @@ project:
|
||||
enabled: true
|
||||
defaultProfile: "review"
|
||||
|
||||
intents:
|
||||
testEnvironment:
|
||||
via: deployer
|
||||
env: test
|
||||
release: null
|
||||
|
||||
entrypoints:
|
||||
verify:
|
||||
kind: make
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||
|
||||
> 本项目基于 ack v0.11.0。
|
||||
> 本项目基于 ack v0.19.0。
|
||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
|
||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。
|
||||
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml`
|
||||
> 与 `regression.yaml`。
|
||||
|
||||
## 项目概览
|
||||
|
||||
@@ -12,10 +13,11 @@
|
||||
- 技术栈:`TypeScript + React (Vite) + Go`
|
||||
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
|
||||
- Base URL:`http://localhost:5173`
|
||||
- 任务板:`docs/ack/tasks.yaml`
|
||||
- 项目知识:`docs/ack/knowledge.yaml`
|
||||
- 交付契约:`docs/ack/delivery.yaml`
|
||||
- 覆盖层文件:`docs/ack/project.md`
|
||||
- 任务板:`.pouch/ack/tasks.yaml`
|
||||
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||
- 交付契约:`.pouch/ack/delivery.yaml`
|
||||
- 回归目录:`.pouch/ack/regression.yaml`
|
||||
- 覆盖层文件:`.pouch/ack/project.md`
|
||||
|
||||
## 稳定规范(引用,不重复)
|
||||
|
||||
@@ -27,10 +29,11 @@
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令:`references/orca-adapter.md`
|
||||
- 验证后交付:`references/delivery.md`
|
||||
- 回归目录与运行:`references/regression.md`
|
||||
|
||||
## Worker 路由
|
||||
|
||||
结构化配置位于 `docs/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层
|
||||
结构化配置位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层
|
||||
`workerReceipts`。本项目默认使用:
|
||||
|
||||
| 角色 | profile ID | 档位 |
|
||||
@@ -39,7 +42,8 @@
|
||||
| Test | `codex-test-standard` | standard |
|
||||
| Developer 升级 | `codex-dev-strong` | strong |
|
||||
|
||||
本文件不保存或拼接 worker shell 命令。
|
||||
项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行
|
||||
任务板校验。本文件不保存或拼接 worker shell 命令。
|
||||
|
||||
## 路径权限
|
||||
|
||||
@@ -54,6 +58,7 @@
|
||||
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||
| `tasks.yaml` | 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 | 项目交付能力,不是执行授权 |
|
||||
|
||||
## 命令
|
||||
@@ -79,8 +84,10 @@ ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动
|
||||
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
||||
path/args。
|
||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
||||
交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。
|
||||
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||
`scripts/validate_regression.py` 执行。
|
||||
测试环境由 ACK 内部调用 deployer;发版机器入口以 `delivery.yaml` 为准,本覆盖层
|
||||
不维护第二套发布或部署命令。
|
||||
|
||||
## 硬规则(其余见 references/)
|
||||
|
||||
@@ -90,14 +97,15 @@ path/args。
|
||||
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
|
||||
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
|
||||
worker;持久化 receipt 只作审计,不授权自动复用旧终端。
|
||||
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
|
||||
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
|
||||
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
|
||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||
`candidate` 不派发,知识库不全量注入。
|
||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
||||
- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与
|
||||
- 交付只在任务 `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=回归笔记"
|
||||
@@ -3,20 +3,18 @@
|
||||
version: 1
|
||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "0.11.0"
|
||||
ackVersion: "0.19.0"
|
||||
project:
|
||||
name: "notes-web"
|
||||
repoPath: "/home/dev/notes-web"
|
||||
baseUrl: "http://localhost:5173"
|
||||
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
deliveryFile: "docs/ack/delivery.yaml"
|
||||
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:
|
||||
- "/home/dev/notes-web-wt/fix-preview"
|
||||
# allowedWorktrees 已废弃(v0.19 起);worker 默认在 --project-root 工作。
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
@@ -29,6 +27,12 @@ project:
|
||||
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"
|
||||
@@ -65,6 +69,27 @@ project:
|
||||
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"
|
||||
@@ -72,6 +97,7 @@ project:
|
||||
|
||||
workerReceipts: []
|
||||
deliveryRuns: []
|
||||
regressionRuns: []
|
||||
|
||||
summary:
|
||||
verified: ["BUG-002"]
|
||||
@@ -104,8 +130,20 @@ tasks:
|
||||
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"
|
||||
- "搜索并选择候选项"
|
||||
@@ -148,6 +186,19 @@ tasks:
|
||||
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"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
## 安装与初始化
|
||||
|
||||
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
||||
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
||||
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与
|
||||
默认关闭的 `delivery.yaml`。
|
||||
- [ ] 已运行 `pouch init ack --project <project-root>`。
|
||||
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、
|
||||
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
|
||||
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
||||
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
||||
@@ -16,14 +16,19 @@
|
||||
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
|
||||
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
||||
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
||||
`docs/ack/knowledge.yaml`。
|
||||
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
|
||||
`.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 与角色/档位一致。
|
||||
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
||||
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
|
||||
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
|
||||
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
|
||||
`--project-root/.pouch/ack/` 推导,worker 路径由 `--worktree` 显式指定。
|
||||
|
||||
## 路径权限
|
||||
|
||||
@@ -33,6 +38,7 @@
|
||||
- [ ] 私有配置只读且不提交。
|
||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||
- [ ] `regression.yaml` 只有 Coordinator 写;Test 只通过 `regressionCandidates` 提名。
|
||||
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||
|
||||
## 任务板
|
||||
@@ -45,6 +51,9 @@
|
||||
## 可选交付
|
||||
|
||||
- [ ] `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 引用均通过
|
||||
@@ -76,6 +85,13 @@
|
||||
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
|
||||
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
|
||||
## 回归
|
||||
|
||||
- [ ] 新项目 `regression.yaml` 为 `cases: []`,没有虚构用例。
|
||||
- [ ] 已运行 `validate_regression.py --tasks ...` 并通过。
|
||||
- [ ] 任务 `verified` 后给出新增/更新/退役/无回归四选一,确认后才写入目录。
|
||||
- [ ] 运行回归先走 deployer 测试环境,再派独立 Test;失败不自动开修。
|
||||
|
||||
## 编排
|
||||
|
||||
- [ ] 已选择 Orca 或手动模式。
|
||||
@@ -85,8 +101,9 @@
|
||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
||||
Orca live metadata 当作自动复用授权。
|
||||
- [ ] 只考虑同轮、空闲、角色/profile/worktree/启动身份匹配的复用候选;历史消息必须
|
||||
由受信后端清理并返回新会话身份。任一条件不符或无法确认清理成功时启动 fresh
|
||||
worker;没有把历史 receipt checksum 或 Orca live metadata 当作复用授权。
|
||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||
@@ -100,8 +117,10 @@
|
||||
- [ ] 每轮写入 `dispatch.rounds`。
|
||||
- [ ] 三轮失败后标记 `leftover` 并填写原因。
|
||||
- [ ] `leftover` 不阻塞其它任务。
|
||||
- [ ] Coordinator 标记整轮完成后,已关闭所有只属于 `verified` 任务的 worker;仍关联
|
||||
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
|
||||
|
||||
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
|
||||
`docs/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
||||
`.pouch/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
|
||||
或 `scripts/`,并更新 `VERSION`。项目特有、跨任务复用且已经验证的经验才写入
|
||||
`docs/ack/knowledge.yaml`。
|
||||
`.pouch/ack/knowledge.yaml`。
|
||||
|
||||
@@ -36,24 +36,33 @@ Coordinator 发现或读取 open 任务
|
||||
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
|
||||
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
|
||||
-> 运行 launcher plan 并审阅 launchFingerprint
|
||||
-> 带 expected fingerprint 启动 fresh worker,把 receipt 写回 tasks.yaml(见 orca-adapter.md)
|
||||
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
|
||||
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md)
|
||||
-> dispatch 给 Developer(--to <worker handle>)
|
||||
-> wait:Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
||||
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
||||
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||
-> writeback fixed_by_dev
|
||||
-> 为 Test 独立解析安全 profile,重新 plan/launch fresh worker 并写回 receipt
|
||||
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先按 deployer
|
||||
绑定拉起 `.pouch/deployer/<env>`,再派 Test;Test 不发明编译或启动命令
|
||||
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
||||
-> dispatch 给 Test(retesting)
|
||||
-> wait:Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
|
||||
-> 确认 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 失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮)
|
||||
-> 累计三轮失败:writeback leftover,继续下一个任务
|
||||
-> Test 因环境无法完成:记录 environmentIncidents,不计轮次;有界恢复并明确告知用户下一步
|
||||
-> Test 观察到产品失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮有效产品复验)
|
||||
-> 累计三轮有效产品失败:writeback leftover,继续下一个任务
|
||||
```
|
||||
|
||||
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
|
||||
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
|
||||
`orca-adapter.md`。v0.10 不自动复用历史 receipt 或既有终端;标题、preview、分支名、
|
||||
worker 自报和无密钥 receipt checksum 都不能代替可信启动 attestation。
|
||||
`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 之前的历史
|
||||
@@ -106,10 +115,10 @@ Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择
|
||||
|
||||
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
|
||||
|
||||
**方式 1:当前 worktree 起 fresh 子 agent(终端)**
|
||||
**方式 1:当前 worktree 复用安全重置的空闲子 agent,或起 fresh 子 agent(终端)**
|
||||
|
||||
```bash
|
||||
# v0.10 不自动复用既有终端;先审阅 plan:
|
||||
# 当前 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> \
|
||||
@@ -132,9 +141,9 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
|
||||
**方式 2:新建隔离 worktree,再在其中起子 agent**
|
||||
|
||||
创建成功后,先把 Orca 返回的绝对路径加入权威任务板的
|
||||
`project.orchestration.allowedWorktrees` 并重新运行 `validate_tasks.py`;未进入
|
||||
allowlist 的路径不能交给 launcher。
|
||||
创建成功后,确认新 worktree 与权威项目根属于同一 Git 仓库(launcher 按
|
||||
`git worktree list` 注册表 + 同 common-dir 约束校验,v0.19 起不再需要
|
||||
`allowedWorktrees` 白名单登记)。
|
||||
|
||||
```bash
|
||||
orca worktree create --name <feature> --base-branch <base> --json
|
||||
@@ -165,9 +174,9 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
| 小改动、追求快 | 当前 worktree |
|
||||
|
||||
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
|
||||
`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在
|
||||
worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护
|
||||
时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
|
||||
副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、
|
||||
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
||||
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||
|
||||
@@ -189,7 +198,9 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
|
||||
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
|
||||
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
|
||||
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
|
||||
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
||||
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
|
||||
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
|
||||
不派 Developer、不消耗产品轮次。
|
||||
|
||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||
@@ -260,7 +271,7 @@ frontendDir:
|
||||
worktreePath:
|
||||
```
|
||||
|
||||
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。
|
||||
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以 deployer 绑定为准,不另写一套启动命令。
|
||||
|
||||
---
|
||||
|
||||
@@ -297,3 +308,32 @@ resolution:
|
||||
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 和关闭回执;关闭
|
||||
不确定时报告并保留审计记录。
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# ACK 交付阶段
|
||||
|
||||
本文件定义可选的 `verified -> review_ready/released` 交付阶段。开发、独立复测和
|
||||
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
|
||||
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
|
||||
交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入
|
||||
`docs/ack/tasks.yaml.deliveryRuns`。
|
||||
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
|
||||
`.pouch/ack/tasks.yaml.deliveryRuns`。
|
||||
|
||||
## 1. 配置与授权不是一回事
|
||||
|
||||
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。ACK 在
|
||||
kickoff 的既有用户确认点同时展示本次 profile、remote、产物目标、环境和停止点;用户
|
||||
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。启用交付
|
||||
时,ACK 在 kickoff 的既有用户确认点默认展示 `defaultProfile`、remote、产物目标、环境
|
||||
和停止点,不得静默省略;用户
|
||||
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
|
||||
channel、environment 或 source revision 漂移时重新确认。
|
||||
|
||||
@@ -42,14 +43,45 @@ channel、environment 或 source revision 漂移时重新确认。
|
||||
stable。
|
||||
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
|
||||
deploy 和 health check,production 还必须声明 rollback。
|
||||
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `review_ready`,不能
|
||||
发布 stable 或部署 production。
|
||||
- `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 和健康检查。派发 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 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填
|
||||
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
|
||||
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
|
||||
`verified` 任务。
|
||||
|
||||
## 4. 运行前检查
|
||||
|
||||
1. 从 `tasks.yaml.project.deliveryFile` 解析文件;未引用或 `enabled=false` 时保持旧 ACK
|
||||
@@ -58,7 +90,7 @@ channel、environment 或 source revision 漂移时重新确认。
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/validate_delivery.py \
|
||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml \
|
||||
.pouch/ack/delivery.yaml --tasks .pouch/ack/tasks.yaml \
|
||||
--project-root <project-root>
|
||||
```
|
||||
|
||||
@@ -84,9 +116,9 @@ channel、environment 或 source revision 漂移时重新确认。
|
||||
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
|
||||
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
|
||||
revision,不能在目标机器重新拉源码构建。
|
||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 可使用已安装的
|
||||
`deb-publisher`;Docker 只有在用户明确指定 `$publish-docker-image` 时才加载该
|
||||
explicit-only skill,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
|
||||
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
|
||||
时才加载,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
||||
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
||||
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
||||
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
||||
@@ -100,7 +132,9 @@ channel、environment 或 source revision 漂移时重新确认。
|
||||
## 6. 状态与恢复
|
||||
|
||||
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
|
||||
`blocked`、`failed`、`review_ready`、`released` 或 `skipped`。部署或 Forge 暂时失败不把
|
||||
`blocked`、`failed`、`validation_ready`、`review_ready`、`released` 或 `skipped`。
|
||||
`validation_ready` 表示已把不可变产物部署到开发/测试环境并通过健康检查,可以交给
|
||||
用户手工验证,不要求存在 PR/MR。部署或 Forge 暂时失败不把
|
||||
任务改回 `failed_retest`。
|
||||
|
||||
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
|
||||
@@ -110,14 +144,17 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
||||
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
|
||||
时,才可从失败步骤继续。
|
||||
|
||||
`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
||||
`validation_ready` 至少记录:source/config revision、所有产物引用与 digest、部署环境、
|
||||
访问地址和用户下一步,以及健康检查证据。`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
|
||||
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
|
||||
“完成”掩盖其中某一阶段失败或待审批。
|
||||
|
||||
## 7. 与低层 Skill 的边界
|
||||
|
||||
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
||||
上传、镜像或 Git 发布实现。`deb-publisher`、`publish-docker-image` 和
|
||||
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
||||
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
||||
调用时,ACK 不能绕过它的触发与授权边界。
|
||||
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`manage-release` 和
|
||||
`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载
|
||||
deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失
|
||||
builder 或 manage-release 时,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 任务。
|
||||
@@ -9,10 +9,11 @@
|
||||
|
||||
1. 目标项目根目录。
|
||||
2. ACK Skill 已全局安装或安装到当前项目。
|
||||
3. `skiff` 命令可用。
|
||||
3. `pouch` 命令可用。
|
||||
|
||||
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent
|
||||
不要覆盖已有的 `.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 项目状态。
|
||||
@@ -22,23 +23,24 @@
|
||||
在目标项目执行:
|
||||
|
||||
```bash
|
||||
skiff init ack
|
||||
pouch init ack
|
||||
```
|
||||
|
||||
或显式指定项目:
|
||||
|
||||
```bash
|
||||
skiff init ack --project <project-root>
|
||||
pouch init ack --project <project-root>
|
||||
```
|
||||
|
||||
命令从 ACK Skill 自带的 `templates/` 生成:
|
||||
|
||||
```text
|
||||
docs/ack/
|
||||
.pouch/ack/
|
||||
├── project.md
|
||||
├── tasks.yaml
|
||||
├── knowledge.yaml
|
||||
└── delivery.yaml # 默认 enabled: false
|
||||
├── delivery.yaml # 默认 enabled: false
|
||||
└── regression.yaml # 默认 cases: []
|
||||
```
|
||||
|
||||
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
||||
@@ -47,24 +49,31 @@ docs/ack/
|
||||
### 旧项目补充知识库
|
||||
|
||||
旧项目已经有 `project.md` 和 `tasks.yaml`、但没有 `knowledge.yaml` 时,不要重跑
|
||||
`skiff init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
|
||||
`templates/knowledge.template.yaml` 生成 `docs/ack/knowledge.yaml`,替换项目名和
|
||||
`pouch init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
|
||||
`templates/knowledge.template.yaml` 生成 `.pouch/ack/knowledge.yaml`,替换项目名和
|
||||
当前时间,保留 `entries: []`。如果现有任务板缺少
|
||||
`project.knowledgeFile`,同一次授权只补
|
||||
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
||||
`knowledgeFile: .pouch/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
||||
知识库和跨文件引用校验。
|
||||
|
||||
### 旧项目补充交付配置
|
||||
|
||||
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
|
||||
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
|
||||
任务板补 `project.deliveryFile: docs/ack/delivery.yaml` 与顶层
|
||||
任务板补 `project.deliveryFile: .pouch/ack/delivery.yaml` 与顶层
|
||||
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
||||
解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。
|
||||
解析结果后才启用。不要重跑 `pouch init ack`,也不要改写已有任务或知识。
|
||||
|
||||
### 旧项目补充回归目录
|
||||
|
||||
`regression.yaml` 对旧项目是可选能力。用户明确要求回归或授权补齐时,从
|
||||
`templates/regression.template.yaml` 生成 `.pouch/ack/regression.yaml`,并只补
|
||||
`project.regressionFile: .pouch/ack/regression.yaml` 与顶层 `regressionRuns: []`。
|
||||
保持 `cases: []`,不要从聊天虚构用例。
|
||||
|
||||
## 完善项目覆盖层
|
||||
|
||||
编辑 `docs/ack/project.md`,填入:
|
||||
编辑 `.pouch/ack/project.md`,填入:
|
||||
|
||||
- 项目名、技术栈、运行命令和 Base URL。
|
||||
- Coordinator、Developer、Test 的实际模型档位。
|
||||
@@ -76,20 +85,24 @@ docs/ack/
|
||||
|
||||
## 完善任务板
|
||||
|
||||
编辑 `docs/ack/tasks.yaml`:
|
||||
编辑 `.pouch/ack/tasks.yaml`:
|
||||
|
||||
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
||||
自动补交付配置。
|
||||
- `updatedAt` 使用当前带时区时间。
|
||||
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
||||
真实值。
|
||||
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
||||
- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。
|
||||
ACK 从命令行 `--project-root` 下固定的 `.pouch/ack/` 布局解析项目状态,不把
|
||||
`repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取,
|
||||
不再参与路径绑定。
|
||||
- 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,并保留顶层
|
||||
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
||||
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
||||
`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||
- 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,并保留顶层
|
||||
`regressionRuns: []`。旧项目只有在采用回归能力时才补这两个字段。
|
||||
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
||||
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
||||
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||
- 非服务项目的 `baseUrl` 写为 `n/a`。
|
||||
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
|
||||
|
||||
@@ -98,7 +111,7 @@ docs/ack/
|
||||
|
||||
## 初始化项目知识
|
||||
|
||||
新项目的 `docs/ack/knowledge.yaml` 保持 `verificationRegistry: {}` 与
|
||||
新项目的 `.pouch/ack/knowledge.yaml` 保持 `verificationRegistry: {}` 与
|
||||
`entries: []`。不要从聊天、README、issue 或单次失败中猜测并激活知识。
|
||||
|
||||
项目运行 ACK 后,Developer 和 Test 可以通过回报提名 `knowledgeCandidates`;
|
||||
@@ -114,36 +127,45 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
|
||||
|
||||
## 初始化项目交付
|
||||
|
||||
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表和空 profile。
|
||||
不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言描述交付要求后,
|
||||
Coordinator 按 `delivery.md` 维护声明式配置:工具 target 与仓库脚本分开引用,产物、
|
||||
目的地、环境和有序 profile 使用稳定 ID。配置中不保存 shell、环境变量值或凭据
|
||||
正文;稳定发布和生产部署必须有显式 approval 步骤。
|
||||
新项目的 `.pouch/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
||||
`intents.testEnvironment: null` 与 `intents.release: null`。
|
||||
不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator
|
||||
按 deployer skill 准备 `.pouch/deployer/<env>`,并把
|
||||
`intents.testEnvironment` 写成 `{via: deployer, env: <env>}`;发版仍指向 profile。
|
||||
配置中不保存 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 docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||
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
|
||||
```
|
||||
|
||||
同时确认:
|
||||
|
||||
- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的
|
||||
`<...>` 占位符。
|
||||
- `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 和
|
||||
`regression.yaml` 没有未替换的 `<...>` 占位符。
|
||||
- `project.overlayFile` 指向真实文件。
|
||||
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
||||
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
|
||||
- `project.knowledgeFile` 指向 `.pouch/ack/knowledge.yaml`。
|
||||
- 新项目的 `project.deliveryFile` 指向 `.pouch/ack/delivery.yaml`;交付默认关闭。
|
||||
- Developer 与 Test 的验证命令可执行。
|
||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||
v0.10 不自动复用旧终端。
|
||||
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
|
||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
|
||||
|
||||
@@ -151,7 +173,7 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||
|
||||
完成后报告:
|
||||
|
||||
- 创建或确认的四个项目文件。
|
||||
- 创建或确认的项目文件(含回归目录)。
|
||||
- 检测到的技术栈和验证命令。
|
||||
- 任务板、项目知识和交付契约校验结果。
|
||||
- 仍需用户补充的值。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
|
||||
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
|
||||
具体 CLI、模型、reasoning effort 和执行模式的机器事实源是
|
||||
`docs/ack/tasks.yaml` 的 `project.orchestration`。`docs/ack/project.md` 只解释项目
|
||||
`.pouch/ack/tasks.yaml` 的 `project.orchestration`。`.pouch/ack/project.md` 只解释项目
|
||||
差异,不能提供另一套启动命令。
|
||||
|
||||
---
|
||||
@@ -20,23 +20,31 @@
|
||||
我要做一个新需求:<一句话需求>。
|
||||
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
|
||||
|
||||
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
||||
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
||||
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 条目,再读
|
||||
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
||||
如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与
|
||||
references/delivery.md,但不要把配置本身当作执行授权。
|
||||
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
||||
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
||||
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
||||
4. 把任务写进 .pouch/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
||||
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
||||
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
||||
deliveryRuns;默认停在 review_ready,stable/production 步骤再次向我确认。
|
||||
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
||||
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
||||
fingerprint 创建 fresh worker;
|
||||
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
|
||||
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
||||
记录、恢复并告诉我下一步,不占产品复验轮次。
|
||||
7. 所选任务都 verified 后,按 regression.md 给出新增/更新/退役/无回归,确认后写入
|
||||
.pouch/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写
|
||||
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
||||
或 review_ready,stable/production 步骤再次向我确认。
|
||||
```
|
||||
|
||||
---
|
||||
@@ -44,20 +52,31 @@
|
||||
## 第 1 步:Coordinator 产出(确认前)
|
||||
|
||||
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
|
||||
2. 任务板 → `docs/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(见 `optimization-method.md` §1)。
|
||||
3. 项目知识 → 从 `docs/ack/knowledge.yaml` 按 component、path、dependency、version
|
||||
2. 任务板 → `.pouch/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(见 `optimization-method.md` §1)。
|
||||
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 docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
|
||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
|
||||
```
|
||||
|
||||
`project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、
|
||||
不匹配的角色/档位和非安全权限必须在派发前 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、构建产物、
|
||||
@@ -76,9 +95,11 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
||||
|
||||
## 第 3 步:审阅并启动 worker
|
||||
|
||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。v0.10 不把历史 receipt
|
||||
或既有 Orca terminal 当作自动复用授权;每次自动派发都创建 fresh worker。原因和
|
||||
边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
||||
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。历史 receipt 或既有 Orca
|
||||
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
|
||||
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
|
||||
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
|
||||
原因和边界见 `model-routing.md` §「Receipt、审计与复用边界」。
|
||||
|
||||
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
|
||||
不能用于匹配或复用旧 receipt / 既有终端:
|
||||
@@ -115,14 +136,14 @@ receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该
|
||||
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
|
||||
dispatch。后续
|
||||
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
|
||||
receipt 仅用于审计,不能在后续轮次自动复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||
receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行拼装 Agent CLI 或 Orca 的
|
||||
底层创建命令。
|
||||
|
||||
新 worktree 时可先运行
|
||||
`orca worktree create --name <feature> --base-branch <base> --json`,再对返回的绝对
|
||||
worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher
|
||||
前,先把新
|
||||
路径加入 `project.orchestration.allowedWorktrees` 并重新运行任务板校验。profile
|
||||
前,确认新 worktree 与权威项目根同一 Git 仓库即可(v0.19 起不再需要登记
|
||||
`allowedWorktrees`)。profile
|
||||
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
|
||||
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
|
||||
选型与升级见 `model-routing.md`。
|
||||
@@ -132,13 +153,14 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调
|
||||
## 第 4 步:跑闭环(每个任务)
|
||||
|
||||
```text
|
||||
task-create → dispatch 给 DEV → 等 worker_done
|
||||
→ 每个角色都通过 plan + expected fingerprint launch fresh worker 并写回 receipt
|
||||
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 → dispatch 给 TEST 复测 → 等 retest_result
|
||||
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
|
||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||
→ Coordinator 读证据终检 → 过则 verified,不过则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||
→ 三轮失败:leftover,升级复盘,继续下一个
|
||||
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
||||
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
|
||||
```
|
||||
|
||||
具体命令见 `orca-adapter.md`(Orca)或 `closed-loop.md` §「手动模式」(无 Orca);派发文案见 `prompt-templates.md`。
|
||||
@@ -153,16 +175,32 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
||||
|
||||
## 第 5 步:可选交付
|
||||
|
||||
用户说「重新布测试环境」时加载 deployer skill 执行
|
||||
`intents.testEnvironment` 绑定;「发布一个版本」时按 `delivery.md` §3.1 的
|
||||
release profile 执行。intent 为 null 时先做交付配置维护。
|
||||
|
||||
所选任务 `verified` 后,按 `regression.md` 把本轮黑盒路径收获进
|
||||
`.pouch/ack/regression.yaml`(新增/更新/退役/无回归四选一,用户确认后写入)。
|
||||
用户说「回归」时先布测试环境,再派 Test 按目录执行。
|
||||
|
||||
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
||||
`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和
|
||||
`delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`,
|
||||
用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和
|
||||
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
|
||||
`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和
|
||||
production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把
|
||||
`tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。
|
||||
前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在
|
||||
approval 步骤再次确认。失败时保留任务的 `verified`,把
|
||||
delivery run 标为 `blocked` 或 `failed`。
|
||||
|
||||
## 第 6 步:收尾
|
||||
|
||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:
|
||||
哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||
Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先 dry-run
|
||||
审阅决策、再 `--apply` 关闭所有只关联 `verified` 任务的 Developer/Test 终端并核对
|
||||
回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、`leftover`
|
||||
或其它未完成任务/未解决环境事件的终端保留,不设置 TTL。一个终端只要仍被任一这类
|
||||
任务引用,就不能因为同时关联了 verified 任务而关闭。
|
||||
|
||||
---
|
||||
|
||||
@@ -171,5 +209,6 @@ delivery run 标为 `blocked` 或 `failed`。
|
||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
||||
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
||||
`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile
|
||||
到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收
|
||||
仅属于 verified 任务的 worker,保留 blocked/failed/leftover worker。
|
||||
|
||||
@@ -57,9 +57,8 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),
|
||||
- 低档位反复产出表面修复。
|
||||
|
||||
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
|
||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。
|
||||
v0.10 不自动复用持久化 receipt 指向的旧终端,Test 也不得使用 Developer 的强档
|
||||
worker。
|
||||
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。模型或
|
||||
profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强档 worker。
|
||||
|
||||
---
|
||||
|
||||
@@ -67,34 +66,47 @@ worker。
|
||||
|
||||
### 机器事实源
|
||||
|
||||
worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
||||
worker 路由的机器可读事实只保存在 `.pouch/ack/tasks.yaml` 的
|
||||
`project.orchestration`。`project.md` 可以解释项目为何选某个档位,但不能另写一份
|
||||
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 为准。
|
||||
|
||||
每个 profile 明确声明:
|
||||
|
||||
- `role`:`developer` 或 `test`;
|
||||
- `cli`:受支持的 Agent CLI;
|
||||
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`);
|
||||
- `tier`:角色模型档位;
|
||||
- `model`:项目 allowlist 中的精确模型 ID;
|
||||
- `reasoningEffort`:CLI 支持时显式声明;
|
||||
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
|
||||
|
||||
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
||||
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
||||
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
||||
`project.orchestration` 必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名
|
||||
`profiles` 和角色 `defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。
|
||||
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。
|
||||
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
|
||||
- `full-access`、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
|
||||
`--permission-mode bypassPermissions`、关闭 sandbox 等模式在 v0.10
|
||||
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
||||
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
|
||||
它不关闭 `--sandbox`。
|
||||
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
|
||||
审批模式,规则层直接允许并默认启用(workspace-write);它不授予
|
||||
full-access,不用于其它 CLI,也不等同于 Grok `--yolo`。
|
||||
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
|
||||
launcher。
|
||||
|
||||
@@ -123,9 +135,15 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||
```
|
||||
|
||||
`--project-root` 始终指向保存权威 `docs/ack/tasks.yaml` 的项目根;`--worktree` 是本次
|
||||
worker 实际工作的绝对路径,两者可以不同。后者仍必须属于同一项目并命中
|
||||
`project.orchestration.allowedWorktrees`。
|
||||
`--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`、任务板内容摘要和 worker worktree identity 都会进入 launch fingerprint,
|
||||
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
|
||||
|
||||
- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。
|
||||
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
|
||||
@@ -148,11 +166,18 @@ 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`、`GIT_*` 和调用者 PATH 均不会
|
||||
透传给 worker。
|
||||
`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。终端创建后,父进程先
|
||||
@@ -206,10 +231,13 @@ receipt 至少把以下事实绑定在一起:
|
||||
|
||||
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
|
||||
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
|
||||
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 v0.10
|
||||
明确禁止根据持久化 receipt 自动复用既有终端:每次需要自动派发 worker,都重新走
|
||||
`plan` → 带 expected fingerprint 的 `launch`,只使用该次 launcher 标准输出中的
|
||||
fresh handle 完成本次派发。
|
||||
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
|
||||
@@ -218,9 +246,10 @@ fresh handle 完成本次派发。
|
||||
提供,而不是把 checksum 冒充成一次性令牌。
|
||||
|
||||
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
|
||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。未来只有
|
||||
在 Orca/ACP 提供启动参数 attestation,或存在项目外可信签发与校验通道后,才开放
|
||||
自动复用。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。只有
|
||||
Orca/ACP 同时提供启动参数 attestation、明确空闲状态、可信历史清理和新会话身份,或
|
||||
ACK 接入等价的项目外可信签发与校验通道,才实际启用自动复用。当前 Orca 不满足这些
|
||||
条件,所以仍创建 fresh worker。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
|
||||
|
||||
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
|
||||
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
|
||||
|
||||
@@ -62,7 +62,54 @@ Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它
|
||||
|
||||
---
|
||||
|
||||
## 4. 三轮失败策略(SSOT)
|
||||
## 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 三轮有效产品失败
|
||||
|
||||
每个任务最多自动派发三轮:
|
||||
|
||||
@@ -73,7 +120,9 @@ round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
|
||||
failed after round 3: 标记 leftover,继续下一个任务
|
||||
```
|
||||
|
||||
三轮失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、Worker 对问题模型理解错了、UI 自动化与实际浏览器状态有差异、需要人工观察或调试工具介入。
|
||||
三轮有效产品失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、
|
||||
Worker 对问题模型理解错了,或需要人工观察和专项调试。环境事件数量不受三轮预算限制,
|
||||
但必须有界恢复和透明报告,不能无限重试。
|
||||
|
||||
留档字段(结构见 `templates/tasks.schema.json`):
|
||||
|
||||
@@ -127,14 +176,18 @@ one dispatch = one bug = one acceptance path
|
||||
|
||||
## 8. 优先让测试可执行化
|
||||
|
||||
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。这类可执行测试由 Test 拥有并维护(见 `roles-and-permissions.md` 权限表的 `<integration_test_paths>`)。优先级:
|
||||
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。黑盒回归目录是
|
||||
`.pouch/ack/regression.yaml`(见 `regression.md`);可执行脚本仍由 Test 维护在
|
||||
`<integration_test_paths>`。优先级:
|
||||
|
||||
1. API smoke。
|
||||
2. 浏览器脚本或 case 文档。
|
||||
3. 单元测试。
|
||||
4. 人工检查清单。
|
||||
1. 把本轮验收信号收获进 `regression.yaml`。
|
||||
2. API smoke。
|
||||
3. 浏览器脚本或 `automationRef`。
|
||||
4. 单元测试。
|
||||
5. 人工检查清单。
|
||||
|
||||
目标不是全部自动化,而是把最容易反复误判的路径自动化。
|
||||
目标不是全部自动化,而是把最容易反复误判的路径变成可重复跑的回归用例。任务
|
||||
`verified` 后必须给出新增/更新/退役/无回归四选一。
|
||||
|
||||
---
|
||||
|
||||
@@ -180,6 +233,7 @@ Developer 回报实际采用的 `knowledgeApplied` 和带当前观测证据的
|
||||
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
|
||||
- 当前工作树有哪些未提交改动?
|
||||
- 是否还有 open / failed_retest 未处理?
|
||||
- 本轮有哪些环境事件?是否已解决?用户下一步是“无需操作”还是一个明确动作?
|
||||
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`?
|
||||
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
|
||||
|
||||
|
||||
@@ -24,27 +24,39 @@ orca orchestration inbox --limit 20 --json
|
||||
```
|
||||
|
||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
|
||||
的活跃编排任务。Developer/Test worker 先按下节检查安全复用候选;当前 Orca 无可信
|
||||
历史清理能力时必须通过本次 launcher 新建。
|
||||
|
||||
---
|
||||
|
||||
## 既有 worker 的处理
|
||||
|
||||
v0.10 **不自动复用任何既有终端**。`receiptHash` 是无密钥 checksum,Orca 当前的
|
||||
terminal metadata 也不包含原始 Agent argv、模型或权限 attestation;两者即使完全
|
||||
匹配,仍不足以证明旧终端由可信 launcher 以目标 profile 创建。标题、preview、分支
|
||||
名或 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。
|
||||
|
||||
| 情况 | 动作 |
|
||||
|------|------|
|
||||
| 历史 receipt 与 live metadata 看似完全匹配 | 只作审计,不自动复用;重新 launch |
|
||||
| 没有 receipt,或只有同名终端 | 不复用;重新 launch |
|
||||
| 同轮空闲,身份完全匹配,历史消息清理得到可信回执 | 生成新的 task/attempt 绑定后复用 |
|
||||
| worker 正在工作、等待回报、状态不明或关联未完成任务 | 不复用;保留原终端并重新 launch |
|
||||
| profile、角色、worktree、runtime/incarnation 任一不匹配 | 不复用;重新 launch |
|
||||
| 无历史消息清理能力,或无法确认清理成功 | 不复用;重新 launch |
|
||||
| 需要并发 worker | 使用下一 `slot` 重新 launch |
|
||||
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
|
||||
|
||||
只有刚刚成功返回的 `launch` 标准输出中的 fresh handle,才可用于紧随其后的本次
|
||||
dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项目外可信签发
|
||||
通道,再单独设计自动复用;不能用项目文档声明跳过这条限制。
|
||||
当前 Orca terminal API 没有返回新会话身份的可信历史消息清理操作,因此当前适配器
|
||||
不会复用 Orca worker;仍只使用刚刚成功返回的 `launch` fresh handle。以后后端补齐
|
||||
reset/attestation 后才能启用上述复用路径,不能用项目文档声明跳过条件。
|
||||
|
||||
---
|
||||
|
||||
@@ -96,9 +108,9 @@ profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 f
|
||||
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
|
||||
`model-routing.md`。
|
||||
|
||||
需要隔离/并行时,可以先用 Orca 创建 worktree。Coordinator 必须先把返回的绝对
|
||||
路径加入 `project.orchestration.allowedWorktrees` 并重新校验 `tasks.yaml`,然后才在
|
||||
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
|
||||
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
|
||||
fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
||||
|
||||
---
|
||||
@@ -114,8 +126,9 @@ Developer: owns implementation and white-box verification.
|
||||
Test: owns independent black-box retest and evidence (verifier != implementer).
|
||||
|
||||
Policy:
|
||||
- Each issue can be dispatched at most 3 rounds.
|
||||
- If still failing after 3 rounds, record as leftover and continue next issue.
|
||||
- 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 tasks.yaml and knowledge.yaml.
|
||||
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
|
||||
EOF
|
||||
@@ -191,18 +204,45 @@ EOF
|
||||
|
||||
---
|
||||
|
||||
## 等待结果
|
||||
## 等待结果:派发后的活性监督
|
||||
|
||||
派发或手动投递后**不能只依赖 `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 秒一次):
|
||||
|
||||
```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–90 秒)而不是 15 分钟:窗口超时是检查点,先 probe
|
||||
再决定继续等待、恢复或上报。
|
||||
|
||||
```bash
|
||||
orca orchestration check \
|
||||
--terminal <coordinator_handle> \
|
||||
--wait \
|
||||
--types worker_done,retest_result,escalation,decision_gate \
|
||||
--timeout-ms 900000 \
|
||||
--timeout-ms 90000 \
|
||||
--json
|
||||
```
|
||||
|
||||
等待超时不等于失败。长任务可继续等待,或检查 worker 终端活性。`worker_done` 来自 Developer,`retest_result`(无该类型时用 `worker_done` + subject 区分)来自 Test。
|
||||
`worker_done` 来自 Developer,`retest_result`(无该类型时用 `worker_done` + subject
|
||||
区分)来自 Test。
|
||||
|
||||
---
|
||||
|
||||
@@ -274,4 +314,36 @@ orca orchestration send \
|
||||
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
|
||||
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
|
||||
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
|
||||
写 `verified`,不过写 `failed_retest`。
|
||||
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
|
||||
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
|
||||
|
||||
---
|
||||
|
||||
## 整轮结束时回收 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 只在
|
||||
后续任务得到验证或用户明确要求清理时回收。
|
||||
|
||||
@@ -58,7 +58,7 @@ Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发
|
||||
- 不要写 tasks.yaml,不要标记 verified。
|
||||
- 不要写 knowledge.yaml,不要自行扩展或全量读取知识库;candidate 不是已生效规则。
|
||||
- 不要把知识正文或 path/args 拼成 shell 命令。只把 verification.ref 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`。
|
||||
- 不要提交或推送,除非用户明确要求。
|
||||
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
|
||||
@@ -128,15 +128,18 @@ Developer 本轮声称(仅供参考,不作数):
|
||||
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
||||
|
||||
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
||||
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
|
||||
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 deployer 绑定拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
|
||||
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
||||
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
||||
1. <observable signal 1>
|
||||
2. <observable signal 2>
|
||||
3. <observable signal 3>
|
||||
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
||||
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
||||
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
||||
证据,则分别列出产品信号与环境限制。
|
||||
- 需要时把本轮通过的黑盒路径写成 `regressionCandidates`(见 regression.md),不要直接改 `.pouch/ack/regression.yaml`。
|
||||
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
|
||||
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
|
||||
对 candidate 使用独立观测验证,不能复述 Developer 的结论作为证据。
|
||||
|
||||
@@ -212,6 +215,16 @@ 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>
|
||||
@@ -227,6 +240,7 @@ knowledgeCandidates:
|
||||
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>
|
||||
```
|
||||
|
||||
@@ -245,6 +259,9 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
||||
已验证(Test 复测通过 + 终检):
|
||||
- <task_id>: <what passed>
|
||||
|
||||
环境事件:
|
||||
- <task_id>: <原因、影响、是否恢复、Coordinator 下一步、userAction>
|
||||
|
||||
遗留:
|
||||
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
|
||||
|
||||
@@ -256,8 +273,12 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
||||
- 新增或更新:<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 / review_ready|released|blocked|failed>
|
||||
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
||||
- PR/MR:<URL and head/base>
|
||||
- 产物:<reference, digest, destination>
|
||||
- 部署:<environment, health/rollback evidence>
|
||||
@@ -267,3 +288,28 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
||||
- <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` 声明,运行证据写在
|
||||
`tasks.yaml.regressionRuns`。新项目初始化会生成空目录;旧项目没有该文件时,
|
||||
「运行回归」先停下来,用户授权后再从模板补齐。
|
||||
|
||||
只有 Coordinator 写 `regression.yaml`。Test 在复测报告里提名
|
||||
`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
|
||||
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
|
||||
`.pouch/ack/`。
|
||||
|
||||
`tests/browser/**` 仍可由 Test 维护可执行脚本。用例可用可选 `automationRef`
|
||||
指向那些脚本;没有脚本时 Test worker 按目录里的步骤和验收信号执行。
|
||||
|
||||
## 2. 用例
|
||||
|
||||
每条用例是给独立 Test worker 的说明书,复用
|
||||
`optimization-method.md` §1 的可观测信号。ACK 不提供 YAML-DSL 解释器,也不强制
|
||||
Playwright。
|
||||
|
||||
读取时用 `scripts/select_regression.py`,不要把完整目录注入上下文:
|
||||
|
||||
```bash
|
||||
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml
|
||||
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
|
||||
--suite full
|
||||
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
|
||||
--case-id REG-login-001
|
||||
```
|
||||
|
||||
默认 `--suite smoke` 只返回 `status: active` 且 `suite: smoke` 的用例。
|
||||
`--suite full` 返回全部 active 用例。写回前仍运行
|
||||
`scripts/validate_regression.py`。
|
||||
|
||||
`surface: browser` 的用例必须走真实页面交互,不能改成只打 API。
|
||||
`surface: api` 以 HTTP 状态和字段为证据。
|
||||
|
||||
## 3. 收获
|
||||
|
||||
任务进入 `verified` 且改了用户可见行为或 API 后,闭环结束前 Coordinator 必须给出
|
||||
四选一,不能跳过:
|
||||
|
||||
- 新增
|
||||
- 更新已有用例
|
||||
- 退役(功能已删除)
|
||||
- 无回归(纯内部重构、leftover、无用户可见变化)
|
||||
|
||||
草案来自任务验收信号和 Test 的独立证据,按 `path + expected` 去重。用户确认后
|
||||
才写入目录。P0 / 主路径默认 `suite: smoke`,其余 `full`。
|
||||
|
||||
## 4. 运行
|
||||
|
||||
用户说「回归」「跑回归测试」时执行本模式,不要求当前有 `verified` 任务。
|
||||
|
||||
1. 目录存在且有选中的 active 用例;否则停止。
|
||||
2. 先按「运行测试环境」调用 deployer,拿到 Base URL。
|
||||
3. Coordinator 派独立 Test worker,只带选中用例、Base URL 和 surface。
|
||||
4. Test 逐条对照 `expected` 回报证据。Coordinator 终检后写入 `regressionRuns`。
|
||||
5. 失败只报告,不自动派 Developer,不占任务三轮预算。用户明确要求修复时,按
|
||||
「修 bug」为每条失败开一个任务。
|
||||
|
||||
环境失败(没有浏览器、打不开部署地址、deployer 失败)记环境事件,不记产品失败。
|
||||
|
||||
默认跑 smoke。用户点名 full 或具体 case id 时按点名范围执行。
|
||||
@@ -12,7 +12,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
||||
|
||||
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
||||
|------|----------|----------|------------|
|
||||
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
||||
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
||||
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
||||
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
||||
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||
@@ -27,7 +27,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
||||
|
||||
上面的表定义了**边界**(谁能碰什么),这一节定义**能力**(每个角色到底该怎么做好自己的事)。每个角色用同一骨架描述:`Outcome`(产出什么)/ `Must Do`(必须做)/ `Must Not`(不能做)/ `Evidence`(拿什么证明)/ `Output`(交付格式)。派发 prompt 会引用这里,见 `prompt-templates.md`。
|
||||
|
||||
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `docs/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
|
||||
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `.pouch/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
|
||||
|
||||
### Coordinator (PM):拆解与终检
|
||||
|
||||
@@ -45,7 +45,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
||||
`dispatchId` 代替。
|
||||
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks` 和
|
||||
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
|
||||
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮。
|
||||
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮有效
|
||||
产品复验。环境失败单独记录、恢复和报告,不占轮次。
|
||||
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
|
||||
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行。
|
||||
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
|
||||
@@ -96,7 +97,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
||||
|
||||
## 路径权限模板
|
||||
|
||||
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `docs/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
|
||||
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `.pouch/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
|
||||
|
||||
| 路径 | Coordinator | Test | Developer | 说明 |
|
||||
|------|:-----------:|:----:|:---------:|------|
|
||||
@@ -109,6 +110,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
||||
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||
| `tasks.yaml` | 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 | 声明项目交付能力,不保存凭据或执行授权 |
|
||||
|
||||
---
|
||||
@@ -131,6 +133,16 @@ 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 写入 `tasks.yaml`,来源不同):
|
||||
|
||||
| 状态 | 依据来源 | 含义 |
|
||||
@@ -139,12 +151,12 @@ failed_retest(累计 3 轮) -> leftover
|
||||
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
|
||||
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
|
||||
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
|
||||
| `failed_retest` | Test 的复测报告 | 复测失败,可继续派发 Developer |
|
||||
| `failed_retest` | Test 的产品复测报告 | 环境对齐后观察到产品验收失败,可继续派发 Developer |
|
||||
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
|
||||
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
|
||||
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
|
||||
|
||||
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
||||
有效复验与环境失败的处理细则见 `optimization-method.md` §4。
|
||||
|
||||
## 交付状态(与任务状态正交)
|
||||
|
||||
@@ -152,11 +164,12 @@ failed_retest(累计 3 轮) -> leftover
|
||||
`tasks.yaml.deliveryRuns`:
|
||||
|
||||
```text
|
||||
planned -> running -> review_ready | released
|
||||
planned -> running -> validation_ready | review_ready | released
|
||||
-> blocked | failed
|
||||
planned -> skipped
|
||||
```
|
||||
|
||||
`validation_ready` 表示开发/测试环境已部署且健康检查通过,等待用户手工验证;
|
||||
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
|
||||
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
|
||||
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
|
||||
@@ -166,11 +179,12 @@ planned -> skipped
|
||||
|
||||
## 项目状态写入约定(并发安全)
|
||||
|
||||
`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源,
|
||||
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
||||
`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识
|
||||
事实源,`regression.yaml` 是黑盒回归用例事实源,`delivery.yaml` 是项目交付能力
|
||||
事实源。为避免多 Agent 并发写冲突:
|
||||
|
||||
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
||||
对它们都是只读的。
|
||||
- **只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`**。
|
||||
Test 与 Developer 对它们都是只读的。
|
||||
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
||||
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
|
||||
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
|
||||
|
||||
@@ -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
@@ -39,8 +39,10 @@ if str(SCRIPT_DIR) not in sys.path:
|
||||
|
||||
from validate_tasks import load_document, validate_builtin # noqa: E402
|
||||
from worker_profiles import ( # noqa: E402
|
||||
GROK_EXECUTABLE_NAME_RE,
|
||||
LAUNCH_PROTOCOL_VERSION,
|
||||
canonical_sha256,
|
||||
executable_basename_matches_cli,
|
||||
profile_hash,
|
||||
render_worker_argv,
|
||||
validate_routing_document,
|
||||
@@ -49,7 +51,7 @@ from worker_profiles import ( # noqa: E402
|
||||
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
||||
RECEIPT_VERSION = 1
|
||||
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
||||
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
|
||||
TASKS_RELATIVE_PATH = Path(".pouch/ack/tasks.yaml")
|
||||
MAX_CONTROL_OUTPUT = 1024 * 1024
|
||||
MAX_RECORD_SIZE = 256 * 1024
|
||||
LAUNCH_TTL_SECONDS = 120
|
||||
@@ -81,6 +83,14 @@ WORKER_ENVIRONMENT_NAMES = CONTROL_ENVIRONMENT_NAMES | {
|
||||
WORKER_CREDENTIAL_NAMES = {
|
||||
"codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}),
|
||||
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
||||
"grok": frozenset({"XAI_API_KEY"}),
|
||||
"omp": frozenset({"OPENCODE_API_KEY"}),
|
||||
}
|
||||
CLI_TITLE_LABELS = {
|
||||
"codex": "CODEX",
|
||||
"cursor-agent": "CURSOR",
|
||||
"grok": "GROK",
|
||||
"omp": "OMP",
|
||||
}
|
||||
INHERITED_ENVIRONMENT_PREFIXES = (
|
||||
"LC_",
|
||||
@@ -141,6 +151,7 @@ def trusted_path_entries() -> list[Path]:
|
||||
candidates = [
|
||||
home / ".local" / "bin",
|
||||
home / ".local" / "share" / "mise" / "shims",
|
||||
home / ".local" / "share" / "mise" / "installs" / "github-can1357-oh-my-pi" / "latest",
|
||||
home / ".cargo" / "bin",
|
||||
Path("/home/linuxbrew/.linuxbrew/bin"),
|
||||
Path("/usr/local/go/bin"),
|
||||
@@ -260,21 +271,44 @@ def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]:
|
||||
if errors:
|
||||
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
|
||||
raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}")
|
||||
|
||||
project = board.get("project")
|
||||
repo_path = project.get("repoPath") if isinstance(project, dict) else None
|
||||
if not isinstance(repo_path, str):
|
||||
raise LaunchError("project.repoPath 必须配置为项目根绝对路径")
|
||||
configured_root = canonical_directory(repo_path, "project.repoPath")
|
||||
if configured_root != project_root:
|
||||
raise LaunchError(
|
||||
f"project.repoPath 与 --project-root 不一致: {configured_root}"
|
||||
)
|
||||
return project_root, board
|
||||
|
||||
|
||||
def _is_under_real_grok_home(resolved: Path) -> bool:
|
||||
"""Return True when ``resolved`` is under a non-symlink ``$HOME/.grok``."""
|
||||
|
||||
home, _ = account_identity()
|
||||
grok_root = home / ".grok"
|
||||
try:
|
||||
root_metadata = os.lstat(grok_root)
|
||||
if stat.S_ISLNK(root_metadata.st_mode) or not stat.S_ISDIR(root_metadata.st_mode):
|
||||
return False
|
||||
grok_root.resolve(strict=True)
|
||||
resolved.relative_to(grok_root.resolve(strict=True))
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_trusted_grok_executable(resolved: Path, metadata: os.stat_result) -> bool:
|
||||
"""Accept Grok's vendor artifact names under ``~/.grok``, or a 0755 ``grok``."""
|
||||
|
||||
if GROK_EXECUTABLE_NAME_RE.fullmatch(resolved.name) is None:
|
||||
return False
|
||||
if not executable_basename_matches_cli(str(resolved), "grok"):
|
||||
return False
|
||||
mode = stat.S_IMODE(metadata.st_mode)
|
||||
if mode & 0o002:
|
||||
return False
|
||||
if resolved.name == "grok" and not (mode & 0o020):
|
||||
return True
|
||||
if not _is_under_real_grok_home(resolved):
|
||||
return False
|
||||
return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid()
|
||||
|
||||
|
||||
def resolve_executable(name: str) -> Path:
|
||||
supported = {"codex", "cursor-agent", "git", "orca"}
|
||||
supported = {"codex", "cursor-agent", "grok", "omp", "git", "orca"}
|
||||
if name not in supported:
|
||||
raise LaunchError(f"不支持的可执行文件: {name}")
|
||||
search_paths = trusted_path_entries()
|
||||
@@ -299,6 +333,10 @@ def resolve_executable(name: str) -> Path:
|
||||
continue
|
||||
if metadata.st_uid not in {0, os.getuid()}:
|
||||
continue
|
||||
if name == "grok":
|
||||
if _is_trusted_grok_executable(resolved, metadata):
|
||||
return resolved
|
||||
continue
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
continue
|
||||
if resolved.name != name:
|
||||
@@ -519,20 +557,13 @@ def assert_git_control_entry(worktree: Path) -> None:
|
||||
def capture_worktree_identity(
|
||||
project_root: Path,
|
||||
worktree_value: str,
|
||||
allowed_worktrees: object,
|
||||
) -> dict:
|
||||
if not isinstance(allowed_worktrees, list):
|
||||
raise LaunchError("project.orchestration.allowedWorktrees 必须是列表")
|
||||
# allowedWorktrees 白名单已废弃(v0.19 起)。worktree 合法性由以下硬约束保证:
|
||||
# 1) 必须是 Git worktree 根目录(不是任意子目录);
|
||||
# 2) 必须出现在 `git worktree list` 注册表中;
|
||||
# 3) 必须与 --project-root 属于同一 Git common-dir(同仓库)。
|
||||
# worker 默认就在 --project-root(权威状态目录)工作,天然满足这些约束。
|
||||
worktree = canonical_directory(worktree_value, "worker worktree")
|
||||
configured_paths: list[Path] = []
|
||||
for index, configured in enumerate(allowed_worktrees):
|
||||
if not isinstance(configured, str):
|
||||
raise LaunchError(f"allowedWorktrees[{index}] 必须是字符串")
|
||||
configured_paths.append(
|
||||
canonical_directory(configured, f"allowedWorktrees[{index}]")
|
||||
)
|
||||
if worktree not in configured_paths:
|
||||
raise LaunchError(f"worker worktree 不在 allowedWorktrees 中: {worktree}")
|
||||
|
||||
git = resolve_executable("git")
|
||||
assert_git_control_entry(project_root)
|
||||
@@ -641,6 +672,7 @@ def build_plan(
|
||||
raise LaunchError("slot 必须是 1..99 的整数")
|
||||
|
||||
project_root, board = load_authoritative_board(project_root_value)
|
||||
board_hash = canonical_sha256(board)
|
||||
find_task(board, task_id)
|
||||
project = board["project"]
|
||||
orchestration = project.get("orchestration")
|
||||
@@ -660,7 +692,6 @@ def build_plan(
|
||||
worktree = capture_worktree_identity(
|
||||
project_root,
|
||||
worktree_value,
|
||||
orchestration.get("allowedWorktrees"),
|
||||
)
|
||||
executable = resolve_executable(str(profile["cli"]))
|
||||
executable_stat = executable.stat()
|
||||
@@ -696,6 +727,8 @@ def build_plan(
|
||||
{
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(project_root),
|
||||
"boardHash": board_hash,
|
||||
"profileId": profile_id,
|
||||
"profileHash": current_profile_hash,
|
||||
"createdFor": created_for,
|
||||
@@ -704,7 +737,10 @@ def build_plan(
|
||||
"slot": slot,
|
||||
}
|
||||
)
|
||||
cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR"
|
||||
try:
|
||||
cli_label = CLI_TITLE_LABELS[str(profile["cli"])]
|
||||
except KeyError as exc:
|
||||
raise LaunchError(f"不支持的 worker CLI: {profile['cli']}") from exc
|
||||
role_label = "DEV" if role == "developer" else "TEST"
|
||||
digest_short = launch_fingerprint.split(":", 1)[-1][:10]
|
||||
title = (
|
||||
@@ -715,6 +751,7 @@ def build_plan(
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"projectRoot": str(project_root),
|
||||
"boardHash": board_hash,
|
||||
"taskId": task_id,
|
||||
"attemptId": attempt_id,
|
||||
"role": role,
|
||||
@@ -1077,6 +1114,8 @@ def build_receipt(
|
||||
"profileId": plan["profileId"],
|
||||
"profileHash": plan["profileHash"],
|
||||
"launchFingerprint": plan["launchFingerprint"],
|
||||
"projectRoot": plan["projectRoot"],
|
||||
"boardHash": plan["boardHash"],
|
||||
"slot": plan["slot"],
|
||||
"createdFor": {
|
||||
"taskId": plan["taskId"],
|
||||
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/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.
|
||||
|
||||
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"}
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
show = run_json(
|
||||
[str(orca), "terminal", "show", "--terminal", handle, "--json"],
|
||||
"terminal show",
|
||||
)
|
||||
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 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(默认全部)")
|
||||
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
|
||||
|
||||
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())
|
||||
@@ -57,7 +57,7 @@ def _validate_knowledge_location(
|
||||
knowledge_path: Path,
|
||||
project_root: Path,
|
||||
) -> str | None:
|
||||
expected = project_root / "docs" / "ack" / "knowledge.yaml"
|
||||
expected = project_root / ".pouch" / "ack" / "knowledge.yaml"
|
||||
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
|
||||
if lexical != expected:
|
||||
return (
|
||||
@@ -171,7 +171,7 @@ def load_authoritative_knowledge(
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
source_fd, open_error = _open_regular_beneath(
|
||||
project_root,
|
||||
"docs/ack/knowledge.yaml",
|
||||
".pouch/ack/knowledge.yaml",
|
||||
require_executable=False,
|
||||
)
|
||||
if open_error is not None or source_fd is None:
|
||||
@@ -329,7 +329,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
help="项目根目录;默认从 knowledge.yaml 的 docs/ack 布局或 Git 推断",
|
||||
help="项目根目录;默认从 knowledge.yaml 的 .pouch/ack 布局或 Git 推断",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""选择 ACK 回归目录中的 active 用例。
|
||||
|
||||
默认返回 smoke 套件。本脚本只输出数据,不执行 steps 或 automationRef。
|
||||
|
||||
用法:
|
||||
python3 select_regression.py .pouch/ack/regression.yaml
|
||||
python3 select_regression.py .pouch/ack/regression.yaml --suite full
|
||||
python3 select_regression.py .pouch/ack/regression.yaml --case-id REG-login-001
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from validate_regression import ID_RE, load_yaml, validate_builtin
|
||||
|
||||
DEFAULT_LIMIT = 50
|
||||
MAX_LIMIT = 200
|
||||
|
||||
|
||||
def select_cases(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
suite: str,
|
||||
case_ids: list[str],
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
cases = data.get("cases")
|
||||
if not isinstance(cases, list):
|
||||
raise ValueError("cases 必须是列表")
|
||||
|
||||
wanted = set(case_ids)
|
||||
selected: list[dict[str, Any]] = []
|
||||
for case in cases:
|
||||
if not isinstance(case, dict):
|
||||
continue
|
||||
if case.get("status") != "active":
|
||||
continue
|
||||
case_id = case.get("id")
|
||||
if wanted:
|
||||
if case_id not in wanted:
|
||||
continue
|
||||
elif suite == "smoke" and case.get("suite") != "smoke":
|
||||
continue
|
||||
selected.append(case)
|
||||
|
||||
missing = wanted - {
|
||||
case.get("id") for case in selected if isinstance(case.get("id"), str)
|
||||
}
|
||||
if missing:
|
||||
raise ValueError("找不到 active 用例: " + ", ".join(sorted(missing)))
|
||||
if len(selected) > limit:
|
||||
raise ValueError(
|
||||
f"命中 {len(selected)} 条,超过 --limit {limit};"
|
||||
"请用 --suite / --case-id 缩小范围"
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="选择 ACK 回归用例")
|
||||
parser.add_argument(
|
||||
"regression", nargs="?", default=".pouch/ack/regression.yaml"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--suite",
|
||||
choices=("smoke", "full"),
|
||||
default="smoke",
|
||||
help="smoke 只返回 suite=smoke;full 返回全部 active 用例",
|
||||
)
|
||||
parser.add_argument("--case-id", action="append", default=[], dest="case_ids")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||
parser.add_argument(
|
||||
"--format", choices=("json", "ids"), default="json", dest="output_format"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
regression_path = Path(args.regression)
|
||||
if not regression_path.is_file():
|
||||
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
|
||||
return 2
|
||||
if not 1 <= args.limit <= MAX_LIMIT:
|
||||
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
|
||||
return 2
|
||||
for case_id in args.case_ids:
|
||||
if ID_RE.fullmatch(case_id) is None:
|
||||
sys.stderr.write(f"--case-id 必须使用 REG-<id> 格式: {case_id}\n")
|
||||
return 2
|
||||
|
||||
data = load_yaml(regression_path, "回归目录")
|
||||
errors = validate_builtin(data)
|
||||
if errors:
|
||||
sys.stderr.write(f"回归目录无效,拒绝选择,共 {len(errors)} 项:\n")
|
||||
for error in errors:
|
||||
sys.stderr.write(f" - {error}\n")
|
||||
return 1
|
||||
|
||||
try:
|
||||
selected = select_cases(
|
||||
data,
|
||||
suite=args.suite,
|
||||
case_ids=args.case_ids,
|
||||
limit=args.limit,
|
||||
)
|
||||
except ValueError as exc:
|
||||
sys.stderr.write(f"回归选择失败: {exc}\n")
|
||||
return 1
|
||||
|
||||
ids = [case.get("id") for case in selected]
|
||||
if args.output_format == "ids":
|
||||
if ids:
|
||||
sys.stdout.write("\n".join(str(item) for item in ids) + "\n")
|
||||
return 0
|
||||
|
||||
payload = {
|
||||
"count": len(selected),
|
||||
"limit": args.limit,
|
||||
"suite": args.suite,
|
||||
"ids": ids,
|
||||
"cases": selected,
|
||||
}
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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())
|
||||
@@ -5,9 +5,9 @@
|
||||
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
|
||||
|
||||
用法:
|
||||
python3 validate_delivery.py docs/ack/delivery.yaml
|
||||
python3 validate_delivery.py docs/ack/delivery.yaml \
|
||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||
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 环境或用法错误。
|
||||
"""
|
||||
@@ -43,6 +43,7 @@ TOP_LEVEL_FIELDS = {
|
||||
"project",
|
||||
"enabled",
|
||||
"defaultProfile",
|
||||
"intents",
|
||||
"entrypoints",
|
||||
"artifacts",
|
||||
"destinations",
|
||||
@@ -98,7 +99,12 @@ 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", "review_ready", "released"}
|
||||
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",
|
||||
@@ -506,6 +512,7 @@ def _validate_profiles(
|
||||
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
|
||||
@@ -610,6 +617,8 @@ def _validate_profiles(
|
||||
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"}:
|
||||
@@ -635,10 +644,21 @@ def _validate_profiles(
|
||||
)
|
||||
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 != "review_ready":
|
||||
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
|
||||
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
|
||||
@@ -661,6 +681,79 @@ def _validate_profiles(
|
||||
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)
|
||||
@@ -706,6 +799,7 @@ def validate_builtin(data: dict[str, Any], project_root: Path | None = None) ->
|
||||
environments=environments,
|
||||
errors=errors,
|
||||
)
|
||||
_validate_intents(data.get("intents"), profiles, errors, project_root)
|
||||
|
||||
if enabled:
|
||||
if default_profile not in profiles:
|
||||
@@ -724,8 +818,8 @@ def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list
|
||||
project = tasks.get("project")
|
||||
if not isinstance(project, dict):
|
||||
return ["tasks.project 必须是对象"]
|
||||
if project.get("deliveryFile") != "docs/ack/delivery.yaml":
|
||||
errors.append("tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml")
|
||||
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)
|
||||
@@ -753,8 +847,8 @@ def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
|
||||
parser.add_argument("delivery", nargs="?", default="docs/ack/delivery.yaml")
|
||||
parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml")
|
||||
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)
|
||||
|
||||
@@ -189,10 +189,10 @@ def _timestamp(value: Any) -> datetime | None:
|
||||
|
||||
|
||||
def infer_project_root(document_path: Path) -> Path | None:
|
||||
"""从标准 docs/ack 布局或 Git marker 推断项目根,不解析文档 symlink。"""
|
||||
"""从标准 .pouch/ack 布局或 Git marker 推断项目根,不解析文档 symlink。"""
|
||||
lexical = document_path.expanduser().absolute()
|
||||
parent = lexical.parent
|
||||
if parent.name == "ack" and parent.parent.name == "docs":
|
||||
if parent.name == "ack" and parent.parent.name in {".pouch", "docs"}:
|
||||
return parent.parent.parent.resolve()
|
||||
for candidate in (parent, *parent.parents):
|
||||
if (candidate / ".git").exists():
|
||||
@@ -240,20 +240,24 @@ def _validate_registry_target(
|
||||
def _tasks_project_root(
|
||||
tasks_data: dict[str, Any], tasks_path: Path | None
|
||||
) -> Path | None:
|
||||
inferred = infer_project_root(tasks_path) if tasks_path else None
|
||||
if inferred is not None and inferred.is_dir():
|
||||
return inferred
|
||||
|
||||
# Legacy task boards may still declare repoPath. It is only a fallback for
|
||||
# non-standard layouts; .pouch/ack location is authoritative when available.
|
||||
project = tasks_data.get("project")
|
||||
repo_path = project.get("repoPath") if isinstance(project, dict) else None
|
||||
if _nonempty(repo_path):
|
||||
candidate = Path(repo_path).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
inferred = infer_project_root(tasks_path) if tasks_path else None
|
||||
if inferred is None:
|
||||
return None
|
||||
candidate = inferred / candidate
|
||||
if not candidate.is_dir():
|
||||
return None
|
||||
return candidate.resolve(strict=True)
|
||||
inferred = infer_project_root(tasks_path) if tasks_path else None
|
||||
return inferred if inferred is not None and inferred.is_dir() else None
|
||||
return None
|
||||
|
||||
|
||||
def _unknown_keys(value: dict[str, Any], allowed: set[str], where: str) -> list[str]:
|
||||
@@ -775,10 +779,10 @@ def _validate_knowledge_file_binding(
|
||||
knowledge_file = project.get("knowledgeFile")
|
||||
if not _nonempty(knowledge_file):
|
||||
return ["[tasks] project.knowledgeFile 必填"]
|
||||
if knowledge_file != "docs/ack/knowledge.yaml":
|
||||
if knowledge_file != ".pouch/ack/knowledge.yaml":
|
||||
errors.append(
|
||||
"[tasks] project.knowledgeFile 必须固定为 "
|
||||
"'docs/ack/knowledge.yaml'"
|
||||
"'.pouch/ack/knowledge.yaml'"
|
||||
)
|
||||
relative = Path(knowledge_file)
|
||||
segments = knowledge_file.replace("\\", "/").split("/")
|
||||
@@ -808,8 +812,8 @@ def _validate_knowledge_file_binding(
|
||||
binding_root = project_root or declared_root
|
||||
if binding_root is None or not binding_root.is_dir():
|
||||
errors.append(
|
||||
"[tasks] 无法确定现有项目根目录;请修正 project.repoPath "
|
||||
"或传入 --project-root"
|
||||
"[tasks] 无法从 .pouch/ack 布局确定现有项目根目录;"
|
||||
"请传入 --project-root"
|
||||
)
|
||||
return errors
|
||||
try:
|
||||
@@ -1067,7 +1071,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--tasks", help="可选 tasks.yaml,用于跨文件引用校验")
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
help="可选项目根目录;默认从 tasks.project.repoPath 或 docs/ack 布局推断",
|
||||
help="可选项目根目录;默认从 tasks.yaml 的 .pouch/ack 布局推断",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/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 yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
load_json_unique,
|
||||
load_yaml_subset,
|
||||
make_unique_pyyaml_loader,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if path.suffix.lower() == ".json":
|
||||
try:
|
||||
data = load_json_unique(content)
|
||||
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
||||
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
data = load_yaml_subset(content)
|
||||
except YamlSubsetError as exc:
|
||||
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
try:
|
||||
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
|
||||
except yaml.YAMLError as exc: # type: ignore
|
||||
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
|
||||
raise SystemExit(1)
|
||||
return data
|
||||
|
||||
|
||||
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())
|
||||
@@ -24,6 +24,7 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from approval_payload import approval_payload_hash
|
||||
from yaml_subset import (
|
||||
DuplicateKeyError,
|
||||
YamlSubsetError,
|
||||
@@ -50,6 +51,8 @@ KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
REGRESSION_RUN_ID_RE = re.compile(r"^RR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
REGRESSION_CASE_ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
||||
SEMVER_RE = re.compile(
|
||||
@@ -102,17 +105,83 @@ DELIVERY_RUN_FIELDS = {
|
||||
"evidence",
|
||||
"updatedAt",
|
||||
}
|
||||
DELIVERY_RUN_OPTIONAL_FIELDS = {"intent"}
|
||||
DELIVERY_RUN_INTENTS = {"testEnvironment", "release"}
|
||||
DELIVERY_STATUSES = {
|
||||
"planned",
|
||||
"running",
|
||||
"blocked",
|
||||
"failed",
|
||||
"validation_ready",
|
||||
"review_ready",
|
||||
"released",
|
||||
"skipped",
|
||||
}
|
||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||
REGRESSION_RUN_FIELDS = {
|
||||
"id",
|
||||
"suite",
|
||||
"caseIds",
|
||||
"status",
|
||||
"sourceRevision",
|
||||
"configRevision",
|
||||
"baseUrl",
|
||||
"results",
|
||||
"evidence",
|
||||
"updatedAt",
|
||||
}
|
||||
REGRESSION_RUN_OPTIONAL_FIELDS = {"deliveryRunId", "taskIds"}
|
||||
REGRESSION_RUN_SUITES = {"smoke", "full", "custom"}
|
||||
REGRESSION_RUN_STATUSES = {
|
||||
"planned",
|
||||
"running",
|
||||
"passed",
|
||||
"failed",
|
||||
"blocked",
|
||||
"skipped",
|
||||
}
|
||||
REGRESSION_RESULT_FIELDS = {"caseId", "result", "evidence"}
|
||||
REGRESSION_RESULTS = {"pass", "fail", "skipped"}
|
||||
REGRESSION_CANDIDATE_FIELDS = {
|
||||
"id",
|
||||
"title",
|
||||
"surface",
|
||||
"suite",
|
||||
"setup",
|
||||
"steps",
|
||||
"expected",
|
||||
"sourceKind",
|
||||
}
|
||||
REGRESSION_CANDIDATE_REQUIRED_FIELDS = {"title", "surface", "steps", "expected"}
|
||||
REGRESSION_SURFACES = {"browser", "api"}
|
||||
REGRESSION_SUITES = {"smoke", "full"}
|
||||
REGRESSION_SOURCE_KINDS = {"feature", "bug"}
|
||||
REGRESSION_EXPECTED_KINDS = {
|
||||
"visible-text",
|
||||
"api-status",
|
||||
"api-field",
|
||||
"url",
|
||||
"interaction",
|
||||
}
|
||||
FEISHU_REQUIRED_FIELDS = {
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||
"attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_OPTIONAL_FIELDS = {"priority", "fixLogic"}
|
||||
FEISHU_CLARIFIED_FIELDS = {
|
||||
"title", "details", "problemStatement", "expectedOutcome", "acceptance",
|
||||
"intakeStatus", "ackTaskId", "attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||
FEISHU_SOURCE_FIELDS = {
|
||||
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
|
||||
"approvedPayloadHash",
|
||||
}
|
||||
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
DISPATCH_FIELDS = {
|
||||
"taskId",
|
||||
"dispatchId",
|
||||
@@ -120,7 +189,34 @@ DISPATCH_FIELDS = {
|
||||
"developer",
|
||||
"test",
|
||||
"rounds",
|
||||
"environmentIncidents",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_FIELDS = {
|
||||
"id",
|
||||
"attemptId",
|
||||
"role",
|
||||
"phase",
|
||||
"status",
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt",
|
||||
"resolvedAt",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_ROLES = {"coordinator", "developer", "test"}
|
||||
ENVIRONMENT_INCIDENT_PHASES = {
|
||||
"launch",
|
||||
"orchestration",
|
||||
"service",
|
||||
"test_data",
|
||||
"browser",
|
||||
"tooling",
|
||||
"permissions",
|
||||
"other",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_STATUSES = {"open", "resolved"}
|
||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||
"kind",
|
||||
"title",
|
||||
@@ -434,7 +530,9 @@ def validate_delivery_runs(
|
||||
if not isinstance(run, dict):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(run, DELIVERY_RUN_FIELDS, where, errors)
|
||||
reject_unknown_fields(
|
||||
run, DELIVERY_RUN_FIELDS | DELIVERY_RUN_OPTIONAL_FIELDS, where, errors
|
||||
)
|
||||
missing = sorted(DELIVERY_RUN_FIELDS - set(run))
|
||||
for field in missing:
|
||||
errors.append(f"{where}.{field}: 必填")
|
||||
@@ -454,13 +552,23 @@ def validate_delivery_runs(
|
||||
if status not in DELIVERY_STATUSES:
|
||||
errors.append(f"{where}.status: 必须是 {sorted(DELIVERY_STATUSES)}")
|
||||
|
||||
intent = run.get("intent")
|
||||
if "intent" in run and intent not in DELIVERY_RUN_INTENTS:
|
||||
errors.append(
|
||||
f"{where}.intent: 必须是 {sorted(DELIVERY_RUN_INTENTS)}"
|
||||
)
|
||||
task_ids = run.get("taskIds")
|
||||
allow_empty_tasks = intent in DELIVERY_RUN_INTENTS
|
||||
if (
|
||||
not isinstance(task_ids, list)
|
||||
or not task_ids
|
||||
or any(not _nonempty_string(task_id) for task_id in task_ids)
|
||||
or (not task_ids and not allow_empty_tasks)
|
||||
or any(not _nonempty_string(task_id) for task_id in (task_ids or []))
|
||||
):
|
||||
errors.append(f"{where}.taskIds: 必须是非空任务 ID 列表")
|
||||
errors.append(
|
||||
f"{where}.taskIds: 必须是任务 ID 列表"
|
||||
if allow_empty_tasks
|
||||
else f"{where}.taskIds: 必须是非空任务 ID 列表"
|
||||
)
|
||||
task_ids = []
|
||||
elif len(task_ids) != len(set(task_ids)):
|
||||
errors.append(f"{where}.taskIds: 不能包含重复值")
|
||||
@@ -526,7 +634,7 @@ def validate_delivery_runs(
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
|
||||
):
|
||||
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
|
||||
if status in {"review_ready", "released"} and not _nonempty_string(digest):
|
||||
if status in {"validation_ready", "review_ready", "released"} and not _nonempty_string(digest):
|
||||
errors.append(
|
||||
f"{artifact_where}.digest: status={status!r} 时必须填写"
|
||||
)
|
||||
@@ -569,12 +677,211 @@ def validate_delivery_runs(
|
||||
not _nonempty_string(item) for item in evidence
|
||||
):
|
||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||
elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence:
|
||||
elif status in {"blocked", "failed", "validation_ready", "review_ready", "released", "skipped"} and not evidence:
|
||||
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
|
||||
if not _nonempty_string(run.get("updatedAt")):
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_regression_fields(task: dict, where: str, errors: list[str]) -> None:
|
||||
if "regressionRefs" in task:
|
||||
refs = task["regressionRefs"]
|
||||
if not isinstance(refs, list):
|
||||
errors.append(f"{where}.regressionRefs: 必须是列表")
|
||||
else:
|
||||
seen: set[str] = set()
|
||||
for index, ref in enumerate(refs):
|
||||
item_where = f"{where}.regressionRefs[{index}]"
|
||||
if not isinstance(ref, str) or REGRESSION_CASE_ID_RE.fullmatch(ref) is None:
|
||||
errors.append(f"{item_where}: 必须使用 REG-<id> 格式")
|
||||
continue
|
||||
if ref in seen:
|
||||
errors.append(f"{item_where}: 不能重复 {ref!r}")
|
||||
seen.add(ref)
|
||||
|
||||
if "regressionCandidates" not in task:
|
||||
return
|
||||
candidates = task["regressionCandidates"]
|
||||
if not isinstance(candidates, list):
|
||||
errors.append(f"{where}.regressionCandidates: 必须是列表")
|
||||
return
|
||||
for index, candidate in enumerate(candidates):
|
||||
item_where = f"{where}.regressionCandidates[{index}]"
|
||||
if not isinstance(candidate, dict):
|
||||
errors.append(f"{item_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
candidate, REGRESSION_CANDIDATE_FIELDS, item_where, errors
|
||||
)
|
||||
missing = sorted(REGRESSION_CANDIDATE_REQUIRED_FIELDS - set(candidate))
|
||||
for field in missing:
|
||||
errors.append(f"{item_where}.{field}: 必填")
|
||||
if "id" in candidate and (
|
||||
not isinstance(candidate.get("id"), str)
|
||||
or REGRESSION_CASE_ID_RE.fullmatch(candidate["id"]) is None
|
||||
):
|
||||
errors.append(f"{item_where}.id: 必须使用 REG-<id> 格式")
|
||||
if not _nonempty_string(candidate.get("title")):
|
||||
errors.append(f"{item_where}.title: 必须是非空字符串")
|
||||
if candidate.get("surface") not in REGRESSION_SURFACES:
|
||||
errors.append(f"{item_where}.surface: 必须是 {sorted(REGRESSION_SURFACES)}")
|
||||
if "suite" in candidate and candidate.get("suite") not in REGRESSION_SUITES:
|
||||
errors.append(f"{item_where}.suite: 必须是 {sorted(REGRESSION_SUITES)}")
|
||||
if "sourceKind" in candidate and (
|
||||
candidate.get("sourceKind") not in REGRESSION_SOURCE_KINDS
|
||||
):
|
||||
errors.append(
|
||||
f"{item_where}.sourceKind: 必须是 {sorted(REGRESSION_SOURCE_KINDS)}"
|
||||
)
|
||||
steps = candidate.get("steps")
|
||||
if (
|
||||
not isinstance(steps, list)
|
||||
or not steps
|
||||
or any(not _nonempty_string(step) for step in steps)
|
||||
):
|
||||
errors.append(f"{item_where}.steps: 必须是非空字符串列表")
|
||||
expected = candidate.get("expected")
|
||||
if not isinstance(expected, list) or not expected:
|
||||
errors.append(f"{item_where}.expected: 必须是非空列表")
|
||||
continue
|
||||
for expected_index, item in enumerate(expected):
|
||||
expected_where = f"{item_where}.expected[{expected_index}]"
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"{expected_where}: 必须是对象")
|
||||
continue
|
||||
if item.get("kind") not in REGRESSION_EXPECTED_KINDS:
|
||||
errors.append(
|
||||
f"{expected_where}.kind: 必须是 {sorted(REGRESSION_EXPECTED_KINDS)}"
|
||||
)
|
||||
if not _nonempty_string(item.get("value")):
|
||||
errors.append(f"{expected_where}.value: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_regression_runs(value: object, errors: list[str]) -> None:
|
||||
if not isinstance(value, list):
|
||||
errors.append("regressionRuns 必须是列表")
|
||||
return
|
||||
|
||||
seen_run_ids: set[str] = set()
|
||||
for index, run in enumerate(value):
|
||||
where = f"regressionRuns[{index}]"
|
||||
if not isinstance(run, dict):
|
||||
errors.append(f"{where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
run,
|
||||
REGRESSION_RUN_FIELDS | REGRESSION_RUN_OPTIONAL_FIELDS,
|
||||
where,
|
||||
errors,
|
||||
)
|
||||
missing = sorted(REGRESSION_RUN_FIELDS - set(run))
|
||||
for field in missing:
|
||||
errors.append(f"{where}.{field}: 必填")
|
||||
|
||||
run_id = run.get("id")
|
||||
if not isinstance(run_id, str) or REGRESSION_RUN_ID_RE.fullmatch(run_id) is None:
|
||||
errors.append(f"{where}.id: 必须使用 RR-<id> 格式")
|
||||
elif run_id in seen_run_ids:
|
||||
errors.append(f"{where}.id: 不能重复 {run_id!r}")
|
||||
else:
|
||||
seen_run_ids.add(run_id)
|
||||
|
||||
if run.get("suite") not in REGRESSION_RUN_SUITES:
|
||||
errors.append(f"{where}.suite: 必须是 {sorted(REGRESSION_RUN_SUITES)}")
|
||||
status = run.get("status")
|
||||
if status not in REGRESSION_RUN_STATUSES:
|
||||
errors.append(f"{where}.status: 必须是 {sorted(REGRESSION_RUN_STATUSES)}")
|
||||
|
||||
case_ids = run.get("caseIds")
|
||||
if (
|
||||
not isinstance(case_ids, list)
|
||||
or not case_ids
|
||||
or any(
|
||||
not isinstance(case_id, str)
|
||||
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||
for case_id in case_ids
|
||||
)
|
||||
):
|
||||
errors.append(f"{where}.caseIds: 必须是非空 REG-<id> 列表")
|
||||
case_ids = []
|
||||
elif len(case_ids) != len(set(case_ids)):
|
||||
errors.append(f"{where}.caseIds: 不能包含重复值")
|
||||
|
||||
task_ids = run.get("taskIds")
|
||||
if task_ids is not None and (
|
||||
not isinstance(task_ids, list)
|
||||
or any(not _nonempty_string(task_id) for task_id in task_ids)
|
||||
):
|
||||
errors.append(f"{where}.taskIds: 必须是任务 ID 列表")
|
||||
delivery_run_id = run.get("deliveryRunId")
|
||||
if delivery_run_id is not None and (
|
||||
not isinstance(delivery_run_id, str)
|
||||
or DELIVERY_RUN_ID_RE.fullmatch(delivery_run_id) is None
|
||||
):
|
||||
errors.append(f"{where}.deliveryRunId: 必须使用 DR-<id> 格式")
|
||||
|
||||
for field in ("sourceRevision", "configRevision"):
|
||||
revision = run.get(field)
|
||||
if revision is not None and (
|
||||
not isinstance(revision, str) or GIT_REVISION_RE.fullmatch(revision) is None
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.{field}: 必须是 null 或 7..64 位小写十六进制 revision"
|
||||
)
|
||||
if status != "skipped":
|
||||
for field in ("sourceRevision", "configRevision", "baseUrl"):
|
||||
if not _nonempty_string(run.get(field)):
|
||||
errors.append(f"{where}.{field}: status={status!r} 时必须填写")
|
||||
|
||||
results = run.get("results")
|
||||
if not isinstance(results, list):
|
||||
errors.append(f"{where}.results: 必须是列表")
|
||||
results = []
|
||||
elif status in {"passed", "failed"} and not results:
|
||||
errors.append(f"{where}.results: status={status!r} 时不能为空")
|
||||
seen_results: set[str] = set()
|
||||
for result_index, result in enumerate(results):
|
||||
result_where = f"{where}.results[{result_index}]"
|
||||
if not isinstance(result, dict):
|
||||
errors.append(f"{result_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(result, REGRESSION_RESULT_FIELDS, result_where, errors)
|
||||
case_id = result.get("caseId")
|
||||
if (
|
||||
not isinstance(case_id, str)
|
||||
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||
):
|
||||
errors.append(f"{result_where}.caseId: 必须使用 REG-<id> 格式")
|
||||
elif case_id in seen_results:
|
||||
errors.append(f"{result_where}.caseId: 不能重复 {case_id!r}")
|
||||
else:
|
||||
seen_results.add(case_id)
|
||||
if case_id not in case_ids:
|
||||
errors.append(f"{result_where}.caseId: 不在 caseIds 中")
|
||||
if result.get("result") not in REGRESSION_RESULTS:
|
||||
errors.append(
|
||||
f"{result_where}.result: 必须是 {sorted(REGRESSION_RESULTS)}"
|
||||
)
|
||||
if not _nonempty_string(result.get("evidence")):
|
||||
errors.append(f"{result_where}.evidence: 必须是非空字符串")
|
||||
if status == "passed" and any(
|
||||
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||
):
|
||||
errors.append(f"{where}: passed 运行不能包含 fail 结果")
|
||||
if status == "failed" and not any(
|
||||
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||
):
|
||||
errors.append(f"{where}: failed 运行必须包含至少一条 fail 结果")
|
||||
|
||||
evidence = run.get("evidence")
|
||||
if not isinstance(evidence, list) or any(
|
||||
not _nonempty_string(item) for item in evidence
|
||||
):
|
||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||
if not _nonempty_string(run.get("updatedAt")):
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
|
||||
|
||||
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||
import jsonschema # type: ignore
|
||||
|
||||
@@ -589,6 +896,7 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||
|
||||
def validate_builtin(data: dict) -> list[str]:
|
||||
errors: list[str] = []
|
||||
project_intake_workflow = "read-only-v1"
|
||||
|
||||
def validate_string_fields(
|
||||
value: dict,
|
||||
@@ -654,24 +962,82 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
||||
"project",
|
||||
)
|
||||
if "bugIntake" in project:
|
||||
intake = project["bugIntake"]
|
||||
if not isinstance(intake, dict):
|
||||
errors.append("project.bugIntake 必须是对象")
|
||||
else:
|
||||
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
|
||||
if intake.get("provider") != "feishu-base":
|
||||
errors.append("project.bugIntake.provider 必须是 feishu-base")
|
||||
workflow = intake.get("workflow", "read-only-v1")
|
||||
if workflow in FEISHU_WORKFLOWS:
|
||||
project_intake_workflow = workflow
|
||||
if workflow not in FEISHU_WORKFLOWS:
|
||||
errors.append("project.bugIntake.workflow 非法")
|
||||
profile = intake.get("profile")
|
||||
if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None:
|
||||
errors.append("project.bugIntake.profile 非法")
|
||||
for key in ("baseToken", "tableId", "viewId"):
|
||||
value = intake.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
||||
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
||||
fields = intake.get("fields")
|
||||
expected_fields = (
|
||||
FEISHU_CLARIFIED_FIELDS
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else FEISHU_REQUIRED_FIELDS
|
||||
)
|
||||
allowed_fields = expected_fields | (
|
||||
set() if workflow == "clarified-writeback-v1" else FEISHU_OPTIONAL_FIELDS
|
||||
)
|
||||
if (
|
||||
not isinstance(fields, dict)
|
||||
or not expected_fields.issubset(fields)
|
||||
or not set(fields).issubset(allowed_fields)
|
||||
):
|
||||
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
|
||||
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
|
||||
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
|
||||
elif len(set(fields.values())) != len(fields):
|
||||
errors.append("project.bugIntake.fields 字段值不能重复")
|
||||
elif workflow == "reviewed-writeback-v1" and not {
|
||||
"fixLogic", "priority"
|
||||
}.issubset(fields):
|
||||
errors.append(
|
||||
"reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic 和 priority"
|
||||
)
|
||||
if (
|
||||
"knowledgeFile" in project
|
||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||
and project.get("knowledgeFile") != ".pouch/ack/knowledge.yaml"
|
||||
):
|
||||
errors.append(
|
||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
||||
"project.knowledgeFile 必须固定为 .pouch/ack/knowledge.yaml"
|
||||
)
|
||||
if (
|
||||
"deliveryFile" in project
|
||||
and project.get("deliveryFile") != "docs/ack/delivery.yaml"
|
||||
and project.get("deliveryFile") != ".pouch/ack/delivery.yaml"
|
||||
):
|
||||
errors.append(
|
||||
"project.deliveryFile 必须固定为 docs/ack/delivery.yaml"
|
||||
"project.deliveryFile 必须固定为 .pouch/ack/delivery.yaml"
|
||||
)
|
||||
if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list):
|
||||
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||
if "deliveryRuns" in data and "deliveryFile" not in project:
|
||||
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
||||
if (
|
||||
"regressionFile" in project
|
||||
and project.get("regressionFile") != ".pouch/ack/regression.yaml"
|
||||
):
|
||||
errors.append(
|
||||
"project.regressionFile 必须固定为 .pouch/ack/regression.yaml"
|
||||
)
|
||||
if "regressionFile" in project and not isinstance(
|
||||
data.get("regressionRuns"), list
|
||||
):
|
||||
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
|
||||
if "regressionRuns" in data and "regressionFile" not in project:
|
||||
errors.append("regressionRuns 存在时 project.regressionFile 必须存在")
|
||||
|
||||
ack_version = data.get("ackVersion")
|
||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||
@@ -710,6 +1076,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
return errors
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
seen_source_refs: set[str] = set()
|
||||
for i, task in enumerate(tasks):
|
||||
where = f"tasks[{i}]"
|
||||
if not isinstance(task, dict):
|
||||
@@ -744,6 +1111,7 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
"assignee",
|
||||
"component",
|
||||
"description",
|
||||
"fixLogic",
|
||||
"expected",
|
||||
"actual",
|
||||
},
|
||||
@@ -751,12 +1119,88 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
)
|
||||
validate_string_lists(
|
||||
task,
|
||||
{"specRefs", "testRefs", "stepsToReproduce"},
|
||||
{"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"},
|
||||
where,
|
||||
)
|
||||
validate_object_fields(task, {"evidence", "verification"}, where)
|
||||
|
||||
if "source" in task:
|
||||
source = task["source"]
|
||||
# `source` was historically an open extension point. Preserve
|
||||
# non-Feishu strings/objects and tighten only the namespaced shape.
|
||||
if isinstance(source, dict) and source.get("kind") == "feishu-base":
|
||||
reject_unknown_fields(source, FEISHU_SOURCE_FIELDS, f"{where}.source", errors)
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or FEISHU_SOURCE_REF_RE.fullmatch(ref) is None:
|
||||
errors.append(f"{where}.source.ref: 必须是不透明 feishu-base SHA-256 引用")
|
||||
else:
|
||||
if ref in seen_source_refs:
|
||||
errors.append(f"{where}.source.ref: 来源引用重复")
|
||||
seen_source_refs.add(ref)
|
||||
record_id = source.get("recordId")
|
||||
if not isinstance(record_id, str) or FEISHU_RECORD_ID_RE.fullmatch(record_id) is None:
|
||||
errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID")
|
||||
if not _nonempty_string(source.get("updatedAt")):
|
||||
errors.append(f"{where}.source.updatedAt: 必须是非空字符串")
|
||||
source_workflow = source.get("workflow", "read-only-v1")
|
||||
if source_workflow not in FEISHU_WORKFLOWS:
|
||||
errors.append(f"{where}.source.workflow: 非法")
|
||||
if (
|
||||
project_intake_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
and source_workflow != project_intake_workflow
|
||||
and status not in {"verified", "leftover"}
|
||||
):
|
||||
errors.append(
|
||||
f"{where}.source.workflow: reviewed 项目的可执行飞书任务必须先迁移审核"
|
||||
)
|
||||
approved_revision = source.get("approvedRevision")
|
||||
stored_payload_hash = source.get("approvedPayloadHash")
|
||||
is_approved_workflow = source_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
if is_approved_workflow and approved_revision is None:
|
||||
errors.append(f"{where}.source.approvedRevision: writeback workflow 必填")
|
||||
elif approved_revision is not None and (
|
||||
not isinstance(approved_revision, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
|
||||
):
|
||||
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
|
||||
if is_approved_workflow:
|
||||
if (
|
||||
not isinstance(stored_payload_hash, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
|
||||
):
|
||||
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
|
||||
required_strings = (
|
||||
("title", "description", "actual", "expected")
|
||||
if source_workflow == "clarified-writeback-v1"
|
||||
else ("title", "priority", "actual", "expected", "fixLogic")
|
||||
)
|
||||
for field in required_strings:
|
||||
if not _nonempty_string(task.get(field)):
|
||||
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
|
||||
required_lists = (
|
||||
("acceptanceCriteria",)
|
||||
if source_workflow == "clarified-writeback-v1"
|
||||
else ("stepsToReproduce", "acceptanceCriteria")
|
||||
)
|
||||
for field in required_lists:
|
||||
items = task.get(field)
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or not items
|
||||
or any(not _nonempty_string(item) for item in items)
|
||||
):
|
||||
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串列表")
|
||||
if (
|
||||
isinstance(stored_payload_hash, str)
|
||||
and re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash)
|
||||
and stored_payload_hash != approval_payload_hash(task)
|
||||
):
|
||||
errors.append(f"{where}.source.approvedPayloadHash: 与任务审核字段不匹配")
|
||||
elif stored_payload_hash is not None:
|
||||
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
||||
|
||||
validate_knowledge_fields(task, where, status, errors)
|
||||
validate_regression_fields(task, where, errors)
|
||||
|
||||
if "dispatch" not in task:
|
||||
dispatch = {}
|
||||
@@ -843,6 +1287,72 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复"
|
||||
)
|
||||
|
||||
incidents = dispatch.get("environmentIncidents", [])
|
||||
if not isinstance(incidents, list):
|
||||
errors.append(f"{where}.dispatch.environmentIncidents: 必须是列表")
|
||||
else:
|
||||
seen_incident_ids: set[str] = set()
|
||||
for incident_index, incident in enumerate(incidents):
|
||||
incident_where = (
|
||||
f"{where}.dispatch.environmentIncidents[{incident_index}]"
|
||||
)
|
||||
if not isinstance(incident, dict):
|
||||
errors.append(f"{incident_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
incident,
|
||||
ENVIRONMENT_INCIDENT_FIELDS,
|
||||
incident_where,
|
||||
errors,
|
||||
)
|
||||
incident_id = incident.get("id")
|
||||
expected_id = (
|
||||
f"{tid}-ENV-{incident_index + 1}"
|
||||
if isinstance(tid, str)
|
||||
else None
|
||||
)
|
||||
if not isinstance(incident_id, str) or incident_id != expected_id:
|
||||
errors.append(f"{incident_where}.id: 应为 {expected_id}")
|
||||
elif incident_id in seen_incident_ids:
|
||||
errors.append(f"{incident_where}.id: 不能重复 {incident_id}")
|
||||
else:
|
||||
seen_incident_ids.add(incident_id)
|
||||
if incident.get("role") not in ENVIRONMENT_INCIDENT_ROLES:
|
||||
errors.append(
|
||||
f"{incident_where}.role: 必须是 coordinator/developer/test"
|
||||
)
|
||||
if incident.get("phase") not in ENVIRONMENT_INCIDENT_PHASES:
|
||||
errors.append(f"{incident_where}.phase: 非法环境阶段")
|
||||
incident_status = incident.get("status")
|
||||
if incident_status not in ENVIRONMENT_INCIDENT_STATUSES:
|
||||
errors.append(f"{incident_where}.status: 必须是 open/resolved")
|
||||
for field in (
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt",
|
||||
):
|
||||
if not _nonempty_string(incident.get(field)):
|
||||
errors.append(f"{incident_where}.{field}: 必须是非空字符串")
|
||||
if "attemptId" in incident and not (
|
||||
incident["attemptId"] is None
|
||||
or _nonempty_string(incident["attemptId"])
|
||||
):
|
||||
errors.append(f"{incident_where}.attemptId: 必须是字符串或 null")
|
||||
if "resolvedAt" in incident and not (
|
||||
incident["resolvedAt"] is None
|
||||
or _nonempty_string(incident["resolvedAt"])
|
||||
):
|
||||
errors.append(f"{incident_where}.resolvedAt: 必须是字符串或 null")
|
||||
if incident_status == "resolved" and not _nonempty_string(
|
||||
incident.get("resolvedAt")
|
||||
):
|
||||
errors.append(
|
||||
f"{incident_where}: resolved 必须填写 resolvedAt"
|
||||
)
|
||||
|
||||
resolution = task.get("resolution")
|
||||
if "resolution" in task:
|
||||
if not isinstance(resolution, dict):
|
||||
@@ -879,6 +1389,8 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||
}
|
||||
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
||||
if "regressionRuns" in data:
|
||||
validate_regression_runs(data["regressionRuns"], errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
|
||||
MIGRATION_MESSAGE = (
|
||||
"ACK v0.10 已停用自由 worker command 校验器;请在 "
|
||||
"docs/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
|
||||
".pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
|
||||
"scripts/launch_worker.py profile-hash|plan|launch。"
|
||||
)
|
||||
|
||||
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/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",
|
||||
"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 pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from launch_worker import ( # noqa: E402
|
||||
LaunchError,
|
||||
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
|
||||
|
||||
|
||||
def classify(tail: str | list[str], heartbeat: object) -> dict[str, object]:
|
||||
if isinstance(tail, list):
|
||||
tail = "\n".join(tail)
|
||||
for label, pattern in STALL_PATTERNS:
|
||||
if pattern.search(tail):
|
||||
return {
|
||||
"status": "stall",
|
||||
"stallReason": label,
|
||||
"heartbeatAt": heartbeat,
|
||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
||||
}
|
||||
if heartbeat:
|
||||
return {
|
||||
"status": "progress",
|
||||
"stallReason": None,
|
||||
"heartbeatAt": heartbeat,
|
||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
||||
}
|
||||
if WORKING_PATTERN.search(tail):
|
||||
return {
|
||||
"status": "running",
|
||||
"stallReason": None,
|
||||
"heartbeatAt": None,
|
||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
||||
}
|
||||
# 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 {
|
||||
"status": "not-started",
|
||||
"stallReason": None,
|
||||
"heartbeatAt": None,
|
||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
||||
}
|
||||
return {
|
||||
"status": "unknown",
|
||||
"stallReason": None,
|
||||
"heartbeatAt": None,
|
||||
"evidence": tail[:MAX_EVIDENCE_CHARS],
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
"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())
|
||||
@@ -24,13 +24,31 @@ LAUNCH_PROTOCOL_VERSION = 1
|
||||
MAX_ROUNDS = 3
|
||||
|
||||
ROLES = frozenset({"developer", "test"})
|
||||
CLIS = frozenset({"codex", "cursor-agent"})
|
||||
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp"})
|
||||
TIERS = frozenset({"standard", "strong"})
|
||||
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
||||
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
||||
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
|
||||
CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"})
|
||||
CLI_REQUIRES_NULL_REASONING_EFFORT = frozenset({"cursor-agent"})
|
||||
|
||||
|
||||
def _cli_choice_text() -> str:
|
||||
return "/".join(sorted(CLIS))
|
||||
|
||||
|
||||
def executable_basename_matches_cli(executable: str, cli: str) -> bool:
|
||||
"""Return whether a resolved executable basename is valid for ``cli``."""
|
||||
|
||||
name = Path(executable).name
|
||||
if name == cli:
|
||||
return True
|
||||
return cli == "grok" and GROK_EXECUTABLE_NAME_RE.fullmatch(name) is not None
|
||||
|
||||
# allowedWorktrees 已废弃(v0.19 起):新任务板不再配置,旧任务板遗留该字段仍被容忍。
|
||||
# ORCHESTRATION_FIELDS 是“允许出现”的字段全集(含废弃字段),
|
||||
# REQUIRED_ORCHESTRATION_FIELDS 是“必须存在”的子集(不含废弃字段)。
|
||||
ORCHESTRATION_FIELDS = frozenset(
|
||||
{
|
||||
"profileVersion",
|
||||
@@ -41,6 +59,15 @@ ORCHESTRATION_FIELDS = frozenset(
|
||||
"defaults",
|
||||
}
|
||||
)
|
||||
REQUIRED_ORCHESTRATION_FIELDS = frozenset(
|
||||
{
|
||||
"profileVersion",
|
||||
"mode",
|
||||
"modelAllowlist",
|
||||
"profiles",
|
||||
"defaults",
|
||||
}
|
||||
)
|
||||
PROFILE_FIELDS = frozenset(
|
||||
{
|
||||
"role",
|
||||
@@ -68,6 +95,7 @@ RECEIPT_FIELDS = frozenset(
|
||||
"receiptHash",
|
||||
}
|
||||
)
|
||||
RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"})
|
||||
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
||||
WORKTREE_FIELDS = frozenset(
|
||||
{
|
||||
@@ -113,6 +141,9 @@ ROLE_DISPATCH_FIELDS = frozenset(
|
||||
|
||||
PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
|
||||
MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$")
|
||||
GROK_EXECUTABLE_NAME_RE = re.compile(
|
||||
r"^grok(?:-(?:linux|darwin|windows)-(?:x86_64|aarch64|arm64))?$"
|
||||
)
|
||||
RECEIPT_ID_RE = re.compile(r"^WR-[0-9a-f]{64}$")
|
||||
LAUNCH_ID_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
@@ -213,7 +244,7 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
||||
if not isinstance(role, str) or role not in ROLES:
|
||||
errors.append(f"{where}.role: must be developer/test")
|
||||
if not isinstance(cli, str) or cli not in CLIS:
|
||||
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
||||
errors.append(f"{where}.cli: must be {_cli_choice_text()}")
|
||||
if not isinstance(tier, str) or tier not in TIERS:
|
||||
errors.append(f"{where}.tier: must be standard/strong")
|
||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
||||
@@ -222,13 +253,17 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
||||
errors.append(
|
||||
f"{where}.permissionMode: must be read-only/workspace-write"
|
||||
)
|
||||
|
||||
if cli == "codex":
|
||||
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT:
|
||||
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
||||
label = "Codex" if cli == "codex" else "Grok"
|
||||
errors.append(
|
||||
f"{where}.reasoningEffort: Codex requires low/medium/high/xhigh"
|
||||
f"{where}.reasoningEffort: {label} requires low/medium/high/xhigh"
|
||||
)
|
||||
elif cli == "cursor-agent" and effort is not None:
|
||||
elif (
|
||||
isinstance(cli, str)
|
||||
and cli in CLI_REQUIRES_NULL_REASONING_EFFORT
|
||||
and effort is not None
|
||||
):
|
||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||
|
||||
if role == "test" and tier != "standard":
|
||||
@@ -318,11 +353,10 @@ def validate_orchestration(
|
||||
return [f"{where}: must be an object"]
|
||||
|
||||
errors = _unknown_fields(orchestration, ORCHESTRATION_FIELDS, where)
|
||||
errors.extend(_missing_fields(orchestration, ORCHESTRATION_FIELDS, where))
|
||||
errors.extend(_missing_fields(orchestration, REQUIRED_ORCHESTRATION_FIELDS, where))
|
||||
|
||||
profile_version = orchestration.get("profileVersion")
|
||||
mode = orchestration.get("mode")
|
||||
allowed_worktrees = orchestration.get("allowedWorktrees")
|
||||
allowlist = orchestration.get("modelAllowlist")
|
||||
profiles = orchestration.get("profiles")
|
||||
defaults = orchestration.get("defaults")
|
||||
@@ -332,20 +366,9 @@ def validate_orchestration(
|
||||
if not isinstance(mode, str) or mode not in ORCHESTRATION_MODES:
|
||||
errors.append(f"{where}.mode: must be orca/manual")
|
||||
|
||||
if not isinstance(allowed_worktrees, list):
|
||||
errors.append(f"{where}.allowedWorktrees: must be a list")
|
||||
else:
|
||||
if mode == "orca" and not allowed_worktrees:
|
||||
errors.append(f"{where}.allowedWorktrees: Orca mode requires at least one path")
|
||||
seen_worktrees: set[str] = set()
|
||||
for index, worktree in enumerate(allowed_worktrees):
|
||||
item_where = f"{where}.allowedWorktrees[{index}]"
|
||||
if not _is_absolute_safe_path(worktree):
|
||||
errors.append(f"{item_where}: must be a safe absolute path other than root")
|
||||
elif worktree in seen_worktrees:
|
||||
errors.append(f"{item_where}: duplicate worktree {worktree!r}")
|
||||
else:
|
||||
seen_worktrees.add(worktree)
|
||||
# allowedWorktrees 已废弃:worker 默认在 --project-root 工作,
|
||||
# 其它 worktree 由 launcher 按“同 git 仓库且已注册”约束(capture_worktree_identity)。
|
||||
# 旧任务板中遗留的该字段被容忍,不再校验。
|
||||
|
||||
errors.extend(_validate_model_allowlist(allowlist, f"{where}.modelAllowlist"))
|
||||
|
||||
@@ -446,14 +469,15 @@ def render_worker_argv(
|
||||
raise ValueError("invalid profile: " + "; ".join(errors))
|
||||
if not _is_absolute_safe_path(executable):
|
||||
raise ValueError("executable must be a safe absolute path other than root")
|
||||
if Path(executable).name != profile["cli"]:
|
||||
if not executable_basename_matches_cli(executable, profile["cli"]):
|
||||
raise ValueError("executable basename must match profile.cli")
|
||||
if not _is_absolute_safe_path(worktree):
|
||||
raise ValueError("worktree must be a safe absolute path other than root")
|
||||
|
||||
model = profile["model"]
|
||||
permission = profile["permissionMode"]
|
||||
if profile["cli"] == "codex":
|
||||
cli = profile["cli"]
|
||||
if cli == "codex":
|
||||
return [
|
||||
executable,
|
||||
"--strict-config",
|
||||
@@ -469,13 +493,50 @@ def render_worker_argv(
|
||||
worktree,
|
||||
]
|
||||
|
||||
argv = [executable, "--model", model]
|
||||
if permission == "read-only":
|
||||
argv.extend(["--mode", "plan"])
|
||||
else:
|
||||
argv.append("--auto-review")
|
||||
argv.extend(["--sandbox", "enabled", "--workspace", worktree])
|
||||
return argv
|
||||
if cli == "cursor-agent":
|
||||
argv = [executable, "--model", model]
|
||||
if permission == "read-only":
|
||||
argv.extend(["--mode", "plan"])
|
||||
else:
|
||||
argv.append("--auto-review")
|
||||
argv.extend(["--sandbox", "enabled", "--workspace", worktree])
|
||||
return argv
|
||||
|
||||
if cli == "omp":
|
||||
# OMP workspace-write workers default to yolo approval (rules allow it);
|
||||
# read-only workers always use always-ask.
|
||||
omp_approval = "always-ask" if permission == "read-only" else "yolo"
|
||||
return [
|
||||
executable,
|
||||
"--model",
|
||||
model,
|
||||
"--thinking",
|
||||
profile["reasoningEffort"],
|
||||
"--approval-mode",
|
||||
omp_approval,
|
||||
"--cwd",
|
||||
worktree,
|
||||
"--no-session",
|
||||
]
|
||||
|
||||
if cli != "grok":
|
||||
raise ValueError(f"unsupported cli: {cli}")
|
||||
grok_permission = "plan" if permission == "read-only" else "acceptEdits"
|
||||
grok_sandbox = "read-only" if permission == "read-only" else "workspace"
|
||||
return [
|
||||
executable,
|
||||
"--model",
|
||||
model,
|
||||
"--reasoning-effort",
|
||||
profile["reasoningEffort"],
|
||||
"--permission-mode",
|
||||
grok_permission,
|
||||
"--always-approve",
|
||||
"--sandbox",
|
||||
grok_sandbox,
|
||||
"--cwd",
|
||||
worktree,
|
||||
]
|
||||
|
||||
|
||||
def receipt_hash(receipt: dict[str, Any]) -> str:
|
||||
@@ -532,25 +593,34 @@ def _validate_requested(value: Any, where: str) -> list[str]:
|
||||
tier = value.get("tier")
|
||||
permission = value.get("permissionMode")
|
||||
if not isinstance(cli, str) or cli not in CLIS:
|
||||
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
||||
errors.append(f"{where}.cli: must be {_cli_choice_text()}")
|
||||
if not isinstance(tier, str) or tier not in TIERS:
|
||||
errors.append(f"{where}.tier: must be standard/strong")
|
||||
model = value.get("model")
|
||||
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
|
||||
errors.append(f"{where}.model: must be a safe model ID")
|
||||
effort = value.get("reasoningEffort")
|
||||
if cli == "codex" and (
|
||||
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT and (
|
||||
not isinstance(effort, str) or effort not in REASONING_EFFORTS
|
||||
):
|
||||
errors.append(f"{where}.reasoningEffort: invalid Codex effort")
|
||||
if cli == "cursor-agent" and effort is not None:
|
||||
label = "Codex" if cli == "codex" else "Grok"
|
||||
errors.append(f"{where}.reasoningEffort: invalid {label} effort")
|
||||
if (
|
||||
isinstance(cli, str)
|
||||
and cli in CLI_REQUIRES_NULL_REASONING_EFFORT
|
||||
and effort is not None
|
||||
):
|
||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
|
||||
errors.append(f"{where}.permissionMode: must be read-only/workspace-write")
|
||||
executable = value.get("executable")
|
||||
if not _is_absolute_safe_path(executable):
|
||||
errors.append(f"{where}.executable: must be a safe absolute path")
|
||||
elif isinstance(cli, str) and cli in CLIS and Path(executable).name != cli:
|
||||
elif (
|
||||
isinstance(cli, str)
|
||||
and cli in CLIS
|
||||
and not executable_basename_matches_cli(str(executable), cli)
|
||||
):
|
||||
errors.append(f"{where}.executable: basename must match cli")
|
||||
if not _is_nonnegative_int(value.get("executableDevice")):
|
||||
errors.append(f"{where}.executableDevice: must be a non-negative integer")
|
||||
@@ -613,8 +683,20 @@ def validate_worker_receipt(
|
||||
|
||||
if not isinstance(receipt, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS, where)
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS | RECEIPT_CONTEXT_FIELDS, where)
|
||||
errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where))
|
||||
project_root = receipt.get("projectRoot")
|
||||
board_hash = receipt.get("boardHash")
|
||||
if (project_root is None) != (board_hash is None):
|
||||
errors.append(f"{where}: projectRoot and boardHash must be present together")
|
||||
if project_root is not None and (
|
||||
not isinstance(project_root, str) or not project_root.startswith("/")
|
||||
):
|
||||
errors.append(f"{where}.projectRoot: must be an absolute path")
|
||||
if board_hash is not None and (
|
||||
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
|
||||
):
|
||||
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
|
||||
|
||||
version = receipt.get("receiptVersion")
|
||||
if version != RECEIPT_VERSION or isinstance(version, bool):
|
||||
@@ -675,11 +757,6 @@ def validate_worker_receipt(
|
||||
profile = candidate
|
||||
else:
|
||||
errors.append(f"{where}.profileId: unknown profile {profile_id!r}")
|
||||
allowed = orchestration.get("allowedWorktrees")
|
||||
if isinstance(worktree, dict) and isinstance(allowed, list):
|
||||
if worktree.get("path") not in allowed:
|
||||
errors.append(f"{where}.worktree.path: is not in allowedWorktrees")
|
||||
|
||||
if profile is not None:
|
||||
try:
|
||||
expected_profile_hash = profile_hash(
|
||||
@@ -728,18 +805,20 @@ def validate_worker_receipt(
|
||||
and isinstance(requested, dict)
|
||||
):
|
||||
try:
|
||||
expected_fingerprint = canonical_sha256(
|
||||
{
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
)
|
||||
facts = {
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
if project_root is not None and board_hash is not None:
|
||||
facts["projectRoot"] = project_root
|
||||
facts["boardHash"] = board_hash
|
||||
expected_fingerprint = canonical_sha256(facts)
|
||||
except ValueError:
|
||||
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
||||
else:
|
||||
@@ -981,6 +1060,7 @@ __all__ = [
|
||||
"profile_hash",
|
||||
"receipt_hash",
|
||||
"render_worker_argv",
|
||||
"executable_basename_matches_cli",
|
||||
"validate_profile",
|
||||
"validate_orchestration",
|
||||
"validate_worker_receipt",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/delivery.schema.json",
|
||||
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/delivery.schema.json",
|
||||
"title": "ACK project delivery contract",
|
||||
"description": "docs/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
|
||||
"description": ".pouch/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
@@ -32,6 +32,7 @@
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
},
|
||||
"intents": { "$ref": "#/definitions/intents" },
|
||||
"entrypoints": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/definitions/id" },
|
||||
@@ -63,6 +64,31 @@
|
||||
"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,
|
||||
@@ -216,7 +242,7 @@
|
||||
"properties": {
|
||||
"stopAt": {
|
||||
"type": "string",
|
||||
"enum": ["verified", "review_ready", "released"]
|
||||
"enum": ["verified", "validation_ready", "review_ready", "released"]
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 复制为 docs/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
|
||||
# 复制为 .pouch/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
|
||||
version: 1
|
||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||
project:
|
||||
@@ -7,6 +7,12 @@ project:
|
||||
enabled: false
|
||||
defaultProfile: null
|
||||
|
||||
# 测试环境由 ACK 内部调用 deployer;发版仍指向本文件的 profile。
|
||||
# null 表示用户尚未说明该操作。
|
||||
intents:
|
||||
testEnvironment: null
|
||||
release: null
|
||||
|
||||
# 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。
|
||||
# 不在此保存 shell、env 或凭据正文。
|
||||
entrypoints: {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/knowledge.schema.json",
|
||||
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/knowledge.schema.json",
|
||||
"title": "ACK project knowledge guardrails",
|
||||
"description": "docs/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
|
||||
"description": ".pouch/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
|
||||
"type": "object",
|
||||
"required": ["version", "updatedAt", "project", "verificationRegistry", "entries"],
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
|
||||
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
|
||||
version: 1
|
||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
|
||||
> `references/` 读取;本文件只保存项目差异。
|
||||
>
|
||||
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `docs/ack/project.md`,
|
||||
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `.pouch/ack/project.md`,
|
||||
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
|
||||
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
||||
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的
|
||||
> `delivery.yaml`,
|
||||
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的
|
||||
> `delivery.yaml` 与空的 `regression.yaml`,
|
||||
> 不复制或链接 Skill。
|
||||
|
||||
## 项目概览
|
||||
@@ -18,10 +18,11 @@
|
||||
- 技术栈:`<tech_stack>`
|
||||
- 运行命令:`<run_command>`
|
||||
- Base URL:`<base_url>`
|
||||
- 任务板:`docs/ack/tasks.yaml`
|
||||
- 项目知识:`docs/ack/knowledge.yaml`
|
||||
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
|
||||
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
||||
- 任务板:`.pouch/ack/tasks.yaml`
|
||||
- 项目知识:`.pouch/ack/knowledge.yaml`
|
||||
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
|
||||
- 回归目录:`.pouch/ack/regression.yaml`
|
||||
- 覆盖层文件:`<overlay_file_path>`(默认 `.pouch/ack/project.md`)
|
||||
|
||||
## 通用规范(由 ACK Skill 按需读取)
|
||||
|
||||
@@ -33,11 +34,12 @@
|
||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||
- 回归目录与运行(可选):`references/regression.md`
|
||||
|
||||
## Worker 路由
|
||||
|
||||
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
|
||||
receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
||||
receipt 全部以 `.pouch/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
||||
`workerReceipts` 为准。本文件不保存可执行 worker 命令。
|
||||
|
||||
默认 profile:
|
||||
@@ -48,11 +50,11 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
||||
| Test | `codex-test-standard` | standard |
|
||||
| Developer 升级 | `codex-dev-strong` | strong |
|
||||
|
||||
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||
项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
|
||||
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
|
||||
`--expected-launch-fingerprint` 启动 fresh worker。v0.10 的 `receiptHash` 只作
|
||||
审计 checksum,不授权自动复用旧终端。
|
||||
`--expected-launch-fingerprint` 启动 fresh worker。`receiptHash` 只作审计 checksum,
|
||||
不授权自动复用旧终端;只有同轮空闲、身份匹配且历史消息可信清理后才可复用。
|
||||
|
||||
## 路径权限(项目覆盖层,必须填实际路径)
|
||||
|
||||
@@ -67,6 +69,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
||||
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||
| `tasks.yaml` | 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 | 项目交付能力,不是执行授权 |
|
||||
|
||||
## 命令(项目覆盖层)
|
||||
@@ -79,7 +82,7 @@ Developer 白盒验证:
|
||||
<local_run_command>
|
||||
```
|
||||
|
||||
Test 黑盒复测:
|
||||
Test 黑盒复测(服务启动以 deployer 绑定的 `intents.testEnvironment` 为准):
|
||||
|
||||
```bash
|
||||
<preflight_command>
|
||||
@@ -96,8 +99,10 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
||||
`ACK_PROJECT_ROOT_DISPLAY`。
|
||||
|
||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
||||
构建/发布/部署的机器入口以 `delivery.yaml` 为准;本文件不维护第二套交付命令。
|
||||
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||
`scripts/validate_regression.py` 执行。
|
||||
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
|
||||
测试环境由 ACK 内部调用 deployer。本文件不维护第二套交付命令。
|
||||
|
||||
## 硬规则(其余见 references/)
|
||||
|
||||
@@ -108,18 +113,21 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
||||
等第二配置面。
|
||||
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
|
||||
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
|
||||
- v0.10 不自动复用持久化 receipt 指向的旧终端;每次自动派发都重新 plan/launch。
|
||||
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
||||
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
||||
通过消息回报。
|
||||
- 只有 Coordinator 写 `tasks.yaml`、`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` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
|
||||
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
|
||||
- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有
|
||||
- `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: []
|
||||
+1260
-170
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,38 @@
|
||||
# 复制为 docs/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
|
||||
# 复制为 .pouch/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
|
||||
version: 1
|
||||
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||
source: "Coordinator (PM) Agent"
|
||||
ackVersion: "<接入时的 ack skill 版本>"
|
||||
project:
|
||||
name: "<project_name>"
|
||||
repoPath: "<repo_path>"
|
||||
baseUrl: "<base_url>"
|
||||
devWorktree: "<dev_worktree>"
|
||||
overlayFile: "docs/ack/project.md"
|
||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||
deliveryFile: "docs/ack/delivery.yaml"
|
||||
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:
|
||||
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
|
||||
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
|
||||
profileVersion: 1
|
||||
mode: "orca"
|
||||
allowedWorktrees:
|
||||
- "<dev_worktree>"
|
||||
modelAllowlist:
|
||||
codex:
|
||||
developer:
|
||||
@@ -28,6 +45,12 @@ project:
|
||||
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"
|
||||
@@ -64,6 +87,27 @@ project:
|
||||
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"
|
||||
@@ -71,6 +115,7 @@ project:
|
||||
|
||||
workerReceipts: []
|
||||
deliveryRuns: []
|
||||
regressionRuns: []
|
||||
|
||||
summary:
|
||||
verified: []
|
||||
@@ -94,10 +139,30 @@ tasks:
|
||||
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>"
|
||||
@@ -136,6 +201,8 @@ tasks:
|
||||
taskId: null
|
||||
dispatchId: null
|
||||
rounds: []
|
||||
# 环境失败不占产品复验轮次;按 BUG-001-ENV-1... 连续编号,并写清 userAction。
|
||||
environmentIncidents: []
|
||||
|
||||
resolution:
|
||||
fixedBy: null
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# builder
|
||||
|
||||
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
|
||||
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
||||
可执行校验器。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- "帮我构建这个项目的 DEB / Docker 镜像"
|
||||
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
||||
- "检查这个项目的 Makefile 是否符合 builder 契约"
|
||||
- "看看项目现在的发布流程"
|
||||
|
||||
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
||||
|
||||
## 项目接入契约
|
||||
|
||||
1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version`
|
||||
+ 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。
|
||||
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
|
||||
2. 运行 `python3 -I -S <builder>/scripts/check.py .` 直到全部 PASS。
|
||||
3. 在项目根 `.env` 配置发布环境变量:
|
||||
|
||||
```text
|
||||
DEB_SERVER_URL=https://deb.example.com
|
||||
DEB_REPOSITORY=main
|
||||
DEB_TOKEN=<token> # 只放 .env 或密钥系统,不进 git
|
||||
DOCKER_REGISTRY=registry.example.com
|
||||
```
|
||||
|
||||
4. 日常发布就是两条命令:`make deb && make push-deb`、`make push-docker`。
|
||||
|
||||
## 版本号
|
||||
|
||||
产物版本从 **当前 HEAD 的 Git 祖先** 推导,不调用 manage-release。
|
||||
正式 tag 仍由 manage-release 创建;builder 只读取。完整规则见
|
||||
[contract.md「版本号」](references/contract.md#版本号)。
|
||||
|
||||
| 谁 | 做什么 |
|
||||
| --- | --- |
|
||||
| manage-release | 选定下一个正式 SemVer,打 annotated tag `vX.Y.Z` |
|
||||
| builder | 读 HEAD:落在稳定 tag 上则打正式产物,否则打测试产物 |
|
||||
| `make version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
|
||||
|
||||
**不要**用 `git tag \| sort -V \| tail -1` 取全仓库最大号,也不要把
|
||||
`git describe --dirty` 的原始字符串写进 DEB 或镜像 tag。
|
||||
|
||||
### 正式(HEAD 恰好是稳定 tag `v1.4.2`)
|
||||
|
||||
```text
|
||||
Git tag v1.4.2
|
||||
规范版本 1.4.2
|
||||
DEB foo_1.4.2_amd64.deb Version: 1.4.2
|
||||
Docker registry/ns/foo:1.4.2
|
||||
```
|
||||
|
||||
稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc`、`-app-N` 等后缀。
|
||||
不是 exact-match 就不是正式包。
|
||||
|
||||
### 测试(其它任何 commit)
|
||||
|
||||
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的
|
||||
分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库,
|
||||
所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。
|
||||
|
||||
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`:
|
||||
|
||||
```text
|
||||
规范 / DEB 1.4.2~feat-login-v2.7+gabc1234
|
||||
文件名 foo_1.4.2~feat-login-v2.7+gabc1234_amd64.deb
|
||||
Docker registry/ns/foo:1.4.2-feat-login-v2.7.gabc1234
|
||||
```
|
||||
|
||||
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
|
||||
安装测试包必须显式指定版本或完整 tag,不能靠无参 `apt upgrade`。
|
||||
|
||||
### 分支名清洗
|
||||
|
||||
小写;`/`、`_` 改为 `-`;去掉其它非法字符;压缩连续 `-`;过长截断。
|
||||
detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` /
|
||||
`GITHUB_REF_NAME`。脏工作树不把 `-dirty` 写进版本(发布本身会拒绝)。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
用 builder 检查这个项目的 Makefile 是否符合契约。
|
||||
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
||||
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
|
||||
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
||||
```
|
||||
|
||||
## 脚本一览
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `scripts/check.py` | 校验项目 Makefile 是否符合契约(`--build` 实构核对产物) |
|
||||
| `scripts/version.sh` | 从 Git 祖先稳定 tag 推导规范版本 / Docker tag |
|
||||
| `scripts/version.mk` | 项目 Makefile `include`,设置 `VERSION` 与 `IMAGE_TAG` |
|
||||
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
||||
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
||||
|
||||
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
|
||||
脏工作树策略等完整规则见 contract.md。
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: builder
|
||||
description: >-
|
||||
按统一契约构建并发布项目的 DEB 包与 Docker 镜像:先校验项目 Makefile 是否符合
|
||||
builder 契约(check.py),再 make 构建产物,经授权后用 skill 自带脚本上传并验证。
|
||||
触发词:构建 deb、发布 deb、上传 deb、推送 apt 仓库、打 Debian 包、构建镜像、
|
||||
发布镜像、推送 Docker 镜像、make push、检查 Makefile 是否符合规范。仅分析打包
|
||||
逻辑或只构建不上传时也可使用;不会在未获授权时执行任何上传。Docker 轨道保持
|
||||
显式触发:用户点名(builder/publish docker)时才走镜像发布。
|
||||
---
|
||||
|
||||
# Builder:DEB / Docker 构建发布
|
||||
|
||||
复用项目已有发布约定,安全地完成"校验 → 构建 → 检查 → 授权 → 上传 → 验证"。
|
||||
|
||||
分工原则:**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求构建、发布、上传 `.deb` 包或 Docker/OCI 镜像。
|
||||
- 用户要求检查项目 Makefile 是否符合 builder 契约。
|
||||
- 用户要求梳理或接通项目现有的 DEB/镜像发布流程。
|
||||
|
||||
不适用:本地安装/卸载 DEB;RPM/APK/语言包管理器;从零设计全新打包体系(先出方案);
|
||||
普通编码与 Dockerfile 编辑。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 0. 校验契约
|
||||
|
||||
```bash
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> # 静态检查
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --build # 额外实构 deb 并核对产物
|
||||
```
|
||||
|
||||
任一 FAIL:停下修复(引导用 create-makefile skill 补齐),不要绕过校验继续发布。
|
||||
完整要求见 [contract.md](references/contract.md)。存量项目未接契约时走第 6 节
|
||||
fallback;成功交付一次后引导用户迁移到契约。
|
||||
|
||||
### 1. 确认发布边界
|
||||
|
||||
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;只要求查看、诊断或构建
|
||||
则停在相应阶段。
|
||||
|
||||
执行上传前确认:
|
||||
|
||||
- 目标服务和仓库来自项目配置(`.env`)或用户输入,不猜测生产端点。
|
||||
- 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复,
|
||||
不用 `set -x` 执行含凭据的命令。
|
||||
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。
|
||||
- Docker 轨道需要已确定 registry/repository;tag 未给出时按契约「版本号」推导。
|
||||
|
||||
脏工作树默认拒绝发布;用户明确接受时设置 `ALLOW_UNCOMMITTED=1` 并在汇报中注明
|
||||
包含的未提交修改。
|
||||
|
||||
### 2. 构建
|
||||
|
||||
```bash
|
||||
make build ARCH=<amd64|arm64> VERSION=<version> # 主产物
|
||||
make deb ARCH=<amd64|arm64> # DEB 项目
|
||||
```
|
||||
|
||||
版本缺省按契约「版本号」从 Git 祖先稳定 tag 推导(正式 = HEAD exact-match
|
||||
的 `vX.Y.Z`;测试 = `X.Y.Z~分支.距离+gSHA`)。不要调用 manage-release 来算
|
||||
产物版本,不要用全仓库最新 tag。构建目标若会自动上传而当前仅获构建授权,改用
|
||||
纯构建目标。执行前确认所需工具可用(docker、dpkg-deb 等)。不得擅自清理宽泛
|
||||
目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。
|
||||
|
||||
### 3. 上传前检查
|
||||
|
||||
```bash
|
||||
find $(DIST_DIR) -maxdepth 2 -type f -name '*.deb' -print
|
||||
<skill-dir>/scripts/verify_deb.sh <exact-package-path.deb> [期望版本] [期望架构]
|
||||
```
|
||||
|
||||
verify_deb.sh 输出元数据、关键内容清单和 SHA-256。匹配到多个包时不凭文件时间猜测,
|
||||
向用户确认唯一产物。镜像轨道无需单独校验步骤(publish_docker.sh 自带远端 inspect)。
|
||||
|
||||
### 4. 发布
|
||||
|
||||
优先 `make push[-deb|-docker]`(契约要求的薄包装);直接调用等价:
|
||||
|
||||
```bash
|
||||
DEB_SERVER_URL=… DEB_TOKEN=… DEB_REPOSITORY=… \
|
||||
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
||||
|
||||
DOCKER_REGISTRY=… \
|
||||
<skill-dir>/scripts/publish_docker.sh # env 优先,flag 可覆盖
|
||||
```
|
||||
|
||||
环境变量缺失时脚本会自动向上查找项目 `.env` 加载(shell 显式值优先)。不把 token
|
||||
作为命令行参数;不把脚本复制进项目。upload_deb.sh 默认请求 `/api/v2/upload/package`
|
||||
(multipart 字段 `package`/`token`/`repository_name`,接受 200/201),协议不符时设
|
||||
`DEB_UPLOAD_PATH` 或改用项目专属逻辑。publish_docker.sh 用 buildx 一步完成构建+推送,
|
||||
多平台只能走它,不能拆进 make。
|
||||
|
||||
### 5. 验证与汇报
|
||||
|
||||
发布成功不能只依据"curl 已执行"/"push 已执行"。综合检查:
|
||||
|
||||
- 上传命令退出码为零,HTTP 状态与响应体明确成功;镜像以 `imagetools inspect`
|
||||
的远端 digest 为准。
|
||||
- 若仓库提供查询/索引/下载地址,确认该版本已可见;索引异步时报告
|
||||
"上传已接受,索引尚待更新",不声称完全可用。
|
||||
|
||||
最终回复给出:包名/镜像引用、版本、架构/platform、产物路径与 SHA-256 或远端 digest、
|
||||
源 commit 与工作区状态、各阶段验证结果、未完成项或覆盖风险。
|
||||
|
||||
## 存量项目 fallback(legacy)
|
||||
|
||||
从项目根目录查找,不预设文件位置:
|
||||
|
||||
```bash
|
||||
rg -n -i --hidden --glob '!.git' \
|
||||
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
||||
```
|
||||
|
||||
重点检查 Makefile、CI 配置、`debian/`、构建脚本和发布文档中的入口、变量传递方式、
|
||||
端点与认证方式。优先复用已有构建入口;上传仍用 builder 脚本。交付后引导迁移到契约
|
||||
(create-makefile + check.py 通过为准)。
|
||||
|
||||
## 修改 builder 自身时
|
||||
|
||||
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
|
||||
业务项目。产物版本只通过 `scripts/version.sh` 推导,不要在项目 Makefile 内联
|
||||
`git describe` 或 `sort -V`。
|
||||
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`。
|
||||
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
|
||||
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
||||
- 仅校验:check.py 结果逐条可解释,修复建议明确。
|
||||
- 仅构建:产物已生成并通过 verify_deb.sh,未发生上传。
|
||||
- 发布:构建检查通过,服务端接受上传,仓库可见性已验证或准确标记为待更新。
|
||||
@@ -0,0 +1,137 @@
|
||||
# Builder 构建发布契约 v1
|
||||
|
||||
本契约是 builder skill 的规范本体。`scripts/check.py` 是它的可执行形态:改契约先改
|
||||
check.py,本文档跟随。所有接入项目按同一套 make 目标、产物形状和环境变量执行,
|
||||
builder 脚本只做发布,不做项目特定的构建逻辑。
|
||||
|
||||
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
|
||||
Agent 只保留授权判断和歧义处理。**
|
||||
|
||||
## 1. Make 目标
|
||||
|
||||
### 必备目标(所有项目)
|
||||
|
||||
| 目标 | 要求 |
|
||||
|------|------|
|
||||
| `help` | 分组列出全部目标;首屏含当前版本 |
|
||||
| `version` | 输出一行版本号,适合脚本消费 |
|
||||
| `clean` | 只删除明确、受限的构建产物目录 |
|
||||
| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** |
|
||||
|
||||
### 条件目标
|
||||
|
||||
| 目标 | 适用 | 要求 |
|
||||
|------|------|------|
|
||||
| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传 |
|
||||
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
|
||||
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` |
|
||||
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
|
||||
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
||||
|
||||
规则:
|
||||
|
||||
1. 项目有 DEB 产物的判据:Makefile 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||
有镜像的判据:项目根存在 `Dockerfile`。
|
||||
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
|
||||
`push` 即可。
|
||||
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
|
||||
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
|
||||
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。
|
||||
|
||||
## 2. 变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 |
|
||||
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
|
||||
| `DIST_DIR` | `dist` | DEB 产物目录 |
|
||||
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
|
||||
|
||||
### 版本号
|
||||
|
||||
`make version` 输出一行规范版本(无 `v` 前缀)。DEB 的 `Version` 与文件名
|
||||
直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`(`make` 通过
|
||||
`scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
|
||||
manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release 事先打好。
|
||||
`version.sh` 不执行 `git fetch`。
|
||||
|
||||
推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才
|
||||
能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**,
|
||||
不是全仓库 `sort -V` 的最大号。稳定 tag 仅 `v<major>.<minor>.<patch>`。
|
||||
|
||||
| 判定 | 规范版本 | DEB 文件 | Docker tag |
|
||||
| --- | --- | --- | --- |
|
||||
| HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_<arch>.deb` | `1.4.2` |
|
||||
| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~<branch>.<n>+g<sha>` | `name_1.4.2~<branch>.<n>+g<sha>_<arch>.deb` | `1.4.2-<branch>.<n>.g<sha>` |
|
||||
| 祖先中没有稳定 tag | `0.0.0~<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
|
||||
|
||||
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使
|
||||
`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含
|
||||
`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。
|
||||
|
||||
`<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用
|
||||
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
|
||||
`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续
|
||||
`-`;过长截断(给 base、距离、SHA 留位置;Docker tag 上限 128)。
|
||||
`<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把
|
||||
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
|
||||
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
|
||||
|
||||
项目 Makefile 不要内联 `git describe` 或 `sort -V`,include 本 skill 的
|
||||
`scripts/version.mk`:
|
||||
|
||||
```makefile
|
||||
include $(HOME)/.pouch/skills/builder/scripts/version.mk
|
||||
```
|
||||
|
||||
## 3. 发布环境变量
|
||||
|
||||
### DEB 轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `DEB_SERVER_URL` | 是 | 仓库服务地址 |
|
||||
| `DEB_TOKEN` | 是 | 认证令牌;只从环境读取,绝不进 argv/日志/git |
|
||||
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
|
||||
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
|
||||
|
||||
### Docker 轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
|
||||
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
|
||||
| `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` |
|
||||
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
|
||||
|
||||
配置来源优先级:shell 已显式设置的值 > 项目根 `.env` > 失败并询问用户。
|
||||
`.env` 由 builder 脚本自动向上查找并加载(不回显任何值);当前 shell 已设置的值
|
||||
优先于 `.env`。
|
||||
|
||||
### 工作区安全
|
||||
|
||||
脏工作树(有未提交修改)默认拒绝发布;`ALLOW_UNCOMMITTED=1` 显式放行并在汇报中
|
||||
注明镜像/包包含哪些未提交修改。该门在 builder 脚本层实现,不在 make 层。
|
||||
|
||||
## 4. 脚本解析顺序
|
||||
|
||||
push 目标与 `version.sh` 定位 builder 脚本时按以下顺序,命中即用,不做静默兜底:
|
||||
|
||||
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
|
||||
2. `$HOME/.pouch/skills/builder/scripts/`(标准 clone 位)
|
||||
|
||||
两个位置都不可用时必须失败并提示:设置 `BUILDER_SKILL_DIR`,或把 pouch 仓库
|
||||
clone 到 `~/.pouch`。
|
||||
|
||||
## 5. 校验
|
||||
|
||||
`python3 -I -S <builder-scripts>/check.py <project-dir> [--build]` 对本项目逐条检查
|
||||
上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构 `make deb` 并核对
|
||||
产物元数据(默认只静态检查配方)。校验失败时的修复路径:用 create-makefile skill
|
||||
补齐或修正 Makefile,不要绕过校验器。
|
||||
|
||||
## 6. 存量项目(legacy fallback)
|
||||
|
||||
未接入契约的项目:builder 仍可按发现流程工作——从 `Makefile`、CI 配置、`debian/`
|
||||
与发布文档中找已有构建/上传入口,优先复用;上传仍使用 builder 脚本。完成一次成功
|
||||
交付后应引导用户用 create-makefile 把项目迁移到本契约,之后以 check.py 为准。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 镜像仓库规则
|
||||
|
||||
执行发布前,从用户输入和当前项目配置中确定以下信息:
|
||||
|
||||
| 字段 | 要求 |
|
||||
| --- | --- |
|
||||
| Registry | 必须显式确定,例如 `registry.example.com` |
|
||||
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
|
||||
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
|
||||
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
|
||||
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
|
||||
| Context | 默认当前项目根目录 |
|
||||
|
||||
信息来源优先级:
|
||||
|
||||
1. 用户本次请求中明确给出的值。
|
||||
2. 当前项目的 `.env` 与 `AGENTS.md`、发布文档。
|
||||
3. Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
|
||||
4. 询问用户。
|
||||
|
||||
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
|
||||
|
||||
## 认证
|
||||
|
||||
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作
|
||||
检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
|
||||
|
||||
不要读取、打印或复制以下内容:
|
||||
|
||||
- registry 密码或访问令牌
|
||||
- `~/.docker/config.json` 中的认证字段
|
||||
- CI secret 的值
|
||||
- 包含凭据的环境变量值
|
||||
|
||||
## Tag 策略
|
||||
|
||||
- 未显式给出 `--tag` / `IMAGE_TAG` 时,使用契约「版本号」渲染出的 tag
|
||||
(正式 `1.4.2`,测试 `1.4.2-feat-login.7.gabc1234`),不要退回
|
||||
`git describe`,不要询问后另选一套。
|
||||
- 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。
|
||||
- `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布,
|
||||
不能代替上面那条唯一身份。
|
||||
Executable
+371
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Executable form of the builder contract (references/contract.md).
|
||||
|
||||
Checks a project's Makefile against the contract by probing make itself with
|
||||
dry runs (`make -n`) instead of parsing Makefile text: includes, conditionals,
|
||||
and variable expansion are resolved by make, so behavior is what gets judged.
|
||||
|
||||
Usage:
|
||||
python3 -I -S check.py <project-dir> [--build]
|
||||
|
||||
Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error.
|
||||
|
||||
Change the contract here first, then mirror the change into contract.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ARCH_VALUES = ("amd64", "arm64")
|
||||
REQUIRED_TARGETS = ("help", "version", "clean", "build")
|
||||
UPLOAD_TOKENS = (
|
||||
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
|
||||
"docker push", "buildx build --push", "buildx --push", "upload_deb.sh",
|
||||
"publish_docker.sh",
|
||||
)
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
|
||||
re.compile(r"\b[A-Za-z0-9_]*token[A-Za-z0-9_]*\s*[:?]?=\s*['\"]?[A-Za-z0-9._\-]{16,}", re.IGNORECASE),
|
||||
)
|
||||
FLOATING_TAGS = (":latest", ":stable")
|
||||
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
|
||||
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
SKIP = "SKIP"
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.failures = 0
|
||||
self.skips = 0
|
||||
|
||||
def add(self, status: str, number: int, title: str, detail: str) -> None:
|
||||
print(f"[{status}] {number}. {title}")
|
||||
for line in detail.splitlines():
|
||||
print(f" {line}")
|
||||
if status == FAIL:
|
||||
self.failures += 1
|
||||
elif status == SKIP:
|
||||
self.skips += 0 if self.skips else 1
|
||||
|
||||
|
||||
def run_make(project: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["make", "-C", str(project), "-n", *args],
|
||||
capture_output=True, text=True, timeout=timeout, check=False,
|
||||
)
|
||||
|
||||
|
||||
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
|
||||
return result.returncode != 0 and (
|
||||
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower()
|
||||
)
|
||||
|
||||
|
||||
BANNER_RE = re.compile(r"^make(?:\[[0-9]+\])?: (进入|离开|Entering|Leaving)")
|
||||
|
||||
|
||||
def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
|
||||
"""Drop make directory banners and dry-run command echoes, keep real output."""
|
||||
lines = []
|
||||
for line in result.stdout.splitlines():
|
||||
if BANNER_RE.match(line.strip()):
|
||||
continue
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith(("echo ", "echo\t", "printf ")):
|
||||
continue
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
|
||||
present: dict[str, bool] = {}
|
||||
lines = []
|
||||
for target in REQUIRED_TARGETS:
|
||||
result = run_make(project, target)
|
||||
ok = result.returncode == 0
|
||||
present[target] = ok
|
||||
lines.append(f"{target}: {'found' if ok else 'missing'}")
|
||||
report.add(PASS if all(present.values()) else FAIL, 1, "必备目标存在(help/version/clean/build)", "\n".join(lines))
|
||||
return present
|
||||
|
||||
|
||||
def check_arch_guard(report: Report, project: Path) -> None:
|
||||
bad = run_make(project, "build", "ARCH=loongarch")
|
||||
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr)
|
||||
default_ok = run_make(project, "build").returncode == 0
|
||||
lines = [
|
||||
f"invalid ARCH rejected: {'yes' if guard_ok else 'NO'}",
|
||||
f"default ARCH works: {'yes' if default_ok else 'no'}",
|
||||
]
|
||||
hint = "" if guard_ok else "\n Hint: add `$(error ARCH must be amd64 or arm64)` guarded by an ifneq filter."
|
||||
if guard_ok and default_ok:
|
||||
report.add(PASS, 2, "ARCH 守卫与缺省值", "\n".join(lines + hint.splitlines()))
|
||||
else:
|
||||
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint)
|
||||
|
||||
|
||||
def check_version_output(report: Report, project: Path) -> None:
|
||||
result = run_make(project, "version")
|
||||
# Dry run: the echoed `@echo <version>` line IS the would-be output.
|
||||
out_lines = [ln.lstrip()[5:] for ln in result.stdout.splitlines() if ln.lstrip().startswith("echo ")]
|
||||
out = "\n".join(out_lines).strip()
|
||||
single = len(out.splitlines()) == 1 and out != ""
|
||||
no_v = single and not out.startswith("v")
|
||||
detail = f"stdout={out!r}"
|
||||
if single and not no_v:
|
||||
detail += "\ncanonical version must not start with 'v'"
|
||||
report.add(
|
||||
PASS if no_v else FAIL,
|
||||
3,
|
||||
"version 输出一行非空规范版本(无 v 前缀)",
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
def check_build_has_no_upload(report: Report, project: Path) -> None:
|
||||
result = run_make(project, "build")
|
||||
text = chr(10).join(clean_make_output(result))
|
||||
hits = [token for token in UPLOAD_TOKENS if token in text]
|
||||
report.add(
|
||||
PASS if not hits else FAIL,
|
||||
4,
|
||||
"build 不含上传动作",
|
||||
"clean" if not hits else "found upload commands in build recipe:\n " + ", ".join(hits),
|
||||
)
|
||||
|
||||
|
||||
def detect_deb_project(recipe_all: str, project: Path) -> bool:
|
||||
return ".deb" in recipe_all or "dpkg-deb" in recipe_all or "debuild" in recipe_all or any(project.glob("debian/*"))
|
||||
|
||||
|
||||
def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> None:
|
||||
dry = run_make(project, "deb")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
problems = []
|
||||
if dry.returncode != 0:
|
||||
problems.append(f"`make -n deb` failed: {dry.stderr.strip() or 'unknown error'}")
|
||||
else:
|
||||
if "dist/" not in text and "$(DIST_DIR)" not in text:
|
||||
problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location")
|
||||
hits = [token for token in UPLOAD_TOKENS if token in text]
|
||||
if hits:
|
||||
problems.append("recipe contains upload commands: " + ", ".join(hits))
|
||||
if "rm -rf /" in text or "rm -rf ~" in text:
|
||||
problems.append("recipe contains unrestricted rm -rf")
|
||||
if built_deb is not None:
|
||||
shape_ok = DEB_SHAPE.match(built_deb.name) is not None
|
||||
if not shape_ok:
|
||||
problems.append(f"artifact name does not match <name>_<version>_<arch>.deb: {built_deb.name}")
|
||||
dpkg = shutil.which("dpkg-deb")
|
||||
if dpkg:
|
||||
info = subprocess.run([dpkg, "--field", str(built_deb), "Package"], capture_output=True, text=True, check=False)
|
||||
if info.returncode != 0 or not info.stdout.strip():
|
||||
problems.append(f"dpkg-deb --info failed on {built_deb.name}")
|
||||
else:
|
||||
problems.append("dpkg-deb unavailable; metadata not verified (--build)")
|
||||
if problems:
|
||||
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
|
||||
else:
|
||||
extra = f"\nartifact: {built_deb.name}" if built_deb else "\n(static recipe check only; run --build to verify real artifact)"
|
||||
report.add(PASS, 5, "deb 目标产物形状与纯构建", extra.lstrip("\n"))
|
||||
|
||||
|
||||
def detect_docker_project(project: Path) -> bool:
|
||||
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
|
||||
|
||||
|
||||
def check_docker_recipe(report: Report, project: Path) -> None:
|
||||
dry = run_make(project, "docker")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
if has_no_rule(dry):
|
||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no docker target)")
|
||||
return
|
||||
problems = []
|
||||
if "--push" in text or " docker push" in text or "docker push\n" in text:
|
||||
problems.append("make docker must be local-only; pushing belongs to publish_docker.sh")
|
||||
if "--platform" in text and "," in text.split("--platform")[1][:80].split()[0]:
|
||||
problems.append("make docker must stay single-platform; multi-platform belongs to publish_docker.sh")
|
||||
report.add(FAIL if problems else PASS, 6, "docker 目标为本地单平台构建", "\n".join(problems) or "local single-platform build")
|
||||
|
||||
|
||||
SCRIPT_RESOLVE_SNIPPETS = tuple(
|
||||
f"{prefix}{name}"
|
||||
for prefix in (
|
||||
"$$BUILDER_SKILL_DIR",
|
||||
"$BUILDER_SKILL_DIR",
|
||||
"$$HOME/.pouch/skills/builder/scripts",
|
||||
"$HOME/.pouch/skills/builder/scripts",
|
||||
"~/.pouch/skills/builder/scripts",
|
||||
"$$HOME/.skills/skills/builder/scripts",
|
||||
"$HOME/.skills/skills/builder/scripts",
|
||||
"~/.skills/skills/builder/scripts",
|
||||
)
|
||||
for name in VALID_SCRIPT_NAMES
|
||||
)
|
||||
|
||||
|
||||
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None:
|
||||
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
|
||||
missing = []
|
||||
inline = []
|
||||
thin = []
|
||||
for target in targets:
|
||||
dry = run_make(project, target)
|
||||
if has_no_rule(dry):
|
||||
missing.append(target)
|
||||
continue
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text]
|
||||
if bad_tokens:
|
||||
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
|
||||
elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \
|
||||
and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \
|
||||
and "publish_docker.sh" not in text:
|
||||
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
|
||||
else:
|
||||
thin.append(target)
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append("missing targets: " + ", ".join(missing))
|
||||
problems.extend(inline)
|
||||
status = PASS if not problems else FAIL
|
||||
detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin)
|
||||
report.add(status, 7, "push 仅委托 builder 脚本(薄包装)", detail)
|
||||
|
||||
|
||||
def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||
makefile = project / "Makefile"
|
||||
included_text = ""
|
||||
problems = []
|
||||
files = [makefile]
|
||||
if makefile.exists():
|
||||
for match in re.finditer(r"^include\s+(.+)$", makefile.read_text(encoding="utf-8"), re.MULTILINE):
|
||||
inc = (project / match.group(1).strip()).resolve()
|
||||
if inc.is_file():
|
||||
files.append(inc)
|
||||
for file in files:
|
||||
text = file.read_text(encoding="utf-8")
|
||||
rel = file.relative_to(project) if file.is_relative_to(project) else file
|
||||
for pattern in SECRET_PATTERNS:
|
||||
for hit in pattern.finditer(text):
|
||||
problems.append(f"{rel}: possible hardcoded secret near `{hit.group(0)[:40]}...`")
|
||||
for tag in FLOATING_TAGS:
|
||||
for line in text.splitlines():
|
||||
stripped = line.split("#", 1)[0]
|
||||
if tag in stripped:
|
||||
problems.append(f"{rel}: implicit floating tag `{tag}` in: {stripped.strip()[:70]}")
|
||||
report.add(FAIL if problems else PASS, 8, "无内联机密、无隐式 latest/stable", "\n".join(problems) or "clean")
|
||||
|
||||
|
||||
def check_script_paths(report: Report) -> None:
|
||||
import os
|
||||
|
||||
candidates = []
|
||||
env_dir = os.environ.get("BUILDER_SKILL_DIR")
|
||||
if env_dir:
|
||||
candidates.append(Path(env_dir) / "scripts")
|
||||
home = Path(os.environ.get("HOME", ""))
|
||||
candidates.append(home / ".pouch" / "skills" / "builder" / "scripts")
|
||||
candidates.append(home / ".skills" / "skills" / "builder" / "scripts")
|
||||
found = next((c for c in candidates if c.is_dir() and any((c / n).is_file() for n in VALID_SCRIPT_NAMES)), None)
|
||||
if found:
|
||||
report.add(PASS, 9, "builder 脚本路径可达", str(found))
|
||||
else:
|
||||
report.add(FAIL, 9, "builder 脚本路径可达", "\n".join([
|
||||
"none of these resolve to scripts/upload_deb.sh:",
|
||||
*(f" {c}" for c in candidates),
|
||||
"Fix: set BUILDER_SKILL_DIR, or clone the pouch repo to ~/.pouch.",
|
||||
]))
|
||||
|
||||
|
||||
def build_project(project: Path) -> Path | None:
|
||||
"""Run `make deb` for real and return the produced .deb, or None."""
|
||||
result = subprocess.run(["make", "-C", str(project), "deb"], capture_output=True, text=True, timeout=1800, check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"--build: `make deb` failed:\n{result.stderr[-2000:]}", file=sys.stderr)
|
||||
return None
|
||||
debs = sorted((p for p in (project / "dist").glob("*.deb") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return debs[0] if debs else None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("project", type=Path, help="project directory containing the Makefile")
|
||||
parser.add_argument("--build", action="store_true", help="actually run `make deb` and verify the artifact")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project = args.project.resolve()
|
||||
makefile = project / "Makefile"
|
||||
if not makefile.is_file():
|
||||
print(f"Error: no Makefile in {project}", file=sys.stderr)
|
||||
return 2
|
||||
if shutil.which("make") is None:
|
||||
print("Error: make is required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
report = Report()
|
||||
|
||||
# Gather every recipe once via dry-running all known targets (best effort).
|
||||
recipe_all_parts = []
|
||||
for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"):
|
||||
result = run_make(project, target)
|
||||
if result.returncode == 0:
|
||||
recipe_all_parts.append(result.stdout)
|
||||
recipe_all = "\n".join(recipe_all_parts)
|
||||
|
||||
present = check_required_targets(report, project)
|
||||
|
||||
built_deb: Path | None = None
|
||||
deb_project = detect_deb_project(recipe_all, project)
|
||||
docker_project = detect_docker_project(project)
|
||||
|
||||
if present["build"]:
|
||||
check_arch_guard(report, project)
|
||||
check_version_output(report, project)
|
||||
check_build_has_no_upload(report, project)
|
||||
else:
|
||||
report.add(SKIP, 2, "ARCH 守卫与缺省值", "(build target missing)")
|
||||
report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)")
|
||||
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
|
||||
|
||||
if deb_project:
|
||||
if args.build:
|
||||
print("--build: running `make deb` ...")
|
||||
built_deb = build_project(project)
|
||||
if built_deb is None:
|
||||
print("--build: no .deb produced; artifact checks degrade to recipe-only.", file=sys.stderr)
|
||||
check_deb_recipe(report, project, built_deb)
|
||||
else:
|
||||
report.add(SKIP, 5, "deb 目标产物形状与纯构建", "(not a DEB project)")
|
||||
|
||||
if docker_project:
|
||||
check_docker_recipe(report, project)
|
||||
else:
|
||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
||||
|
||||
dual = deb_project and docker_project
|
||||
check_push_delegates(report, project, dual)
|
||||
check_secrets_and_tags(report, project)
|
||||
check_script_paths(report)
|
||||
|
||||
total_fail = report.failures
|
||||
print()
|
||||
if total_fail:
|
||||
print(f"RESULT: FAILED ({total_fail} check(s) failed)")
|
||||
return 1
|
||||
print("RESULT: PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
sed -n '2,30p' "$0"
|
||||
}
|
||||
|
||||
# Build and publish a Docker image with buildx. Configuration comes from the
|
||||
# environment first (optionally loaded from the project root .env); flags
|
||||
# override.
|
||||
#
|
||||
# Usage:
|
||||
# publish_docker.sh [--registry HOST] [--repository PATH] [--tag TAG] \
|
||||
# [--platform LIST] [options]
|
||||
#
|
||||
# Environment:
|
||||
# DOCKER_REGISTRY Required (or --registry)
|
||||
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
|
||||
# IMAGE_TAG Optional, default: version.sh --docker (or --tag)
|
||||
# PLATFORMS Optional, default: linux/amd64 (or --platform)
|
||||
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
|
||||
# DOCKER_CONTEXT Optional, default: . (--context)
|
||||
# DOCKER_BUILDER Optional buildx builder name (--builder)
|
||||
# ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
|
||||
#
|
||||
# Options:
|
||||
# --load Load a single-platform image instead of pushing
|
||||
# --dry-run Print the resolved build without executing it
|
||||
# -h, --help Show this help
|
||||
|
||||
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
|
||||
# Load project .env without printing values; explicitly exported shell values keep precedence.
|
||||
if [[ -n "$project_root" && -f "$project_root/.env" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key=${key%%[[:space:]]*}
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
if [[ -n "${!key:-}" ]]; then
|
||||
continue # shell value already set: wins over .env
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||
printf -v "$key" '%s' "$value"
|
||||
export "$key"
|
||||
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
|
||||
fi
|
||||
|
||||
git_repo_name=
|
||||
if [[ -n "$project_root" ]]; then
|
||||
git_repo_name=$(basename "$(git -C "$project_root" rev-parse --show-toplevel)")
|
||||
fi
|
||||
|
||||
registry=${DOCKER_REGISTRY:-}
|
||||
repository=${DOCKER_REPOSITORY:-$git_repo_name}
|
||||
tag=${IMAGE_TAG:-}
|
||||
platform=${PLATFORMS:-linux/amd64}
|
||||
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
|
||||
build_context=${DOCKER_CONTEXT:-.}
|
||||
builder=${DOCKER_BUILDER:-}
|
||||
mode=push
|
||||
dry_run=false
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--registry) registry=$2; shift 2 ;;
|
||||
--repository) repository=$2; shift 2 ;;
|
||||
--tag) tag=$2; shift 2 ;;
|
||||
--platform) platform=$2; shift 2 ;;
|
||||
--file) dockerfile=$2; shift 2 ;;
|
||||
--context) build_context=$2; shift 2 ;;
|
||||
--builder) builder=$2; shift 2 ;;
|
||||
--load) mode=load; shift ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$registry" && ( "$registry" == *://* || "$registry" == */* ) ]]; then
|
||||
echo "Error: registry must be a bare host without scheme or slash: $registry" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$registry" ]]; then
|
||||
echo "Error: DOCKER_REGISTRY (or --registry) is required." >&2
|
||||
echo "Set it in the environment or the project root .env." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$repository" || "$repository" == /* || "$repository" == */ || "$repository" != */* ]]; then
|
||||
echo "Error: repository must be namespace/name without leading or trailing slash: $repository" >&2
|
||||
exit 2
|
||||
fi
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
version_sh=$script_dir/version.sh
|
||||
|
||||
if [[ -z "$tag" ]]; then
|
||||
if [[ ! -x "$version_sh" ]]; then
|
||||
echo "Error: version.sh not found next to publish_docker.sh: $version_sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$project_root" ]]; then
|
||||
echo "Error: IMAGE_TAG (or --tag) is required outside a git repository." >&2
|
||||
exit 2
|
||||
fi
|
||||
tag=$("$version_sh" -C "$project_root" --docker) || {
|
||||
echo "Error: failed to derive IMAGE_TAG from Git ancestry." >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
if [[ "$tag" == *:* || "$tag" == */* ]]; then
|
||||
echo "Error: tag must not contain : or /: $tag" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Official-shaped tags (X.Y.Z or vX.Y.Z) are only legal on that exact Git tag.
|
||||
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ && -x "$version_sh" && -n "$project_root" ]]; then
|
||||
derived=$("$version_sh" -C "$project_root" --docker) || true
|
||||
expected=${tag#v}
|
||||
if [[ "$derived" != "$expected" ]]; then
|
||||
echo "Error: IMAGE_TAG $tag looks official but HEAD is $derived" >&2
|
||||
echo "Official X.Y.Z is allowed only when HEAD exact-matches vX.Y.Z." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
|
||||
echo "Error: refusing to publish floating tag 'latest'; pass an explicit version." >&2
|
||||
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ "$mode" == load && "$platform" == *,* ]]; then
|
||||
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$dockerfile" ]]; then
|
||||
echo "Error: Dockerfile not found: $dockerfile" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$build_context" ]]; then
|
||||
echo "Error: build context not found: $build_context" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$dry_run" == false ]] && ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
|
||||
if [[ "$mode" == push && "$dry_run" == false && -n "$project_root" ]] \
|
||||
&& git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
|
||||
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
|
||||
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
|
||||
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
|
||||
exit 3
|
||||
fi
|
||||
fi
|
||||
|
||||
image_ref="${registry}/${repository}:${tag}"
|
||||
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
|
||||
if [[ -n "$builder" ]]; then
|
||||
build_cmd+=(--builder "$builder")
|
||||
fi
|
||||
if [[ "$mode" == push ]]; then
|
||||
build_cmd+=(--push)
|
||||
else
|
||||
build_cmd+=(--load)
|
||||
fi
|
||||
build_cmd+=("$build_context")
|
||||
|
||||
printf 'Image: %s\n' "$image_ref"
|
||||
printf 'Platform: %s\n' "$platform"
|
||||
printf 'Dockerfile: %s\n' "$dockerfile"
|
||||
printf 'Context: %s\n' "$build_context"
|
||||
printf 'Mode: %s\n' "$mode"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
printf 'Command: %s\n' "${build_cmd[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"${build_cmd[@]}"
|
||||
|
||||
if [[ "$mode" == push ]]; then
|
||||
docker buildx imagetools inspect "$image_ref"
|
||||
fi
|
||||
@@ -15,12 +15,33 @@ Options:
|
||||
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
|
||||
-h Show help
|
||||
|
||||
The endpoint must accept multipart fields named package, token, and
|
||||
repository_name. Authentication is read only from DEB_TOKEN so it is not
|
||||
exposed in the process command line.
|
||||
Environment variables may live in the project root .env; this script walks up
|
||||
from the current directory, loads it silently (existing shell values win), and
|
||||
never echoes variable values. The endpoint must accept multipart fields named
|
||||
package, token, and repository_name. Authentication is read only from
|
||||
DEB_TOKEN so it is not exposed in the process command line.
|
||||
|
||||
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Locate project root (.git) upward from cwd for .env loading and git checks.
|
||||
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
|
||||
# Load project .env without printing values; explicitly exported shell values keep precedence.
|
||||
if [[ -n "$project_root" && -f "$project_root/.env" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key=${key%%[[:space:]]*}
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
if [[ -n "${!key:-}" ]]; then
|
||||
continue # shell value already set: wins over .env
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||
printf -v "$key" '%s' "$value"
|
||||
export "$key"
|
||||
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
|
||||
fi
|
||||
|
||||
server_url=${DEB_SERVER_URL:-}
|
||||
repository=${DEB_REPOSITORY:-}
|
||||
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
|
||||
@@ -40,9 +61,20 @@ shift $((OPTIND - 1))
|
||||
|
||||
if [[ -z "$server_url" || -z "$repository" || -z "$token" || $# -eq 0 ]]; then
|
||||
echo "Error: DEB_SERVER_URL, DEB_TOKEN, DEB_REPOSITORY, and at least one file are required." >&2
|
||||
echo "Set them in the environment or the project root .env." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
|
||||
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
|
||||
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
|
||||
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
|
||||
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
|
||||
exit 3
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$upload_path" != /* ]]; then
|
||||
echo "Error: upload path must start with /" >&2
|
||||
exit 2
|
||||
@@ -130,4 +162,3 @@ echo "Done. Success: $success_count, Failed: $fail_count"
|
||||
if ((fail_count > 0)); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
verify_deb.sh FILE.deb [EXPECTED_VERSION] [EXPECTED_ARCH]
|
||||
|
||||
Prints package metadata, key content listing, and SHA-256. When an expected
|
||||
version and/or architecture is given, mismatches fail with a non-zero exit.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 3 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
package=$1
|
||||
expected_version=${2:-}
|
||||
expected_arch=${3:-}
|
||||
fail=0
|
||||
|
||||
if [[ ! -f "$package" ]]; then
|
||||
echo "Error: file not found: $package" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -s "$package" ]]; then
|
||||
echo "Error: empty file: $package" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||
echo "Error: dpkg-deb is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "== metadata =="
|
||||
info=$(dpkg-deb --info "$package") || {
|
||||
echo "Error: dpkg-deb --info failed; not a valid Debian package." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$info"
|
||||
|
||||
package_name=$(dpkg-deb --field "$package" Package 2>/dev/null || true)
|
||||
package_version=$(dpkg-deb --field "$package" Version 2>/dev/null || true)
|
||||
package_arch=$(dpkg-deb --field "$package" Architecture 2>/dev/null || true)
|
||||
|
||||
# Debian versions never start with 'v'; git tags usually do. Compare normalized.
|
||||
expected_version=${expected_version#v}
|
||||
|
||||
if [[ -z "$package_name" || -z "$package_version" || -z "$package_arch" ]]; then
|
||||
echo "FAIL: missing Package/Version/Architecture field." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ -n "$expected_version" && "$package_version" != "$expected_version" ]]; then
|
||||
echo "FAIL: version mismatch: expected $expected_version, got $package_version" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$expected_arch" && "$package_arch" != "$expected_arch" ]]; then
|
||||
echo "FAIL: architecture mismatch: expected $expected_arch, got $package_arch" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# Filename shape per contract: <name>_<version>_<arch>.deb
|
||||
base=$(basename -- "$package")
|
||||
if [[ ! "$base" =~ ^[^_]+_[^_]+_[^_]+\.deb$ ]]; then
|
||||
echo "FAIL: filename does not match <name>_<version>_<arch>.deb: $base" >&2
|
||||
fail=1
|
||||
elif [[ -n "$package_version" && ! "$base" == *"${package_version}"* ]]; then
|
||||
echo "FAIL: filename version does not match package Version ($package_version): $base" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
echo "== contents (top level + binaries) =="
|
||||
dpkg-deb --contents "$package" | sed -n '1,40p'
|
||||
|
||||
echo "== maintainer scripts permissions (when present) =="
|
||||
control_dir=$(mktemp -d)
|
||||
trap 'rm -rf -- "$control_dir"' EXIT
|
||||
if dpkg-deb --control "$package" "$control_dir" 2>/dev/null; then
|
||||
found_scripts=false
|
||||
for script in preinst postinst prerm postrm; do
|
||||
if [[ -f "$control_dir/$script" ]]; then
|
||||
found_scripts=true
|
||||
mode=$(stat -c '%a' "$control_dir/$script")
|
||||
if [[ $mode =~ .*[2367]$ ]]; then
|
||||
echo "OK: $script mode $mode"
|
||||
else
|
||||
echo "FAIL: $script not executable (mode $mode)" >&2
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ "$found_scripts" == false ]]; then
|
||||
echo "(no maintainer scripts)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "== sha256 =="
|
||||
sha256sum "$package"
|
||||
|
||||
if ((fail > 0)); then
|
||||
echo "VERIFY: FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "VERIFY: OK"
|
||||
@@ -0,0 +1,42 @@
|
||||
# Include from a project Makefile. Sets VERSION (canonical, no leading v)
|
||||
# and IMAGE_TAG (Docker rendering) via builder version.sh.
|
||||
#
|
||||
# Command-line / environment VERSION= is passed as --version (official X.Y.Z
|
||||
# only when HEAD exact-matches that tag). Unset or empty VERSION is derived.
|
||||
#
|
||||
# include $(HOME)/.pouch/skills/builder/scripts/version.mk
|
||||
|
||||
_builder_scripts_dir := $(dir $(lastword $(MAKEFILE_LIST)))
|
||||
|
||||
ifeq ($(BUILDER_VERSION_SH),)
|
||||
BUILDER_VERSION_SH := $(wildcard $(_builder_scripts_dir)version.sh)
|
||||
endif
|
||||
ifeq ($(BUILDER_VERSION_SH),)
|
||||
ifneq ($(BUILDER_SKILL_DIR),)
|
||||
BUILDER_VERSION_SH := $(wildcard $(BUILDER_SKILL_DIR)/scripts/version.sh)
|
||||
endif
|
||||
endif
|
||||
ifeq ($(BUILDER_VERSION_SH),)
|
||||
BUILDER_VERSION_SH := $(wildcard $(HOME)/.pouch/skills/builder/scripts/version.sh)
|
||||
endif
|
||||
ifeq ($(BUILDER_VERSION_SH),)
|
||||
BUILDER_VERSION_SH := $(wildcard $(HOME)/.skills/skills/builder/scripts/version.sh)
|
||||
endif
|
||||
ifeq ($(BUILDER_VERSION_SH),)
|
||||
$(error version.sh not found; set BUILDER_SKILL_DIR or clone pouch to ~/.pouch)
|
||||
endif
|
||||
|
||||
ifeq ($(filter command line environment,$(origin VERSION)),)
|
||||
VERSION := $(shell "$(BUILDER_VERSION_SH)")
|
||||
else ifeq ($(strip $(VERSION)),)
|
||||
VERSION := $(shell "$(BUILDER_VERSION_SH)")
|
||||
else
|
||||
VERSION := $(shell "$(BUILDER_VERSION_SH)" --version "$(VERSION)")
|
||||
endif
|
||||
ifeq ($(strip $(VERSION)),)
|
||||
$(error version.sh produced an empty version)
|
||||
endif
|
||||
|
||||
ifeq ($(origin IMAGE_TAG),undefined)
|
||||
IMAGE_TAG := $(shell "$(BUILDER_VERSION_SH)" --docker --version "$(VERSION)")
|
||||
endif
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
version.sh [-C GIT_DIR] [--docker] [--version VER]
|
||||
|
||||
Print one line: the canonical artifact version (Debian Version, no leading v).
|
||||
With --docker, print the Docker tag rendering of that version.
|
||||
|
||||
Derivation (builder contract «版本号»):
|
||||
official HEAD exact-match of vX.Y.Z → X.Y.Z
|
||||
test nearest ancestor stable tag → X.Y.Z~branch.n+gSHA
|
||||
no tag no stable tag reachable from HEAD → 0.0.0~branch+gSHA
|
||||
|
||||
Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not
|
||||
the highest version in the repository. This script does not fetch tags.
|
||||
|
||||
--version VER overrides derivation. Official-shaped VER (X.Y.Z, optional
|
||||
leading v) is accepted only when HEAD exact-matches that tag. Test-shaped
|
||||
VER is used as-is.
|
||||
|
||||
Branch name: git symbolic-ref, or BUILD_BRANCH / CI_COMMIT_BRANCH /
|
||||
GITHUB_REF_NAME when detached, else "detached". Sanitized to [a-z0-9-],
|
||||
max 32 characters.
|
||||
EOF
|
||||
}
|
||||
|
||||
root=.
|
||||
mode=canonical
|
||||
override=
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-C) root=$2; shift 2 ;;
|
||||
--docker) mode=docker; shift ;;
|
||||
--version) override=$2; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "Error: not a git repository: $root" >&2
|
||||
exit 2
|
||||
fi
|
||||
root=$(git -C "$root" rev-parse --show-toplevel)
|
||||
|
||||
gitc() {
|
||||
git -C "$root" "$@"
|
||||
}
|
||||
|
||||
is_stable_tag() {
|
||||
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
}
|
||||
|
||||
is_official_version() {
|
||||
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
}
|
||||
|
||||
# Strip one leading v if present.
|
||||
strip_v() {
|
||||
local v=$1
|
||||
if [[ "$v" == v* ]]; then
|
||||
v=${v#v}
|
||||
fi
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
sanitize_branch() {
|
||||
local b=$1
|
||||
b=$(printf '%s' "$b" | tr '[:upper:]' '[:lower:]')
|
||||
b=${b//\//-}
|
||||
b=${b//_/-}
|
||||
b=$(printf '%s' "$b" | tr -cd 'a-z0-9-')
|
||||
b=$(printf '%s' "$b" | tr -s '-')
|
||||
b=${b#-}
|
||||
b=${b%-}
|
||||
if ((${#b} > 32)); then
|
||||
b=${b:0:32}
|
||||
b=${b%-}
|
||||
fi
|
||||
if [[ -z "$b" ]]; then
|
||||
b=unknown
|
||||
fi
|
||||
printf '%s' "$b"
|
||||
}
|
||||
|
||||
branch_slug() {
|
||||
local b
|
||||
b=$(gitc rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$b" == HEAD ]]; then
|
||||
b=${BUILD_BRANCH:-${CI_COMMIT_BRANCH:-${GITHUB_REF_NAME:-detached}}}
|
||||
fi
|
||||
sanitize_branch "$b"
|
||||
}
|
||||
|
||||
short_sha() {
|
||||
gitc rev-parse HEAD | cut -c1-7
|
||||
}
|
||||
|
||||
# Highest stable tag pointing at HEAD, or empty.
|
||||
official_tag_at_head() {
|
||||
local tag best=
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
is_stable_tag "$tag" || continue
|
||||
if [[ -z "$best" ]]; then
|
||||
best=$tag
|
||||
elif printf '%s\n%s\n' "$best" "$tag" | sort -V | tail -n 1 | grep -qx "$tag"; then
|
||||
best=$tag
|
||||
fi
|
||||
done < <(gitc tag --points-at HEAD)
|
||||
printf '%s' "$best"
|
||||
}
|
||||
|
||||
# Stable ancestor tag with the fewest commits to HEAD (not sort -V globally).
|
||||
nearest_stable_tag() {
|
||||
local tag dist best_dist="" best_tag=""
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] || continue
|
||||
is_stable_tag "$tag" || continue
|
||||
dist=$(gitc rev-list --count "${tag}..HEAD")
|
||||
if [[ -z "$best_dist" ]] || ((dist < best_dist)); then
|
||||
best_dist=$dist
|
||||
best_tag=$tag
|
||||
elif ((dist == best_dist)); then
|
||||
if printf '%s\n%s\n' "$best_tag" "$tag" | sort -V | tail -n 1 | grep -qx "$tag"; then
|
||||
best_tag=$tag
|
||||
fi
|
||||
fi
|
||||
done < <(gitc tag --merged HEAD)
|
||||
printf '%s' "$best_tag"
|
||||
}
|
||||
|
||||
derive_canonical() {
|
||||
local tag base n sha branch
|
||||
tag=$(official_tag_at_head)
|
||||
if [[ -n "$tag" ]]; then
|
||||
printf '%s' "${tag#v}"
|
||||
return
|
||||
fi
|
||||
sha=$(short_sha)
|
||||
branch=$(branch_slug)
|
||||
base=$(nearest_stable_tag)
|
||||
if [[ -z "$base" ]]; then
|
||||
printf '0.0.0~%s+g%s' "$branch" "$sha"
|
||||
return
|
||||
fi
|
||||
n=$(gitc rev-list --count "${base}..HEAD")
|
||||
printf '%s~%s.%s+g%s' "${base#v}" "$branch" "$n" "$sha"
|
||||
}
|
||||
|
||||
to_docker() {
|
||||
local v=$1
|
||||
v=${v//\~/-}
|
||||
v=${v//+g/.g}
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
canonical=
|
||||
if [[ -n "$override" ]]; then
|
||||
canonical=$(strip_v "$override")
|
||||
if [[ "$canonical" == *'_'* ]]; then
|
||||
echo "Error: version must not contain '_': $canonical" >&2
|
||||
exit 2
|
||||
fi
|
||||
if is_official_version "$canonical"; then
|
||||
derived=$(derive_canonical)
|
||||
if [[ "$derived" != "$canonical" ]]; then
|
||||
echo "Error: --version $canonical looks official but HEAD is $derived" >&2
|
||||
echo "Official X.Y.Z is allowed only when HEAD exact-matches vX.Y.Z." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
else
|
||||
canonical=$(derive_canonical)
|
||||
fi
|
||||
|
||||
if [[ -z "$canonical" ]]; then
|
||||
echo "Error: empty version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$mode" == docker ]]; then
|
||||
tag=$(to_docker "$canonical")
|
||||
if [[ "$tag" == *:* || "$tag" == */* || "$tag" == *'~'* ]]; then
|
||||
echo "Error: docker tag still contains illegal characters: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$tag"
|
||||
else
|
||||
printf '%s\n' "$canonical"
|
||||
fi
|
||||
@@ -1,80 +0,0 @@
|
||||
# deb-publisher
|
||||
|
||||
帮助 Agent 复用项目已有的 DEB 打包与发布方式,完成构建、包检查、上传和发布验证。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
当你希望 Agent 处理以下任务时使用:
|
||||
|
||||
- “帮我构建这个项目的 DEB 包”
|
||||
- “把 1.2.3 版本的 DEB 发布到包仓库”
|
||||
- “看看项目里的 DEB 发布流程”
|
||||
- “检查这个 DEB 是否可以发布”
|
||||
|
||||
只想在本机安装一个 `.deb`,或者要构建 RPM、Docker 镜像时,不需要使用这个 skill。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
请准备或确认:
|
||||
|
||||
- 要构建或发布的版本号。
|
||||
- 项目已经配置好构建脚本、Make 目标或 `debian/` 目录;上传脚本由 skill 自带,
|
||||
不需要放进项目。
|
||||
- 发布所需的令牌已经放入项目约定的环境变量或密钥系统。
|
||||
- 如果要真实发布,明确告诉 Agent 目标仓库以及是否允许覆盖同版本。
|
||||
|
||||
不要把令牌直接粘贴到对话、命令参数或项目文件中。
|
||||
|
||||
## 使用示例
|
||||
|
||||
仅分析,不产生或上传包:
|
||||
|
||||
```text
|
||||
看看这个项目的 DEB 是怎么构建和发布的。
|
||||
```
|
||||
|
||||
只构建和检查,不上传:
|
||||
|
||||
```text
|
||||
使用 deb-publisher 构建 1.2.3 的 DEB,检查包元数据和内容,不要上传。
|
||||
```
|
||||
|
||||
构建并发布:
|
||||
|
||||
```text
|
||||
使用 deb-publisher 构建并发布 1.2.3 的 amd64 DEB 到项目已配置的测试仓库。
|
||||
```
|
||||
|
||||
发布已有产物:
|
||||
|
||||
```text
|
||||
使用 deb-publisher 检查并发布 ./dist/example_1.2.3_amd64.deb。
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
Agent 会优先发现和复用项目已有入口,然后:
|
||||
|
||||
1. 确认版本、架构、产物路径、目标仓库和授权范围。
|
||||
2. 构建 DEB,或定位你指定的已有产物。
|
||||
3. 检查包的元数据、内容和 SHA-256。
|
||||
4. 在你明确要求发布时,通过 skill 自带的 `scripts/upload_deb.sh` 上传。
|
||||
5. 检查服务端响应,并在仓库支持时确认该版本已经可见。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
结果中应包含包名、版本、架构、产物路径、SHA-256,以及构建、上传和仓库可见性的
|
||||
独立状态。异步索引尚未完成时,Agent 应明确说明“上传已接受,索引待更新”。
|
||||
|
||||
## 上传协议
|
||||
|
||||
自带脚本适用于接收 multipart 字段 `package`、`token` 和 `repository_name` 的
|
||||
HTTP DEB 仓库,默认路径是 `/api/v2/upload/package`。使用以下环境变量:
|
||||
|
||||
- `DEB_SERVER_URL`:仓库服务地址。
|
||||
- `DEB_TOKEN`:认证令牌。
|
||||
- `DEB_REPOSITORY`:目标仓库名。
|
||||
- `DEB_UPLOAD_PATH`:可选的上传路径覆盖。
|
||||
|
||||
令牌只从环境变量读取,不作为命令行参数传递。若你的仓库使用其他 API 协议,应先告诉
|
||||
Agent,不要直接套用该脚本。
|
||||
@@ -1,150 +0,0 @@
|
||||
---
|
||||
name: deb-publisher
|
||||
description: >-
|
||||
构建并发布 Debian DEB 包:发现项目已有的 Makefile 和打包入口,使用 skill 自带的
|
||||
通用上传脚本提交包,校验包元数据与内容,并验证发布结果。触发词:构建 deb、
|
||||
发布 deb、上传 deb、提交 deb、推送 apt 仓库、打 Debian 包。仅分析打包逻辑时也可使用,
|
||||
但不会在未获授权时执行上传。
|
||||
---
|
||||
|
||||
# DEB Publisher
|
||||
|
||||
复用项目已有发布约定,安全地完成“发现入口 → 构建 → 检查 → 上传 → 验证”。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求构建、发布、上传或提交 `.deb` 包。
|
||||
- 用户要求梳理或接通项目现有的 DEB 发布流程。
|
||||
- 用户要求把已经生成的 `.deb` 推送到 APT/DEB 包仓库。
|
||||
|
||||
## 不适用
|
||||
|
||||
- 只需要安装或卸载本地 DEB 包。
|
||||
- 目标是 RPM、APK、容器镜像或语言包管理器。
|
||||
- 用户只要求设计全新的 Debian 打包体系;此时应先完成方案设计。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 发现项目约定
|
||||
|
||||
从项目根目录查找,不预设文件位置:
|
||||
|
||||
```bash
|
||||
rg -n -i --hidden --glob '!.git' \
|
||||
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- `Makefile`、CI 配置、`debian/`、构建脚本和发布文档。
|
||||
- 版本号、包名、架构、产物目录和仓库名如何传入。
|
||||
- 发布端点、认证方式以及发布是否由构建目标自动触发。
|
||||
- 当前工作树和目标版本是否匹配。
|
||||
|
||||
优先复用已有构建入口。上传默认使用本 skill 的 `scripts/upload_deb.sh`,不要把它
|
||||
复制到项目中;仅当目标仓库协议不兼容时才复用或修改项目专属上传逻辑。
|
||||
|
||||
### 2. 确认发布边界
|
||||
|
||||
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;若用户只要求查看、
|
||||
诊断或构建,则停在相应阶段。
|
||||
|
||||
执行上传前确认:
|
||||
|
||||
- 目标服务和仓库来自项目配置或用户输入,不猜测生产端点。
|
||||
- 认证令牌已通过环境变量或密钥系统提供。
|
||||
- 目标版本、架构和产物路径能够从构建配置推导。
|
||||
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问用户。
|
||||
|
||||
绝不把令牌写入命令输出、文件、提交或最终回复。不要用 `set -x` 执行含凭据的脚本。
|
||||
|
||||
### 3. 构建包
|
||||
|
||||
使用项目声明的构建目标,并显式传入版本。例如项目提供 Make 目标时:
|
||||
|
||||
```bash
|
||||
make build-deb VERSION="$RELEASE_VERSION"
|
||||
```
|
||||
|
||||
如果构建目标会自动上传,而当前仅获构建授权,应改用其纯构建子目标。执行前检查
|
||||
所需工具和环境,例如 Docker、`dpkg-deb`、编译器、SSH 访问或前端工具链。
|
||||
|
||||
不得擅自清理宽泛目录。若脚本包含 `rm -rf`,先解析并确认目标是明确、受限的构建目录。
|
||||
|
||||
### 4. 上传前检查
|
||||
|
||||
定位唯一目标产物;若匹配多个包,不凭文件时间猜测:
|
||||
|
||||
```bash
|
||||
find <artifact-dir> -maxdepth 2 -type f -name '*.deb' -print
|
||||
dpkg-deb --info <package.deb>
|
||||
dpkg-deb --contents <package.deb>
|
||||
```
|
||||
|
||||
至少验证:
|
||||
|
||||
- 文件存在、非空且 `dpkg-deb --info` 成功。
|
||||
- `Package`、`Version`、`Architecture` 与本次发布一致。
|
||||
- 包内容包含预期的主程序或关键文件。
|
||||
- maintainer scripts 存在时权限正确,且没有明显的宿主机破坏性操作。
|
||||
|
||||
建议记录 SHA-256:
|
||||
|
||||
```bash
|
||||
sha256sum <package.deb>
|
||||
```
|
||||
|
||||
### 5. 发布
|
||||
|
||||
解析当前 `SKILL.md` 所在目录,以绝对路径调用随 skill 分发的脚本:
|
||||
|
||||
```bash
|
||||
DEB_SERVER_URL="$DEB_SERVER_URL" \
|
||||
DEB_TOKEN="$DEB_TOKEN" \
|
||||
DEB_REPOSITORY="$DEB_REPOSITORY" \
|
||||
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
||||
```
|
||||
|
||||
不要把脚本复制进当前项目,也不要将 token 作为命令行参数。脚本默认请求
|
||||
`/api/v2/upload/package`,以 multipart 字段 `package`、`token`、
|
||||
`repository_name` 上传,接受 `200` 和 `201` 为成功。端点路径不同时可设置
|
||||
`DEB_UPLOAD_PATH`。
|
||||
|
||||
调用前确认目标服务使用上述协议;不兼容时不要强行调用。传入刚刚校验过的确切路径,
|
||||
不要使用宽泛 glob。项目已有 `make upload-deb` 时,检查它是否只是包装了同一协议:
|
||||
如果是,直接使用 skill 脚本;若 CI 或其他人仍依赖 Make 目标,可将目标改为调用已安装
|
||||
skill 的脚本,但不要提交脚本副本。
|
||||
|
||||
脚本支持多个确切文件路径,会汇总每个文件的结果,并在任一失败时返回非零。
|
||||
|
||||
### 6. 验证与汇报
|
||||
|
||||
发布成功不能只依据“curl 已执行”。综合检查:
|
||||
|
||||
- 上传命令退出码为零。
|
||||
- HTTP 状态和响应体明确表示成功。
|
||||
- 若仓库提供只读查询、索引或下载地址,再确认该包和版本已可见。
|
||||
- 若索引更新是异步的,报告“上传已接受,索引尚待更新”,不要声称已完全可用。
|
||||
|
||||
最终回复给出:
|
||||
|
||||
- 包名、版本、架构。
|
||||
- 产物路径和 SHA-256。
|
||||
- 目标服务/仓库的非敏感标识。
|
||||
- 构建、上传及仓库可见性各自的验证结果。
|
||||
- 任何未完成项或回滚/覆盖风险。
|
||||
|
||||
## 修改已有发布逻辑时
|
||||
|
||||
- 保持项目现有变量名和调用入口,避免无关重构。
|
||||
- 修复行为缺陷时增加最小静态检查或可离线运行的测试。
|
||||
- 可用 `bash -n` 检查脚本语法;项目有 ShellCheck 时一并运行。
|
||||
- skill 自带上传脚本是 SSOT;通用上传行为的修改应落在
|
||||
`skills/deb-publisher/scripts/upload_deb.sh`,不要同步复制到业务项目。
|
||||
- 不通过真实生产上传来测试脚本,除非用户明确授权并给出测试版本或测试仓库。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
||||
- 仅构建:DEB 已生成,元数据、内容和校验和通过检查,未发生上传。
|
||||
- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。
|
||||
@@ -0,0 +1,122 @@
|
||||
# deployer
|
||||
|
||||
把「服务怎么跑」写进 Git,部署方法由这个 skill 自带:换台电脑、换个项目都能直接用。
|
||||
|
||||
支持三种用法:
|
||||
|
||||
- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有 Compose 服务(如 app00)
|
||||
- **项目内 Compose 环境**:在普通项目里放 `.pouch/deployer/{prod,test,dev}/`,
|
||||
把这个项目的生产/测试/开发环境用 rsync + docker compose 部署
|
||||
- **Argo CD / GitOps**:项目里放 `.pouch/deployer/argocd.yaml`;Agent 改 GitOps 并开 MR,
|
||||
你合并后由 Argo CD 同步。仓库既可以只写 Git 地址(部署时浅 clone),也可以指定本机已有目录。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
|
||||
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
|
||||
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
|
||||
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
|
||||
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
|
||||
- ACK 说「重新布测试环境」或跑回归前要先拉起 `.pouch/deployer/test`
|
||||
- 需要一张「哪台机器跑哪些服务」的清单
|
||||
- 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- Compose:本机 Python 3、`rsync`、`ssh`;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
|
||||
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
|
||||
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
# 独立配置中心
|
||||
帮我把 vyyo1/naiveproxy 的配置改完部署上去
|
||||
升级 vora3/gpt-load 的镜像版本
|
||||
列一下现在所有服务和各自在哪台机器上
|
||||
新增一个服务 uptime 到 vora3,先帮我建好目录结构
|
||||
vhom1 上那个 naiveproxy 为什么 sync 失败?
|
||||
|
||||
# deb 包安装
|
||||
把 ./gpt-load_1.2.0_amd64.deb 装到 web1 上
|
||||
把这个目录里的三个 .deb 都推到 deploy@nas 再安装
|
||||
web1 能出网,直接让它从 https://... 把包拉下来装
|
||||
|
||||
# 项目内环境
|
||||
给这个项目建好 .pouch/deployer,prod 和 test 分别放到两台机器上
|
||||
把 test 环境重新部署一下
|
||||
prod 的 compose 加个 redis,改完发上去
|
||||
|
||||
# Argo CD
|
||||
把这个项目接到 argocd,仓库写成 git@git.example.com:org/infra-gitops.git
|
||||
GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||
升镜像 tag,改 GitOps 开 MR
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
|
||||
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
|
||||
2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`)
|
||||
3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启)
|
||||
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
|
||||
5. 同步后查看容器状态和日志确认生效
|
||||
6. 只针对你指定的那一个服务操作,不会批量动整台机器
|
||||
7. Argo CD:读 `.pouch/deployer/argocd.yaml`,浅 clone 或使用 `repo_dir`,改清单,推分支开 MR,停下来等你合并;
|
||||
不直接 kubectl 发布,不把 Harbor/TLS 密钥提交进 Git
|
||||
|
||||
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
|
||||
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
|
||||
|
||||
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据
|
||||
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
- 脚本输出显示同步完成、远程命令执行成功
|
||||
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
|
||||
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
|
||||
- 域名/端口类服务能 curl 通
|
||||
- Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问
|
||||
|
||||
## Argo CD 的两种接法
|
||||
|
||||
在业务项目里放 `.pouch/deployer/argocd.yaml`。Agent 只改 GitOps 并开 MR,**你合并之后** Argo CD 才部署。镜像、namespace、域名、Secret 名以 GitOps 清单为准,不必在这个文件里再抄一遍。
|
||||
|
||||
### 1. 只写仓库地址(默认)
|
||||
|
||||
本机不用长期放 GitOps 仓库。部署时 Agent 浅 clone 到临时目录,改完开 MR,用完删掉。
|
||||
|
||||
```yaml
|
||||
# .pouch/deployer/argocd.yaml
|
||||
repo: git@git.example.com:org/infra-gitops.git
|
||||
```
|
||||
|
||||
`https://git.example.com/org/infra-gitops.git` 也可以。对本机 git 来说这个地址要能 clone 和 push。
|
||||
|
||||
对 Agent 说:
|
||||
|
||||
```text
|
||||
把这个项目接到 argocd,仓库是 git@git.example.com:org/infra-gitops.git
|
||||
升到 v0.0.2,改 GitOps 开 MR
|
||||
```
|
||||
|
||||
### 2. 指定本地目录(不 clone)
|
||||
|
||||
GitOps 仓库已经 checkout 在旁边时,写 `repo_dir`,Agent 直接进这个目录改、推分支、开 MR。
|
||||
|
||||
```yaml
|
||||
# .pouch/deployer/argocd.yaml
|
||||
repo: git@git.example.com:org/infra-gitops.git
|
||||
repo_dir: ../infra-gitops
|
||||
```
|
||||
|
||||
也可以只写 `repo_dir`(远程从该目录的 `origin` 读)。相对路径相对**业务项目根**。
|
||||
|
||||
对 Agent 说:
|
||||
|
||||
```text
|
||||
GitOps 已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||
```
|
||||
|
||||
两种接法都可以再加 `path:`,仅当 Application 目录不是默认的 `argocd/applications/<项目名>` 时才需要。
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
name: deployer
|
||||
description: >-
|
||||
管理两类部署:多机 Docker Compose(仓库存 compose.yaml 与静态配置,本 skill 脚本
|
||||
同步到 SSH 节点后 docker compose 应用),以及 Argo CD GitOps(改 GitOps 仓库清单、
|
||||
开 PR/MR,用户合并后由 Argo CD 同步)。当用户要求部署、同步、升级、重启远程
|
||||
Compose 服务,向节点装 deb,新增/迁移/下线服务,梳理节点清单,make deploy TGT、
|
||||
_config.yaml、rsync、tar over SSH、NAS 部署失败;或要求 ArgoCD / GitOps / K8s
|
||||
部署、更新 Application、升镜像 tag、开 MR 让用户合并部署;或 ACK 要求拉起/
|
||||
重布项目测试环境时使用。
|
||||
---
|
||||
|
||||
# deployer:Compose 节点与 Argo CD GitOps
|
||||
|
||||
两条轨道,配置都在仓库里,方法由本 skill 提供。
|
||||
|
||||
- **Compose**:本地改 `compose.yaml` → 脚本同步到 SSH 节点 → 远程 `docker compose`。
|
||||
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用
|
||||
Compose 的 `sync.py`/`remote.py` 去推集群。
|
||||
|
||||
---
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 部署 / 同步 / 升级 / 重启某个远程 Docker Compose 服务
|
||||
- 向节点安装 deb 包:scp 上传本地 .deb 后 dpkg/apt 安装,或从 URL 远程拉取安装
|
||||
- 新增、迁移、下线一个服务;梳理「哪台机器跑什么」
|
||||
- sync 失败排查、证书丢失、改了配置不生效等运维问题
|
||||
- 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步
|
||||
- Argo CD / GitOps / 集群部署:新增 Application、改清单、升镜像 tag、开 MR 等用户合并
|
||||
- ACK Coordinator 拉起或重布项目测试环境(`.pouch/deployer/<env>`)
|
||||
|
||||
## 不适用
|
||||
|
||||
- 单机 docker 日常使用(无多机同步诉求)
|
||||
- Nomad,或绕过 GitOps 用 `kubectl apply` 当常规发布
|
||||
- 构建并推送镜像(走 builder);本 skill 只改 GitOps 里对该镜像的引用
|
||||
|
||||
---
|
||||
|
||||
## 核心模型(先读懂再动手)
|
||||
|
||||
### 轨道选择
|
||||
|
||||
| 信号 | 轨道 |
|
||||
|------|------|
|
||||
| ArgoCD / GitOps / 集群 / 开 MR 部署 / 项目有 `.pouch/deployer/argocd.yaml` | Argo CD,见 [argocd.md](references/argocd.md) |
|
||||
| sync、`TGT=`、某台机器、`compose.yaml` | Compose(下文布局与步骤) |
|
||||
| 两者都有且意图不清 | 先问 |
|
||||
|
||||
### Compose
|
||||
|
||||
- **仓库只放数据**:`compose.yaml`、Caddyfile、Traefik 动态配置等静态配置进 Git;
|
||||
运行时数据(证书、数据库、上传文件)永不进 Git,也永不参与同步范围。
|
||||
- **每个可部署服务目录必须有 `compose.yaml`**,且能解析出目标节点 `node`
|
||||
(来自该目录、部署根或祖先目录的 `_config.yaml`,或父目录名恰为 SSH Host 别名)。
|
||||
- **`node` 即 SSH Host 别名**(`~/.ssh/config`),支持 `user@host` 形式。
|
||||
- `unused/` 下不参与自动发现与部署。
|
||||
|
||||
### Argo CD
|
||||
|
||||
源项目 `.pouch/deployer/argocd.yaml`:`repo` 写 Git 地址(部署时浅 clone),或加 `repo_dir` 用已有 checkout。
|
||||
改 GitOps 清单,不要改 Compose 脚本。密钥不入库。两种接法见 skill README,步骤见 [argocd.md](references/argocd.md)。
|
||||
|
||||
### 两种 Compose 布局
|
||||
|
||||
**A. 独立配置中心仓库**(如 app00):仓库根即部署根,
|
||||
`DEPLOYER_ROOT=/path/to/repo` 指定后按仓库内相对路径操作:
|
||||
|
||||
```
|
||||
repo/
|
||||
├── _config.yaml # 可选,全局默认
|
||||
├── vyyo1/_config.yaml # node: vyyo1(主机目录)
|
||||
│ └── naiveproxy/ # 服务目录:compose.yaml + 可选 _config.yaml
|
||||
└── unused/
|
||||
```
|
||||
|
||||
远程目录名 = 目录末级名:`vyyo1/naiveproxy` → `/opt/app/naiveproxy`。
|
||||
|
||||
**B. 项目内环境布局**:项目根放 `.pouch/deployer/{prod,test,dev}/`,
|
||||
每个环境一个目录。从项目内任意位置运行脚本即自动发现(也可用 `DEPLOYER_ROOT`
|
||||
显式指定),无需环境变量:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── src/ ... # 项目本体
|
||||
└── .pouch/deployer/
|
||||
├── _config.yaml # 三个环境共享默认(node/base_path 等)
|
||||
├── argocd.yaml # 可选,Argo CD 指针(不是 compose 环境)
|
||||
├── prod/
|
||||
│ ├── compose.yaml # 生产 compose 与配置
|
||||
│ └── _config.yaml # 环境级覆盖
|
||||
├── test/compose.yaml
|
||||
└── dev/compose.yaml
|
||||
```
|
||||
|
||||
项目模式下远程目录名自动加项目前缀 `{git仓库名}-{env}`
|
||||
(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖;
|
||||
`_config.yaml` 写 `name:` 可显式指定。
|
||||
|
||||
## 被 ACK 调用
|
||||
|
||||
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对项目
|
||||
`.pouch/deployer/<env>`(通常是 `test`)按下面 Compose 轨道执行。ACK 只负责何时
|
||||
布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、
|
||||
Argo CD 合入和节点级批量操作仍须用户明确要求,不能因为 ACK 调用就扩大范围。
|
||||
|
||||
## 步骤
|
||||
|
||||
### Compose 轨道
|
||||
|
||||
#### 0. 定位部署根
|
||||
|
||||
skill 目录下的 `scripts/deploy/` 是通用部署工具链(lib/sync/remote/list),
|
||||
不依赖具体项目路径。部署根按以下顺序解析:
|
||||
|
||||
1. 环境变量 `DEPLOYER_ROOT` 显式指定(独立配置中心仓库用这个)
|
||||
2. 从当前目录向上找 `.pouch/deployer/`(项目内环境布局自动发现)
|
||||
3. skill 安装位置兜底(仅用于查看,没有可部署服务)
|
||||
|
||||
```bash
|
||||
# <skill-dir> = 本 SKILL.md 所在目录,先解析出来记下
|
||||
# 布局 A:显式指定仓库根
|
||||
export DEPLOYER_ROOT=/path/to/your-compose-repo
|
||||
python3 <skill-dir>/scripts/deploy/list.py
|
||||
|
||||
# 布局 B:在项目内直接跑即可(cwd 在项目里)
|
||||
python3 <skill-dir>/scripts/deploy/list.py
|
||||
```
|
||||
|
||||
#### 1. 摸底:列出服务与节点
|
||||
|
||||
上一步的 `list.py` 输出全部服务与节点分布;新增环境/服务后重跑确认被发现。
|
||||
项目布局下 `prod/test/dev` 各显示为 `{项目名}-{env}`。`argocd.yaml` 不会被 list.py 当成 compose 服务。
|
||||
|
||||
#### 2. 解析单个服务
|
||||
|
||||
```bash
|
||||
# 查看 node、远程路径、排除规则(sync.py 干跑会打印这些信息)
|
||||
python3 <skill-dir>/scripts/deploy/sync.py <service-path>
|
||||
```
|
||||
|
||||
或直接读服务目录及祖先的 `_config.yaml`。
|
||||
|
||||
#### 3. 命令选择(语义严格区分)
|
||||
|
||||
| 意图 | 命令 |
|
||||
|------|------|
|
||||
| 只同步文件,不动容器 | `sync.py <svc>` |
|
||||
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
|
||||
| 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) |
|
||||
| 镜像 tag 变更升级 | `sync.py <svc> && remote.py <svc> upgrade` |
|
||||
| 仅重启,不同步文件 | `remote.py <svc> restart` |
|
||||
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
|
||||
|
||||
#### 4. 项目侧 Makefile(可选薄封装)
|
||||
|
||||
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。
|
||||
没有 Makefile 时直接调 python 脚本即可,不要新建封装层。
|
||||
|
||||
#### 5. 新增服务 / 环境 checklist
|
||||
|
||||
独立仓库布局:
|
||||
|
||||
1. 在合适分类目录创建服务文件夹,写 `compose.yaml`
|
||||
2. 在服务目录或祖先目录放 `_config.yaml`(至少能解析出 `node`)
|
||||
3. 有运行时目录 → 加进 `sync_exclude`
|
||||
4. 远程首次建目录:`ssh <node> "mkdir -p <base_path>/<name>"`
|
||||
5. 首次部署:sync + up
|
||||
6. 验证:ps + logs,必要时 curl/ssh 检查端口
|
||||
|
||||
项目环境布局:
|
||||
|
||||
1. 项目根建 `.pouch/deployer/{env}/`(env 通常为 prod/test/dev)
|
||||
2. 每个环境写 `compose.yaml`;三个环境共享的 node/base_path 放
|
||||
`.pouch/deployer/_config.yaml`
|
||||
3. 环境有差异(不同主机、不同排除项)→ 在该环境的 `_config.yaml` 覆盖
|
||||
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
|
||||
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉)
|
||||
|
||||
#### 6. 下线服务
|
||||
|
||||
独立仓库布局:配置移入 `unused/`(自动脱离发现体系),远程按需手动清理:
|
||||
`ssh <node> "cd <base_path>/<name> && docker compose down"`,数据卷按需保留或删除。
|
||||
项目环境布局:删除对应 `.pouch/deployer/{env}/` 目录即可脱离发现体系,远程清理同上。
|
||||
|
||||
#### 7. 向节点安装 deb 包
|
||||
|
||||
`deb.py` 把 deb 包发到节点并安装。目标两种写法:仓库内目录
|
||||
(复用 `_config.yaml` 继承链解析 node/port/identity_file,如 `hosts/web1`),
|
||||
或裸 SSH 别名 / `user@host`(须在 `~/.ssh/config` 中,可加 `--port`/`--identity`)。
|
||||
|
||||
```bash
|
||||
# 本地 .deb → scp 上传 → 远程 apt 安装(失败自动 apt -f 修依赖),成功后删暂存包
|
||||
python3 <skill-dir>/scripts/deploy/deb.py <target> push ./foo_1.0_amd64.deb --yes
|
||||
|
||||
# 仅上传到远程暂存目录(默认 {base_path}/.debs;裸主机为 /tmp/deployer-debs)
|
||||
python3 <skill-dir>/scripts/deploy/deb.py <target> scp ./foo_1.0_amd64.deb
|
||||
|
||||
# 安装该节点暂存目录里已上传的全部 .deb(配合 scp 分步操作)
|
||||
python3 <skill-dir>/scripts/deploy/deb.py <target> dpkg --yes
|
||||
|
||||
# 远程直接从 URL 下载安装(机器能出网时免上传)
|
||||
python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1.0_amd64.deb --yes
|
||||
```
|
||||
|
||||
- 非 root 用户走 `sudo -n`(需配好免密 sudo);`--yes` 传 `-y` 免交互,
|
||||
无终端交互能力,没配 sudo 免密/密钥时会直接失败。
|
||||
- 升级同版本号前想先看包信息:`ssh <node> "dpkg -I <暂存路径>"`;
|
||||
装完验证:`ssh <node> "dpkg -l | grep <pkg>"`。
|
||||
|
||||
### Argo CD 轨道
|
||||
|
||||
完整步骤与 `argocd.yaml` 字段见 [argocd.md](references/argocd.md)。
|
||||
|
||||
1. 读项目 `.pouch/deployer/argocd.yaml`(无则只问 Git 地址,写成 `repo:`)。
|
||||
有 `repo_dir` 则用该目录;否则把 `repo` 浅 clone 到临时目录,用完删除。
|
||||
2. 在工作副本里按**已有应用惯例**新增 Application,或只改镜像 tag / 清单。
|
||||
3. 从最新默认分支拉出分支,commit、push,用 `glab`/`gh`/`tea` 开 MR;CLI 对项目 404 则把
|
||||
`git push` 给出的网页建单链接交给用户。
|
||||
4. **停在 MR**,不合并、不 `kubectl apply` 工作负载。
|
||||
5. 无 app-of-apps 时提醒用户首次 `kubectl apply` 那份 `application.yaml`。
|
||||
6. Harbor 拉镜像 Secret、TLS Secret 不入库;只在工作负载所在 ns 准备,可从其他 ns 拷贝。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止节点级批量操作**:所有 sync/up/recreate/upgrade/restart 必须按单服务执行。
|
||||
批量升级风险过高,逐个来。
|
||||
- **rsync 带 `--delete`**:远程多余文件会被删除。运行时数据必须放在
|
||||
默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径
|
||||
(如 `/data01/docker/<svc>/`),否则会被清掉。
|
||||
- **镜像固定 tag**,不用 `:latest` 漂移;成对升级的服务(如 proxy 客户端/服务端)要同步升。
|
||||
- **密钥**:Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
|
||||
不要提交新密钥进 Git。
|
||||
- **Git 安全**:不 `--force` 推送、不硬 reset,除非用户明确要求。Argo CD 轨道不直接推默认分支。
|
||||
- **NAS / Synology 特例**:部分 NAS 的 SSH 用户禁用 rsync 协议(Permission denied)。
|
||||
表现是 sync 报错但 ssh 正常。处理顺序:
|
||||
1. 该节点 `_config.yaml` 写真实 `base_path`(如 `/volume1/docker`,避开符号链接路径)
|
||||
2. 仍失败则手动 tar over SSH 推送:
|
||||
|
||||
```bash
|
||||
tar czf - -C <服务目录> . --exclude='data' --exclude='_data' \
|
||||
| ssh <node> "mkdir -p <base_path>/<name> && cd <base_path>/<name> && tar xzf -"
|
||||
ssh <node> "cd <base_path>/<name> && /usr/local/bin/docker compose up -d"
|
||||
```
|
||||
|
||||
tar 不会删除远程多余文件;需清理旧文件时手动 SSH 删除。
|
||||
3. Synology 上 docker 路径可能是 `/usr/local/bin/docker`
|
||||
|
||||
## 验证
|
||||
|
||||
- Compose:`list.py` 输出全部服务与节点分布,数量与预期一致
|
||||
- 每次 sync/deploy 后 `remote.py <svc> ps` 容器 Up、`logs` 无报错
|
||||
- 升级后额外确认镜像 tag 与 compose.yaml 一致
|
||||
- 改 Traefik/Caddy 路由后 curl 对应域名验证生效
|
||||
- Argo CD:MR 可打开且含本次清单;用户合并后 Application Synced;有 Ingress 则 curl healthz
|
||||
|
||||
## scripts/
|
||||
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `scripts/deploy/lib.py` | 解析服务目录、合并继承 `_config.yaml`、SSH/rsync/scp 参数构造 |
|
||||
| `scripts/deploy/sync.py` | rsync -avz --delete 同步;无 rsync 时 tar over SSH 兜底 |
|
||||
| `scripts/deploy/remote.py` | SSH 远程 docker compose:up/recreate/restart/upgrade/ps/logs |
|
||||
| `scripts/deploy/deb.py` | deb 包分发安装:push(scp+apt)/scp/dpkg/apt(URL) |
|
||||
| `scripts/deploy/list.py` | 扫描全部可部署服务 |
|
||||
|
||||
## references/
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `references/config-reference.md` | `_config.yaml` 字段完整说明与继承合并规则 |
|
||||
| `references/argocd.md` | Argo CD 轨道:`argocd.yaml`、新增/升级、开 MR、ns 级 Secret |
|
||||
@@ -0,0 +1,150 @@
|
||||
# Argo CD GitOps 轨道
|
||||
|
||||
改 GitOps 仓库里的 Application / 清单 → 开 PR/MR → **用户合并** → Argo CD 同步到集群。
|
||||
Agent 不直接 `kubectl apply` 工作负载,也不合并 MR。
|
||||
|
||||
Compose 的 `_config.yaml` / `sync.py` / `remote.py` 不用于本轨道。
|
||||
|
||||
## 项目配置
|
||||
|
||||
`argocd.yaml` 只做指针:GitOps 仓库的 Git 地址,以及可选的本地目录。Application 名、镜像、namespace、域名、Secret 名的权威在 GitOps 清单里,不要再抄一份。
|
||||
不要嵌进 `_config.yaml`(脚本解析器不支持嵌套映射)。
|
||||
|
||||
两种接法(人类说明见 skill `README.md`):
|
||||
|
||||
```yaml
|
||||
# 1)只写地址:部署时浅 clone 到临时目录,开完 MR 删掉
|
||||
repo: git@git.example.com:org/infra-gitops.git
|
||||
|
||||
# 2)已有 checkout:不 clone,直接在目录里改
|
||||
# repo: git@git.example.com:org/infra-gitops.git # 可选;缺省用该目录 origin
|
||||
repo_dir: ../infra-gitops # 相对路径相对项目根
|
||||
|
||||
# path: argocd/applications/other-name # 仅当目录不是 argocd/applications/<源项目名>
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `repo` | 与 `repo_dir` 至少一个 | Git 远程地址(`git@host:group/name.git` 或 `https://...`) |
|
||||
| `repo_dir` | 与 `repo` 至少一个 | 本地 checkout;相对**项目根**或绝对路径 |
|
||||
| `path` | 否 | 默认 `argocd/applications/<源项目 git 仓库名>` |
|
||||
|
||||
`repo` 写成本地路径(`../`、`/`、`~/`)时,当作 `repo_dir`(旧写法兼容)。
|
||||
|
||||
有 `repo_dir` 且目录是 git 仓库 → **不 clone**,在里面改。`repo` 同时存在且与 `origin` 指向不同仓库时停下问用户。
|
||||
只有 `repo` 地址 → `git clone --depth 1` 到 `mktemp -d`;开完 MR(或确认失败)后删掉临时目录。不要 clone 进源项目。
|
||||
|
||||
无此文件:只问 Git 地址,写成 `repo:` 再动手。
|
||||
|
||||
其余一律推导,不要写进这个文件:
|
||||
|
||||
| 需要的信息 | 从哪来 |
|
||||
|------------|--------|
|
||||
| 推送 remote | `repo` 地址,或 `git -C <repo_dir> remote get-url origin` |
|
||||
| Application 名 | `path` 末级,或 `application.yaml` 的 `metadata.name` |
|
||||
| 镜像(无 tag) | 清单里的 `image:`;首次无清单则项目 Makefile / `.env` 的 registry + name |
|
||||
| 升 tag 改哪个文件 | 在 `path` 下搜该镜像 |
|
||||
| namespace / host / imagePullSecret / TLS Secret | `application.yaml` 与 Ingress/Deployment |
|
||||
|
||||
默认 `path` 不存在:再按 `metadata.name == 源项目名` 搜;还没有则走「新增」,不要把 namespace/host 写回 `argocd.yaml`。
|
||||
|
||||
## 摸底
|
||||
|
||||
1. 读 `argocd.yaml`,按上一节得到工作副本(浅 clone 或 `repo_dir`)。`repo_dir` 先 `git fetch`;工作区有无关改动则 `git worktree add` 隔离。
|
||||
2. 打开 `path/`:有 `application.yaml` 视为已接入;只有惯例目录、没有本应用则走「新增」。
|
||||
3. **清单写法跟目标 GitOps 仓库已有应用走**,不要另起一套:
|
||||
- 自建镜像:`application.yaml` + `manifests/`(Deployment/Service/Ingress)
|
||||
- 上游 Helm:`sources[]` 里 chart + 本仓库 `values.yaml`
|
||||
4. 从兄弟应用抄:`spec.project`、`destination.server`、`syncPolicy`、IngressClass、TLS 引用方式。
|
||||
5. 仓库 README 写明「无 app-of-apps / 需逐个创建 Application」时,合入 main **不会**自动注册新 Application。
|
||||
|
||||
## 新增 Application
|
||||
|
||||
向用户确认清单里写不出的项:namespace、对外 host(或仅 ClusterIP)、是否要 `imagePullSecrets`、TLS Secret 名、镜像 tag。
|
||||
|
||||
然后在工作副本里:
|
||||
|
||||
1. 从最新默认分支拉出 `feat/<application>`(浅 clone 已在默认分支;`repo_dir` 从 `origin/<default>` 拉)。
|
||||
2. 建 `path/application.yaml` + 清单(或 values),字段对齐兄弟应用。
|
||||
3. 镜像用固定 tag,不用 `:latest`。
|
||||
4. Secret、dockerconfigjson、口令 **不入库**;Deployment 只引用 Secret 名。
|
||||
|
||||
## 升级 / 改清单
|
||||
|
||||
已有 Application 时:
|
||||
|
||||
1. 在 `path` 下搜到的镜像行,把 `image: <name>:<old>` 改成新 tag。
|
||||
2. 只改本次要求的清单;不要顺手改无关 values。
|
||||
3. tag 必须已经推进镜像仓库(本轨道不负责 `docker push`,那是 builder)。
|
||||
|
||||
## 提交 MR
|
||||
|
||||
1. 分支从最新 `origin/<default>` 拉出,名称如 `feat/<application>` 或 `chore/<application>-<tag>`。
|
||||
2. 只提交 GitOps 仓库内本次文件。
|
||||
3. `git push -u` 到 `origin`;不 `--force`、不硬 reset。
|
||||
4. 按 remote 选 CLI:**GitLab `glab mr create`**、**GitHub `gh pr create`**、**Gitea/Forgejo `tea pulls create`**。
|
||||
创建前读该子命令 `--help`;指定 title、body、base、head。
|
||||
5. CLI 对项目 404(token 看不到仓库)而 SSH push 已成功:把 `git push` 打印的「create merge request」网页链接交给用户,不要读 token 配置、不要改用 curl 带 token。
|
||||
6. **停在 MR**。不合并、不替用户点 Sync。浅 clone 的临时目录在得到 MR 链接(或确认失败)后删除。
|
||||
|
||||
MR 正文写清:改了什么、镜像引用、合入后用户还要做的事(首次 apply Application、拷 Secret、验 healthz/域名)。
|
||||
|
||||
## 合入后交给用户
|
||||
|
||||
无 app-of-apps 时,**第一次**注册 Application(之后改 manifests 合入即可):
|
||||
|
||||
```bash
|
||||
kubectl apply -f <path>/application.yaml
|
||||
```
|
||||
|
||||
TLS / 拉镜像 Secret 必须已在**工作负载所在 ns** 存在。没有则从已有 ns 拷贝(见下),不要新建一份写进 Git。
|
||||
|
||||
## Secret(ns 级,不入库)
|
||||
|
||||
`imagePullSecrets` 与 Ingress `tls.secretName` 都只在 Pod/Ingress 所在 namespace 生效。
|
||||
不是集群每个 ns 都要建,只给**实际跑这个负载的 ns** 准备。
|
||||
|
||||
列出已有拉镜像 Secret(名字以 Deployment 的 `imagePullSecrets` 为准):
|
||||
|
||||
```bash
|
||||
kubectl get secret <secret> -A
|
||||
```
|
||||
|
||||
拷到目标 ns(类型须为 `kubernetes.io/dockerconfigjson`):
|
||||
|
||||
```bash
|
||||
kubectl get secret <secret> -n SOURCE -o json \
|
||||
| jq 'del(
|
||||
.metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp,
|
||||
.metadata.selfLink, .metadata.ownerReferences, .metadata.managedFields,
|
||||
.metadata.annotations, .status
|
||||
) | .metadata.namespace = "DEST"' \
|
||||
| kubectl apply -f -
|
||||
```
|
||||
|
||||
没有 `jq`:
|
||||
|
||||
```bash
|
||||
kubectl get secret <secret> -n SOURCE \
|
||||
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d > /tmp/dockerconfig.json
|
||||
kubectl -n DEST create secret generic <secret> \
|
||||
--type=kubernetes.io/dockerconfigjson \
|
||||
--from-file=.dockerconfigjson=/tmp/dockerconfig.json
|
||||
rm /tmp/dockerconfig.json
|
||||
```
|
||||
|
||||
本环境没有 kubeconfig 时,把命令交给用户执行,不要假装已经建好。
|
||||
|
||||
## 验证
|
||||
|
||||
- MR URL 可打开,head 含本次清单,base 为约定默认分支
|
||||
- 用户合并后:Argo CD 中该 Application Synced(automated 关闭时用户需手动 Sync)
|
||||
- 升级:集群里容器镜像 tag 与清单一致
|
||||
- 有 Ingress:curl 域名 healthz / 业务路径;HTTPS 失败先查 TLS Secret 是否在该 ns
|
||||
|
||||
## 不要做
|
||||
|
||||
- 把 Compose 的 rsync/`remote.py` 套到集群
|
||||
- 常规发布用 `kubectl apply` 工作负载绕过 GitOps
|
||||
- 提交 Harbor 密码、`dockerconfigjson`、TLS 私钥
|
||||
- 在未获「合并」授权时 `glab mr merge` / `gh pr merge`
|
||||
@@ -0,0 +1,140 @@
|
||||
# `_config.yaml` 配置参考
|
||||
|
||||
`_config.yaml` 供 **Compose 轨道**脚本解析,决定同步目标与排除规则。可放在**服务目录、部署根或其任意祖先目录**;子目录中的字段覆盖父目录(继承合并)。
|
||||
|
||||
Argo CD 轨道用独立文件 `.pouch/deployer/argocd.yaml`,字段见 [argocd.md](argocd.md)。
|
||||
不要把 `argocd:` 嵌进本文件(脚本解析器不支持嵌套映射)。
|
||||
|
||||
## 放置位置(两种布局)
|
||||
|
||||
| 布局 | 部署根 | `_config.yaml` 典型位置 |
|
||||
|------|--------|------------------------|
|
||||
| 独立配置中心仓库(`DEPLOYER_ROOT` 指向) | 仓库根 | 主机目录 `vyyo1/_config.yaml`、服务目录 |
|
||||
| 项目内环境 `.pouch/deployer/{env}/` | `.pouch/deployer/` | 根级共享默认、各环境目录覆盖 |
|
||||
|
||||
项目布局示例:
|
||||
|
||||
```yaml
|
||||
# .pouch/deployer/_config.yaml — 三个环境共享
|
||||
node: my-vps
|
||||
base_path: /srv/apps
|
||||
|
||||
# .pouch/deployer/prod/_config.yaml — 仅生产环境差异
|
||||
node: prod-vps # 覆盖父级
|
||||
name: my-project-api # 可选,覆盖默认的 {项目名}-{env}
|
||||
```
|
||||
|
||||
## 字段一览
|
||||
|
||||
| 字段 | 必填 | 可继承 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `node` | 是 | 是 | SSH 目标主机别名 |
|
||||
| `port` | 否 | 是 | SSH 端口,默认 22 |
|
||||
| `identity_file` | 否 | 是 | SSH 私钥路径 |
|
||||
| `base_path` | 否 | 是 | 远程部署根目录,默认 `/opt/app` |
|
||||
| `sync_exclude` | 否 | 是 | rsync 排除目录列表 |
|
||||
| `name` | 否 | 是 | 远程目录名覆盖(见下) |
|
||||
|
||||
## name(可选)
|
||||
|
||||
远程目录名默认取服务/环境文件夹最后一级名称。**项目内环境布局**默认改为
|
||||
`{git仓库名}-{env}`(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖。
|
||||
写 `name:` 显式指定最终远程目录名(不含 base_path 部分)。
|
||||
|
||||
## node(必填)
|
||||
|
||||
对应 `~/.ssh/config` 中的 `Host` 别名,支持带用户形式:
|
||||
|
||||
```yaml
|
||||
node: deploy@prod
|
||||
```
|
||||
|
||||
独立仓库布局默认远程目录名 = 服务文件夹最后一级名称:`vyyo1/naiveproxy` → `{base_path}/naiveproxy`;
|
||||
项目环境布局见上文 name 一节。
|
||||
兜底规则:合并后仍无 `node` 时,若**直接父目录名**是 SSH Host 别名则作为 node。
|
||||
|
||||
|
||||
## port(可选)
|
||||
|
||||
```yaml
|
||||
node: edge
|
||||
port: 2222
|
||||
```
|
||||
|
||||
sync 通过 `rsync -e "ssh -p PORT"` 连接,remote 使用 `ssh -p PORT`。
|
||||
与 `~/.ssh/config` 的 `Port` 同时存在时以 `_config.yaml` 为准(命令行覆盖 config)。
|
||||
|
||||
## identity_file(可选)
|
||||
|
||||
```yaml
|
||||
node: edge
|
||||
identity_file: ~/.ssh/deploy_ed25519
|
||||
```
|
||||
|
||||
须为绝对路径或 `~` 开头;私钥不进 Git。带 passphrase 的密钥需事先加入 ssh-agent,脚本不支持交互输入。
|
||||
|
||||
## base_path(可选)
|
||||
|
||||
```yaml
|
||||
node: nas
|
||||
base_path: /volume1/docker # 默认路径是符号链接的设备写真实路径
|
||||
```
|
||||
|
||||
实际同步目标 `{base_path}/{服务名}`。
|
||||
|
||||
## sync_exclude(可选)
|
||||
|
||||
rsync 排除的目录名列表(仅目录名)。默认已排除 `data`、`_data`。
|
||||
子级重新定义时**整体替换**父级列表(非追加):
|
||||
|
||||
```yaml
|
||||
sync_exclude:
|
||||
- data
|
||||
- _data
|
||||
- logs
|
||||
```
|
||||
|
||||
运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker/<svc>/`),避免被 `--delete` 清掉。
|
||||
|
||||
---
|
||||
|
||||
## 继承合并规则
|
||||
|
||||
1. 收集**部署根自身**的 `_config.yaml`(全局默认),再收集部署根到服务目录路径上的所有 `_config.yaml`
|
||||
2. 按祖先 → 子顺序合并;同名字段后者覆盖前者;`sync_exclude` 整体替换
|
||||
3. 无任何配置且父目录名为 SSH Host 别名 → 兜底为 node
|
||||
|
||||
## 配置组织建议
|
||||
|
||||
| 场景 | 建议 |
|
||||
|------|------|
|
||||
| 同一 SSH 主机多个服务 | 主机目录写一份 `node`/`base_path`,子服务免重复 |
|
||||
| 各服务目标不同 | 服务目录单独写 `_config.yaml` |
|
||||
| 个别覆盖 | 子目录只写差异字段 |
|
||||
| 项目三环境同主机 | `.pouch/deployer/_config.yaml` 写共享 node,各环境只放差异 |
|
||||
| 项目环境分属不同主机 | 各环境 `_config.yaml` 分别写 `node` |
|
||||
|
||||
## 示例
|
||||
|
||||
```yaml
|
||||
# hosts/nas/_config.yaml — 该节点下所有服务共享
|
||||
node: nas
|
||||
base_path: /volume1/docker
|
||||
|
||||
# hosts/web1/_config.yaml
|
||||
node: web1
|
||||
port: 2222
|
||||
identity_file: ~/.ssh/web1_ed25519
|
||||
|
||||
# hosts/web2/special/_config.yaml — 覆盖父级的 node
|
||||
node: deploy@web2-backup
|
||||
```
|
||||
|
||||
## 可部署服务判定
|
||||
|
||||
同时满足才被 `list.py` 发现:
|
||||
|
||||
- 目录内有 `compose.yaml`
|
||||
- 能通过继承或兜底解析出 `node`
|
||||
- 独立仓库布局:路径中不含 `unused/` 段
|
||||
- 项目环境布局:位于部署根 `.pouch/deployer/` 内(其外的项目文件不扫描)
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
远程分发并安装 deb 包。
|
||||
|
||||
目标 <target> 两种写法:
|
||||
1. 仓库内目录(含其祖先的 _config.yaml,解析 node/port/identity_file),
|
||||
例如 hosts/web1 或 hosts/web1/myapp —— 与 sync.py/remote.py 的目标语义一致。
|
||||
2. 裸 SSH 别名或 user@host(须在 ~/.ssh/config 中),可配合 --port/--identity。
|
||||
|
||||
用法:
|
||||
python deb.py <target> apt <deb-URL> # 远程 wget 下载 + apt 安装
|
||||
python deb.py <target> push <本地.deb> [...] # scp 上传 + dpkg 安装(失败自动 apt -f 修依赖)
|
||||
python deb.py <target> scp <本地.deb> [...] # 仅上传到远程暂存目录
|
||||
python deb.py <target> dpkg # 安装该节点暂存目录里已有的包
|
||||
|
||||
选项:
|
||||
--yes apt/dpkg 免交互(-y / --assume-yes)
|
||||
--staging-dir 远程暂存目录;默认目录目标用 {base_path}/.debs,裸主机用 /tmp/deployer-debs
|
||||
|
||||
示例:
|
||||
python deb.py hosts/web1/apt-proxy push ./gpt-load_1.0_amd64.deb
|
||||
python deb.py deploy@web1 apt https://example.com/pkg.deb --yes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lib import (
|
||||
project_root,
|
||||
run_remote,
|
||||
scp_base_args,
|
||||
service_info,
|
||||
ssh_config_hosts,
|
||||
)
|
||||
|
||||
BARE_HOST_STAGING = "/tmp/deployer-debs"
|
||||
|
||||
|
||||
def apt_prefix() -> str:
|
||||
"""root 直接跑 apt/dpkg,否则加 sudo -n(免密 sudo)。"""
|
||||
return 'APT="apt-get"; [ "$(id -u)" = 0 ] || APT="sudo -n apt-get"'
|
||||
|
||||
|
||||
class Target:
|
||||
"""安装目标:仓库内服务目录继承配置,或 ~/.ssh/config 中的裸主机别名。"""
|
||||
|
||||
def __init__(self, spec: str, port: int | None, identity: str | None, staging: str) -> None:
|
||||
self.spec = spec
|
||||
abs_dir = (project_root() / spec).resolve()
|
||||
if abs_dir.is_dir():
|
||||
self.info = service_info(spec)
|
||||
# 暂存目录只跟节点走(base_path/.debs),同一节点共享一个暂存区,
|
||||
# scp 与 dpkg 分步操作时才不会因目录不同而对不上
|
||||
default_staging = f"{self.info['base_path']}/.debs"
|
||||
else:
|
||||
host = spec.split("@", 1)[-1]
|
||||
if host not in ssh_config_hosts():
|
||||
print(
|
||||
f"错误: {spec!r} 既不是仓库内的服务/主机目录,"
|
||||
f"也不是 ~/.ssh/config 中的 Host 别名"
|
||||
)
|
||||
sys.exit(1)
|
||||
self.info = {
|
||||
"node": spec,
|
||||
"port": port,
|
||||
"identity_file": identity,
|
||||
}
|
||||
default_staging = BARE_HOST_STAGING
|
||||
self.staging_dir = staging or default_staging
|
||||
|
||||
@property
|
||||
def node(self) -> str:
|
||||
return self.info["node"]
|
||||
|
||||
def ssh(self, script: str) -> int:
|
||||
return run_remote(self.info, script)
|
||||
|
||||
def upload(self, local_paths: list[str]) -> list[str]:
|
||||
"""scp 上传文件到暂存目录,返回远程侧文件名列表。"""
|
||||
names = []
|
||||
for p in local_paths:
|
||||
name = Path(p).name
|
||||
if not name.endswith(".deb"):
|
||||
print(f"错误: 不是 .deb 文件: {p}")
|
||||
sys.exit(1)
|
||||
names.append(name)
|
||||
|
||||
remote = f"{self.node}:{shlex.quote(self.staging_dir)}/"
|
||||
cmd = [*scp_base_args(self.info), *local_paths, remote]
|
||||
print(f"正在上传到 {self.node}:{self.staging_dir}")
|
||||
print(f"$ {' '.join(cmd)}")
|
||||
print("-" * 60)
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"错误: scp 上传失败,退出码: {e.returncode}")
|
||||
sys.exit(e.returncode or 1)
|
||||
except FileNotFoundError:
|
||||
print("错误: scp 命令未找到")
|
||||
sys.exit(1)
|
||||
return names
|
||||
|
||||
def install_staged(self, names: list[str], *, assume_yes: bool) -> int:
|
||||
"""在远程对暂存目录里的指定包执行 apt 安装(失败自动 -f 修依赖)。"""
|
||||
quoted = " ".join(shlex.quote(f"{self.staging_dir}/{n}") for n in names)
|
||||
yes = " -y" if assume_yes else ""
|
||||
script = "\n".join(
|
||||
[
|
||||
"set -e",
|
||||
f"mkdir -p {shlex.quote(self.staging_dir)}",
|
||||
apt_prefix(),
|
||||
f"$APT install{yes} {quoted} || $APT install{yes} -f",
|
||||
]
|
||||
)
|
||||
return self.ssh(script)
|
||||
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def action_apt(target: Target, deb: str, *, assume_yes: bool) -> int:
|
||||
"""远程从 URL 安装:wget 到暂存目录后 apt-get install。"""
|
||||
if not deb.startswith(("http://", "https://", "ftp://")):
|
||||
print(f"提示: {deb!r} 不是 URL,请改用 push 上传本地包")
|
||||
return 2
|
||||
filename = deb.rstrip("/").rsplit("/", 1)[-1]
|
||||
staged = f"{target.staging_dir}/{filename}"
|
||||
yes = " -y" if assume_yes else ""
|
||||
script = "\n".join(
|
||||
[
|
||||
"set -e",
|
||||
f"mkdir -p {shlex.quote(target.staging_dir)}",
|
||||
apt_prefix(),
|
||||
f"wget -qO {_quote(staged)}.part {_quote(deb)}",
|
||||
f"mv {_quote(staged)}.part {_quote(staged)}",
|
||||
f"$APT install{yes} {_quote(staged)}",
|
||||
f"rm -f {_quote(staged)}",
|
||||
]
|
||||
)
|
||||
return target.ssh(script)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="向远程节点分发并安装 deb 包")
|
||||
parser.add_argument("target", help="仓库内目录(hosts/web1[/svc])或 SSH 别名(user@host)")
|
||||
parser.add_argument("action", choices=["apt", "push", "scp", "dpkg"])
|
||||
parser.add_argument("packages", nargs="*", help="apt 的 .deb URL;push/scp 的本地 .deb 文件")
|
||||
parser.add_argument("--yes", action="store_true", help="apt/dpkg 免交互(-y / --assume-yes)")
|
||||
parser.add_argument("--staging-dir", default="", help="远程暂存目录,默认 {base_path}/.debs")
|
||||
parser.add_argument("--port", type=int, help="SSH 端口(仅裸主机目标)")
|
||||
parser.add_argument("--identity", help="SSH 私钥路径(仅裸主机目标)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.chdir(project_root())
|
||||
target = Target(args.target, args.port, args.identity, args.staging_dir)
|
||||
|
||||
if args.action == "apt":
|
||||
if len(args.packages) != 1:
|
||||
parser.error("apt 需要恰好一个 .deb URL")
|
||||
return action_apt(target, args.packages[0], assume_yes=args.yes)
|
||||
|
||||
if args.action in ("push", "scp"):
|
||||
if not args.packages:
|
||||
parser.error(f"{args.action} 至少需要一个本地 .deb 文件")
|
||||
missing = [p for p in args.packages if not Path(p).is_file()]
|
||||
if missing:
|
||||
print(f"错误: 本地文件不存在: {', '.join(missing)}")
|
||||
return 1
|
||||
names = target.upload(args.packages)
|
||||
if args.action == "scp":
|
||||
print(f"已上传: {', '.join(names)} -> {target.node}:{target.staging_dir}")
|
||||
print("后续安装: python deb.py {} dpkg".format(args.target))
|
||||
return 0
|
||||
rc = target.install_staged(names, assume_yes=args.yes)
|
||||
if rc == 0:
|
||||
staged = " ".join(shlex.quote(f"{target.staging_dir}/{n}") for n in names)
|
||||
target.ssh(f"rm -f {staged}")
|
||||
return rc
|
||||
|
||||
# dpkg:安装暂存目录中已上传的全部包
|
||||
yes = " -y" if args.yes else ""
|
||||
glob = shlex.quote(f"{target.staging_dir}") + "/*.deb"
|
||||
script = "\n".join(
|
||||
[
|
||||
apt_prefix(),
|
||||
f"ls -1 {glob} >/dev/null 2>&1 || {{ echo '错误: {target.node}:{target.staging_dir} 没有暂存的 .deb'; exit 1; }}",
|
||||
f"$APT install{yes} {glob} || $APT install{yes} -f",
|
||||
]
|
||||
)
|
||||
return target.ssh(script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+365
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
"""deployer 部署工具公共模块:解析部署根、服务目录与 _config.yaml。
|
||||
|
||||
支持两种布局:
|
||||
1. 独立配置中心仓库(DEPLOYER_ROOT 指向,或 skill 安装位置)
|
||||
2. 项目内环境目录 .pouch/deployer/{prod,test,dev}/(从 CWD 自动发现;兼容 .skiff/deployer)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BASE_PATH = "/opt/app"
|
||||
DEFAULT_SYNC_EXCLUDES = ("data", "_data")
|
||||
_SKILL_DIR = Path(__file__).resolve().parent.parent # scripts/
|
||||
PROJECT_ROOT: Path | None = None
|
||||
_PROJECT_LAYOUT_DIRS = (".pouch", ".skiff")
|
||||
|
||||
|
||||
def _find_project_root() -> Path:
|
||||
"""部署根:DEPLOYER_ROOT > 从 CWD 向上找 .pouch/deployer > skill 安装位置。"""
|
||||
env = os.environ.get("DEPLOYER_ROOT", "").strip()
|
||||
if env:
|
||||
p = Path(env).expanduser().resolve()
|
||||
if not p.is_dir():
|
||||
print(f"错误: DEPLOYER_ROOT 不是目录: {p}")
|
||||
sys.exit(1)
|
||||
return p
|
||||
cur = Path.cwd()
|
||||
while True:
|
||||
for dirname in _PROJECT_LAYOUT_DIRS:
|
||||
cand = cur / dirname / "deployer"
|
||||
if cand.is_dir():
|
||||
return cand
|
||||
if cur == cur.parent:
|
||||
break
|
||||
cur = cur.parent
|
||||
return _SKILL_DIR.parent
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
global PROJECT_ROOT
|
||||
if PROJECT_ROOT is None:
|
||||
PROJECT_ROOT = _find_project_root()
|
||||
return PROJECT_ROOT
|
||||
|
||||
|
||||
def in_project_layout(root: Path | None = None) -> bool:
|
||||
"""部署根是否为某项目内的 .pouch/deployer/。"""
|
||||
root = root or project_root()
|
||||
return root.name == "deployer" and root.parent.name in _PROJECT_LAYOUT_DIRS
|
||||
|
||||
|
||||
def project_display_name(root: Path | None = None) -> str:
|
||||
"""项目名:git 仓库名优先,否则 .pouch 的父目录名。"""
|
||||
root = root or project_root()
|
||||
anchor = root.parent.parent if in_project_layout(root) else root
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(anchor), "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return Path(proc.stdout.strip()).name
|
||||
except OSError:
|
||||
pass
|
||||
return anchor.name
|
||||
|
||||
|
||||
def load_config(config_path: os.PathLike | str, *, required: bool = True) -> dict:
|
||||
"""解析精简版 _config.yaml(仅支持本项目使用的字段)。"""
|
||||
config: dict = {}
|
||||
current_list_key: str | None = None
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("- ") and current_list_key:
|
||||
config.setdefault(current_list_key, []).append(line[2:].strip())
|
||||
continue
|
||||
|
||||
current_list_key = None
|
||||
if ":" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not value:
|
||||
current_list_key = key
|
||||
config.setdefault(key, [])
|
||||
continue
|
||||
|
||||
config[key] = value
|
||||
except FileNotFoundError:
|
||||
if required:
|
||||
print(f"错误: 配置文件不存在: {config_path}")
|
||||
sys.exit(1)
|
||||
return {}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def config_paths_for_service(service_dir: str) -> list[Path]:
|
||||
"""收集部署根自身及服务目录各层 _config.yaml(祖先在前,服务目录在后)。
|
||||
|
||||
部署根的 _config.yaml(如 .pouch/deployer/_config.yaml)作为全局默认,
|
||||
对所有环境/服务生效。
|
||||
"""
|
||||
root = project_root()
|
||||
rel = Path(service_dir)
|
||||
paths: list[Path] = []
|
||||
root_config = root / "_config.yaml"
|
||||
if root_config.is_file():
|
||||
paths.append(root_config)
|
||||
for depth in range(1, len(rel.parts) + 1):
|
||||
config_path = root / Path(*rel.parts[:depth]) / "_config.yaml"
|
||||
if config_path.is_file() and config_path != root_config:
|
||||
paths.append(config_path)
|
||||
return paths
|
||||
|
||||
|
||||
def merge_service_config(service_dir: str) -> tuple[dict, list[Path]]:
|
||||
"""合并服务目录及其祖先的 _config.yaml,子级覆盖父级。"""
|
||||
merged: dict = {}
|
||||
sources = config_paths_for_service(service_dir)
|
||||
for config_path in sources:
|
||||
merged.update(load_config(config_path, required=True))
|
||||
return merged, sources
|
||||
|
||||
|
||||
def ssh_config_hosts() -> set[str]:
|
||||
"""读取 ~/.ssh/config 中的 Host 别名(不含通配符)。"""
|
||||
global _SSH_HOSTS
|
||||
if _SSH_HOSTS is not None:
|
||||
return _SSH_HOSTS
|
||||
|
||||
hosts: set[str] = set()
|
||||
config_path = Path.home() / ".ssh" / "config"
|
||||
if config_path.is_file():
|
||||
for raw_line in config_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if not line.lower().startswith("host "):
|
||||
continue
|
||||
for host in line.split()[1:]:
|
||||
if "*" in host or "?" in host or "!" in host:
|
||||
continue
|
||||
hosts.add(host)
|
||||
|
||||
_SSH_HOSTS = hosts
|
||||
return hosts
|
||||
|
||||
|
||||
_SSH_HOSTS: set[str] | None = None
|
||||
|
||||
|
||||
def parse_port(raw: str, *, strict: bool = True) -> int | None:
|
||||
"""解析 SSH 端口,无效时 strict 模式下退出。"""
|
||||
try:
|
||||
port = int(raw)
|
||||
except ValueError:
|
||||
if strict:
|
||||
print(f"错误: port 必须是整数: {raw!r}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
if not 1 <= port <= 65535:
|
||||
if strict:
|
||||
print(f"错误: port 超出有效范围 1-65535: {port}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
return port
|
||||
|
||||
|
||||
def resolve_identity_file(raw: str, *, strict: bool = True) -> str | None:
|
||||
"""解析 SSH 私钥路径(expanduser,须为绝对路径且文件存在)。"""
|
||||
path = os.path.expanduser(raw)
|
||||
if not os.path.isabs(path):
|
||||
if strict:
|
||||
print(f"错误: identity_file 必须是绝对路径或 ~ 开头: {raw!r}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
if not os.path.isfile(path):
|
||||
if strict:
|
||||
print(f"错误: identity_file 不存在: {path}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def ssh_base_args(info: dict) -> list[str]:
|
||||
"""ssh 可执行文件及 -p / -i 等选项(不含 host 与 remote command)。"""
|
||||
args = ["ssh"]
|
||||
port = info.get("port")
|
||||
if port is not None:
|
||||
args.extend(["-p", str(port)])
|
||||
identity_file = info.get("identity_file")
|
||||
if identity_file:
|
||||
args.extend(["-i", identity_file])
|
||||
return args
|
||||
|
||||
|
||||
def ssh_cmd(info: dict, remote_command: str) -> list[str]:
|
||||
"""构建 ssh 命令行(含可选 -p / -i)。"""
|
||||
return [*ssh_base_args(info), info["node"], remote_command]
|
||||
|
||||
|
||||
def rsync_ssh_args(info: dict) -> list[str]:
|
||||
"""rsync 需自定义 ssh 时通过 -e 传入 port / identity_file。"""
|
||||
base = ssh_base_args(info)
|
||||
if len(base) == 1:
|
||||
return []
|
||||
return ["-e", shlex.join(base)]
|
||||
|
||||
|
||||
def scp_base_args(info: dict) -> list[str]:
|
||||
"""scp 可执行文件及 -P / -i 选项(注意 scp 的端口是大写 P)。"""
|
||||
args = ["scp"]
|
||||
port = info.get("port")
|
||||
if port is not None:
|
||||
args.extend(["-P", str(port)])
|
||||
identity_file = info.get("identity_file")
|
||||
if identity_file:
|
||||
args.extend(["-i", identity_file])
|
||||
return args
|
||||
|
||||
|
||||
def run_remote(info: dict, remote_command: str, *, remote_cwd: str | None = None) -> int:
|
||||
"""在远程节点执行 shell 命令并返回退出码;remote_cwd 提供时先切换目录。"""
|
||||
shell = f"cd {remote_cwd} && {remote_command}" if remote_cwd else remote_command
|
||||
endpoint = info["node"]
|
||||
if info.get("port") is not None:
|
||||
endpoint += f":{info['port']}"
|
||||
location = f"{endpoint} {remote_cwd}" if remote_cwd else endpoint
|
||||
print(f"远程执行: {location}")
|
||||
print(f"$ {remote_command}")
|
||||
print("-" * 60)
|
||||
try:
|
||||
return subprocess.run(ssh_cmd(info, shell), check=False).returncode
|
||||
except FileNotFoundError:
|
||||
print("错误: ssh 命令未找到")
|
||||
return 1
|
||||
|
||||
|
||||
def node_from_parent_dir(service_dir: str) -> str | None:
|
||||
"""父目录名若是 SSH Host 别名,则作为 node。"""
|
||||
parent = Path(service_dir.rstrip("/")).parent.name
|
||||
if not parent or parent == ".":
|
||||
return None
|
||||
if parent in ssh_config_hosts():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def service_info(service_dir: str, *, strict: bool = True) -> dict | None:
|
||||
"""解析单个服务目录,返回 node、远程路径等信息。"""
|
||||
root = project_root()
|
||||
abs_dir = (root / service_dir).resolve()
|
||||
if not abs_dir.is_dir():
|
||||
if strict:
|
||||
print(f"错误: 服务目录不存在: {service_dir}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
|
||||
env_name = abs_dir.name
|
||||
rel_dir = str(abs_dir.relative_to(root))
|
||||
config, sources = merge_service_config(rel_dir)
|
||||
|
||||
# 远程目录名:_config.yaml 的 name 显式覆盖;项目布局默认 {项目名}-{env}
|
||||
# 防止同主机上多个项目的 prod/test 相互覆盖
|
||||
if "name" in config:
|
||||
name = str(config["name"])
|
||||
elif in_project_layout(root):
|
||||
name = f"{project_display_name(root)}-{env_name}"
|
||||
if strict:
|
||||
print(f"提示: 项目模式,远程名自动加前缀: {name!r}(_config.yaml 写 name: 可覆盖)")
|
||||
else:
|
||||
name = env_name
|
||||
|
||||
node = config.get("node")
|
||||
if not node:
|
||||
node = node_from_parent_dir(rel_dir)
|
||||
if node and strict and not config:
|
||||
print(f"提示: 未找到 _config.yaml,使用父目录 SSH 主机 {node!r}")
|
||||
|
||||
if not node:
|
||||
if strict:
|
||||
print(f"错误: 无法解析 node: {service_dir}")
|
||||
print(" 请在服务目录或其祖先目录添加 _config.yaml,或确保父目录是 SSH 主机别名")
|
||||
sys.exit(1)
|
||||
return None
|
||||
|
||||
base_path = config.get("base_path", DEFAULT_BASE_PATH)
|
||||
excludes = config.get("sync_exclude") or list(DEFAULT_SYNC_EXCLUDES)
|
||||
remote_dir = f"{base_path}/{name}"
|
||||
port = parse_port(config["port"], strict=strict) if "port" in config else None
|
||||
identity_file = (
|
||||
resolve_identity_file(config["identity_file"], strict=strict)
|
||||
if "identity_file" in config
|
||||
else None
|
||||
)
|
||||
|
||||
info = {
|
||||
"service_dir": str(abs_dir),
|
||||
"name": name,
|
||||
"node": node,
|
||||
"port": port,
|
||||
"identity_file": identity_file,
|
||||
"base_path": base_path,
|
||||
"remote_dir": remote_dir,
|
||||
"sync_exclude": excludes,
|
||||
"config_sources": [str(p.relative_to(root)) for p in sources],
|
||||
}
|
||||
local_config = abs_dir / "_config.yaml"
|
||||
if strict and sources and not local_config.is_file():
|
||||
rel_sources = info["config_sources"]
|
||||
if len(rel_sources) == 1:
|
||||
print(f"提示: 使用继承配置 {rel_sources[0]!r}")
|
||||
else:
|
||||
print(f"提示: 使用继承配置 {' -> '.join(rel_sources)!r}")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def is_deployable_dir(path: Path, root: Path) -> bool:
|
||||
if "unused" in path.parts or "__pycache__" in path.parts:
|
||||
return False
|
||||
if any(part in _PROJECT_LAYOUT_DIRS for part in path.parts) and root.name != "deployer":
|
||||
return False
|
||||
if not (path / "compose.yaml").is_file():
|
||||
return False
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def discover_services(node: str | None = None) -> list[dict]:
|
||||
"""扫描仓库内所有带 compose.yaml 且能解析 node 的服务。"""
|
||||
root = project_root()
|
||||
services: list[dict] = []
|
||||
|
||||
for compose_path in sorted(root.glob("**/compose.yaml")):
|
||||
service_dir = compose_path.parent
|
||||
if not is_deployable_dir(service_dir, root):
|
||||
continue
|
||||
rel = str(service_dir.relative_to(root))
|
||||
info = service_info(rel, strict=False)
|
||||
if info is None:
|
||||
continue
|
||||
if node is None or info["node"] == node:
|
||||
services.append(info)
|
||||
|
||||
return services
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""列出仓库内所有可部署服务及其目标节点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
from lib import discover_services, project_root
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = project_root()
|
||||
os.chdir(root)
|
||||
services = discover_services()
|
||||
if not services:
|
||||
print("未找到任何可部署服务(需 compose.yaml 且能解析 node)")
|
||||
return 0
|
||||
|
||||
by_node: dict[str, list[str]] = defaultdict(list)
|
||||
for info in services:
|
||||
rel = os.path.relpath(info["service_dir"], root)
|
||||
by_node[info["node"]].append(rel)
|
||||
|
||||
print(f"共 {len(services)} 个服务,分布在 {len(by_node)} 个节点:\n")
|
||||
for node in sorted(by_node):
|
||||
print(f"[{node}]")
|
||||
for service in by_node[node]:
|
||||
print(f" - {service}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
在远程节点上执行 docker compose 操作。
|
||||
|
||||
用法:
|
||||
python remote.py <服务目录> <命令>
|
||||
|
||||
命令:
|
||||
up 启动/更新容器(不拉镜像)
|
||||
recreate 强制重建容器(up -d --force-recreate,改配置后用)
|
||||
restart 重启 compose 内所有服务
|
||||
upgrade 拉取镜像并重建容器
|
||||
ps 查看容器状态
|
||||
logs 查看最近日志(非 follow)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from lib import project_root, run_remote, service_info
|
||||
|
||||
REMOTE_COMMANDS = {
|
||||
"up": "docker compose up -d",
|
||||
"recreate": "docker compose up -d --force-recreate",
|
||||
"restart": "docker compose restart",
|
||||
"upgrade": "docker compose pull && docker compose up -d",
|
||||
"ps": "docker compose ps",
|
||||
"logs": "docker compose logs --tail=100",
|
||||
}
|
||||
|
||||
|
||||
def run_for_service(service_dir: str, action: str) -> int:
|
||||
info = service_info(service_dir)
|
||||
compose_command = REMOTE_COMMANDS[action]
|
||||
endpoint = info["node"]
|
||||
if info.get("port") is not None:
|
||||
endpoint = f"{endpoint}:{info['port']}"
|
||||
print(f"服务: {service_dir} -> {endpoint}:{info['remote_dir']}")
|
||||
return run_remote(info, compose_command, remote_cwd=info["remote_dir"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="在远程节点执行 docker compose 操作")
|
||||
parser.add_argument("target", help="服务目录,例如 hosts/web1/myapp")
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=sorted(REMOTE_COMMANDS),
|
||||
help="远程操作",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.chdir(project_root())
|
||||
return run_for_service(args.target, args.action)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
目录同步脚本
|
||||
将指定目录同步到远程机器
|
||||
|
||||
用法:
|
||||
python sync.py <目录路径>
|
||||
例如: python sync.py hosts/web1/myapp
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from lib import DEFAULT_BASE_PATH, project_root, rsync_ssh_args, service_info, ssh_cmd
|
||||
|
||||
|
||||
def sync_directory(info: dict) -> int:
|
||||
if shutil.which("rsync"):
|
||||
return _sync_rsync(info)
|
||||
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)")
|
||||
return _sync_tar(info)
|
||||
|
||||
|
||||
def _sync_rsync(info: dict) -> int:
|
||||
source_dir = info["service_dir"]
|
||||
node = info["node"]
|
||||
remote_path = f"{node}:{info['remote_dir']}"
|
||||
|
||||
rsync_cmd = [
|
||||
"rsync",
|
||||
"-avz",
|
||||
"--delete",
|
||||
*rsync_ssh_args(info),
|
||||
]
|
||||
for item in info["sync_exclude"]:
|
||||
rsync_cmd.append(f"--exclude={item}/")
|
||||
|
||||
rsync_cmd.extend([f"{source_dir}/", remote_path])
|
||||
|
||||
print(f"正在同步 {source_dir} 到 {remote_path}")
|
||||
if info["sync_exclude"]:
|
||||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||
print(f"执行命令: {' '.join(rsync_cmd)}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
subprocess.run(rsync_cmd, check=True)
|
||||
print("-" * 60)
|
||||
print("同步完成!")
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print("错误: rsync 命令未找到,请确保已安装 rsync")
|
||||
return 1
|
||||
|
||||
|
||||
def _sync_tar(info: dict) -> int:
|
||||
source_dir = info["service_dir"]
|
||||
remote_dir = info["remote_dir"]
|
||||
tar_cmd = ["tar", "czf", "-", "-C", source_dir]
|
||||
for item in info["sync_exclude"]:
|
||||
tar_cmd.append(f"--exclude={item}")
|
||||
tar_cmd.append(".")
|
||||
|
||||
remote_shell = (
|
||||
f"mkdir -p {shlex.quote(remote_dir)} && "
|
||||
f"tar xzf - -C {shlex.quote(remote_dir)}"
|
||||
)
|
||||
ssh = ssh_cmd(info, remote_shell)
|
||||
print(f"正在同步 {source_dir} 到 {info['node']}:{remote_dir}")
|
||||
if info["sync_exclude"]:
|
||||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||
print(f"执行命令: tar | {' '.join(ssh)}")
|
||||
print("-" * 60)
|
||||
|
||||
tar = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE)
|
||||
try:
|
||||
completed = subprocess.run(ssh, stdin=tar.stdout, check=False)
|
||||
finally:
|
||||
if tar.stdout:
|
||||
tar.stdout.close()
|
||||
tar.wait()
|
||||
if tar.returncode:
|
||||
print(f"错误: tar 打包失败,退出码: {tar.returncode}")
|
||||
return tar.returncode
|
||||
if completed.returncode:
|
||||
print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}")
|
||||
return completed.returncode
|
||||
print("-" * 60)
|
||||
print("同步完成!")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将指定目录同步到远程机器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python sync.py hosts/web1/myapp
|
||||
python sync.py infra/traefik --base-path /opt/app
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"directory",
|
||||
help="要同步的目录路径(相对项目根,例如: hosts/web1/myapp)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--base-path",
|
||||
default=DEFAULT_BASE_PATH,
|
||||
help=f"远程基础路径(默认: {DEFAULT_BASE_PATH})",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
info = service_info(args.directory)
|
||||
if args.base_path != DEFAULT_BASE_PATH:
|
||||
name = info["name"]
|
||||
info["base_path"] = args.base_path
|
||||
info["remote_dir"] = f"{args.base_path}/{name}"
|
||||
|
||||
print(f"目标节点: {info['node']}")
|
||||
if info.get("port") is not None:
|
||||
print(f"SSH 端口: {info['port']}")
|
||||
if info.get("identity_file"):
|
||||
print(f"SSH 密钥: {info['identity_file']}")
|
||||
print(f"源目录: {args.directory}")
|
||||
print(f"远程路径: {info['remote_dir']}")
|
||||
print()
|
||||
|
||||
return sync_directory(info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
# draw-prototype-flow
|
||||
|
||||
把产品想法或需求文档画成一份可编辑的 Draw.io 低保真原型,并同步表达用户动线、页面状态、角色权限和关键规则。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 想从零梳理 Web、移动端或后台系统的页面原型与用户流程。
|
||||
- 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。
|
||||
- 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。
|
||||
|
||||
可以指定「快速 / 标准 / 完整」档位;不指定时默认按产品评审所需的标准档交付。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
请用 draw-prototype-flow 为一个企业访客预约系统画低保真原型和用户动线。
|
||||
覆盖访客申请、员工审批、前台核验,以及驳回、过期和无权限状态,输出 editable.drawio。
|
||||
```
|
||||
|
||||
```text
|
||||
检查 current.drawio,把所有主按钮的去向、审批状态和错误恢复路径补完整,并保持现有视觉风格。
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
1. 整理角色、页面、动作、状态和规则。
|
||||
2. 绘制可编辑页面、关键状态和带标签的用户动线。
|
||||
3. 补充页面索引、权限矩阵或字段规则等必要规格。
|
||||
4. 校验 Draw.io 结构与跨图引用,并说明假设和未决项。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: draw-prototype-flow
|
||||
description: >-
|
||||
将产品想法、需求文档或现有系统整理成可编辑的低保真 Draw.io 原型、用户动线和产品规则说明板。
|
||||
当用户提出画原型图、线框图、用户流程、用户动线、页面跳转、交互状态、wireframe、user flow、
|
||||
UX flow、产品方案可视化,或要求检查和完善现有 .drawio 产品原型时使用。适合同时表达页面、弹窗、
|
||||
异常态、角色权限、状态流转、字段规则与操作反馈;不用于像素级高保真 UI、纯技术架构图或单张插画。
|
||||
---
|
||||
|
||||
# 绘制低保真原型与用户动线
|
||||
|
||||
把原型、动线和关键规则放进同一套可追踪的产品模型,交付原生可编辑的 `.drawio` 文件。
|
||||
|
||||
## 使用资源
|
||||
|
||||
- 快速单页原型复制 `assets/prototype-flow-template.drawio`,保留其画板尺寸、语义色和基础组件,再替换占位内容。
|
||||
- 多页面原型复制 `assets/scaffold-spec.example.json`,填写页面清单和 `transitions`,再运行 `scripts/scaffold_drawio.py` 生成可编辑骨架与总览连线,禁止手工从空 XML 重复搭建外壳。
|
||||
- 开始绘制前读取 `references/design-patterns.md`;遇到复杂业务、多个角色或超过 8 个页面时,再读取 `references/product-model.md`。
|
||||
- 完成后运行 `scripts/validate_drawio.py`。该脚本可检查常见的压缩或未压缩 Draw.io 文件;Agent 输出时仍设置 `compressed="false"`,便于审阅、差异比较和后续修改。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 确认交付边界
|
||||
|
||||
从用户材料中提取目标用户、核心任务、终端形态、必须覆盖的流程和输出路径。信息不全但不影响主流程时,采用合理假设并在交付说明中列出;只有会改变产品范围或关键流程时才询问。
|
||||
|
||||
默认交付:
|
||||
|
||||
- 原生可编辑 `.drawio`;
|
||||
- 一张端到端动线总览;
|
||||
- 核心任务的页面与关键状态;
|
||||
- 页面索引、状态流转和角色权限等必要规格;
|
||||
- 可选的 PNG/SVG 预览,仅作为查看副本,不能代替源文件。
|
||||
|
||||
根据请求选择交付档位,避免把概念验证画成大而全的规格库:
|
||||
|
||||
- **快速**:1 条核心动线、3~8 个画板、最高风险的 1~2 个异常;适合早期讨论。
|
||||
- **标准(默认)**:核心任务、8~20 个画板、动线总览、关键状态和必要的权限/字段规则;适合产品评审。
|
||||
- **完整**:多角色、多业务域、系统异常、状态机、权限矩阵和详细规格;适合开发与测试对齐。
|
||||
|
||||
用户没有指定时,根据材料成熟度选择,先说明档位和不覆盖的内容。
|
||||
|
||||
### 2. 先建模,再画图
|
||||
|
||||
建立稳定 ID,避免用会随排序变化的显示序号充当引用:
|
||||
|
||||
- 角色:`R-APPLICANT`、`R-APPROVER`;
|
||||
- 页面:`P-LOGIN`、`P-ORDER-LIST`;
|
||||
- 状态:`S-DRAFT`、`S-PENDING`;
|
||||
- 动作:`A-SUBMIT`、`A-REJECT`。
|
||||
|
||||
至少整理以下关系:
|
||||
|
||||
1. 角色 → 目标 → 入口;
|
||||
2. 页面/弹窗 → 可见信息 → 可执行动作;
|
||||
3. 动作 → 前置条件 → 成功去向 → 失败反馈;
|
||||
4. 业务对象 → 状态 → 可编辑性 → 角色权限;
|
||||
5. 字段 → 必填/值域/联动 → 校验反馈。
|
||||
|
||||
复杂任务使用 `references/product-model.md` 中的清单模板。先消除断头页面、无去向按钮和无入口状态,再开始排版。
|
||||
|
||||
### 3. 选择画布结构
|
||||
|
||||
- 不超过 12 个交互画板:可放在一个 Draw.io 页面,按「主流程 → 异常态 → 规格」排列。
|
||||
- 超过 12 个交互画板:默认拆成多个 Draw.io 页面,例如 `00-总览`、`10-申请人`、`20-审批人`、`30-异常状态`、`40-规格`。总览页保留所有跨域跳转。
|
||||
- 用户明确需要一张巨幅说明板时才使用单页矩阵;每行最多 5 个 1600×900 画板,横向间距 120,纵向间距 260,先放原型、再放流程和规格。
|
||||
|
||||
这里的“交互画板”只统计用户可进入的页面、弹窗和异常状态;动线总览、页面索引、字段字典和权限矩阵不参与 12 个阈值计算。生成多页骨架:
|
||||
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/scaffold_drawio.py scaffold-spec.json output.drawio
|
||||
```
|
||||
|
||||
每个画板标题使用「显示序号 · 页面名 · 状态」;底部元信息写角色、入口、核心功能和下一步,但跨页引用使用稳定 ID,例如 `下一步:P-DETAIL`,不要手写易漂移的序号。
|
||||
|
||||
### 4. 绘制页面与状态
|
||||
|
||||
遵循 `references/design-patterns.md` 的视觉语法,并保持低保真:
|
||||
|
||||
- 使用一致的应用外壳、导航、标题层级、表单、表格、按钮和反馈组件;
|
||||
- 主操作、成功、警告、危险和辅助跳转使用固定语义色;
|
||||
- 按钮、字段、弹窗和表格单元格保持独立图元,禁止把整个页面栅格化为图片;
|
||||
- 为核心任务画成功路径,也覆盖与业务相关的加载、空数据、校验失败、权限不足、并发冲突和不可逆确认;
|
||||
- 弹窗作为触发页面的明确状态,必须同时画取消/返回路径和确认后的结果;
|
||||
- 内容优先使用真实语义的示例数据,避免大段 Lorem Ipsum。
|
||||
|
||||
### 5. 绘制动线
|
||||
|
||||
在总览中按角色或阶段使用泳道。所有跳转必须使用 Draw.io edge,并设置真实的 `source`、`target`:
|
||||
|
||||
- 节点写页面稳定 ID 与名称;
|
||||
- 连线标签写用户动作或系统结果,如「提交审批」「保存失败」「自动完结」;
|
||||
- 判断节点写条件,分支在线上写具体结果;
|
||||
- 成功用绿、警告/可恢复异常用橙、破坏性操作用红、普通导航用蓝;
|
||||
- 连线优先正交,绕开画板与文字,不用无来源或无目标的装饰箭头;
|
||||
- 每个入口可达,每个非终态有去向,每个终态有清晰结果。
|
||||
|
||||
### 6. 补齐产品规格
|
||||
|
||||
原型之后至少提供页面与动作索引。按复杂度增加:
|
||||
|
||||
- 角色 × 页面 × 操作权限矩阵;
|
||||
- 状态流转、触发动作、操作者和结果;
|
||||
- 字段字典、值域、必填与联动规则;
|
||||
- 异常反馈与恢复动作;
|
||||
- 报表、导出或批处理口径。
|
||||
|
||||
不要为凑数量复制页面。规格应解释原型无法准确表达的规则,并使用与原型相同的稳定 ID。
|
||||
|
||||
### 7. 一致性检查
|
||||
|
||||
逐项核对:
|
||||
|
||||
1. 每个核心任务都能从入口走到明确结果;
|
||||
2. 每个可点击主操作都在动线或规格中有去向;
|
||||
3. 每条连线的源按钮、目标页面和标签语义一致;
|
||||
4. 页面状态、角色权限、字段规则和按钮可编辑性不冲突;
|
||||
5. 确认弹窗、失败反馈和恢复路径成对出现;
|
||||
6. 所有引用使用稳定 ID,显示序号没有被当作唯一标识;
|
||||
7. 文字没有明显截断,图元不重叠,连线不穿过主要内容。
|
||||
|
||||
运行结构校验:
|
||||
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/validate_drawio.py <output.drawio> --min-frames 1
|
||||
```
|
||||
|
||||
存在两个以上交互画板时追加 `--min-edges 1`,确保至少有一条真实动线;正式交付按核心转移清单设置更高的最小连线数。
|
||||
|
||||
多页面或正式交付追加 `--strict`;如果环境有 Draw.io 导出工具,再导出 PNG/SVG 并目视检查全图和 2~3 个关键画板。不要把包含私有需求的文件上传到公共在线转换服务。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- `.drawio` 能被 XML 解析并在 diagrams.net/Draw.io 打开;
|
||||
- 图元和文字可单独编辑,关键跳转是有源和目标的连接线;
|
||||
- 核心流程、关键分支、角色权限和业务状态相互一致;
|
||||
- 校验无 error;正式交付在 `--strict` 下也无 warning,或逐条说明保留原因;
|
||||
- 向用户列出产物路径、采用的假设、覆盖范围以及仍待产品决策的问题。
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" agent="draw-prototype-flow" version="24.7.17" type="device" compressed="false">
|
||||
<diagram id="prototype-template" name="00-原型与动线">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="label-p01" value="01 · P-LIST · 任务列表" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="0" y="15" width="1000" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="0" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-nav" value="产品名称
工作台
我的任务
数据概览
系统设置" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#061B3A;strokeColor=#061B3A;fontColor=#C8D6EA;fontSize=16;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=32;spacingLeft=32;" vertex="1" parent="1"><mxGeometry x="0" y="70" width="260" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-title" value="我的任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="120" width="500" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-desc" value="查看进度、继续处理并创建新任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=14;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="170" width="600" height="30" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-filter" value="状态:全部 关键词:请输入名称 查询" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;arcSize=8;" vertex="1" parent="1"><mxGeometry x="310" y="225" width="1240" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-new" value="+ 创建任务" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="1330" y="130" width="220" height="48" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-table-head" value="编号 名称 状态 更新时间 操作" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="335" width="1240" height="54" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-table-row1" value="T-001 示例任务 A 待处理 2026-08-04 10:30 查看 编辑" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="389" width="1240" height="62" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-table-row2" value="T-002 示例任务 B 已完成 2026-08-03 16:20 查看" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F7F9FC;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="310" y="451" width="1240" height="62" as="geometry"/></mxCell>
|
||||
<mxCell id="p01-meta" value="角色:R-USER|入口:登录后首页|功能:筛选、查看、创建|下一步:P-EDIT" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="310" y="915" width="1240" height="30" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p02" value="02 · P-EDIT · 创建任务 · 编辑中" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1720" y="15" width="1000" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="1720" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-nav" value="产品名称
工作台
我的任务
数据概览
系统设置" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#061B3A;strokeColor=#061B3A;fontColor=#C8D6EA;fontSize=16;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=32;spacingLeft=32;" vertex="1" parent="1"><mxGeometry x="1720" y="70" width="260" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-title" value="创建任务" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="2030" y="120" width="500" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-form" value="" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2030" y="210" width="1240" height="570" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-field1" value="任务名称 *
请输入任务名称" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2080" y="270" width="540" height="74" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-field2" value="任务类型 *
请选择类型 ▾" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2670" y="270" width="540" height="74" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-field3" value="任务说明
请输入必要的背景、范围和完成标准" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;verticalAlign=top;spacingTop=14;spacingLeft=14;arcSize=8;" vertex="1" parent="1"><mxGeometry x="2080" y="390" width="1130" height="180" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-cancel" value="取消" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="2720" y="700" width="200" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-submit" value="提交" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#1768E8;strokeColor=#1768E8;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="2970" y="700" width="240" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p02-meta" value="角色:R-USER|入口:P-LIST / A-CREATE|状态:S-EDITING|下一步:P-CONFIRM" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="2030" y="915" width="1240" height="30" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p03" value="03 · P-CONFIRM · 提交确认" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="3440" y="15" width="1000" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="3440" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-context" value="创建任务页面(保留用户已填写内容)" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#718198;fontSize=18;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="3540" y="170" width="1400" height="650" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-overlay" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#10284B;strokeColor=none;opacity=25;" vertex="1" parent="1"><mxGeometry x="3440" y="70" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-modal" value="" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#CBD6E5;arcSize=10;" vertex="1" parent="1"><mxGeometry x="3750" y="300" width="980" height="420" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-title" value="确认提交任务?" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=26;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="3820" y="355" width="600" height="45" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-desc" value="提交后将进入处理流程。你仍可在任务详情中查看进度。" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#40516A;fontSize=16;fontFamily=Microsoft YaHei;align=left;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry x="3820" y="425" width="820" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-back" value="返回修改" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#AFC1DA;fontColor=#27405F;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="4080" y="590" width="220" height="52" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-confirm" value="确认提交" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#18864B;strokeColor=#18864B;fontColor=#FFFFFF;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="4340" y="590" width="260" height="52" as="geometry"/></mxCell>
|
||||
<mxCell id="p03-meta" value="角色:R-USER|触发:P-EDIT / A-SUBMIT|取消:返回编辑|确认:S-SUBMITTED" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#718198;fontSize=13;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="3750" y="915" width="1240" height="30" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p04" value="04 · FLOW-OVERVIEW · 端到端用户动线" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="0" y="1175" width="1200" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p04-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="0" y="1230" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p04-lane-user" value="R-USER 用户" style="swimlane;html=0;rounded=0;startSize=70;horizontal=0;fillColor=#EEF4FF;strokeColor=#9CBDF0;fontColor=#1768E8;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="70" y="1310" width="1460" height="330" as="geometry"/></mxCell>
|
||||
<mxCell id="p04-lane-system" value="R-SYSTEM 系统" style="swimlane;html=0;rounded=0;startSize=70;horizontal=0;fillColor=#F4F7FB;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=16;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="70" y="1670" width="1460" height="330" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-start" value="开始" style="ellipse;whiteSpace=wrap;html=0;fillColor=#EEF4FF;strokeColor=#1768E8;fontColor=#1768E8;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="180" y="1430" width="120" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-list" value="P-LIST
任务列表" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="410" y="1415" width="220" height="100" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-edit" value="P-EDIT
创建任务" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#1768E8;fontColor=#10284B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="760" y="1415" width="220" height="100" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-confirm" value="P-CONFIRM
提交确认" style="rhombus;whiteSpace=wrap;html=0;fillColor=#FFF8E8;strokeColor=#D97706;fontColor=#604A16;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1110" y="1395" width="220" height="140" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-process" value="S-SUBMITTED
创建处理记录" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#EAF8F0;strokeColor=#89D3A8;fontColor=#18864B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;arcSize=10;" vertex="1" parent="1"><mxGeometry x="760" y="1785" width="260" height="100" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-end" value="完成" style="ellipse;whiteSpace=wrap;html=0;fillColor=#EAF8F0;strokeColor=#18864B;fontColor=#18864B;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1190" y="1800" width="120" height="70" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e1" value="进入" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="flow-start" target="flow-list"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e2" value="创建" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="flow-list" target="flow-edit"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e3" value="提交" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#D97706;fontColor=#40516A;" edge="1" parent="1" source="flow-edit" target="flow-confirm"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e4" value="确认" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="flow-confirm" target="flow-process"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="flow-e5" value="处理成功" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="flow-process" target="flow-end"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="label-p05" value="05 · SPEC-INDEX · 页面、状态与操作索引" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=24;fontStyle=1;fontFamily=Microsoft YaHei;" vertex="1" parent="1"><mxGeometry x="1720" y="1175" width="1200" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-bg" value="" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;" vertex="1" parent="1"><mxGeometry x="1720" y="1230" width="1600" height="900" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-title" value="页面与动作索引" style="text;html=0;strokeColor=none;fillColor=none;fontColor=#10284B;fontSize=30;fontStyle=1;fontFamily=Microsoft YaHei;align=left;" vertex="1" parent="1"><mxGeometry x="1810" y="1310" width="700" height="50" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-head" value="稳定 ID 页面/状态 角色 主操作 下一步" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#EAF0F8;strokeColor=#D9E2EF;fontColor=#263750;fontSize=14;fontStyle=1;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1400" width="1420" height="56" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-row1" value="P-LIST 任务列表 R-USER A-CREATE P-EDIT" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1456" width="1420" height="64" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-row2" value="P-EDIT 创建任务 R-USER A-SUBMIT P-CONFIRM" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#F7F9FC;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1520" width="1420" height="64" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-row3" value="P-CONFIRM 提交确认 R-USER A-CONFIRM S-SUBMITTED" style="rounded=0;whiteSpace=wrap;html=0;fillColor=#FFFFFF;strokeColor=#D9E2EF;fontColor=#40516A;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;" vertex="1" parent="1"><mxGeometry x="1810" y="1584" width="1420" height="64" as="geometry"/></mxCell>
|
||||
<mxCell id="p05-note" value="规则:所有原型、动线、状态机与权限矩阵复用稳定 ID;显示序号只代表当前阅读顺序。" style="rounded=1;whiteSpace=wrap;html=0;fillColor=#FFF8E8;strokeColor=#E6BE65;fontColor=#604A16;fontSize=14;fontFamily=Microsoft YaHei;align=left;spacingLeft=18;arcSize=8;" vertex="1" parent="1"><mxGeometry x="1810" y="1730" width="1420" height="80" as="geometry"/></mxCell>
|
||||
|
||||
<mxCell id="cross-e1" value="创建任务" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#1768E8;fontColor=#40516A;" edge="1" parent="1" source="p01-new" target="p02-title"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
<mxCell id="cross-e2" value="提交确认" style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=0;endArrow=classic;endFill=1;strokeWidth=2;strokeColor=#18864B;fontColor=#40516A;" edge="1" parent="1" source="p02-submit" target="p03-title"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"title": "产品名称",
|
||||
"columns": 3,
|
||||
"transitions": [
|
||||
{
|
||||
"from": "P-LIST",
|
||||
"to": "P-EDIT",
|
||||
"label": "创建任务",
|
||||
"kind": "primary"
|
||||
},
|
||||
{
|
||||
"from": "P-EDIT",
|
||||
"to": "P-LIST",
|
||||
"label": "提交成功",
|
||||
"kind": "success"
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"name": "00-总览",
|
||||
"kind": "flow",
|
||||
"frames": [
|
||||
{
|
||||
"id": "FLOW-OVERVIEW",
|
||||
"title": "端到端用户动线",
|
||||
"role": "ALL",
|
||||
"state": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10-核心页面",
|
||||
"kind": "screen",
|
||||
"frames": [
|
||||
{
|
||||
"id": "P-LIST",
|
||||
"title": "任务列表",
|
||||
"role": "R-USER",
|
||||
"state": "S-DEFAULT"
|
||||
},
|
||||
{
|
||||
"id": "P-EDIT",
|
||||
"title": "创建任务",
|
||||
"role": "R-USER",
|
||||
"state": "S-EDITING"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "40-规格",
|
||||
"kind": "spec",
|
||||
"frames": [
|
||||
{
|
||||
"id": "SPEC-PAGE-INDEX",
|
||||
"title": "页面与动作索引",
|
||||
"role": "ALL",
|
||||
"state": ""
|
||||
},
|
||||
{
|
||||
"id": "SPEC-PERMISSION",
|
||||
"title": "角色权限矩阵",
|
||||
"role": "ALL",
|
||||
"state": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user