46 Commits

Author SHA1 Message Date
laily 4ce08ec89e Merge branch 'main' of https://git.yumee.top/laily/.pouch 2026-09-04 20:10:00 +08:00
laily b28acc66d8 feat: add host-sec-check 2026-09-04 11:54:07 +08:00
laily c2d6a5a0d7 feat(deployer): skip sync on localhost and complete skill READMEs
Redeploy locally with docker compose when the node is 127.0.0.1 and
the image already exists. Make list/sync/remote/deb importable under
python3 -I -S. Add human README.md for every builtin skill, slim ack's
user-facing docs, and drop unused OpenSpec config.
2026-09-04 00:07:15 +08:00
laily ef22c6829e refactor(skills): slim ack/builder/deployer for layered loading
Move mode-specific steps into references so SKILL.md only keeps routing and fail-closed rules.
2026-08-26 11:26:58 +08:00
laily 2be0964d73 feat(pouch): add skill structure audit to cut token waste
Teach pouch to optimize a named skill's loading layout: keep SKILL.md as a
router, move mode-specific steps to references, and measure footprint with
an audit script instead of dumping the whole skill into context.
2026-08-26 10:36:24 +08:00
laily 681aa9e237 feat(builder): load publish credentials from .env.builder
Keep DEB/Docker publish keys out of the project's .env. Scripts and
check.py --ready only read .env.builder; empty values count as missing.
2026-08-25 16:58:56 +08:00
laily 10d8800f07 feat: add skill init/check and isolate builder makefile
Give ack, builder, and deployer an explicit init/check mode that reports
missing project config instead of failing mid-work. Point builder at
makefile.builder so its contract targets do not collide with an existing
Makefile.
2026-08-25 16:49:28 +08:00
laily e9d2b5fde6 fix(builder): point version.mk at ~/.pouch after merging main
Keep ~/.skills as a lookup fallback so existing checkouts still resolve
version.sh.
2026-08-25 15:29:08 +08:00
laily f0b71b5bec Merge remote-tracking branch 'origin/main' into rename 2026-08-25 15:27:32 +08:00
laily 4423e7df1a feat: update 2026-08-25 15:26:29 +08:00
laily 12e00bd594 Merge branch 'main' into rename
Keep pouch naming and .pouch/ack project state, and bring in ACK
regression mode, deployer test-environment binding, and manage-release
updates from main.
2026-08-25 15:23:48 +08:00
laily f3cd56b78e feat: rename skills/skiff to pouch and move ACK state under .pouch
Use ~/.pouch, the pouch CLI, and .pouch.yaml as the SSOT container.
Keep the inner skills/ packages, and store ACK project state in
.pouch/ack instead of docs/ack.
2026-08-25 15:20:02 +08:00
laily 03d6298145 Merge pull request 'feat: update release' (#7) from fix-release into main
Reviewed-on: laily/.skills#7
2026-08-25 14:42:58 +08:00
laily a7a201962a feat: update release 2026-08-25 14:42:26 +08:00
laily ad6695245b feat(ack): bind test env to deployer and add regression mode
ACK 0.19.0 hands test-environment deploys to the deployer skill,
documents bug-fix as a first-class scenario, and adds
docs/ack/regression.yaml harvest plus a /ack regression run.
2026-08-25 14:41:05 +08:00
laily ee31278947 feat(deployer): add Argo CD / GitOps workflow with MR-based deployment 2026-08-24 20:37:55 +08:00
laily 282a6809ac test(ack): align allowedWorktrees deprecation tests with v0.19 contract
e4d4319 deprecated the allowedWorktrees whitelist (git worktree registry +
same common-dir checks replace it) but left 4 tests asserting the old
contract:

- test_ack_skill: template must NOT contain allowedWorktrees: anymore,
  only the deprecation comment
- test_ack_worker_profiles: legacy allowlist field is tolerated (no
  'requires at least one path'); receipt no longer validated against it
- test_ack_launch_worker: capture_worktree_identity dropped the
  allowlist arg; assert registered-worktree pass, symlink impostor and
  unregistered-rejection under the new signature
2026-08-24 15:40:02 +08:00
laily a8efa5f359 feat(skiff): replace opencode target with agents standard dir
- targets: cursor, claude, codex, agents (~/.agents/skills, .agents/skills)
- agents dir covers OMP native discovery; drop omp/opencode targets
- project-level agents/cursor/codex share .agents/skills via idempotent symlinks
- migrate local state: remove ~/.config/opencode/skills links, reinstall into ~/.agents/skills
2026-08-24 14:25:08 +08:00
ace 47bd454fa3 feat(builder): merge deb-publisher + publish-docker-image into contract-driven builder skill
- skills/builder: SKILL.md, README.md, references/contract.md (make/publish
  contract v1), references/registry.md
- scripts/check.py: executable contract checker (make dry-run probes, secret
  scan, push thin-wrapper and script path checks; --build verifies real .deb)
- scripts/upload_deb.sh: migrated from deb-publisher, adds project .env
  auto-load and dirty-worktree publish gate
- scripts/publish_docker.sh: migrated from publish-docker-image publish.sh,
  now env-first (DOCKER_REGISTRY/REPOSITORY/IMAGE_TAG/PLATFORMS), refuses
  floating latest and multi-platform --load
- scripts/verify_deb.sh: metadata/content/sha256 verification with v-prefix
  normalization
- orc: deb+docker stages both route to $builder; routing table, DAGs,
  README, config untouched stage names; tests updated
- ack delivery.md + skiff source-model.md: reference builder
- remove skills/deb-publisher and skills/publish-docker-image
2026-08-24 12:52:53 +08:00
laily e7a139e2cb feat(deployer): add deb package distribution and install via scp/apt 2026-08-24 11:25:43 +08:00
laily e4d4319919 feat: add deployer 2026-08-24 09:44:59 +08:00
laily 31bc5f45ce feat: add memory-distill skill
Task-end session distill into general and per-skill project memory stores via memories/manifest.md.
2026-08-24 00:46:20 +08:00
laily 1c52a3996c feat(ack): reclaim verified worker terminals via script after round end 2026-08-23 23:14:29 +08:00
laily 5a906f24fa feat(ack): supervise worker liveness after dispatch instead of blind wait 2026-08-23 23:06:22 +08:00
laily d33bc3ccaf refactor(ack): default OMP workers to yolo approval, drop approvalMode switch 2026-08-23 22:32:30 +08:00
laily e0fffd6845 feat(ack): make OMP yolo config-driven via approvalMode 2026-08-23 22:28:58 +08:00
laily b7b6d814b2 docs(ack): clarify OMP yolo as authorized approval mode 2026-08-23 22:25:36 +08:00
laily 1a74b15ed5 feat: update 2026-08-23 22:02:06 +08:00
laily 02fafb4bf6 feat(ack): support OMP workers 2026-08-23 22:00:32 +08:00
laily 7dfdf80e9e feat(ack): add grok workers and allow --always-approve
Grok is a first-class worker CLI. Launcher argv includes --always-approve so
unattended tool calls are not blocked; sandbox stays required.
2026-08-23 19:08:54 +08:00
laily c113f68bf4 feat: add draw-prototype-flow 2026-08-04 13:55:32 +08:00
laily 5ff8899b48 feat: update manage-release 2026-08-04 13:55:16 +08:00
laily 5018a1801d feat(ack): refine intake and validation workflow 2026-08-04 10:52:37 +08:00
laily b9c82b5520 feat: update 2026-08-03 17:07:21 +08:00
laily 0954ad542a feat(ack): add Feishu bug review approval gate 2026-08-03 15:26:02 +08:00
laily 4d078e8258 docs(ack): sync version references and research sources 2026-08-03 00:41:07 +08:00
laily 6855ac7078 feat: update feishu intake 2026-08-02 14:08:11 +08:00
laily e9b34747cc fix(orc): support trusted release shell workflow 2026-08-01 21:06:42 +08:00
laily f5bf35c722 feat(orc): centralize host-aware routing 2026-08-01 20:04:55 +08:00
laily 34bbb97406 Merge pull request 'feat: add ORC engineering orchestration' (#6) from support-release into main
Reviewed-on: laily/.skills#6
2026-08-01 18:01:22 +08:00
laily 337f1a9098 feat(orc): add tiered engineering orchestration 2026-08-01 17:49:25 +08:00
laily f02a34e751 revert(ack): restore three-role collaboration scope
This reverts commit a0f1c15b85.
2026-08-01 17:48:33 +08:00
laily e7f42e835a Merge pull request 'feat(ack): add one-off delivery routing' (#5) from support-release into main
Reviewed-on: laily/.skills#5
2026-08-01 15:33:23 +08:00
laily a0f1c15b85 feat(ack): add one-off delivery routing 2026-08-01 15:29:34 +08:00
laily f8d03fad4d feat(ack): add Feishu bug intake 2026-08-01 14:37:34 +08:00
laily 5b0e41199d Merge pull request 'feat(ack): add project delivery workflow' (#4) from add-extro-info into main
Reviewed-on: laily/.skills#4
2026-08-01 14:24:32 +08:00
176 changed files with 19927 additions and 2652 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
# Python(skiff CLI 运行时产物)
# Python(pouch CLI 运行时产物)
__pycache__/
*.py[cod]
*$py.class
@@ -31,3 +31,4 @@ Thumbs.db
# 本地环境配置(勿提交密钥)
.env
.env.*
.pouch
+9
View File
@@ -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: []
+71
View File
@@ -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`,不提交或推送。
+839
View File
@@ -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
+94 -99
View File
@@ -1,30 +1,27 @@
# Agent Skills 仓库
# pouch
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。Skill 内容、规范包与可运行的 skiff CLI 在本仓库一并维护。
自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。仓库名、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。旧名是 `skills` / `pouch`。
| 仓库 | 地址 | 职责 |
| --------------- | ------------------------------------------------------------------------ | -------------------------- |
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill、规范包、skiff CLI 的当前 SSOT |
| **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 |
---
## 快速开始
需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时按 README 的 [让 Agent 代装](README.md#让-agent-代装) 执行,不要只用 `pouch select`。
```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
# 2. 安装其他 skill
skiff add declarative-openspec-loop -g
# 3. 查看状态
skiff list
skiff status
# install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
pouch select -g
pouch list
pouch status
```
---
@@ -32,22 +29,14 @@ skiff status
## 仓库结构
```
skills/
├── _template/ # 新建 skill 的脚手架
├── ack/ # 完整 ACK skill,含 references/templates/scripts
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md
│ ├── SKILL.md
│ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md
│ └── reference.md
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
catalog.yaml # skiff 预置 Skill 来源目录
AGENTS.md # 本文档
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类)
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 本文档
```
**本仓库包含**:`skills/`、`skiff/`、`bin/skiff`、`catalog.yaml`、`AGENTS.md`
**本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
**本仓库不包含**:各项目的 skill 启用清单
---
@@ -57,27 +46,36 @@ AGENTS.md # 本文档
### 内置(Builtin)
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
| [skiff](skills/skiff/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 |
| Skill | 说明 |
| --- | --- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
项目状态,不复制或链接 Skill 内容:
```bash
skiff init ack
pouch init ack
```
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(检查配置并引导补齐),
不要 `pouch init builder` / `pouch init deployer`。
### 预置目录(Catalog)
在 `catalog.yaml` 中预置,通过 skiff 拉取安装:
在 `catalog.yaml` 中预置,通过 pouch 拉取安装:
| Source | 来源 |
@@ -86,8 +84,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 +93,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 +124,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,16 +155,10 @@ 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 资料分类
| 类型 | 位置 | 维护方式 |
| --- | --- | --- |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
### 多 Agent 路径
@@ -175,16 +168,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 +196,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 +235,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 健康检查与修复 |
---
@@ -255,7 +249,8 @@ skiff sync
```
skill-name/
├── SKILL.md # 必需
├── SKILL.md # 必需,给 Agent
├── README.md # 必需,给人类
├── reference.md # 可选
├── examples.md # 可选
└── scripts/ # 可选
@@ -279,12 +274,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` 和 `README.md`
3. `pouch check my-skill`
4. 用户确认后执行 `pouch finalize my-skill`
5. `pouch add my-skill -a cursor -g -y` 验证
6. 在本仓库 commit;需要的项目再用 `pouch add my-skill` 启用
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
@@ -296,7 +291,7 @@ symlink 正确时,Agent 在项目里改 skill 文件 = 直接改 SSOT:
```
project/.agents/skills/foo/SKILL.md
→ ~/.skills/skills/foo/SKILL.md
→ ~/.pouch/skills/foo/SKILL.md
→ 在本仓库 commit
```
@@ -311,7 +306,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 +316,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 +329,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 add <name> -g` | 任意 |
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
| 看状态 | `pouch status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `skiff fetch <name>` | 任意 |
| 更新外部 skill | `pouch fetch <name>` | 任意 |
---
@@ -348,7 +343,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)
+147 -49
View File
@@ -1,84 +1,180 @@
# Agent Skills
# pouch
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。Skill 内容与 **skiff** CLI 在本仓库一并维护。
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,按 [让 Agent 代装](#让-agent-代装) 自己执行,不要只把命令贴回去。
## 前置
- Python 3、git
- Cursor、Claude Code、Codex,或其它兼容 Agent Skills 的 Agent
- `~/.local/bin` 需要在 `PATH` 里(`install.sh` 会写入 bash / zsh / fish 的 rc,当前 shell 要重开或 `source` 一次才生效)
## 快速开始
```bash
git clone https://git.yumee.top/laily/skills.git ~/.skills
cd ~/.skills
./install.sh # 安装 CLI,并将 skiff 项目 skill 安装到所有 Agent
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch
./install.sh
skiff add declarative-openspec-loop -g
skiff select # 交互式选择并批量安装
skiff list
skiff status
# install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
# 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
pouch select # 交互选择要装的 skill(默认当前项目)
pouch select -g # 或装到全局,所有项目都能用
pouch list
pouch status
```
从旧的 `~/.skills` / `pouch` 迁移:
```bash
mv ~/.skills ~/.pouch
~/.pouch/install.sh
```
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
## 让 Agent 代装
用户说「帮我安装 pouch」「把 skill 装上」时按本节做。CLI 只认 `~/.pouch`。不要用 `pouch select`(必须交互式 TTY)。不要在未确认时覆盖已有的 `~/.pouch`。
### 1. 确认本机
需要 `python3` 和 `git`。缺了先说明怎么装,不要继续。
### 2. 让 `~/.pouch` 指向本仓库
按现状只走一条:
| 现状 | 动作 |
| --- | --- |
| 当前目录就是这份仓库(有 `install.sh` 和 `skills/`),且没有 `~/.pouch` | `ln -sfn "$(pwd)" "$HOME/.pouch"` |
| `~/.pouch` 已在且含 `skills/`、`install.sh` | 沿用 |
| 只有 `~/.skills` | `mv ~/.skills ~/.pouch` |
| 都没有 | `git clone https://git.yumee.top/laily/pouch.git ~/.pouch`。失败则问用户本地路径或可用 remote |
| `~/.pouch` 在,但不是这份仓库 | 停下问用户,不要删、不要覆盖 |
### 3. 装 CLI 和 `pouch` skill
```bash
"$HOME/.pouch/install.sh"
export PATH="$HOME/.local/bin:$PATH"
command -v pouch
pouch status
```
`install.sh` 会改 rc,当前会话仍要 `export PATH`。不要让用户重开终端才继续。
### 4. 安装用户要的其它 skill
先 `pouch add --list`。用户没点名就问一次:装哪些、全局还是当前项目。能判断就不要追问。
```bash
pouch add <name> -g -y # 全局,所有项目都能用
pouch add <name> -y # 仅当前项目
```
默认 `pouch add` 装到当前工作目录。cwd 是 pouch 仓库本身时用 `-g`,除非用户就在维护 pouch。用户是在自己的应用仓库里请你代装,才做项目级安装。
`install.sh` 已经全局装好 `pouch` skill。其它 skill 不要默认全装。
### 5. 验收并告诉用户下一步
```bash
pouch status
pouch doctor
```
说明:打开 Cursor / Claude Code / Codex 后,按各 skill README 里的示例说话即可。软链异常则 `pouch doctor --fix`。已经装好时只汇报状态,问还要不要加 skill,不要重新 clone。
## 装完之后怎么用
1. 打开已安装目标的 Agent(Cursor / Claude Code / Codex 等)。
2. 项目级安装:打开那个项目再说话。全局安装(`-g`):任意项目都可以。
3. 按对应 skill 的 README,把示例请求发给 Agent。
`./install.sh` 只全局安装 `pouch`。其它 skill 都要 `pouch add` 或 `pouch select`。
```bash
cd ~/code/my-app
pouch add ack # 当前项目
pouch add discussion-notes -g # 全局
```
## 仓库结构
```
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
skiff/ # CLI 源码(Python 3)
bin/skiff # CLI 入口
catalog.yaml # skiff 预置 Skill 来源目录
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 详细规范与架构说明
```
| 路径 | 说明 |
|------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI |
| [catalog.yaml](catalog.yaml) | skiff 预置 Skill 来源目录 |
| [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
## 自研 Skill
| Skill | 说明 |
|-------|------|
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
| --- | --- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
ACK 是包含规范、模板与校验脚本的完整 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,并在任务
验证通过后收获回归用例。
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
新建 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 和 README.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:
- name: declarative-openspec-loop
- name: ack
source: builtin
- name: think
source: catalog:waza
@@ -90,9 +186,9 @@ targets: # 可选,默认 all
```
```bash
skiff add declarative-openspec-loop
skiff sync
skiff remove declarative-openspec-loop
pouch add ack
pouch sync
pouch remove ack
```
## Catalog 与 Custom Source
@@ -100,15 +196,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 +218,24 @@ npx skills find typescript
```
本仓库
├── skills/<name>/ ←── skiff install / enable
├── skills/<name>/ ←── pouch add / select
├── 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 +243,8 @@ npx skills find typescript
## 文档
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
- [skiff/README.md](skiff/README.md) — CLI 命令参考与开发说明
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
- 各 skill 的人类说明见上表 `README.md`
## 参考
+2 -2
View File
@@ -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
View File
@@ -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>
#
+46
View File
@@ -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
View File
@@ -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 和引用格式保持不变。
第一版包括:
+106
View File
@@ -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
View File
@@ -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() {
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+254
View File
@@ -0,0 +1,254 @@
# 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 自带模板初始化项目状态(目前用于 ack) |
### Skill 安装
| 命令 | 说明 |
|------|------|
| `pouch add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
| `pouch select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
| `pouch remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
| `pouch add --list` | 列出可用 builtin skill |
| `pouch publish [paths] -m MSG [--push]` | 在 ~/.pouch 内 git add/commit/push |
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
全局目标路径:
| Agent | 路径 |
|-------|------|
| cursor | `~/.cursor/skills/` |
| claude | `~/.claude/skills/` |
| codex | `~/.codex/skills/` |
| agents | `~/.agents/skills/`(agents 标准目录,覆盖 OMP) |
### 预置 Catalog Source
| 命令 | 说明 |
|------|------|
| `pouch catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
| `pouch fetch <name>` | 克隆或更新 catalog source checkout |
| `pouch add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
| `pouch add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
会显示在 `pouch select` 的候选列表中。`path` 可以直接指向含
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`pouch add <name>`
安装全部,`pouch select` 则展开为 `<name>/<skill>` 供分别勾选。同一
`repo` 与 `ref` 共享一份 Git checkout。
### 交互式批量安装
```bash
pouch select # 当前项目,全部 Agent
pouch select -a codex # 当前项目,仅 Codex
pouch select -g # 全局安装
pouch select --project ~/code/app # 指定项目
```
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
取消。普通 `pouch select` 只安装到项目,并在每一项旁只读显示各 Agent 的
全局安装状态;`pouch select -g` 只安装到全局。已经安装到目标范围的 skill
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `pouch remove`。如果全局
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
项目模式会把成功选择的项目写入 `.pouch.yaml`。非交互环境请使用
`pouch add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
skill 条目中,后续 `pouch sync` 不会扩散到其他 Agent。
### Custom source(多-skill 仓库)
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
```bash
# Git 仓库,默认 clone 到 ~/.local/share/pouch/sources/company
pouch source add company \
git@git.company.com:platform/agent-skills.git \
--ref main \
--skills-path internal/skills
# 或接入已有本地仓库
pouch source add company \
--local ~/code/company-agent-skills \
--skills-path skills
pouch source list
pouch source fetch company
pouch list --source company
pouch add company/code-review -g -a codex
```
| 命令 | 说明 |
|------|------|
| `pouch source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
| `pouch source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
| `pouch source list` / `show <name>` | 查看 source |
| `pouch source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
| `pouch source remove <name>` | 移除配置并保留 checkout |
配置保存在 `~/.config/pouch/config.yaml`。Git/SSH 认证复用本机 Git 配置,
pouch 不保存 token。可以使用 `company/code-review`,也可以使用
`pouch add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
### 项目级
| 命令 | 说明 |
|------|------|
| `pouch enable <name> [--target all] [--project <dir>]` | 写入 `.pouch.yaml` 并创建项目软链 |
| `pouch disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
| `pouch sync [--target all] [--project <dir>]` | 按 `.pouch.yaml` 重建软链 |
项目目标路径:
| Agent | 路径 |
|-------|------|
| cursor | `<project>/.agents/skills/` |
| claude | `<project>/.claude/skills/` |
| codex | `<project>/.agents/skills/` |
| agents | `<project>/.agents/skills/`(与 cursor/codex 共用路径,软链幂等) |
### 脚手架与健康检查
| 命令 | 说明 |
|------|------|
| `pouch create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
| `pouch check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
| `pouch finalize <name>` | 校验草稿并移动到正式 `skills/` |
| `pouch doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
## 常用工作流
### 新建并全局启用自研 skill
```bash
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
pouch check my-skill
pouch finalize my-skill
pouch publish skills/my-skill -m "add my-skill" --push
pouch add my-skill -a cursor -g -y
pouch doctor -a cursor
```
### 在项目中启用 skill
```bash
cd ~/code/my-app
pouch add discussion-notes -a cursor -y
```
### 添加 Catalog Source
```bash
pouch catalog add my-ext https://github.com/org/repo --ref main
pouch fetch my-ext
pouch add my-ext -g
```
## 源码结构
```
pouch/
├── __init__.py # 版本号
├── __main__.py # python3 -m pouch 入口
├── cli.py # 命令定义与调度
├── paths.py # 路径常量与 Agent 目标
├── skills.py # builtin/catalog/custom 统一解析
├── catalog.py # catalog.yaml 读写与 Skill 发现
├── 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)
- 各 skill 的人类说明:`skills/<name>/README.md`
+3
View File
@@ -0,0 +1,3 @@
"""pouch — Agent Skills 安装与管理 CLI。"""
__version__ = "0.6.0"
+1 -1
View File
@@ -1,4 +1,4 @@
from skiff.cli import main
from pouch.cli import main
if __name__ == "__main__":
main()
+2 -2
View File
@@ -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()})
+4 -4
View File
@@ -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
View File
@@ -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)
+3 -3
View File
@@ -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
View File
@@ -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"
)
+10 -10
View File
@@ -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]
+1 -1
View File
@@ -165,7 +165,7 @@ def select_skills(
)
footer = (
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
)
stdscr.addnstr(
height - 1,
+8 -8
View File
@@ -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}")
+28 -28
View File
@@ -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 的同步关系,不复用旧名称。
+5 -5
View File
@@ -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)}
+1 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
"""轻量 YAML 读写(覆盖 skiff 使用的子集,无第三方依赖)。"""
"""轻量 YAML 读写(覆盖 pouch 使用的子集,无第三方依赖)。"""
from __future__ import annotations
+1 -1
View File
@@ -1 +1 @@
# skiff 仅使用 Python 标准库,无第三方依赖
# pouch 仅使用 Python 标准库,无第三方依赖
-251
View File
@@ -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)
-3
View File
@@ -1,3 +0,0 @@
"""skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.6.0"
-52
View File
@@ -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"
)
+27
View File
@@ -27,11 +27,38 @@ description: >-
2. 第二步
3. 第三步
## 运营类 skill 可选:初始化 / 检查 / 工作
需要项目配置才能工作的 skill(例如构建、部署、协作闭环)增加这三个模式:
- **初始化**(用户点名才跑):探项目 → 只建本 skill 拥有的安全空结构 → 用仓库
证据填能填的 → 列出待配置项。不覆盖已有文件,不猜密钥、主机、仓库地址。
- **检查**(只读):同一套诊断,不写文件。
- **工作**:配置不齐就停,告诉用户先初始化。禁止静默初始化。
报告格式:
```text
## <skill> 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: …
下一步: 一句话
```
项目状态放在 skill 真正消费的位置(覆盖层、`makefile.builder`、compose),不要为了对齐
而新建一层没人读的 `.pouch/<skill>/`。`pouch init` 只用于确实有 templates/
覆盖层的 skill。需要 make 目标或发布凭据的 skill 用自己的文件名(如 `makefile.builder`、
`.env.builder`),不要占用用户的 `Makefile` 或 `.env`。
---
## 注意事项
- 约束或边界条件
- `SKILL.md` 只留模式路由、全模式安全边界,以及「若 X 则读 `references/Y.md`」。
单模式步骤不要写进正文。
## 验证
+78 -184
View File
@@ -1,227 +1,121 @@
# ACK
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
- Coordinator 拆解需求、派发任务并终检。
- Developer 实现并执行白盒验证。
- Test 独立执行黑盒复测。
- Coordinator 拆解需求、派发任务并终检
- Developer 实现并做白盒验证
- Test 独立做黑盒复测
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
`leftover`,然后继续处理其它任务。
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
稳定发布与生产部署始终保留人工批准点。
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
只在你显式说 `/ack` 或 `$ack` 时运行。
## 什么时候使用
| 场景 | 怎么说 | 结果 |
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/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 # 当前项目
pouch add ack -g # 全局
```
或只安装到当前项目:
## 使用前准备
```bash
skiff add ack
```
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
- 项目是 Git 仓库。
- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok 或 OMP。
- 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
## 初始化项目
```bash
skiff init ack
skiff init ack --project ~/code/my-app
pouch init ack
pouch init ack --project ~/code/my-app
```
初始化后,项目只保存自己的 ACK 状态:
也可以直接说 `/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 Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
## Skill 结构
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件。
## 使用示例
```text
skills/ack/
├── SKILL.md
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
/ack 初始化
/ack 检查配置
/ack 处理这个需求:给后台加导出 CSV
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
/ack 处理飞书里待整理的 bug
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
/ack 重新布一下测试环境,我要测试
/ack 发布一个版本
/ack 回归
/ack 跑 full 回归
/ack 回归 REG-login-001
/ack 修这些回归失败
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
已经独立验证的项目知识护栏。
`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在
`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。
## 检查项目状态
Agent 会从当前 ACK Skill 目录解析校验脚本:
```bash
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>
```
Coordinator 可以按当前任务上下文做确定性推荐:
```bash
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
--component web --path web/app.py --tag long-running-service --limit 10
```
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
需要执行知识项引用的检查时,只传 registry ID 给 ACK 的安全执行入口:
```bash
python3 <ack-skill-dir>/scripts/run_verification.py \
docs/ack/knowledge.yaml check-api-contract --project-root <project-root>
```
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
`O_NOFOLLOW` 打开知识库和检查文件;检查内容复制到匿名、尽可能 sealed 的稳定
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
项目内无 symlink 的权威
`docs/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
和 `ACK_PROJECT_ROOT` 都固定到该根 fd;后者是只在检查进程存活期间有效的
`/proc/self/fd/...` 或 `/dev/fd/...` 路径。原始可读路径另放在
`ACK_PROJECT_ROOT_DISPLAY`,只能用于日志,不能用于资源访问。Runner 还提供
`ACK_VERIFICATION_REF` 和 `ACK_VERIFICATION_PATH`;检查脚本必须据此定位资源,
不能依赖 `$0` 或 `__file__` 所在目录,因为实际执行的是匿名快照。
知识先由 Developer 或 Test 作为 `candidate` 提名,经独立验证和 Coordinator gate
后才能成为 `active`。Coordinator 按路径、组件、依赖、版本和标签推荐相关知识,
确认后将固定 revision 的 `knowledgeRefs` 写入任务上下文;每轮只派发命中的少量
条目,不全量注入知识库。
旧项目只有 `project.md` 和 `tasks.yaml` 时,不要重跑初始化。由 `/ack` 检查现有
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
同时只补 `project.knowledgeFile: docs/ack/knowledge.yaml`,再运行跨文件校验。
只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`。知识正文不能作为自由 shell
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
## 配置与运行交付
用户可以直接向 `/ack` 描述项目差异,例如“这个项目用 `make deb` 构建 DEB,推到
preview APT 源,再部署到开发机”。ACK 会把它维护成
`docs/ack/delivery.yaml` 中的声明式 entrypoint、artifact、destination、environment
和 profile,校验后展示 diff;首次配置保持关闭,确认后才启用。
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
凭据值。ACK 在任务进入 `verified` 后,按用户确认的 profile 执行,并把 revision、
PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml.deliveryRuns`。默认
profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。
具体契约见 `references/delivery.md`。
## 启动 Worker
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md`
或任务里保存完整启动命令、额外 argv 或环境变量。
先审阅计划,不产生终端:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree>
```
确认后执行 `launch`,并显式绑定刚审阅的 fingerprint:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定
argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
不能跨任务或跨轮次改挂。
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
Codex bypass、Cursor YOLO/force 和关闭 sandbox 都会 fail closed;在有可信平台
审批或独立签发通道之前,不用项目文件伪装成用户授权。旧任务板没有结构化
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。由于 Orca 当前
不能证明旧终端的原始 argv/模型/权限,v0.10 不自动复用既有 worker;每次自动派发
都重新 `plan` 并用 expected fingerprint 启动 fresh worker。
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
重复启动,结果不确定时必须先 reconcile。
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
直接重试。
## 开始一个需求
初始化完成后可以直接说:
```text
/ack 处理这个需求:<一句话需求>
```
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。
首次配置交付可以说:
首次配发版也可以一次说清:
```text
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
```
之后处理需求时只需在确认计划中选择 profile:
之后做需求时只要在确认计划里选 profile:
```text
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。
/ack 处理这个需求:…。任务验证通过后执行 review profile。
```
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:先写产品文档、任务拆分和可观测验收;修 bug 只写短说明、复现和验收。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
6. 回归失败只报告,不会自动开修。
7. 未获授权不提交、不推送、不创建终端、不部署,也不改项目的 `AGENTS.md` / `CLAUDE.md`。
飞书收件时,你维护标题、详细描述和附件。Coordinator 整理问题说明、期望和验收并写回飞书。你针对当前 revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
## 如何判断完成
- 初始化:五个状态文件齐,报告为完成,或列出待配置项和下一步。
- 需求 / bug:任务变为 `verified`,或三轮后记为 `leftover`;回归目录有本轮用例,或明确「无回归」。
- 测试环境:目标环境容器 Up,健康检查通过。
- 发版:停在约定档位;stable / 生产有单独批准记录。
- 回归:列出通过和失败的用例;失败不会被自动拿去修。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
+92 -173
View File
@@ -1,187 +1,106 @@
---
name: ack
description: >-
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
工作,或配置并执行任务验证后的项目交付流程时使用。
初始化、检查并运行 ACK 三角色闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 .pouch/ack、按 ACK 做需求或修 bug、指挥
Coordinator/Developer/Test、配置或运行测试环境与发版、或跑回归时使用。
---
# ACK 项目协作入口
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
内容。
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、
`delivery.yaml` 和 `regression.yaml`。不要复制 Skill 内容,不要修改 `AGENTS.md`、
`CLAUDE.md`。
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
脚本都相对此目录访问,不依赖固定的全局安装路径。
开始时解析当前 `SKILL.md` 所在目录为 `<ack-skill-dir>`。优先
`git rev-parse --show-toplevel` 作为项目根。
## 选择模式
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
或其它 Agent 指令文件。
## 初始化
1. 确认 `skiff` 可执行,并检查 `<project>/docs/ack` 是否存在。
2. 不存在时执行:
```bash
skiff init ack --project <project-root>
```
该命令从本 Skill 的 `templates/` 生成项目状态,不会在项目中创建 Skill
软链接或资源副本。
3. 如果 `docs/ack` 已存在,不重复初始化、不覆盖文件;转入“检查”。旧项目只有
`project.md` 与 `tasks.yaml` 时,先报告缺少 `knowledge.yaml`。用户授权后,
从 `templates/knowledge.template.yaml` 生成这个缺失文件并替换项目名和时间;若
`tasks.yaml` 尚无 `project.knowledgeFile`,同时只补
`docs/ack/knowledge.yaml` 这一项。不要重跑 `skiff init`,也不要改写其它已有
项目状态。
4. 读取项目的公开配置和文档,例如 README、语言清单、包管理清单、测试配置与
CI,确定项目名、技术栈、源码/规格/测试路径及真实可执行命令。
5. 完善 `docs/ack/project.md`:
- 用实际项目值替换全部占位符。
- 无服务地址时把 Base URL 写为 `n/a`,不要虚构端口。
- 无法从项目证据确定的命令写为 `n/a`,并在结果中列为待配置项。
- 只写项目差异,不复制 `references/` 中的通用规范。
6. 完善 `docs/ack/tasks.yaml` 的项目信息。纯初始化且用户没有提供真实任务时,
删除模板示例任务并保留 `tasks: []`;不要虚构需求或缺陷。
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
猜测并激活知识。
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
9. 更新 `updatedAt`,并运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
```
10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有
`<...>` 占位符。
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
缺失值。
11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
不推送。
## 检查
1. 检查以下路径:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- `docs/ack/knowledge.yaml`
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
存在。
3. 查找未替换占位符,并核对项目路径、覆盖层路径、Developer 白盒命令、Test
黑盒命令和 Base URL。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
缺少可选交付配置而宣称失败。
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
必须同时为空或同时填写。
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
## 工作
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
2. 依次读取:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
- `<ack-skill-dir>/references/kickoff.md`
- kickoff 指定且与当前任务相关的 references 文件
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
`<ack-skill-dir>/references/delivery.md`
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
不全量注入知识库。
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
步骤放入同一份计划。确认前不派发实现,也不执行交付。
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`;
`candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。
7. 执行知识项的 `verification.ref` 时,只调用
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
也不要给 runner 注入额外命令或参数。
8. 不把 `worker_done` 或 Test 自报成功直接当作完成。每项最多三轮,仍失败则记录
`leftover` 并继续其它任务。
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署
必须在对应步骤再次取得明确批准。
## 交付配置维护
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: []`;不改写其它
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
3. 运行 delivery、tasks 和跨文件校验;需要的脚本不存在、不可执行、引用不完整或
涉及凭据正文时 fail closed。凭据只写 secret 名称,值由外部环境提供。
4. 用户确认后才把配置设为启用。配置修改只影响下一次 delivery run;已确认或正在
执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改
扩大权限。
- 初始化、接入 ACK:执行「初始化」。
- 检查配置是否完整:执行「检查」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer,不复制 compose)。
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
- 任务板使用 Orca 时,编排命令见 [orca-adapter.md](references/orca-adapter.md)。
## 边界
- 不修改或追加任何项目 Agent 指令文件,包括 `AGENTS.md`。
- 不在项目中维护第二份 ACK 通用规范、模板或任务 schema。
- 不猜测项目命令、服务地址、worker handle 或模型名称。
- 不把 full-access、bypass、YOLO/force、关闭 sandbox 或项目内“授权”字段当成
v0.10 自动 worker 的合法配置;当前一律 fail closed。
- 不把无密钥 `receiptHash` 或 Orca live metadata 当作旧终端的启动 attestation;
v0.10 不自动复用既有 worker。
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
launch ID、外部 record 和 Orca live state 完成人工核对。
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
提交、推送、创建终端、新 worktree、发布产物或部署。
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;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 目录读取。
- 不覆盖已有 `.pouch/ack`;除用户确认的任务或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。
- 不猜测命令、地址、worker handle 或模型名;无法确定写 `n/a`。
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。
- 只有 Coordinator 写任务板、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
- 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。`indeterminate` / `reconcile required` 不直接重试。
- 产品失败才占三轮;环境失败写 `environmentIncidents`。见 [optimization-method.md](references/optimization-method.md) §4。
## 初始化
1. 确认 `pouch` 可执行。`.pouch/ack` 不存在则 `pouch init ack --project <project-root>`;已存在则不覆盖、转入「检查」。
2. 按 [init-new-project.md](references/init-new-project.md) 完善项目状态。不从 README/CI 猜测并启用交付或知识;初始化 **不** 自动初始化 deployer 或 builder。
3. 运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \
--tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
```
4. 检查上述文件是否仍有 `<...>` 占位符。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
```text
## ack 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: pouch …
下一步: 一句话
```
## 检查
只读,不自动修复。核对清单见 [adoption-checklist.md](references/adoption-checklist.md)。用 `select_tasks.py` 看配置;跑与「初始化」相同的四个校验器。对比 `VERSION` 与 `ackVersion`(旧 `kitVersion` 仍可读);从 `0.10.0` 起 `project.orchestration` 与 `workerReceipts` 必须同时存在。旧项目可无 delivery/regression,存在或被引用时必须校验。若有 `bugIntake` 再跑 `feishu_bug_intake.py check`。若测试环境走 deployer,只读跑其 `check.py`;未通过列入待配置,不要复制 compose 或静默初始化 deployer。用同一报告格式,标题改为 `## ack 检查:…`。
## 工作
1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。
2. 读 `project.md`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。修 bug 写短问题说明、复现和可观测验收,不写大 PRD。
4. 用户已确认后按 [closed-loop.md](references/closed-loop.md) 执行。当前会话担任 Coordinator,不亲自写代码或跑测试。
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
6. 知识 `verification.ref` 只经 `run_verification.py`。不把 `worker_done` 或 Test 自报成功当作完成。环境失败先有界恢复并报告 `userAction`;三轮产品失败记 `leftover`。
7. 任务 `verified` 且改了可见行为或 API 后,转入「运行回归」收获用例。若本次计划含交付,再走对应交付模式;默认最多到 `validation_ready` 或 `review_ready`。
8. 整轮完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,只回收仅属于 `verified` 任务的 worker。关闭结果不确定则记录,不伪报。
## 飞书收件
若存在 `project.bugIntake`,读 [feishu-bug-intake.md](references/feishu-bug-intake.md)。用户针对当前 `draftRevision` 明确审核通过并亲自把飞书状态改为 `已确认` 前:不创建或刷新任务、不启动 worker、不派发、不改应用代码。Coordinator 不得自行写入 `已确认`。未声明 workflow 的旧配置只按 `read-only-v1`,不得写回。来源消失或读取失败时不删除已有任务。
## 启动 worker
只使用 `scripts/launch_worker.py plan|launch`。不得直接 `orca terminal create --command`,不得拼接自由 command、argv、executable、env 或 cwd。读 [model-routing.md](references/model-routing.md)。先审阅 `plan.launchFingerprint`,再作为 `launch --expected-launch-fingerprint` 传入。派发文案用 [prompt-templates.md](references/prompt-templates.md)。派发后 terminal read 确认已开始;卡在审批、未回车或额度限制按环境失败处理。等待期间用 `scripts/worker_probe.py`,不盲等 `worker_done`。
## 交付配置维护
读 [delivery.md](references/delivery.md)。测试环境写成 `{via: deployer, env: <env>}`;`.pouch/deployer/<env>` 未就绪则停止并加载 deployer「初始化」,不在 ACK 里复制 compose。发版写在同一份 `delivery.yaml`,不保存 shell。本地 `npm run dev` / `go run` 不算测试环境。旧项目首次启用只补文件指针与 `deliveryRuns: []`,保持 `enabled: false`,用户确认后才启用。进行中的 run 使用开始时的 commit/config 快照。
## 运行测试环境
读 `delivery.yaml` 与 [delivery.md](references/delivery.md),并加载 deployer。`enabled` 非 true 或 intent 为 null:停止,转入交付配置维护。intent 必须是 `{via: deployer, env: <env>}`;旧 profile ID 字符串要先迁移。不要用 ACK delivery profile 布环境,不猜测启动命令。不要求任务已 `verified`。对 `.pouch/deployer/<env>` 按 deployer:list → 按服务 sync+up → 健康检查。证据写入 `deliveryRuns`(`intent: testEnvironment`,`profile: deployer-<env>`)。未安装、缺目录、`check.py` 失败或健康检查失败:fail closed,报告 `userAction`,不记产品失败。
## 运行回归
读 [regression.md](references/regression.md)。缺目录则停止;用户授权后从模板生成空文件并只补指针与 `regressionRuns: []`。用 `select_regression.py` 读 active 用例(默认 `--suite smoke`);无命中则停止。先执行「运行测试环境」,再派独立 Test worker。Coordinator 不亲自点浏览器或打 API;`browser` 不得改成只打 API。终检写入 `regressionRuns`。失败不自动派 Developer,不占三轮预算。任务 `verified` 后给出新增/更新/退役/无回归四选一,用户确认后写入;Test 只提名。
## 运行版本发布
1. 读同一份 `delivery.yaml` 与 [delivery.md](references/delivery.md)。
2. `enabled` 不为 true,或 `intents.release` 为 null:停止,先做交付配置维护。
3. 按该 profile 顺序执行。stable 发布和生产部署的 `approval` 不能用口头「发版」代替。
4. 证据写入 `deliveryRuns`,`intent: release`;绑定了任务时 `taskIds` 仍只能引用 `verified` 任务。
+1 -1
View File
@@ -1 +1 @@
0.11.0
0.19.0
+2 -2
View File
@@ -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
+21 -13
View File
@@ -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=回归笔记"
+59 -8
View File
@@ -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"
+29 -10
View File
@@ -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`。
+58 -18
View File
@@ -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 和关闭回执;关闭
不确定时报告并保留审计记录。
+55 -17
View File
@@ -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,46 @@ 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 和健康检查。环境目录不存在或 deployer `check.py` 未通过
时,加载 deployer skill 的「初始化」,不要在 ACK 里复制 compose 命令。派发
Test 复测或跑回归前,若该 intent 已配置且 `enabled: true`,Coordinator 也先
执行它。不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动
命令。旧的 profile ID 字符串不再执行,必须迁到 `{via: deployer, env: <env>}`。
本地进程启动写在 `project.md`,不算这个 intent。
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
口头「发版」不能代替 stable/production 的 `approval` 步骤。
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
- 用户触发的 intent 运行写入 `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 +91,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 +117,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 +133,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 +145,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 的合法调用路径。
+148
View File
@@ -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 任务。
+62 -43
View File
@@ -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,47 +127,53 @@ 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
把 `intents.testEnvironment` 写成 `{via: deployer, env: <env>}`,若
`.pouch/deployer/<env>` 尚未就绪则加载 deployer skill 的「初始化」;发版仍指向
profile。ACK 初始化不自动跑 deployer 初始化。
配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式
approval 步骤。
## 初始化回归目录
新项目的 `.pouch/ack/regression.yaml` 保持 `cases: []`。不要从 README 或聊天猜测
用例。任务 `verified` 后按 `regression.md` 收获。
## 校验
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py 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` 条目没有被派发。
## 初始化报告
完成后报告:
按 `SKILL.md`「初始化」最后一步的格式报告(`## ack 初始化:完成 | 部分完成 | 阻塞`),
列出已具备项、待配置项(路径 + 字段 + 示例)、工具链和下一步。
- 创建或确认的四个项目文件。
- 检测到的技术栈和验证命令。
- 任务板、项目知识和交付契约校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
具体阻塞项。除非用户明确要求,不提交、不推送。
只有结构校验通过且必填项目事实完整时才称「完成」;否则称「部分完成」或「阻塞」。
除非用户明确要求,不提交、不推送。
+70 -42
View File
@@ -1,6 +1,8 @@
# 如何开始一个需求(Kickoff)
从零开一个需求的启动手册。角色/权限见 `roles-and-permissions.md`,闭环见 `closed-loop.md`,模型见 `model-routing.md`。
从零开一个需求的启动手册。本文件只覆盖确认前的产品文档、任务拆分与验收信号。
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
不要在本文件开头预读其它规范。
---
@@ -9,7 +11,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` 只解释项目
差异,不能提供另一套启动命令。
---
@@ -18,25 +20,33 @@
```text
我要做一个新需求:<一句话需求>。
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
你作为 ack 的 Coordinator(PM),按 ACK Skill 执行:
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/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,但不要把配置本身当作执行授权。
1. 先读 .pouch/ack/project.md,并用 `scripts/select_tasks.py .pouch/ack/tasks.yaml`
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
完整 tasks.yaml 注入上下文。校验 .pouch/ack/knowledge.yaml 并用
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目。
本 kickoff 只做确认前的产品文档与任务拆分;用户确认后的闭环、飞书、启动
worker、交付和回归按 ACK Skill 的模式路由按条件加载,不要在本步预读其它
references。配置本身不是执行授权。
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
4. 把任务写进 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 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
.pouch/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
或 review_ready,stable/production 步骤再次向我确认。
```
---
@@ -44,20 +54,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`,验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 项目知识 → 从 `.pouch/ack/knowledge.yaml` 按 component、path、dependency、version
和 tag 推荐 `active` 条目,Coordinator 确认后写入固定 revision 的显式
`knowledgeRefs`。candidate 不参与选择。
4. 校验结构与引用:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py 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、构建产物、
@@ -67,18 +88,19 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
## 第 2 步:决定 worktree
见 `closed-loop.md` §「子任务放哪」:
- 需求大 / 要并行 / 要保基线分支干净 → 新建隔离 worktree。
- 小改动 / 串行修复 → 当前 worktree 起子 agent。
用户确认后的闭环步骤由 SKILL.md「工作」加载,不要在本步预读其它规范。
---
## 第 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。
复用边界以 SKILL.md「启动 worker」为准,不要在本步预读其它规范。
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
不能用于匹配或复用旧 receipt / 既有终端:
@@ -115,33 +137,34 @@ 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`。
选型与升级以 SKILL.md「启动 worker」为准。
---
## 第 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`。
Orca / 手动命令与派发文案由 SKILL.md「启动 worker」按条件加载。
Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 worker 全量读取
知识库。知识正文不得作为自由 shell 执行;需要命令时只能引用项目已审查的检查
@@ -153,16 +176,20 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
## 第 5 步:可选交付
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和
production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把
delivery run 标为 `blocked` 或 `failed`。
用户说「重新布测试环境」或「发布一个版本」时,转入 SKILL.md 对应模式,不要在本步预读交付规范。intent 为 null 时先做交付配置维护。
所选任务 `verified` 后,转入 SKILL.md「运行回归」给出新增/更新/退役/无回归四选一,用户确认后写入 `.pouch/ack/regression.yaml`。用户说「回归」时先布测试环境,再派 Test 按目录执行。
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,转入 SKILL.md 对应交付模式。启用交付时必须在计划中默认列出 `defaultProfile`,用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和 config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入 `tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把 delivery run 标为 `blocked` 或 `failed`。
## 第 6 步:收尾
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
一轮结束时 Coordinator 必须能回答:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。复验轮次争议以 SKILL.md「边界」为准。
Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先 dry-run
审阅决策、再 `--apply` 关闭所有只关联 `verified` 任务的 Developer/Test 终端并核对
回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、`leftover`
或其它未完成任务/未解决环境事件的终端保留,不设置 TTL。一个终端只要仍被任一这类
任务引用,就不能因为同时关联了 verified 任务而关闭。
---
@@ -171,5 +198,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。
+50 -21
View File
@@ -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 -8
View File
@@ -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 的知识?
+91 -19
View File
@@ -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 只在
后续任务得到验证或用户明确要求清理时回收。
+51 -5
View File
@@ -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。
```
+73
View File
@@ -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 时按点名范围执行。
+25 -11
View File
@@ -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
+11
View File
@@ -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)
+64
View File
@@ -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
+65 -26
View File
@@ -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"],
+196
View File
@@ -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())
+3 -3
View File
@@ -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)
+132
View File
@@ -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())
+261
View File
@@ -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())
+104 -10
View File
@@ -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)
+14 -10
View File
@@ -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)
+378
View File
@@ -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())
+523 -11
View File
@@ -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。"
)
+156
View File
@@ -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())
+134 -54
View File
@@ -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",
+29 -3
View File
@@ -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",
+7 -1
View File
@@ -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: {}
+2 -2
View File
@@ -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 -1
View File
@@ -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>"
+28 -20
View File
@@ -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: []
File diff suppressed because it is too large Load Diff
+75 -8
View File
@@ -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
+148
View File
@@ -0,0 +1,148 @@
# builder
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
可执行校验器。
## 什么时候使用
- "用 builder 初始化这个项目"
- "帮我构建这个项目的 DEB / Docker 镜像"
- "把 1.2.3 发布到包仓库 / 镜像仓库"
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
- "看看项目现在的发布流程"
只构建不上传时明确说明即可;上传永远需要你显式授权。
## 安装
```bash
pouch add builder # 当前项目
pouch add builder -g # 全局
```
不要 `pouch init builder`。对新项目说「用 builder 初始化」。
## 使用前准备
- Python 3 和 `make`。
- DEB 轨道需要 `dpkg-deb`;Docker 轨道需要 Docker 和 buildx。
- 发布时在项目根 `.env.builder` 填写仓库地址和 token(不要写进 `.env`,也不要进 git)。
- 正式版本号由 [manage-release](../manage-release/README.md) 打 annotated tag;builder 只读当前 HEAD。
- 脏工作树默认拒绝发布。
## 项目接入契约
对新项目说「用 builder 初始化」。Agent 会探测轨道、按
`templates/makefile.builder` 写出项目根 `makefile.builder`,并列出缺的发布配置。
不改用户已有的 `Makefile` 或 `.env`。
1. `makefile.builder` 目标:`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 . --ready` 直到构建项 PASS。
缺发布键只挡住上传,不挡住构建。
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`):
```text
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
DOCKER_REGISTRY=registry.example.com
```
4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
`make -f makefile.builder 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 -f makefile.builder version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
**不要**用 `git tag \| sort -V \| tail -1` 取全仓库最大号,也不要把
`git describe --dirty` 的原始字符串写进 DEB 或镜像 tag。
### 正式(HEAD 恰好是稳定 tag `v1.4.2`)
```text
Git tag v1.4.2
规范版本 1.4.2
DEB foo_1.4.2_amd64.deb Version: 1.4.2
Docker registry/ns/foo:1.4.2
```
稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc`、`-app-N` 等后缀。
不是 exact-match 就不是正式包。
### 测试(其它任何 commit)
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的
分支名、相对距离、短 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 初始化这个项目。
用 builder 检查这个项目的 makefile.builder 是否符合契约。
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
```
## Agent 会做什么
1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,不改你已有的 `Makefile` 或 `.env`。
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
Docker 只有你点名镜像时才走。token 不会写进输出、提交或回复。
## 如何判断完成
- 初始化:`check.py --ready` 的构建项 PASS。只缺发布键算部分完成,仍可构建、不能上传。
- 构建:产物路径、规范版本、架构与 `make -f makefile.builder version` 一致。
- 发布:HTTP 成功或远端 digest 对得上;回复里有包名或镜像引用、SHA-256 / digest、源 commit。
- 测试包不会通过无参 `apt upgrade` 盖住正式包。
## 脚本一览
| 脚本 | 用途 |
|------|------|
| `scripts/check.py` | 校验契约(`--ready` 含工具链与发布键名,`--build` 实构核对产物) |
| `templates/makefile.builder` | 初始化用的契约文件骨架,拷到项目根 |
| `templates/env.builder` | 初始化用的 `.env.builder` 骨架(注释键,不含值) |
| `scripts/version.sh` | 从 Git 祖先稳定 tag 推导规范版本 / Docker tag |
| `scripts/version.mk` | `makefile.builder` `include`,设置 `VERSION` 与 `IMAGE_TAG` |
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
脏工作树策略等完整规则见 contract.md。
+65
View File
@@ -0,0 +1,65 @@
---
name: builder
description: >-
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker
镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。
---
# Builder:DEB / Docker 构建发布
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
`.pouch/builder/`,不要改用户的 `Makefile` / `makefile` / `.env`。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。
## 选择模式
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
## 初始化
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。
3. 没有 `makefile.builder`:把 `<skill-dir>/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它)。`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成 `push: push-deb push-docker`。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非用户明确要求按契约改。不要调用 create-makefile(版本规则冲突)。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
```text
## builder 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: make / docker / dpkg-deb(缺则怎么装,不擅自安装)
下一步: 一句话
```
```text
# .env.builder 键名示例
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=
DOCKER_REGISTRY=registry.example.com
```
## 检查
只读。运行 `check.py <project-dir> --ready`,用同一报告格式,标题改为 `## builder 检查:…`。不写 `makefile.builder` / `.env.builder`,不改用户 Makefile 或 `.env`。用户明确要求修复后再转入初始化。
## 工作流
契约见 [contract.md](references/contract.md)。Docker 的 registry/tag 不明确时再读 [registry.md](references/registry.md)。
1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。
2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。
3. 构建:`make -f makefile.builder build ARCH=<amd64|arm64> VERSION=<version>`;DEB 再 `make -f makefile.builder deb ARCH=<...>`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
4. DEB 上传前:`verify_deb.sh <exact-package-path.deb>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
+160
View File
@@ -0,0 +1,160 @@
# Builder 构建发布契约 v1
本契约是 builder skill 的规范本体。`scripts/check.py` 是它的可执行形态:改契约先改
check.py,本文档跟随。所有接入项目按同一套 make 目标、产物形状和环境变量执行,
builder 脚本只做发布,不做项目特定的构建逻辑。
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
Agent 只保留授权判断和歧义处理。**
契约文件固定为项目根 `makefile.builder`,调用方式:
```bash
make -f makefile.builder <target>
```
不要把 builder 目标写进用户的 `Makefile` 或 `makefile`。`check.py` 只读
`makefile.builder`。
发布凭据固定为项目根 `.env.builder`。不要把这些键写进用户的 `.env`。脚本不读取
`.env`。
## 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.builder` 配方引用 `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 -f makefile.builder version` 输出一行规范版本(无 `v` 前缀)。DEB 的
`Version` 与文件名直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`
(`makefile.builder` 通过 `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.builder` 不要内联 `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.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
不要读取或改写用户 `.env`。空值视为未配置。不要提交 `.env.builder`。
### 工作区安全
脏工作树(有未提交修改)默认拒绝发布;`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 -f makefile.builder deb` 并核对产物元数据(默认只静态检查配方)。`--ready`
额外检查轨道工具链,以及 `.env.builder` / 环境中的发布键名是否存在且非空(不读取、
不打印值;缺键只挡住发布)。校验失败时的修复路径:按 `templates/makefile.builder` 补齐或
修正 `makefile.builder`,再跑 check.py,不要绕过校验器,不要改用户 Makefile,
不要用 create-makefile(版本推导与本契约冲突)。
## 6. 存量项目(legacy fallback)
未接入契约的项目:builder 仍可按发现流程工作——从用户 `Makefile`、CI 配置、`debian/`
与发布文档中找已有构建/上传入口,优先复用;上传仍使用 builder 脚本。从项目根查找,
不预设文件位置:
```bash
rg -n -i --hidden --glob '!.git' \
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
```
完成一次成功交付后应引导用户按 `templates/makefile.builder` 写入项目根
`makefile.builder`,之后以 check.py 为准。不把契约目标合并进用户 Makefile。
+42
View File
@@ -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.builder` 与 `AGENTS.md`、发布文档。不要读用户 `.env`。
3. `makefile.builder`、用户 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 只有在用户明确要求时才额外发布,
不能代替上面那条唯一身份。
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""Executable form of the builder contract (references/contract.md).
Checks a project's makefile.builder against the contract by probing make
itself with dry runs (`make -f makefile.builder -n`) instead of parsing
makefile text: includes, conditionals, and variable expansion are resolved
by make, so behavior is what gets judged. The default Makefile/makefile is
not read.
Usage:
python3 -I -S check.py <project-dir> [--build] [--ready]
Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error.
`--ready` 额外检查轨道工具链和发布环境变量键名(只看键是否存在,永不打印值)。
无 makefile.builder 时普通模式退出 2;`--ready` 输出结构化 FAIL 并继续工具链/发布键检查。
Change the contract here first, then mirror the change into contract.md.
"""
from __future__ import annotations
import argparse
import hashlib
import os
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")
BUILDER_MAKEFILE = "makefile.builder"
BUILDER_ENV = ".env.builder"
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, dry_run: bool = True
) -> subprocess.CompletedProcess[str]:
cmd = ["make", "-C", str(project), "-f", BUILDER_MAKEFILE]
if dry_run:
cmd.append("-n")
cmd.extend(args)
return subprocess.run(
cmd, 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 / BUILDER_MAKEFILE
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")
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def env_file_keys(project: Path) -> set[str]:
"""Return nonempty key names in `.env.builder`. Never return or print values."""
path = project / BUILDER_ENV
keys: set[str] = set()
if not path.is_file():
return keys
try:
text = path.read_text(encoding="utf-8")
except OSError:
return keys
for raw in text.splitlines():
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
match = ENV_KEY_LINE.match(stripped)
if not match:
continue
value = match.group(2).strip().strip("'\"")
if value:
keys.add(match.group(1))
return keys
def key_present(key: str, env_keys: set[str]) -> bool:
return bool(os.environ.get(key)) or key in env_keys
def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool
) -> None:
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
problems = []
if docker_project:
docker_ok = shutil.which("docker") is not None
lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}")
if not docker_ok:
problems.append("install docker to build/publish images")
else:
lines.append("docker: skipped (no docker track)")
if deb_project:
dpkg_ok = shutil.which("dpkg-deb") is not None
lines.append(
f"dpkg-deb: {'found' if dpkg_ok else 'MISSING (blocks make deb / --build)'}"
)
if not dpkg_ok:
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
else:
lines.append("dpkg-deb: skipped (no deb track)")
report.add(
FAIL if problems else PASS,
10,
"轨道工具链",
"\n".join(lines + ([""] + problems if problems else [])),
)
def check_ready_env_keys(
report: Report, project: Path, *, deb_project: bool, docker_project: bool
) -> None:
env_keys = env_file_keys(project)
lines = []
missing: list[str] = []
if not deb_project and not docker_project:
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track")
return
if deb_project:
for key in DEB_ENV_KEYS:
found = key_present(key, env_keys)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
else:
lines.append("DEB_*: skipped (no deb track)")
if docker_project:
for key in DOCKER_ENV_KEYS:
found = key_present(key, env_keys)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
else:
lines.append("DOCKER_*: skipped (no docker track)")
if missing:
lines.extend(
[
"",
f"blocks publish, not build. Put keys in the environment or {BUILDER_ENV}:",
*[f" {key}=" for key in missing],
f"Do not commit {BUILDER_ENV}. Do not put these keys in `.env`. Never print values.",
]
)
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "\n".join(lines))
return
report.add(PASS, 11, "发布环境变量键名(不读取值)", "\n".join(lines))
def check_script_paths(report: Report) -> None:
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 -f makefile.builder deb` for real and return the produced .deb, or None."""
result = run_make(project, "deb", timeout=1800, dry_run=False)
if result.returncode != 0:
print(
f"--build: `make -f {BUILDER_MAKEFILE} 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
MAKEFILE_HINT = (
f"Fix: copy <builder-skill>/templates/{BUILDER_MAKEFILE} to the project "
f"root as {BUILDER_MAKEFILE}. Do not put builder targets in Makefile or "
"makefile. Keep help/build/clean/version, include builder "
"scripts/version.mk, and enable deb/docker/push* for the tracks this "
"project actually uses. Then re-run check.py. Do not use create-makefile."
)
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 makefile.builder",
)
parser.add_argument(
"--build",
action="store_true",
help="actually run `make -f makefile.builder deb` and verify the artifact",
)
parser.add_argument(
"--ready",
action="store_true",
help="also check toolchain and publish env key names (init/check mode)",
)
args = parser.parse_args(argv)
project = args.project.resolve()
makefile = project / BUILDER_MAKEFILE
if not makefile.is_file():
if not args.ready:
print(f"Error: no {BUILDER_MAKEFILE} in {project}", file=sys.stderr)
return 2
report = Report()
report.add(
FAIL,
1,
f"{BUILDER_MAKEFILE} 存在",
f"no {BUILDER_MAKEFILE} in {project}\n{MAKEFILE_HINT}",
)
skip_detail = f"(no {BUILDER_MAKEFILE})"
for number, title, detail in (
(2, "ARCH 守卫与缺省值", skip_detail),
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
(4, "build 不含上传动作", skip_detail),
(5, "deb 目标产物形状与纯构建", skip_detail),
(6, "docker 目标为本地单平台构建", skip_detail),
(7, "push 仅委托 builder 脚本(薄包装)", skip_detail),
(8, "无内联机密、无隐式 latest/stable", skip_detail),
(9, "builder 脚本路径可达", skip_detail),
):
report.add(SKIP, number, title, detail)
docker_project = detect_docker_project(project)
deb_project = any(project.glob("debian/*"))
check_ready_toolchain(report, deb_project=deb_project, docker_project=docker_project)
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
)
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
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(f"--build: running `make -f {BUILDER_MAKEFILE} 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)
if args.ready:
check_ready_toolchain(
report, deb_project=deb_project, docker_project=docker_project
)
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
)
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())
+183
View File
@@ -0,0 +1,183 @@
#!/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.builder);
# flags override. Does not read `.env`.
#
# 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 .env.builder without printing values; shell values win. Do not read `.env`.
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; 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.builder
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
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.builder." >&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,34 @@ 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.builder; this script
loads it silently (existing shell values win), never echoes values, and does
not read `.env`. 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.builder loading and git checks.
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
# Load .env.builder without printing values; shell values win. Do not read `.env`.
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; 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.builder
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
fi
server_url=${DEB_SERVER_URL:-}
repository=${DEB_REPOSITORY:-}
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
@@ -40,9 +62,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.builder." >&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 +163,3 @@ echo "Done. Success: $success_count, Failed: $fail_count"
if ((fail_count > 0)); then
exit 1
fi
+111
View File
@@ -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"
+42
View File
@@ -0,0 +1,42 @@
# Include from makefile.builder. 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
+195
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
# Builder publish credentials. Copy to the project root as `.env.builder`.
# Do not commit this file. Do not put these keys in the project's `.env`.
# Uncomment and fill the keys for tracks this project uses.
# Shell-exported values override this file.
#
# DEB_SERVER_URL=
# DEB_TOKEN=
# DEB_REPOSITORY=
# DEB_UPLOAD_PATH=
# DOCKER_REGISTRY=
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS=
+60
View File
@@ -0,0 +1,60 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# 不要把这些目标写进用户的 Makefile / makefile。
# 初始化时按项目轨道删掉未使用的 deb / docker 段。
# 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。
#
# include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。
ARCH ?= amd64
ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))
$(error ARCH must be amd64 or arm64)
endif
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
include $(HOME)/.pouch/skills/builder/scripts/version.mk
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version clean build deb docker push push-deb push-docker
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build"
@echo " make -f makefile.builder version - 打印规范版本 / canonical version"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder deb - 打 DEB(只构建不上传)"
@echo " make -f makefile.builder docker - 本地单平台镜像"
@echo " make -f makefile.builder push / push-deb / push-docker"
version:
@echo $(VERSION)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实构建命令。不得 curl/scp/docker push。
build:
mkdir -p $(DIST_DIR)
@echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)"
deb: build
mkdir -p $(DIST_DIR)
dpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
docker:
docker build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) .
push-deb:
$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
push-docker:
$(BUILDER_SCRIPTS)/publish_docker.sh
# 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker
push: push-deb
-80
View File
@@ -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,不要直接套用该脚本。
-150
View File
@@ -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,55 @@
# declarative-openspec-loop
你提供「怎样算符合预期」的校验方式,Agent 按 OpenSpec 走 propose → apply → 校验,不通过就分析原因再迭代,直到通过。
## 什么时候使用
- 已经能说清目标,也有办法判断结果对不对,希望 Agent 自动做到通过为止。
- 会说「声明式编程」「按预期循环」「做到校验通过」。
- 不适合:还在选型、还没有校验手段、只想改一小处且自己盯着看。
## 安装
```bash
pouch add declarative-openspec-loop # 当前项目
pouch add declarative-openspec-loop -g # 全局
```
## 使用前准备
- 本机已安装 [openspec](https://openspec.dev/) CLI。
- 当前 Agent 已安装 OpenSpec 的三个子 skill:`openspec-explore`、`openspec-propose`、`openspec-apply-change`。
- 你必须给出校验方式,缺了 Agent 会先问,不会空跑。可用下面任意组合:
- 校验命令,退出码 0 即通过,比如 `make test-one2`
- 预期文档,里面有校验项表
- 清单:日志关键字、输出文件、数值容差
- 自由描述:Agent 会先归纳成可执行检查,向你确认后再跑
## 使用示例
```text
按声明式循环做:让登录失败时返回统一错误码。
校验:跑 pytest tests/test_login.py,全部 green 即通过。最多 3 轮。
```
```text
目标:拟合轮廓与 docs/tmp/expect_desc.md 一致。
校验:make test-one2,且日志覆盖文档「校验项」表全部 8 项。
```
## Agent 会做什么
1. 记下 change 名称和校验方式,写到 `docs/tmp/<change>/expectation.md`。
2. 必要时先探索;然后 propose 出 proposal / design / tasks,每个任务尽量带验收。
3. apply 实现,再跑你给的校验。
4. 失败按原因分级:小 bug 直接修;任务遗漏就补 tasks;方向错了才改设计。默认最多 5 轮(你可改上限)。同一错误连续 2 轮会停下来问你。
5. 通过后给出变更摘要和思路,过程记录留在 `docs/tmp/<change>/`。
不会在没校验方式时开跑,也不会到上限后自己放弃或无限重试。
## 如何判断完成
- 你指定的校验命令退出码 0,或校验项全部满足。
- Agent 给出变更文件列表和思路。
- `docs/tmp/<change>/` 里有 `expectation.md`、每轮 `iteration_*.md` 和校验原始输出。
- 到上限或反复失败时停下,并说明当前进度和未通过项,而不是假装完成。
+137
View File
@@ -0,0 +1,137 @@
# 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),也可以指定本机已有目录。
## 什么时候使用
- 给新项目初始化 deployer:检查缺什么,引导补 `.pouch/deployer/`
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
- ACK 说「重新布测试环境」或跑回归前要先拉起 `.pouch/deployer/test`
- 需要一张「哪台机器跑哪些服务」的清单
- 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署
## 安装
```bash
pouch add deployer # 当前项目
pouch add deployer -g # 全局
```
不要 `pouch init deployer`。对新项目说「用 deployer 初始化」。
## 使用前准备
- 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
# 新项目
用 deployer 初始化这个项目
给这个项目接上 test 环境,SSH 别名是 my-vps
# 独立配置中心
帮我把 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 环境重新部署一下
本机测试镜像已经有了,直接重启,别 sync
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. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
3. 否则用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`),再远程 `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`,远程多余的文件会被删除;数据库、证书等运行时数据
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
## 如何判断完成
- 本机:`docker compose ps` 容器 Up。远程:sync 成功且 `remote.py 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/<项目名>` 时才需要。
+88
View File
@@ -0,0 +1,88 @@
---
name: deployer
description: >-
初始化或检查项目部署配置;管理多机 Docker Compose(脚本同步后远程
compose)与 Argo CD GitOps(改清单开 MR,用户合并后同步)。触发词:初始化
deployer、部署、sync、升级、装 deb、ArgoCD、GitOps;ACK 拉起测试环境时也可使用。
---
# deployer:Compose 节点与 Argo CD GitOps
- **Compose**:本地改 `compose.yaml` → 脚本同步到 SSH 节点 → 远程 `docker compose`。
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用 Compose 的 `sync.py`/`remote.py` 去推集群。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。不要创建 `.pouch/deployer/` 之外的
假配置中心,也不要用 `pouch init deployer`。
## 选择模式
- 初始化、接入 deployer,或给新项目建测试/生产环境:执行「初始化」。
- 检查 `.pouch/deployer`、node、compose 是否齐全:执行「检查」。
- 部署、重新部署、同步、升级、重启、布测试环境、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- ArgoCD / GitOps / 开 MR 部署:执行「Argo CD」。步骤见 [argocd.md](references/argocd.md)。
- 两者都有且意图不清:先问。
- 不适用:没有 `.pouch/deployer/` 的随手 `docker compose`、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。本机 node 仍走本 skill,镜像已在本机则跳过 sync。
## 边界
- 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。
- `rsync` 带 `--delete`:运行时数据必须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。
- `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
- 不 `--force` 推送、不硬 reset,除非用户明确要求。Argo CD 不直接推默认分支。
- 镜像固定 tag,不用 `:latest`;成对升级的服务要同步升。
- ACK 调用不能把范围扩到生产环境、Argo 合入或节点级批量。
## 初始化
独立配置中心仓库(已设 `DEPLOYER_ROOT`)只做检查,不要改成项目内布局。`_config.yaml` 字段见 [config-reference.md](references/config-reference.md)。
1. 探测:`.pouch/deployer/`、根目录 compose、`Dockerfile`、ACK `intents.testEnvironment`、`argocd.yaml`。
2. Compose 与 Argo 都有且意图不清:先问。两边都要也可以,必须分开确认。
3. Compose / 新项目(ACK 默认需要 `test`):默认只建 `test`;`prod`/`dev` 用户点名再加。问 SSH Host 别名;没给不要写假 node,目录可建、列为待配置。有根目录 compose:提议迁到 `.pouch/deployer/<env>/`,确认后才动。只有 Dockerfile:可给单服务 compose 草稿,用户确认后写入,不发明多服务网格。
4. Argo:只问 GitOps `repo`(或 `repo_dir`),写 `.pouch/deployer/argocd.yaml`。不 clone、不开 MR、不 `kubectl apply`。
5. 运行(不 SSH、不 up):
```bash
python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
```
6. 缺 `node` / compose / ssh 别名 = 部分完成或阻塞。不覆盖已有 compose/`_config.yaml`。除非用户明确要求,不部署、不提交。
```text
## deployer 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: ssh / rsync(缺则怎么装,不擅自安装)
下一步: 一句话
```
## 检查
只读。运行 `scripts/deploy/check.py --project <project-root>`,用同一报告格式,标题改为 `## deployer 检查:…`。不写文件、不 SSH。用户明确要求修复后再转入初始化。
## 被 ACK 调用
ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的默认路径布测试环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
## Compose 操作
重启 / 重新部署 / 同步 / 布测试环境走默认路径。不要读 compose.md、不要初始化、不要读 Makefile。
1. 在项目根执行:`python3 -I -S <skill-dir>/scripts/deploy/list.py`
2. 只操作**一个**服务:用户点名的 env;「测试」→ `test`,没有 `test` 就用非 `prod` 的那个。未点名 `prod` 不要动它。
3. 不要改 compose tag、不要构建镜像、不要看其它环境。
4. 本机镜像:node 的 ssh `HostName` 是 `127.0.0.1`/`localhost`,且 `docker image inspect` 看得到 compose 里的 tag → **不要** `sync.py` / `remote.py`。进 `.pouch/deployer/<env>/` 执行 `docker compose up -d --force-recreate`,再 `ps`。完。
5. 否则才 sync + remote:`python3 -I -S <skill-dir>/scripts/deploy/sync.py <svc>` 然后 `python3 -I -S <skill-dir>/scripts/deploy/remote.py <svc> recreate`。只要重启:`remote.py <svc> restart`。
6. 远程路径验证:`remote.py <svc> ps`;异常再 `logs`。完。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。
## Argo CD
读 [argocd.md](references/argocd.md)。读 `.pouch/deployer/argocd.yaml`(无则只问 Git 地址写成 `repo:`)。按已有应用惯例改清单,从默认分支拉出分支开 MR。**停在 MR**,不合并、不 `kubectl apply` 工作负载。无 app-of-apps 时提醒用户首次 apply 那份 `application.yaml`。Harbor / TLS Secret 不入库。
+150
View File
@@ -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`
+163
View File
@@ -0,0 +1,163 @@
# Compose 轨道
同步文件、远程 `docker compose`、装 deb、新增/下线服务。Argo CD 不走本文件。
## 目录
- [两种布局](#两种布局)
- [定位部署根](#定位部署根)
- [列出服务](#列出服务)
- [命令选择](#命令选择)
- [新增服务 / 环境](#新增服务--环境)
- [下线服务](#下线服务)
- [安装 deb](#安装-deb)
- [NAS / rsync 失败](#nas--rsync-失败)
- [验证](#验证)
## 两种布局
**A. 独立配置中心仓库**:仓库根即部署根,`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
│ └── _config.yaml # 环境级覆盖
├── test/compose.yaml
└── dev/compose.yaml
```
项目模式下远程目录名自动加项目前缀 `{git仓库名}-{env}`(如 `my-project-prod`);`_config.yaml` 写 `name:` 可显式指定。
## 定位部署根
`scripts/deploy/` 是通用工具链,不依赖具体项目路径。部署根顺序:
1. 环境变量 `DEPLOYER_ROOT`(独立配置中心仓库用这个)
2. 从当前目录向上找 `.pouch/deployer/`(项目内环境布局)
3. skill 安装位置兜底(仅查看,没有可部署服务)
```bash
# <skill-dir> = 本 skill 的 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
```
## 列出服务
`list.py` 输出全部服务与节点分布;新增环境/服务后重跑确认被发现。项目布局下 `prod/test/dev` 各显示为 `{项目名}-{env}`。`argocd.yaml` 不会被当成 compose 服务。
查看单个服务的 node、远程路径、排除规则:
```bash
python3 <skill-dir>/scripts/deploy/sync.py <service-path>
```
或读服务目录及祖先的 `_config.yaml`。
## 命令选择
语义严格区分,必须按**单服务**执行:
| 意图 | 命令 |
|------|------|
| 本机已有镜像(node 是 localhost,image 已在本机 docker) | 不要 sync。在 env 目录 `docker compose up -d --force-recreate` |
| 重新部署远程环境 | `sync.py <svc> && remote.py <svc> recreate` |
| 只同步文件,不动容器 | `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` |
脚本一律 `python3 -I -S <skill-dir>/scripts/deploy/<name>.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 tar over SSH 兜底。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
## 新增服务 / 环境
独立仓库布局:
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` 会清掉)
## 下线服务
独立仓库布局:配置移入 `unused/`(自动脱离发现体系),远程按需手动清理:
`ssh <node> "cd <base_path>/<name> && docker compose down"`,数据卷按需保留或删除。
项目环境布局:删除对应 `.pouch/deployer/{env}/` 目录即可脱离发现体系,远程清理同上。
## 安装 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
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>"`。
## NAS / rsync 失败
部分 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 删除。Synology 上 docker 路径可能是 `/usr/local/bin/docker`。
## 验证
- `list.py` 输出全部服务与节点分布,数量与预期一致
- 每次 sync/deploy 后 `remote.py <svc> ps` 容器 Up、`logs` 无报错
- 升级后额外确认镜像 tag 与 `compose.yaml` 一致
- 改 Traefik/Caddy 路由后 curl 对应域名验证生效
@@ -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/` 内(其外的项目文件不扫描)
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Readiness checker for deployer project layout.
Usage:
python3 -I -S check.py [--project DIR]
Resolves the deploy root as DEPLOYER_ROOT, else <project>/.pouch/deployer
(or .skiff/deployer), else walking up from cwd. Does not SSH, rsync, or
start containers. Exit 0 = PASS (SKIP allowed), 1 = FAIL, 2 = usage.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import sys
from pathlib import Path
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
import lib # noqa: E402
PASS = "PASS"
FAIL = "FAIL"
SKIP = "SKIP"
_PROJECT_LAYOUT_DIRS = (".pouch", ".skiff")
_REPO_OR_DIR = re.compile(r"^(repo|repo_dir)\s*:", re.MULTILINE)
class Report:
def __init__(self) -> None:
self.failures = 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
def node_in_ssh_config(node: str, hosts: set[str]) -> bool:
if node in hosts:
return True
if "@" in node:
_, host = node.rsplit("@", 1)
return host in hosts
return False
def resolve_deploy_root(project: Path | None) -> tuple[Path | None, str]:
env = os.environ.get("DEPLOYER_ROOT", "").strip()
if env:
path = Path(env).expanduser().resolve()
return (path if path.is_dir() else None), "DEPLOYER_ROOT"
if project is not None:
root = project.resolve()
for dirname in _PROJECT_LAYOUT_DIRS:
candidate = root / dirname / "deployer"
if candidate.is_dir():
return candidate, "project"
return None, "project"
found = lib._find_project_root()
if found.name == "deployer" and found.parent.name in _PROJECT_LAYOUT_DIRS:
return found, "project"
if found == lib._SKILL_DIR.parent:
return None, "cwd"
return found, "cwd"
def compose_service_dirs(root: Path) -> list[Path]:
dirs: list[Path] = []
for compose in sorted(root.glob("**/compose.yaml")):
service_dir = compose.parent
if not lib.is_deployable_dir(service_dir, root):
continue
dirs.append(service_dir)
return dirs
def check_layout(report: Report, root: Path | None, source: str, project: Path | None) -> bool:
if root is None:
if source == "DEPLOYER_ROOT":
report.add(
FAIL,
1,
"部署根存在",
"DEPLOYER_ROOT is set but is not a directory",
)
else:
hint_root = project.resolve() if project is not None else Path.cwd()
report.add(
FAIL,
1,
"部署根存在",
"\n".join(
[
f"no .pouch/deployer under {hint_root}",
"Fix: run deployer 初始化 and create .pouch/deployer/",
" _config.yaml # node: <ssh-host-alias>",
" test/compose.yaml # default env for ACK",
]
),
)
return False
kind = "project layout" if lib.in_project_layout(root) else "standalone deploy root"
report.add(PASS, 1, "部署根存在", f"{root} ({kind}, via {source})")
return True
def check_toolchain(report: Report) -> None:
ssh_ok = shutil.which("ssh") is not None
rsync_ok = shutil.which("rsync") is not None
lines = [
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}",
]
if not ssh_ok:
lines.append("install openssh-client")
report.add(FAIL, 2, "工具链", "\n".join(lines))
return
report.add(PASS if rsync_ok else SKIP, 2, "工具链", "\n".join(lines))
def check_argocd(report: Report, root: Path) -> bool:
path = root / "argocd.yaml"
if not path.is_file():
report.add(SKIP, 3, "Argo CD 指针", "no argocd.yaml")
return False
text = path.read_text(encoding="utf-8")
if _REPO_OR_DIR.search(text):
report.add(PASS, 3, "Argo CD 指针", "argocd.yaml has repo or repo_dir")
return True
report.add(
FAIL,
3,
"Argo CD 指针",
"argocd.yaml exists but has neither repo: nor repo_dir:\n"
"Fix: repo: git@host:org/infra-gitops.git",
)
return True
def check_services(report: Report, root: Path) -> None:
services = compose_service_dirs(root)
if not services:
report.add(
FAIL,
4,
"至少有一个 compose.yaml",
"no compose.yaml under the deploy root\n"
"Fix: add .pouch/deployer/<env>/compose.yaml (env usually test)",
)
report.add(SKIP, 5, "每个服务能解析 node", "(no compose.yaml)")
report.add(SKIP, 6, "node 出现在 SSH config", "(no compose.yaml)")
report.add(SKIP, 7, "list 可发现服务", "(no compose.yaml)")
return
rels = [str(path.relative_to(root)) for path in services]
report.add(PASS, 4, "至少有一个 compose.yaml", "\n".join(rels))
hosts = lib.ssh_config_hosts()
node_lines = []
ssh_lines = []
node_fail = False
ssh_fail = False
for path in services:
rel = str(path.relative_to(root))
info = lib.service_info(rel, strict=False)
if info is None:
node_lines.append(f"{rel}: MISSING node")
ssh_lines.append(f"{rel}: skipped (no node)")
node_fail = True
continue
node = str(info["node"])
node_lines.append(f"{rel}: node={node}")
if node_in_ssh_config(node, hosts):
ssh_lines.append(f"{rel}: {node} in ~/.ssh/config")
else:
ssh_lines.append(f"{rel}: {node} NOT in ~/.ssh/config")
ssh_fail = True
if node_fail:
node_lines.extend(
[
"",
"Fix: write node in _config.yaml (deploy root or env dir).",
"Example:",
" node: my-vps",
" base_path: /opt/app",
"Do not invent a hostname. It must be an SSH Host alias.",
]
)
report.add(FAIL, 5, "每个服务能解析 node", "\n".join(node_lines))
else:
report.add(PASS, 5, "每个服务能解析 node", "\n".join(node_lines))
if ssh_fail or node_fail:
if ssh_fail:
ssh_lines.extend(
[
"",
"Fix: add a Host entry to ~/.ssh/config for the node alias.",
"This check does not open an SSH connection.",
]
)
report.add(FAIL, 6, "node 出现在 SSH config", "\n".join(ssh_lines))
else:
report.add(PASS, 6, "node 出现在 SSH config", "\n".join(ssh_lines))
found = lib.discover_services()
if not found:
report.add(
FAIL,
7,
"list 可发现服务",
"compose.yaml exists but discover_services found none "
"(need resolvable node)",
)
return
names = [os.path.relpath(item["service_dir"], root) for item in found]
report.add(PASS, 7, "list 可发现服务", f"{len(found)} service(s): " + ", ".join(names))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--project",
type=Path,
default=None,
help="project root (looks for .pouch/deployer); ignored when DEPLOYER_ROOT is set",
)
args = parser.parse_args(argv)
report = Report()
project = args.project.resolve() if args.project is not None else None
root, source = resolve_deploy_root(project)
layout_ok = check_layout(report, root, source, project)
check_toolchain(report)
if not layout_ok:
report.add(SKIP, 3, "Argo CD 指针", "(no deploy root)")
report.add(SKIP, 4, "至少有一个 compose.yaml", "(no deploy root)")
report.add(SKIP, 5, "每个服务能解析 node", "(no deploy root)")
report.add(SKIP, 6, "node 出现在 SSH config", "(no deploy root)")
report.add(SKIP, 7, "list 可发现服务", "(no deploy root)")
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
assert root is not None
lib.PROJECT_ROOT = root
lib._SSH_HOSTS = None
previous_cwd = Path.cwd()
try:
os.chdir(root)
has_argocd = check_argocd(report, root)
services = compose_service_dirs(root)
if services:
check_services(report, root)
elif has_argocd:
report.add(SKIP, 4, "至少有一个 compose.yaml", "Argo CD only; no compose env")
report.add(SKIP, 5, "每个服务能解析 node", "Argo CD only")
report.add(SKIP, 6, "node 出现在 SSH config", "Argo CD only")
report.add(SKIP, 7, "list 可发现服务", "Argo CD only")
else:
check_services(report, root)
finally:
os.chdir(previous_cwd)
print()
if report.failures:
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
print("RESULT: PASSED")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+205
View File
@@ -0,0 +1,205 @@
#!/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
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import ( # noqa: E402
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())
+365
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More