78 Commits

Author SHA1 Message Date
laily 0918f2c916 add what-is-it 2026-11-02 15:51:41 +08:00
laily a75f130c7e catalog: add ego-lite source 2026-10-31 15:20:11 +08:00
laily a15a046e4e feat(ship): merge short-ship and long-ship into one entry
Promote ship to the user-facing delivery skill. Kind is short or long
from an explicit mode, then self-judgment, then a question, defaulting
to short. Old names remain deprecation stubs.
2026-10-24 14:27:19 +08:00
laily 7d6f3fe8e5 add knowledge-explainer 2026-10-20 16:58:29 +08:00
laily 181cce6bfe feat: rename prototype workspace and document versioning 2026-10-19 10:54:02 +08:00
laily 49cd594233 feat: group prototype journeys and simplify HTML preview 2026-10-19 10:37:56 +08:00
laily adaa8e4edd feat: add test-preflight runner and deployer prepare adapters
agent-runtime executes fixed tester node operations over JSON stdin.
deployer prepare brings up the local test Compose service the same way.
test-preflight now passes the tester binding; tester select skips
unauthorized cases before creating a runner.
2026-10-19 09:28:50 +08:00
laily 9075ff6a5a feat: add product prototype skill and studio 2026-10-19 09:26:02 +08:00
laily 3a18a27dc6 feat(tester): add integration-first journey execution 2026-10-15 18:11:28 +08:00
laily c2eb207bd2 feat(test-preflight): add verify and prepare skill 2026-10-15 18:04:59 +08:00
laily ffa7cbd63e feat(tester): add client journey testing standard 2026-10-15 16:48:54 +08:00
laily baed23d02a test(tester): validate CSV regression summary totals 2026-10-15 11:08:33 +08:00
laily 9ba9b3da0f feat(builder): query CI via project status script
When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom
command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only
polls that script and no longer guesses forge CLIs.
2026-10-15 09:56:51 +08:00
laily 0c9a530390 chore(tester): drop HX_TEST_HOST alias
HOST is the only accepted test-service address from env, local.yaml, or catalog vars.
2026-10-15 09:44:23 +08:00
laily 5fef8247fc feat(tester): require caller artifact locators instead of --release
Mobile runs now take --artifact android|ios=<path-or-url> from the caller
and create a fresh isolated worktree each time. Drop CI release/manifest
lookup.
2026-10-15 01:05:09 +08:00
laily 2fcf16627d fix(tester): expose bundle provisioning failure action 2026-10-14 17:30:11 +08:00
laily 706c553778 feat(tester): provision CI mobile bundles in shared skill 2026-10-14 17:29:45 +08:00
laily db92baf3a7 fix tester multi-root fan-out execution 2026-10-14 15:35:13 +08:00
laily fbf0ca8cd8 fix(deployer): align stale test contracts 2026-10-14 12:21:09 +08:00
laily 8d48d2d335 feat(ship): enforce test manifest handoff 2026-10-14 12:16:55 +08:00
laily d7ebb6ee85 feat: update 2026-10-14 12:14:31 +08:00
laily 19aac17a0a feat(ship): clarify delivery stop semantics 2026-10-14 11:49:50 +08:00
laily 125bfe0c23 chore: sync pouch skill workflows 2026-10-14 10:58:03 +08:00
laily 57879cffdd feat(ship): add stage audit trail and sortable ids 2026-10-14 10:57:16 +08:00
laily 4814bc1026 feat(ship): run every project test surface in regression 2026-10-14 09:57:08 +08:00
laily 7be8b9c3d9 test(tester): cover nested catalog discovery 2026-10-14 09:27:17 +08:00
laily f8e490e9b5 fix(tester): discover catalogs in subprojects 2026-10-14 09:26:39 +08:00
laily 95946d4278 fix: support explicitly authorized Codex deployment without sandbox
Production agents repeatedly failed before SSH because the launcher only accepted workspace-write profiles. Accept the explicit Codex danger-full-access profile through the same validated plan and receipt path, and cover profile, argv and receipt validation with regression tests.
2026-10-13 23:47:34 +08:00
laily af6e139d30 feat: rename bugfix-ship/requirement-flow to short-ship/long-ship with shared ship records
short-ship and long-ship share one ship-id record under .pouch/ship/<id>/.
specifier writes PRDs into spec/; delivery-loop appends evidence pointers.
Old names stay as deprecation stubs so existing installs keep working.
2026-10-13 23:11:58 +08:00
laily 93341b3c69 feat: upadte 2026-10-13 20:17:26 +08:00
laily 0eefc52b44 feat: update deployer 2026-10-13 19:47:51 +08:00
laily 8390706df8 feat: auto-update pouch and catalog sources during normal use
Add taste-skill to catalog.yaml, and refresh pouch plus cloned catalog
or custom Git sources on a 24h interval so users do not need a
separate update command.
2026-10-13 16:41:55 +08:00
laily d3b6732d40 docs: clarify HTML previewer integration 2026-10-13 00:21:22 +08:00
laily ab29598a9a add delivery workflow skills 2026-10-12 20:34:20 +08:00
laily c9faedc39d fix(builder): refresh git index before publish gate 2026-10-12 14:53:58 +08:00
laily f05ac24c57 fix(agent-runtime): finish builder sessions on make exit 2026-10-12 13:32:56 +08:00
laily de194370e6 fix(agent-runtime): resolve imported Orca worktrees 2026-10-12 13:32:35 +08:00
laily 299f6dd9b2 fix(builder): detect platform tracks independently 2026-10-12 13:26:40 +08:00
laily 558c6facc2 feat(agent-runtime): unify agent bindings and sessions 2026-10-12 13:22:47 +08:00
laily 32da57d19b feat: upadte 2026-10-12 12:53:10 +08:00
laily 51f7c2e60c fix(builder): keep project script config in makefile 2026-10-12 12:52:15 +08:00
laily 544cbcca32 feat(builder): support project-local distribution scripts 2026-10-12 12:26:10 +08:00
laily a35cc916cb feat(tester): initialize agent target from Orca selectors 2026-10-12 01:14:52 +08:00
laily 5ba1d8e3d6 feat: add previewer static serving 2026-10-11 18:19:46 +08:00
laily 0ec8ea81d8 docs: list specifier skill 2026-10-11 17:57:28 +08:00
laily 824049f774 feat: add specifier skill 2026-10-11 17:56:38 +08:00
laily 1100eec884 feat: add Android and iOS tester skills 2026-10-11 17:29:04 +08:00
laily cf78d0cab2 feat(host-sec-check): add staged hardening and remediation 2026-10-10 18:59:38 +08:00
laily 86ce602b28 feat(deployer): sync 前备份远程服务目录
rsync --delete 会清掉目标目录里的多余文件;先拷到 $HOME/bakeup/deployer-skill,失败则中止同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 23:45:45 +08:00
laily 130dcc36c5 feat(ack): shard task records for merge-safe workspaces 2026-09-20 19:15:43 +08:00
laily fd13dad2b0 fix(tester): default local host to loopback 2026-09-20 14:47:30 +08:00
laily 38c4c7f91d fix(ack): close Orca terminals after worker completion 2026-09-19 19:49:23 +08:00
laily 147b63aae6 fix(ack): use 60 second coordinator wait windows 2026-09-19 19:40:37 +08:00
laily 94c66bc695 fix(ack): bound worker polling and detect stale heartbeats 2026-09-19 19:30:20 +08:00
laily 2917708b10 fix(ack): keep coordinator loop active after dispatch 2026-09-19 18:46:18 +08:00
laily f469c8f5f5 feat(ack): support project agent routing overrides 2026-09-19 17:52:14 +08:00
laily 44cee4be91 feat(fixds): 增加「用户在赶时间」硬规则
用户默认在赶时间:每个动作、每次追问、每段输出都要能缩短拿到结果的时间。
先给结论再给背景、独立命令一次发出、能自查的不问用户、同一事实不重复、
无关线索不展开、同一路径两轮无产出就换方法。

同时划清边界:省掉的是多余动作与多余追问,不是必要确认——要求与实测冲突、
越界改动、破坏性操作仍须先问。规则编号顺延为 1-7,README 与案例文件的
判定标准同步更新。
2026-09-19 14:09:02 +08:00
laily 146182c1b9 feat(fixds): 新增 DeepSeek Flash 模型行为约束 skill
把一次会话复盘中确认的浪费动作固化为硬规则,面向 DeepSeek Flash /
DeepSeek V4.1 Flash:先验证再实现、搜索必须有边界、只在被要求的范围内动作
(改项目外目录先问)、命令一次写对、测试按影响面、要求与实测冲突时报告并
等待确认而不擅自改需求。

不设运行时模型判定,只在 description 与正文标记适用范围,避免留出「模型标识
不匹配就不遵循」的逃生口。真实案例与代价放在 references/anti-patterns.md。
2026-09-19 14:05:15 +08:00
laily e60755f280 fix(builder): 版本时间格式改回 YYYYMMDDTHHMM
按要求保留字母分隔符:20260918T1722。分钟精度不变,同一分支同日多次
构建仍能正确排序。

记录切换边界:Debian 里字母排在标点之前,YYYYMMDDT… 小于旧格式的
YYYYMMDD.…,切换当天同日的旧格式包会压住新包,需要在仓库里把那些旧包
移除或停用;次日新格式始终更大。回归测试显式断言这行边界。
2026-09-19 13:32:16 +08:00
laily eb2d0ce651 fix(builder): 版本时间精确到分钟,修复同日重构建的 apt 排序
同日同分支的测试版本前缀完全相同,只剩 +g<sha> 可区分;Debian 版本比较把
hex 当普通文本,新构建可能小于旧构建,apt upgrade 因此拿不到最新包。

build_date() → build_stamp(),改为 YYYYMMDDHHMM,时间与日期拼成一段数字
才能按数值比较。不能写成 20260918T1722:Debian 里字母排在标点之前,带 T
的版本小于同日旧格式包的 20260918.…,修复当天仍抢不过已上传的旧包。

补回归测试,用 dpkg --compare-versions 锁定同日重建更大、且压过旧格式同日包。
2026-09-19 13:23:52 +08:00
laily b476176f3b Merge branch 'main' of https://git.yumee.top/laily/.pouch 2026-09-16 23:09:21 +08:00
laily cc9af04a40 tester v2: 术语对齐 pytest/JUnit/Robot,补齐 automation 与 not-run
字段改名(每项取舍的理由见新增的 references/concepts.md):
- 用例 status -> lifecycle, suite -> tag, surface -> driver,
  cleanup -> teardown, skip_unless -> assume;删除 requires
- risk 取值 safe/act/harm -> readonly/mutating/destructive(单调三级,
  与 gate 正交)
- catalog auth_env -> authorize;授权变量统一为
  AUTHORIZE_MUTATING / AUTHORIZE_DESTRUCTIVE

新增用例字段 automation(automated/assisted/manual),默认 automated,
并在 validate 里机器校验:manual 禁用 driver 与 how.run 且必须有
how.steps;assisted 至少一步以 [人工] 开头。select 新增 --automation。

结果词新增 not-run,区分「主动不跑」(skipped:retired/pending/超风险
上限/未授权/assume 不成立)与「没轮到」(失败即停后、被分类前置挡住整组、
需人工而无人值守)。

status_probe 定为分类级前置条件:catalog 顶层为默认,categories[] 同名键
覆盖(缺省继承 / false 关闭 / mapping 整体替换)。select 按分类返回生效值,
并把 probe.url 用 vars 展开(solve Agent 无法自行解析 ${VAR} 的问题),
同批返回 vars 供 how 里的 ${...} 替换。

新增 references/concepts.md:术语表(每词一句唯一定义 + 业界锚点 +
反例)、v1->v2 映射、以及 risk/automation/not-run/status_probe 的设计理由。
其余文档(SKILL/README/schema/levels/run/maintain)与三个脚本(catalog、
generate_cases_csv、generate_run_csv)同步更新。
2026-09-16 23:07:08 +08:00
laily d791f5584c refactor(releaser): 将 manage-release 改名为 releaser
- skills/manage-release -> skills/releaser(git mv,内容不变)
- releaser 的 frontmatter/标题改名,description 保留旧名作为触发词
- skills/manage-release/ 保留为纯弃用占位(SKILL.md + README.md,指向 releaser),
  已装软链与旧项目 .pouch.yaml 不再悬空或失效
- 同步或引用:AGENTS.md、README.md、docs/getting-started.md、orc(routing/SKILL/README)、
  ack delivery、builder(SKILL/README/contract)、memory-distill、pouch onboard 与
  suggest_skills
- 测试:catalog 需列出旧名占位,新增弃用占位断言,orc children 纳入旧名占位
2026-09-16 15:14:12 +08:00
laily ca3156ed93 Merge branch 'main' of https://git.yumee.top/laily/.pouch 2026-09-16 14:40:34 +08:00
laily bdfd2ff342 feat: update tester 2026-09-16 14:40:33 +08:00
laily ca090cd7f2 feat: update skills 2026-09-15 12:29:40 +08:00
laily 2783946cec Merge branch 'main' of https://git.yumee.top/laily/.pouch 2026-09-10 17:12:11 +08:00
laily a47e899702 feat(tester): add catalog-driven black-box test skill
Maintain and run project test catalogs under .pouch/tester, filtered by
category, id, and risk (safe/act/harm). Scripts generate the human CSV
from cases YAML.
2026-09-10 16:34:38 +08:00
laily 2ba027098f feat(builder): Android/iOS 构建与 copy_artifact 发布
新增 APK/IPA 轨道和 copy_artifact.sh(本地目录或 container:/dir)。
push 必须委托该脚本,禁止在 makefile 里内联 docker cp。
2026-09-09 01:13:56 +08:00
laily aa7a265e76 feat(builder): declare multi-arch builds with ARCHES
Project architecture lists belong in makefile.builder, not .env.builder.
deb/push-deb fan out when ARCH is omitted; Docker platforms derive from
ARCHES. Legacy makefiles without ARCHES keep the old single-arch behavior.
2026-09-08 16:31:53 +08:00
laily c248c3f0e3 feat: update readme 2026-09-08 15:40:22 +08:00
laily 9b3e16cb63 feat: update readme 2026-09-08 15:25:58 +08:00
laily 06865939e1 fix(builder): bump test versions to next patch
After a stable tag vX.Y.Z, untagged commits now produce
X.Y.(Z+1)~date.branch.n+gSHA so test DEBs sort above the last
official release and still below the next official X.Y.(Z+1).
2026-09-08 14:30:49 +08:00
laily 852dee3a69 feat(builder): append YYYYMMDD to test versions 2026-09-08 10:59:17 +08:00
laily 83deb886b5 feat(deployer): fall back to scp when rsync is missing 2026-09-07 15:43:38 +08:00
laily 17643bc17b feat: add eli5
Port anthropics/claude-plugins-community eli5 into builtin skills as a
single-page HTML picture explainer for readers with no background.
2026-09-04 23:15:03 +08:00
laily 4ce08ec89e Merge branch 'main' of https://git.yumee.top/laily/.pouch 2026-09-04 20:10:00 +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
376 changed files with 27573 additions and 1277 deletions
+1
View File
@@ -31,3 +31,4 @@ Thumbs.db
# 本地环境配置(勿提交密钥)
.env
.env.*
.pouch
+49 -38
View File
@@ -12,16 +12,14 @@
## 快速开始
需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时:pouch skill 已安装则走其「安装 pouch」模式;否则读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行。不要只用 `pouch select`。
```bash
# 1. 克隆并关联
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch && ./install.sh
# install.sh 会自动把 pouch 项目 skill 安装到所有 Agent
# 2. 安装其他 skill
pouch add declarative-openspec-loop -g
# 3. 查看状态
# install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
pouch select -g
pouch list
pouch status
```
@@ -31,19 +29,11 @@ pouch 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
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 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/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
@@ -56,16 +46,37 @@ AGENTS.md # 本文档
### 内置(Builtin)
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行三角色闭环、测试环境、发版与回归 |
| [pouch](skills/pouch/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
| Skill | 说明 |
| --- | --- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 用 Node 提供 HTML/静态文件预览,返回 URL 并复用已有服务 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [ship](skills/ship/README.md) | 把一次变更从说明推进到配置允许的 PR、合并或部署;short 或 long |
| [short-ship](skills/short-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [long-ship](skills/long-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 ship |
| [delivery-loop](skills/delivery-loop/README.md) | ship 与 pr-ship 共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
改名后旧名保留一个纯占位 skill,避免已装软链悬空和旧项目 `.pouch.yaml` 失效。确认没有
项目再按旧名启用后,删除对应占位目录即可,不丢任何内容。
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
@@ -86,11 +97,13 @@ builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(
| Source | 来源 |
| --- | --- |
| waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) |
| taste-skill | [https://github.com/Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) |
```bash
pouch fetch waza
pouch add waza/think -g
pouch add taste-skill/taste-skill -g
```
### 自定义仓库(Custom Sources)
@@ -164,12 +177,6 @@ catalog.yaml ←── pouch 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 路径
@@ -239,6 +246,7 @@ pouch sync
| `pouch add <name> [-g]` | 项目或全局安装(symlink) |
| `pouch remove <name> [-g]` | 移除 symlink |
| `pouch catalog add` / `pouch fetch` | 管理和拉取 catalog source |
| `pouch update` | 更新 pouch 自身和已拉取的 catalog / custom source |
### 草稿与健康检查
@@ -260,7 +268,8 @@ pouch sync
```
skill-name/
├── SKILL.md # 必需
├── SKILL.md # 必需,给 Agent
├── README.md # 必需,给人类
├── reference.md # 可选
├── examples.md # 可选
└── scripts/ # 可选
@@ -285,7 +294,7 @@ description: >-
### 新建流程
1. `pouch create my-skill --idea "..." --from-project .`
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md`
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` 验证
@@ -340,12 +349,14 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| 我要… | 命令 | 在哪 |
| ---------- | --------------------------------- | ---- |
| 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
| 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 |
| 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 |
| 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
| 全局启用 | `pouch install <name>` | 任意 |
| 项目启用 | `pouch enable <name>` | 项目目录 |
| 全局启用 | `pouch add <name> -g` | 任意 |
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
| 看状态 | `pouch status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `pouch fetch <name>` | 任意 |
| 更新外部 skill | `pouch fetch <name>` 或日常命令自动更新 | 任意 |
---
+88 -23
View File
@@ -2,15 +2,28 @@
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。
给同事看怎么装、怎么打版本、怎么构建,读 [docs/getting-started.md](docs/getting-started.md)。人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。
## 前置
- Python 3、git
- Cursor、Claude Code、Codex,或其它兼容 Agent Skills 的 Agent
- `~/.local/bin` 需要在 `PATH` 里(`install.sh` 会写入 bash / zsh / fish 的 rc,当前 shell 要重开或 `source` 一次才生效)
## 快速开始
```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch
./install.sh # 安装 CLI,并将 pouch 项目 skill 安装到所有 Agent
./install.sh
pouch add declarative-openspec-loop -g
pouch select # 交互式选择并批量安装
# install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
# 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
pouch select # 交互选择要装的 skill(默认当前项目)
pouch select -g # 或装到全局,所有项目都能用
pouch list
pouch status
```
@@ -24,34 +37,79 @@ mv ~/.skills ~/.pouch
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
## 让 Agent 代装
对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。步骤只维护在
[skills/pouch/references/install.md](skills/pouch/references/install.md):
确认 python3/git → 处理 `~/.pouch` → `install.sh` → 用短清单介绍 skill → 问要装哪些。
不要用 `pouch select`。不要在未确认时覆盖已有的 `~/.pouch`。
自己装仍看上面的「快速开始」。
## 装完之后怎么用
1. 打开已安装目标的 Agent(Cursor / Claude Code / Codex 等)。
2. 项目级安装:打开那个项目再说话。全局安装(`-g`):任意项目都可以。
3. 按对应 skill 的 README,把示例请求发给 Agent。
`./install.sh` 只全局安装 `pouch`。其它 skill 都要 `pouch add` 或 `pouch select`。
```bash
cd ~/code/my-app
pouch add ack # 当前项目
pouch add discussion-notes -g # 全局
```
## 仓库结构
```
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md
skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 详细规范与架构说明
```
| 路径 | 说明 |
|------|------|
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts |
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
| 路径 | 说明 |
| ---------------------------- | ----------------------------------------------------- |
| [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
## 自研 Skill
| Skill | 说明 |
|-------|------|
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
| [pouch](skills/pouch/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态:
| Skill | 说明 |
| ----------------------------------------------------------------------- | ------------------------------------------- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 用 Node 提供 HTML/静态文件预览,返回 URL 并复用已有服务 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [ship](skills/ship/README.md) | 把一次变更从说明推进到配置允许的 PR、合并或部署;short 或 long |
| [short-ship](skills/short-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [long-ship](skills/long-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 ship |
| [delivery-loop](skills/delivery-loop/README.md) | ship 与 pr-ship 共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
```bash
pouch init ack
@@ -62,11 +120,13 @@ pouch init ack --project ~/app
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
验证通过后收获回归用例。
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
新建 skill:
```bash
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md 和 README.md
pouch check my-skill
pouch finalize my-skill
pouch add my-skill -g # 全局安装验证
@@ -89,7 +149,7 @@ pouch add <name> -g -a cursor
```yaml
skills:
- name: declarative-openspec-loop
- name: ack
source: builtin
- name: think
source: catalog:waza
@@ -101,9 +161,9 @@ targets: # 可选,默认 all
```
```bash
pouch add declarative-openspec-loop
pouch add ack
pouch sync
pouch remove declarative-openspec-loop
pouch remove ack
```
## Catalog 与 Custom Source
@@ -113,6 +173,8 @@ pouch remove declarative-openspec-loop
```bash
pouch fetch waza
pouch add waza/think -g
pouch add taste-skill/taste-skill -g # 只要核心 frontend skill
pouch add taste-skill -g # 或装整个 Taste Skill collection
```
接入团队自己的本地目录或 Git 仓库:
@@ -133,7 +195,7 @@ npx skills find typescript
```
本仓库
├── skills/<name>/ ←── pouch install / enable
├── skills/<name>/ ←── pouch add / select
├── catalog.yaml ←── 预置来源发现与 fetch
└── pouch/ ←── python3 -m pouch
↑
@@ -157,8 +219,10 @@ npx skills find typescript
## 文档
- [docs/getting-started.md](docs/getting-started.md):给同事的安装、打版本、构建教程
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
- 各 skill 的人类说明见上表 `README.md`
## 参考
@@ -166,3 +230,4 @@ npx skills find typescript
- [Vercel skills CLI](https://github.com/vercel-labs/skills)
- [skills.sh](https://skills.sh)
- [Cursor Skills 文档](https://cursor.com/docs/context/skills)
+20
View File
@@ -29,3 +29,23 @@ waza:
- engineering
- workflow
- skill-collection
taste-skill:
repo: https://github.com/Leonxlnx/taste-skill.git
ref: main
path: skills
description: Taste Skill collection for anti-slop frontend, redesign, image generation, brand kits, and output enforcement.
tags:
- frontend
- design
- skill-collection
ego-lite:
repo: https://github.com/citrolabs/ego-lite.git
ref: main
path: skills
description: ego lite browser automation skill for opening, operating, testing, and reviewing websites with the user's browser context.
tags:
- browser
- automation
- skill-collection
+234
View File
@@ -0,0 +1,234 @@
# pouch 上手:安装、打版本、构建
pouch 是团队自研 skill 的仓库和安装工具。Skill 是给 Agent 看的工作说明书。你对着 Cursor、Claude Code 或 Codex 说话,Agent 按说明书干活。这份仓库里还有 CLI,用来把 skill 装到各 Agent。
这篇只讲日常会碰到的三件事:把 pouch 装上、用 releaser 打正式版本、用 builder 构建 DEB 或 Docker 镜像。其它 skill 装上就能喊,需要时对 Agent 说「有哪些自研 skill」。
## pouch 怎么工作
Skill 的正文只存在一份,在 `~/.pouch/skills/<name>/`。装到 Cursor、Claude、Codex 时走软链,改一份全处生效。项目根的 `.pouch.yaml` 记录这个仓库启用了哪些 skill,类似 `package.json` 记依赖。
日常几乎不直接跑 skill 里的脚本。CLI 负责安装和维护,干活是对 Agent 说人话。授权写在请求里:没说 push、没说上传,Agent 就不做。
| 你想做的 | 对谁说 | 不要 |
| ------------------- | -------------- | ----------------------- |
| 打正式版本、annotated tag | releaser | 让 builder 选下一个 SemVer |
| 打 DEB 或 Docker 镜像 | builder | 让 releaser 去构建或上传 |
releaser 选定正式 SemVer 并打 annotated tag。builder 只读当前 HEAD:HEAD 恰好落在 `vX.Y.Z` 上就打正式包,否则打测试包。
## 安装 pouch
需要 Python 3、git,以及至少一种兼容 Agent Skills 的 Agent。
```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch && ./install.sh
export PATH="$HOME/.local/bin:$PATH"
pouch status
```
`install.sh` 会把 `pouch` 命令放到 `~/.local/bin`,并把 pouch 这一个 skill 全局装到所有 Agent。其它 skill 不会自动全装。当前 shell 找不到 `pouch` 时,重开终端,或 `source` 对应 rc。
也可以对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。不要覆盖已经存在的 `~/.pouch`。
从旧目录迁过来:
```bash
mv ~/.skills ~/.pouch
~/.pouch/install.sh
```
装完看一眼:
```bash
pouch list
pouch status
pouch doctor
```
软链异常就 `pouch doctor --fix`。日常用 pouch 命令时会按间隔自动更新自身和已拉取的外部 source;要立刻更新就 `pouch update`。
## 把要用的 skill 装上
发版本和构建这两个,全局装一次,所有项目都能喊:
```bash
pouch add releaser -g -y
pouch add builder -g -y
```
只想在当前仓库启用:
```bash
cd ~/code/my-app
pouch add releaser -y
pouch add builder -y
```
也可以对 Agent 说「把这个项目该用的 skill 接上」,它会按仓库信号建议,再 `pouch add`。builder 不要 `pouch init builder`,初始化是对 Agent 说「用 builder 初始化这个项目」。
交互多选:`pouch select -g`。取消勾选不会卸载,卸载用 `pouch remove`。
## 怎么对 Agent 说话
打开已安装目标的 Agent。项目级安装要打开那个项目再说话;全局安装任意项目都可以。请求里写清 skill 名、目标和授权范围。
```text
使用 releaser,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
用 builder 初始化这个项目。
用 builder 构建当前版本的 DEB,先不要上传。
```
人看各 skill 目录下的 `README.md`。`SKILL.md` 是给 Agent 的指令,不必通读。
## 用 releaser 打新版本
代码已经合进默认分支、测试过了,要打正式 tag 时用它。它不构建 DEB,也不推镜像。
### 还没决定版本号
```text
使用 releaser 检查当前版本和这次改动,建议下一个版本号。只分析,不修改文件。
```
Agent 会读项目版本规则、现有 tag 和实际改动,给出建议和依据。没有项目约定时,单一协调版本默认 SemVer,tag 形如 `v1.6.0`。用户输入 `v1.6.0` 时规范版本是 `1.6.0`,不要产生双前缀。
### 默认短路径:直接打 tag
项目以 Git tag 作为版本来源,且不需要改 VERSION 文件或把 CHANGELOG 写回仓库时,走这条:
```text
使用 releaser,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。根据上一个稳定 tag 到目标 commit 的实际变化生成更新说明,把说明写入 annotated tag,并验证远端 tag 的 commit 和说明。不要创建 Forge Release。
```
请求里写清目标版本、基线分支、打到哪一步。没写 push、没写创建平台 Release,Agent 就不会做。只发 Git tag 时不要求 GitHub / GitLab / Gitea CLI,但 Git remote 必须可读写。
Agent 会锁定远端目标 commit,生成带 Summary、Changes、Validation、Base commit 的更新说明,创建 annotated tag 并推送,再从远端核对。不会给未合并分支打正式 tag,也不会覆盖已有同名 tag。
### 需要改版本文件时走长路径
权威版本文件或仓库内 CHANGELOG 必须随发布改掉时,才创建 `release/v1.6.0` 这类分支和 PR。可以一次说完,也可以拆开授权:
```text
使用 releaser 完成 1.6.0 发布:从 main 创建分支,完成版本更新和验证,创建 PR,检查通过后合并,推送 v1.6.0 tag。不要创建 Forge Release。
```
中途停了,下次说「使用 releaser 检查 v1.6.0 当前发布状态,从尚未完成的步骤继续。不要重复创建分支、PR、tag 或 release。」
要在 Gitea / GitHub 上建平台 Release 时,在请求里写明。没写就不会建。Forge Release 复用 tag 里同一份说明。
### 判断打没打成
远端 tag 解引用后的 commit 就是目标 commit,tag object 里有完整更新说明。只分析时会返回建议版本和依据。卡在某步时会说明停在哪、下次怎么续,不会用「发布成功」盖住未完成的步骤。
## 用 builder 构建项目
builder 按统一契约打 DEB 和 Docker 镜像。项目根会多两个文件:`makefile.builder`(构建入口)和 `.env.builder`(发布凭据,不进 git)。它不改你已有的 `Makefile` 或 `.env`。
正式版本号仍由 releaser 打 tag。builder 只读当前 HEAD,不调用 releaser。
### 接入一个项目
需要 Python 3 和 `make`。打 DEB 还要 `dpkg-deb`,打镜像还要 Docker 和 buildx。在项目根对 Agent 说:
```text
用 builder 初始化这个项目。
```
Agent 会探测轨道、写出 `makefile.builder`,缺发布配置时列出来。然后在 `.env.builder` 填仓库地址和 token:
```text
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token>
DOCKER_REGISTRY=registry.example.com
```
token 只放 `.env.builder` 或密钥系统,不要写进 `.env`,也不要进 git。检查契约:
```text
用 builder 检查这个项目的 makefile.builder 是否符合契约。
```
构建项通过就可以打本地包。缺发布键只挡住上传。
日常构建入口:
```bash
make -f makefile.builder help
make -f makefile.builder version
make -f makefile.builder build
make -f makefile.builder deb
```
### 版本从哪来
`make -f makefile.builder version` 输出一行规范版本,无 `v` 前缀。基线是 HEAD 祖先上最近的稳定 tag,稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc` 这类后缀。
HEAD 恰好是 `v1.6.0` 时,产物是正式的 `1.6.0`。其它 commit 打测试号:祖先最近稳定 tag 的 patch 机械 +1,再挂构建日、分支、距离、短 SHA。测试 DEB 用 `~`,于是 `1.6.0` &lt; `1.6.1~…` &lt; `1.6.1`。已装上一正式版的机器可以升到 bugfix 测试包;同号正式包仍盖住测试。
分支 `feat/login-v2`,相对 `v1.6.0` 第 7 个 commit,短 SHA `abc1234`,构建日 `20260908`:
```text
规范 / DEB 1.6.1~20260908.feat-login-v2.7+gabc1234
Docker registry/ns/foo:1.6.1-20260908.feat-login-v2.7.gabc1234
```
不要自己 `git tag | sort -V | tail -1` 取全仓库最大号,也不要把 `git describe --dirty` 的原始字符串写进包名。
### 构建和发布
只构建:
```text
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
```
确认后再上传:
```text
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
```
自己跑也可以:
```bash
make -f makefile.builder deb
make -f makefile.builder push-deb
```
Docker 只有点名镜像时 Agent 才走。脏工作树默认拒绝发布。可能覆盖同版本时会先问你。token 不会出现在输出、提交或回复里。
## 一条完整发布链
代码合进 `main` 且检查通过之后,按这个顺序:
1. 对 Agent:`使用 releaser,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。`
2. 确认远端 `v1.6.0` 指向那次 commit。
3. checkout 到该 tag,或确保当前 HEAD 就是它。
4. 对 Agent:`用 builder 构建当前版本的 DEB,先不要上传。`
5. 核对 `make -f makefile.builder version` 输出 `1.6.0`,再授权上传。
先构建再打 tag,得到的是测试包,版本号会变成下一 patch 的 `~` 形式,不是正式号。
## 常见卡住的地方
- 当前 shell 找不到 `pouch`:`~/.local/bin` 还没进 PATH,重开终端或 `source` 对应 rc。
- 在 `~/.cursor/skills/` 里直接改文件:改的是软链目标,应该改 `~/.pouch/skills/`。Claude Code 有时会把软链替换成普通文件,用 `pouch doctor --fix` 重建。
- `pouch init builder` 这条路径不存在:对 Agent 说「用 builder 初始化」。
- 请求里没写清授权:Agent 会分析或只做到本地,不会擅自 push tag 或上传包。
- 工作区有未提交修改:builder 默认拒绝发布;releaser 不会 stash 或覆盖你的文件。
- 同名 tag 已在远端指向别的 commit:releaser 会停,不会 force push。
- HEAD 不在稳定 tag 上却想打正式包:先打 tag,再构建。
## 还想看什么
- 仓库总览:[README.md](../README.md)
- CLI 命令:[pouch/README.md](../pouch/README.md)
- releaser:[skills/releaser/README.md](../skills/releaser/README.md)
- builder:[skills/builder/README.md](../skills/builder/README.md)
- 设计原则和编写规范:[AGENTS.md](../AGENTS.md)
-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
+34 -3
View File
@@ -65,7 +65,7 @@ pouch bootstrap
| 命令 | 说明 |
|------|------|
| `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
| `pouch update` | 在 `~/.pouch` 执行 `git pull`,更新 pouch 自身 |
| `pouch update` | 更新 pouch 自身,以及已拉取的 catalog / custom source |
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态(目前用于 ack) |
### Skill 安装
@@ -105,6 +105,33 @@ collection 会自动发现下一层所有含 `SKILL.md` 的目录;`pouch add <
安装全部,`pouch select` 则展开为 `<name>/<skill>` 供分别勾选。同一
`repo` 与 `ref` 共享一份 Git checkout。
预置 collection 示例:
```bash
pouch add waza/think -g
pouch add taste-skill/taste-skill -g
pouch add taste-skill -g # 安装 Taste Skill 全部子 skill
```
### 自动更新
日常执行 `pouch list`、`pouch add`、`pouch status` 等命令时,pouch 会按间隔
顺带更新自身和已经 clone 过的 catalog / custom Git source,不必单独跑
`pouch fetch` 或 `pouch update`。默认每 24 小时最多自动更新一次;网络失败或
工作区不干净时跳过,不打断当前命令。`pouch add <catalog>` 在安装前会再刷新
该来源,以便拿到最新子 skill。
在 `~/.config/pouch/config.yaml` 里关闭或改间隔:
```yaml
auto_update:
enabled: true
interval: 24h # 也可用 1d、60m、3600
```
本次跳过:`pouch --no-auto-update list`,或设置环境变量 `POUCH_NO_AUTO_UPDATE=1`。
立刻更新全部:`pouch update`。
### 交互式批量安装
```bash
@@ -201,7 +228,7 @@ pouch doctor -a cursor
```bash
cd ~/code/my-app
pouch add declarative-openspec-loop -a cursor -y
pouch add discussion-notes -a cursor -y
```
### 添加 Catalog Source
@@ -222,6 +249,8 @@ pouch/
├── paths.py # 路径常量与 Agent 目标
├── skills.py # builtin/catalog/custom 统一解析
├── catalog.py # catalog.yaml 读写与 Skill 发现
├── config.py # 用户配置与自动更新设定
├── auto_update.py # 命令执行时顺带更新 pouch 与已拉取 source
├── sources.py # custom source 配置、发现与 Git 管理
├── project.py # .pouch.yaml 管理
├── symlinks.py # 软链创建/检查/修复
@@ -238,7 +267,8 @@ pouch/
| `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 配置 |
| `CONFIG_FILE` | `~/.config/pouch/config.yaml` | custom source 与 auto_update 配置 |
| `STATE_FILE` | `~/.local/share/pouch/state.yaml` | 自动更新时间戳 |
| `SOURCES_DIR` | `~/.local/share/pouch/sources/` | custom Git source 默认 checkout |
## 注意事项
@@ -251,3 +281,4 @@ pouch/
- [项目 README](../README.md)
- [AGENTS.md](../AGENTS.md)
- 各 skill 的人类说明:`skills/<name>/README.md`
+308
View File
@@ -0,0 +1,308 @@
"""在日常命令里顺带更新 pouch 自身和已拉取的外部 source。"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from pouch import yaml_io
from pouch.catalog import catalog_checkout_path, load_catalog
from pouch.config import load_auto_update_settings
from pouch.paths import POUCH_HOME, STATE_FILE
from pouch.sources import load_sources, source_checkout
GIT_TIMEOUT_SECONDS = 20
_SKIP_COMMANDS = {"update", "publish"}
_TRUTHY = {"1", "true", "yes", "on"}
_refreshed_paths: set[str] = set()
@dataclass(frozen=True)
class UpdateResult:
name: str
status: str
detail: str = ""
@property
def changed(self) -> bool:
return self.status == "updated"
def _print_err(msg: str) -> None:
print(msg, file=sys.stderr)
def _run_git(
repo: Path,
*args: str,
check: bool = False,
timeout: int = GIT_TIMEOUT_SECONDS,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(repo), *args],
check=check,
text=True,
capture_output=True,
timeout=timeout,
)
def _is_git_repo(path: Path) -> bool:
if not path.is_dir():
return False
try:
result = _run_git(path, "rev-parse", "--is-inside-work-tree")
except (OSError, subprocess.TimeoutExpired):
return False
return result.returncode == 0 and result.stdout.strip() == "true"
def _working_tree_dirty(path: Path) -> bool:
result = _run_git(path, "status", "--porcelain")
return result.returncode != 0 or bool(result.stdout.strip())
def _head_sha(path: Path) -> str:
result = _run_git(path, "rev-parse", "HEAD")
return result.stdout.strip() if result.returncode == 0 else ""
def refresh_git_checkout(
path: Path,
ref: str | None = None,
*,
name: str,
strict: bool = False,
allow_dirty: bool = False,
) -> UpdateResult:
key = str(path.resolve()) if path.exists() else str(path)
if key in _refreshed_paths:
return UpdateResult(name, "current")
if not _is_git_repo(path):
_refreshed_paths.add(key)
result = UpdateResult(name, "skipped", "不是 git 仓库")
if strict:
raise SystemExit(f"{name} 不是 git 仓库: {path}")
return result
if not allow_dirty and _working_tree_dirty(path):
_refreshed_paths.add(key)
result = UpdateResult(name, "skipped", "工作区不干净")
if strict:
raise SystemExit(f"{name} 工作区不干净,拒绝更新: {path}")
return result
before = _head_sha(path)
commands: list[tuple[str, ...]] = [("fetch", "--all", "--tags")]
if ref:
commands.append(("checkout", ref))
commands.append(("pull", "--ff-only"))
try:
for command in commands:
ran = _run_git(path, *command, check=strict)
if ran.returncode != 0:
detail = (ran.stderr or ran.stdout or "git 失败").strip()
return UpdateResult(name, "failed", detail)
except subprocess.CalledProcessError as exc:
detail = ((exc.stderr or exc.stdout or str(exc))).strip()
if strict:
raise SystemExit(f"更新 {name} 失败: {detail}") from exc
return UpdateResult(name, "failed", detail)
except subprocess.TimeoutExpired:
result = UpdateResult(name, "failed", "git 超时")
if strict:
raise SystemExit(f"更新 {name} 超时: {path}") from None
return result
except OSError as exc:
result = UpdateResult(name, "failed", str(exc))
if strict:
raise SystemExit(f"更新 {name} 失败: {exc}") from exc
return result
after = _head_sha(path)
_refreshed_paths.add(key)
if before and after and before != after:
return UpdateResult(name, "updated", after[:8])
return UpdateResult(name, "current")
def update_pouch_home(*, strict: bool = False) -> UpdateResult:
return refresh_git_checkout(
POUCH_HOME,
name="pouch",
strict=strict,
allow_dirty=strict,
)
def update_cloned_catalogs(*, strict: bool = False) -> list[UpdateResult]:
results: list[UpdateResult] = []
seen: set[Path] = set()
for name, entry in load_catalog().items():
repo = str(entry.get("repo", ""))
if repo.startswith("~"):
continue
dest = catalog_checkout_path(name, entry)
resolved = dest.resolve() if dest.exists() else dest
if resolved in seen or not dest.is_dir():
continue
seen.add(resolved)
results.append(
refresh_git_checkout(
dest,
str(entry.get("ref", "main") or "main"),
name=f"catalog:{name}",
strict=strict,
)
)
return results
def update_custom_sources(*, strict: bool = False) -> list[UpdateResult]:
results: list[UpdateResult] = []
for name, entry in load_sources().items():
if entry.get("local_path"):
continue
checkout = source_checkout(name, entry)
if not checkout.is_dir():
continue
results.append(
refresh_git_checkout(
checkout,
str(entry.get("ref", "main") or "main"),
name=f"custom:{name}",
strict=strict,
)
)
return results
def run_updates(*, strict_pouch: bool = False) -> list[UpdateResult]:
results = [update_pouch_home(strict=strict_pouch)]
results.extend(update_cloned_catalogs(strict=False))
results.extend(update_custom_sources(strict=False))
return results
def load_state(path: Path | None = None) -> dict[str, Any]:
path = path or STATE_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
return data if isinstance(data, dict) else {}
def save_state(data: dict[str, Any], path: Path | None = None) -> None:
path = path or STATE_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def record_auto_update(path: Path | None = None) -> None:
state = load_state(path)
state["last_auto_update"] = datetime.now(timezone.utc).isoformat()
save_state(state, path)
def _interval_elapsed(state: dict[str, Any], interval_seconds: int) -> bool:
if interval_seconds <= 0:
return True
raw = state.get("last_auto_update")
if not raw:
return True
try:
last = datetime.fromisoformat(str(raw))
except ValueError:
return True
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
elapsed = (datetime.now(timezone.utc) - last).total_seconds()
return elapsed >= interval_seconds
def _env_disabled() -> bool:
return os.environ.get("POUCH_NO_AUTO_UPDATE", "").strip().lower() in _TRUTHY
def _acquire_lock(path: Path) -> Any | None:
path.parent.mkdir(parents=True, exist_ok=True)
try:
lock_file = path.open("a+")
except OSError:
return None
try:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
lock_file.close()
return None
return lock_file
def _release_lock(lock_file: Any | None) -> None:
if lock_file is None:
return
try:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except OSError:
pass
try:
lock_file.close()
except OSError:
pass
def reset_refresh_cache() -> None:
_refreshed_paths.clear()
def report_results(results: Iterable[UpdateResult], *, prefix: str = "[pouch]") -> None:
changed = [item.name for item in results if item.changed]
failed = [item for item in results if item.status == "failed"]
if changed:
_print_err(f"{prefix} 已自动更新: {', '.join(changed)}")
for item in failed:
detail = f": {item.detail}" if item.detail else ""
_print_err(f"{prefix} 更新 {item.name} 失败,已跳过{detail}")
def maybe_auto_update(*, command: str | None = None, disabled: bool = False) -> list[UpdateResult]:
if disabled or command in _SKIP_COMMANDS or _env_disabled():
return []
if not POUCH_HOME.is_dir():
return []
settings = load_auto_update_settings()
if not settings.enabled:
return []
state = load_state()
if not _interval_elapsed(state, settings.interval_seconds):
return []
lock_file = _acquire_lock(STATE_FILE.with_name("auto-update.lock"))
if lock_file is None:
return []
try:
# 拿到锁后再读一次,避免并发重复拉取。
state = load_state()
if not _interval_elapsed(state, settings.interval_seconds):
return []
results = run_updates(strict_pouch=False)
record_auto_update()
report_results(results)
return results
except Exception as exc: # noqa: BLE001 — 自动更新不得打断用户命令
_print_err(f"[pouch] 自动更新失败,已跳过: {exc}")
return []
finally:
_release_lock(lock_file)
+85 -16
View File
@@ -58,6 +58,7 @@ from pouch.skills import (
validate_skill_dir,
validate_skill_name,
)
from pouch.auto_update import maybe_auto_update, record_auto_update, refresh_git_checkout, run_updates
from pouch.sources import (
discover_source_skills,
fetch_source,
@@ -250,15 +251,33 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None
return names
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
def _ensure_source_fetched(
name: str,
source: str | None = None,
*,
refresh: bool = False,
) -> None:
name, source = split_skill_spec(name, source)
catalog = load_catalog()
sources = load_sources()
if source in sources:
root = source_skills_root(source, sources[source])
entry = sources[source]
root = source_skills_root(source, entry)
if not root.is_dir():
_print(f"拉取 source: {source}")
fetch_source(source, sources[source])
fetch_source(source, entry)
elif refresh and not entry.get("local_path"):
result = refresh_git_checkout(
source_checkout(source, entry),
str(entry.get("ref", "main") or "main"),
name=f"custom:{source}",
)
if result.status == "updated":
_err(f"[pouch] 已更新 custom:{source}")
elif result.status == "failed":
_err(
f"[pouch] 更新 custom:{source} 失败,使用本地缓存: {result.detail}"
)
return
catalog_name = (
@@ -274,7 +293,11 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
source in (None, "catalog", catalog_name)
or source == f"catalog:{catalog_name}"
):
_ensure_catalog_fetched(catalog_name, catalog[catalog_name])
_ensure_catalog_fetched(
catalog_name,
catalog[catalog_name],
refresh=refresh,
)
return
if source not in (None, "catalog"):
@@ -283,17 +306,31 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
if name not in catalog:
return
_ensure_catalog_fetched(name, catalog[name])
_ensure_catalog_fetched(name, catalog[name], refresh=refresh)
def _ensure_catalog_fetched(name: str, entry: dict[str, object]) -> None:
def _ensure_catalog_fetched(
name: str,
entry: dict[str, object],
*,
refresh: bool = False,
) -> None:
path = catalog_skill_path(name, entry)
repo = catalog_repo(entry)
ref = str(entry.get("ref", "main") or "main")
dest = catalog_checkout_path(name, entry)
if dest.is_dir() and (dest / ".git").exists() and refresh and not str(entry.get("repo", "")).startswith("~"):
result = refresh_git_checkout(dest, ref, name=f"catalog:{name}")
if result.status == "updated":
_err(f"[pouch] 已更新 catalog:{name}")
elif result.status == "failed":
_err(f"[pouch] 更新 catalog:{name} 失败,使用本地缓存: {result.detail}")
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
return
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
return
repo = catalog_repo(entry)
ref = entry.get("ref", "main")
dest = catalog_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise SystemExit(
@@ -334,14 +371,17 @@ def _expand_install_request(
catalog = load_catalog()
sources = load_sources()
if source in sources:
_ensure_source_fetched(name, source, refresh=True)
return [(name, source)]
if source == "catalog" and name in catalog:
_ensure_catalog_fetched(name, catalog[name], refresh=True)
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, "catalog")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source in catalog:
_ensure_catalog_fetched(source, catalog[source], refresh=True)
available = _catalog_skill_names(source, catalog[source])
if name not in available:
raise SystemExit(
@@ -349,13 +389,14 @@ def _expand_install_request(
)
return [(name, f"catalog:{source}")]
if source is None and name in catalog:
_ensure_catalog_fetched(name, catalog[name], refresh=True)
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, f"catalog:{name}")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source is None and name in sources:
_ensure_source_fetched(name, name)
_ensure_source_fetched(name, name, refresh=True)
available = discover_source_skills(name, sources[name])
if not available:
raise SystemExit(f"custom source {name!r} 中没有可安装的 skill")
@@ -375,7 +416,7 @@ def _install_skill(
source: str | None = None,
) -> str:
name, source = split_skill_spec(name, source)
_ensure_source_fetched(name, source)
_ensure_source_fetched(name, source, refresh=True)
skill_path, resolved_source = resolve_skill_source(name, source=source)
links = [
(target, agent_skill_dir(target, project_root=project_root) / name)
@@ -540,7 +581,23 @@ def cmd_update(args: argparse.Namespace) -> None:
del args
ensure_pouch_home()
_print(f"更新 pouch: {POUCH_HOME}")
subprocess.run(["git", "-C", str(POUCH_HOME), "pull"], check=True)
results = run_updates()
record_auto_update()
labels = {
"updated": "已更新",
"current": "已是最新",
"skipped": "跳过",
"failed": "失败",
}
for item in results:
extra = f"({item.detail})" if item.detail else ""
line = f"{labels.get(item.status, item.status)} {item.name}{extra}"
if item.status == "failed":
_err(line)
else:
_print(line)
if any(item.status == "failed" for item in results):
raise SystemExit("部分来源更新失败")
def _installed_links(
@@ -640,7 +697,7 @@ def cmd_add(args: argparse.Namespace) -> None:
elif source == "catalog":
names = list(load_catalog())
else:
_ensure_source_fetched("", source)
_ensure_source_fetched("", source, refresh=True)
names = list_custom_skills(source)[source]
else:
names = list_builtin_skills()
@@ -662,7 +719,7 @@ def cmd_add(args: argparse.Namespace) -> None:
original_links: list[tuple[Path, str | None]] = []
for skill_name, source in requests:
validate_skill_name(skill_name)
_ensure_source_fetched(skill_name, source)
_ensure_source_fetched(skill_name, source, refresh=True)
resolve_skill_source(skill_name, source=source)
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / skill_name
@@ -1122,7 +1179,7 @@ def cmd_sync(args: argparse.Namespace) -> None:
for entry in iter_manifest_skills(data):
name = entry["name"]
source = entry.get("source")
_ensure_source_fetched(name, source)
_ensure_source_fetched(name, source, refresh=True)
skill_path, _ = resolve_manifest_skill(entry)
skill_targets = targets
if entry.get("targets"):
@@ -1702,6 +1759,11 @@ def build_parser() -> argparse.ArgumentParser:
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills)",
)
parser.add_argument("--version", action="version", version=f"pouch {__version__}")
parser.add_argument(
"--no-auto-update",
action="store_true",
help="本次跳过自动更新 pouch 与已拉取的 source",
)
sub = parser.add_subparsers(dest="command", required=True)
@@ -1711,7 +1773,10 @@ def build_parser() -> argparse.ArgumentParser:
)
p_bootstrap.set_defaults(func=cmd_bootstrap)
p_update = sub.add_parser("update", help="通过 git pull 更新 pouch 自身")
p_update = sub.add_parser(
"update",
help="更新 pouch 自身以及已拉取的 catalog / custom source",
)
p_update.set_defaults(func=cmd_update)
p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
@@ -1845,6 +1910,10 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
maybe_auto_update(
command=getattr(args, "command", None),
disabled=bool(getattr(args, "no_auto_update", False)),
)
args.func(args)
+87
View File
@@ -0,0 +1,87 @@
"""用户级 pouch 配置(~/.config/pouch/config.yaml)。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pouch import yaml_io
from pouch.paths import CONFIG_FILE
DEFAULT_AUTO_UPDATE_INTERVAL = "24h"
_INTERVAL_UNITS = {
"s": 1,
"m": 60,
"h": 3600,
"d": 86400,
}
@dataclass(frozen=True)
class AutoUpdateSettings:
enabled: bool = True
interval_seconds: int = 24 * 3600
def load_user_config(path: Path | None = None) -> dict[str, Any]:
path = path or CONFIG_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"pouch 配置格式错误: {path}")
return data
def save_user_config(data: dict[str, Any], path: Path | None = None) -> None:
path = path or CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def parse_interval(value: Any) -> int:
if value is None or value is True:
return AutoUpdateSettings().interval_seconds
if isinstance(value, bool):
raise ValueError(f"无效的自动更新间隔: {value!r}")
if isinstance(value, int):
if value < 0:
raise ValueError(f"无效的自动更新间隔: {value!r}")
return value
text = str(value).strip().lower()
if not text:
return AutoUpdateSettings().interval_seconds
if text.isdigit():
return int(text)
unit = text[-1]
amount = text[:-1]
if unit in _INTERVAL_UNITS and amount.isdigit():
return int(amount) * _INTERVAL_UNITS[unit]
raise ValueError(f"无效的自动更新间隔: {value!r}(例如 24h、1d、60m、3600)")
def load_auto_update_settings(
config: dict[str, Any] | None = None,
path: Path | None = None,
) -> AutoUpdateSettings:
if config is None:
config = load_user_config(path)
raw = config.get("auto_update", True)
if raw is False:
return AutoUpdateSettings(enabled=False)
if raw is True or raw is None:
return AutoUpdateSettings()
if not isinstance(raw, dict):
return AutoUpdateSettings()
enabled = raw.get("enabled", True)
if enabled is False:
return AutoUpdateSettings(enabled=False)
try:
interval = parse_interval(raw.get("interval", DEFAULT_AUTO_UPDATE_INTERVAL))
except ValueError as exc:
raise SystemExit(str(exc)) from exc
return AutoUpdateSettings(enabled=bool(enabled), interval_seconds=interval)
+7
View File
@@ -45,11 +45,18 @@ CONFIG_FILE = _first_existing_file(
HOME / ".config" / "skiff" / "config.yaml",
default=HOME / ".config" / "pouch" / "config.yaml",
)
DATA_DIR = _first_existing_dir(
HOME / ".local" / "share" / "pouch",
HOME / ".local" / "share" / "skiff",
default=HOME / ".local" / "share" / "pouch",
)
SOURCES_DIR = _first_existing_dir(
DATA_DIR / "sources",
HOME / ".local" / "share" / "pouch" / "sources",
HOME / ".local" / "share" / "skiff" / "sources",
default=HOME / ".local" / "share" / "pouch" / "sources",
)
STATE_FILE = DATA_DIR / "state.yaml"
AGENT_GLOBAL: dict[str, Path] = {
"cursor": HOME / ".cursor" / "skills",
+6 -13
View File
@@ -6,7 +6,7 @@ import subprocess
from pathlib import Path
from typing import Any
from pouch import yaml_io
from pouch.config import load_user_config, save_user_config
from pouch.paths import CONFIG_FILE, SOURCES_DIR
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
@@ -21,27 +21,20 @@ def validate_source_name(name: str) -> None:
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or CONFIG_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"pouch 配置格式错误: {path}")
data = load_user_config(path)
raw = data.get("sources", {})
if raw is None:
return {}
if not isinstance(raw, dict):
raise SystemExit(f"pouch 配置 sources 格式错误: {path}")
raise SystemExit(f"pouch 配置 sources 格式错误: {path or CONFIG_FILE}")
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
data = load_user_config(path)
data["sources"] = sources
save_user_config(data, path)
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
+9 -1
View File
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
return True
if lower == "false":
return False
if raw == "[]":
return []
if raw == "{}":
return {}
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
return int(raw)
return raw
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
return f"{pad}{{}}\n"
lines: list[str] = []
for key, value in data.items():
if isinstance(value, (dict, list)):
if isinstance(value, list) and not value:
lines.append(f"{pad}{key}: []")
elif isinstance(value, dict) and not value:
lines.append(f"{pad}{key}: {{}}")
elif isinstance(value, (dict, list)):
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
lines.append(f"{pad}{key}:")
for item in value:
+72 -263
View File
@@ -1,28 +1,25 @@
# ACK
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
- Coordinator 拆解需求、派发任务并终检。
- Developer 实现并执行白盒验证。
- Test 独立执行黑盒复测。
- Coordinator 拆解需求、派发任务并终检
- Developer 实现并做白盒验证
- Test 独立做黑盒复测
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
`leftover`,然后继续处理其它任务。
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份
`.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进
`.pouch/ack/regression.yaml`;之后可以单独跑回归。
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
只在你显式说 `/ack` 或 `$ack` 时运行。
## 使用场景
## 什么时候使用
| 场景 | 怎么说 | 结果 |
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 |
| 做需求 | `/ack 处理这个需求:…` | 先建 ship 记录;复杂需求走 specifier / ship long 模式,小改动走 ship |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 ship |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
@@ -32,17 +29,19 @@ ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
## 安装
全局安装:
```bash
pouch add ack -g
pouch add ack # 当前项目
pouch add ack -g # 全局
```
或只安装到当前项目:
## 使用前准备
```bash
pouch add ack
```
- 项目是 Git 仓库。
- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok、OMP 或 Pi。
- 使用 Pi 时需安装 `pi`;默认模型用 `agents.yaml` 中 `cli: pi`、`model: null` 表示。
- 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
## 初始化项目
@@ -51,282 +50,92 @@ pouch init ack
pouch init ack --project ~/code/my-app
```
初始化后,项目只保存自己的 ACK 状态:
也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
```text
.pouch/ack/
├── project.md
├── tasks.yaml
├── tasks.yaml # 项目配置与 taskStore 指针
├── task-records/ # 追加式任务/执行记录
├── knowledge.yaml
├── delivery.yaml # 默认 enabled: false
└── regression.yaml # 默认 cases: []
├── regression.yaml # 默认 cases: []
└── agents.yaml # 可选:临时覆盖 Developer/Test 的 CLI、模型和权限
```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
目录读取。
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
ACK 从当前命令指定的 `--project-root/.pouch/ack/` 定位项目状态,不要求在
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件;需要消除单文件冲突时,先运行 `scripts/migrate_tasks.py` 的 dry-run。
## Skill 结构
```text
skills/ack/
├── SKILL.md
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml、regression.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # 状态校验、任务/知识/回归选择、安全验证执行与结构化 worker launcher
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`.pouch/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`.pouch/ack/tasks.yaml` 保存当前任务状态;`.pouch/ack/knowledge.yaml` 保存跨任务复用、
已经独立验证的项目知识护栏。
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一契约;测试环境由 ACK
内部调用 deployer,运行证据记在 `tasks.yaml.deliveryRuns`。
`.pouch/ack/regression.yaml` 是黑盒回归用例目录,运行证据记在
`tasks.yaml.regressionRuns`。
## 检查项目状态
Agent 会从当前 ACK Skill 目录解析校验脚本:
任务状态追加示例:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \
--tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial
```
Coordinator 可以按当前任务上下文做确定性推荐:
```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 BUG-001
python3 <ack-skill-dir>/scripts/select_knowledge.py .pouch/ack/knowledge.yaml \
--component web --path web/app.py --tag long-running-service --limit 10
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
--suite full --case-id REG-login-001
```
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
上下文。
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
需要执行知识项引用的检查时,只传 registry ID 给 ACK 的安全执行入口:
```bash
python3 <ack-skill-dir>/scripts/run_verification.py \
.pouch/ack/knowledge.yaml check-api-contract --project-root <project-root>
```
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
`O_NOFOLLOW` 打开知识库和检查文件;检查内容复制到匿名、尽可能 sealed 的稳定
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
项目内无 symlink 的权威
`.pouch/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: .pouch/ack/knowledge.yaml`,再运行跨文件校验。
只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`。知识正文
不能作为自由 shell 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK
不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
## 配置与运行交付
测试环境走 deployer 的项目内布局(通常是 `.pouch/deployer/test`),发版仍用
delivery profile。可以直接说:
```text
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你。
```
ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成
`intents.release` 指向的 profile。`.pouch/deployer/<env>` 还没就绪时,ACK
会转去 deployer 的初始化,而不是自己编 compose。首次配置保持关闭,确认后才启用。
之后可以说:
## 使用示例
```text
/ack 初始化
/ack 检查配置
/ack 处理这个需求:给后台加导出 CSV
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
/ack 处理飞书里待整理的 bug
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
/ack 重新布一下测试环境,我要测试
/ack 发布一个版本
```
对应 intent 未配置时先问清楚并写回同一文件,不猜测。运行测试环境时 ACK 加载
deployer skill,不复制 compose 命令。本地进程启动写在 `project.md`,不算这个
模式。intent 运行不要求当前有 `verified` 任务;`deliveryRuns.intent` 记录是
测试环境还是发版。
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
profile 只能停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须经过
对应 approval 步骤。具体契约见 `references/delivery.md`。
## 启动 Worker
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md`
或任务里保存完整启动命令、额外 argv 或环境变量。
先审阅计划,不产生终端:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree>
```
确认后执行 `launch`,并显式绑定刚审阅的 fingerprint:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定
argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
不能跨任务或跨轮次改挂。
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭
sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件
伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用
停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数,workspace-write 默认 yolo。旧任务板没有结构化
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
启动 fresh worker。
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
重复启动,结果不确定时必须先 reconcile。
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
直接重试。
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
## 开始一个需求
初始化完成后可以直接说:
```text
/ack 处理这个需求:<一句话需求>
```
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。任务 `verified` 后会把本轮黑盒路径
收进 `.pouch/ack/regression.yaml`,确认后才写入。
## 修复 bug
可以直接说:
```text
/ack 修这个 bug:<现象、复现、期望>
```
Coordinator 写短问题说明、复现步骤和可观测验收,不写大 PRD;你确认后再派发。
Developer 先补会失败的用例再修,后续闭环与做需求相同。
配了飞书 `bugIntake` 时,飞书是审核前的唯一协作区。你只需维护标题、详细描述和
附件;Coordinator 整理问题说明、期望效果和验收标准并写回飞书。你针对当前
revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
```text
/ack 处理飞书里待整理的 bug
```
## 运行回归
平时做需求和修 bug 结束后,ACK 按本轮内容更新 `.pouch/ack/regression.yaml`。
之后可以单独跑:
```text
/ack 回归
/ack 跑 full 回归
/ack 回归 REG-login-001
/ack 修这些回归失败
```
ACK 先按 deployer 布测试环境,再派独立 Test 按用例用浏览器或 API 执行。失败只
报告,不会自动开修;要修再说 `/ack 修这些回归失败`。默认跑 smoke。细则见
`references/regression.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 时才停下来请求决策。
临时调整角色启动方式时直接说明意图,例如:
```text
/ack 让这个项目的 dev agent 用 pi 默认模型,test agent 用 cursor-agent auto 模型
```
ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时优先于
`tasks.yaml.project.orchestration`;删除后自动回退。配置 hash 会绑定启动计划和 receipt,
修改后必须重新审阅并生成 plan。
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:规格和任务写到 `.pouch/ship/<id>/`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
6. 回归失败只报告,不会自动开修。
7. 未获授权不提交、不推送、不创建终端、不部署,也不改项目的 `AGENTS.md` / `CLAUDE.md`。
飞书收件时,你维护标题、详细描述和附件。Coordinator 整理问题说明、期望和验收并写回飞书。你针对当前 revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
## 如何判断完成
- 初始化:五个状态文件齐,报告为完成,或列出待配置项和下一步。
- 需求 / bug:任务变为 `verified`,或三轮后记为 `leftover`;回归目录有本轮用例,或明确「无回归」。
- 测试环境:目标环境容器 Up,健康检查通过。
- 发版:停在约定档位;stable / 生产有单独批准记录。
- 回归:列出通过和失败的用例;失败不会被自动拿去修。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型;从 `0.16.0` 起,
`delivery.yaml` 可用 `intents.testEnvironment` 与 `intents.release` 把测试环境部署和
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
`--always-approve`,sandbox 仍必开;从 `0.19.0` 起 `intents.testEnvironment` 改为
deployer 绑定,ACK 内部调用 deployer skill 布测试环境,并增加
`.pouch/ack/regression.yaml` 与「运行回归」模式。旧的测试环境 profile ID 字符串不再
执行,需要迁到 `{via: deployer, env: <env>}`。旧项目可以不迁移回归目录而继续使用
原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
+64 -16
View File
@@ -8,10 +8,24 @@ description: >-
# ACK 项目协作入口
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、
`delivery.yaml` 和 `regression.yaml`。不要复制 Skill 内容,不要修改 `AGENTS.md`、
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
`delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
`CLAUDE.md`。
执行位置和 profile 选择可以统一写在项目根 `.pouch/agent.yaml` 的 `ack.<name>` binding:
`location` 提供 Orca environment/host/project/path,`profile` 提供 cli/model/
`reasoningEffort`/permission。ACK 的 `agents.yaml` 或 tasks orchestration 仍保留其
`profileVersion`、`modelAllowlist`、fingerprint/receipt 字段,并由 ACK adapter 做兼容
映射;不要为了接入 common runtime 删除旧 receipt 或绕过 launcher。远程 ACK worker
必须先按 binding 精确定位 worktree,不能把本机目录当远程目录。
固定配置文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`;任务执行记录从
`.pouch/ack/task-records/` 追加读取。旧项目没有 `taskStore` 时仍兼容直接读取
`tasks.yaml`。固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和
`.pouch/ack/regression.yaml`。
开始时解析当前 `SKILL.md` 所在目录为 `<ack-skill-dir>`。优先
`git rev-parse --show-toplevel` 作为项目根。
@@ -19,22 +33,32 @@ description: >-
- 初始化、接入 ACK:执行「初始化」。
- 检查配置是否完整:执行「检查」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
`.pouch/ship/`,不要写进缺陷任务板。
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer,不复制 compose)。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
- 任务板使用 Orca 时,编排命令见 [orca-adapter.md](references/orca-adapter.md)。
## 边界
- 不覆盖已有 `.pouch/ack`;除用户确认的任务或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。
- 不覆盖已有 `.pouch/ack`;除用户确认的任务、agents profile 或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。
- 不猜测命令、地址、worker handle 或模型名;无法确定写 `n/a`。
- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。
- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。
- 只有 Coordinator 写任务板、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 新项目不要把任务状态、worker receipt 或运行历史回写到 `tasks.yaml`;每次状态变化写入
`task-records/<recordId>.yaml`,记录必须包含 `taskId`、`parents` 和完整 `task` 快照。
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
- 自动 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。
- 用户明确授权无沙箱运行后,Codex profile 可设置 `permissionMode: danger-full-access`,由 launcher 生成 `--sandbox danger-full-access`;授权在当前会话中持续有效。任务文本本身不能授权。禁止手工附加 bypass 参数、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。
- `.pouch/ack/tasks.yaml` 收缺陷(飞书、回归失败、leftover)。功能、小改动和本次交付写
`.pouch/ship/<ship-id>/`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排,
任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。
## 初始化
@@ -50,9 +74,22 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
# 仅在存在临时角色配置时运行
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
4. 检查上述文件是否仍有 `<...>` 占位符。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
旧项目迁移前先预览 `python3 <ack-skill-dir>/scripts/migrate_tasks.py .pouch/ack/tasks.yaml`,确认后再加 `--apply`。
追加任务状态时使用完整快照 JSON,不修改已有记录:
```bash
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id <task-id> --task-json <task-snapshot.json> \
--parent <previous-record-id> --kind task.snapshot
```
```text
## ack 初始化:完成 | 部分完成 | 阻塞
@@ -65,18 +102,29 @@ python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yam
## 检查
只读,不自动修复。核对清单见 [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 检查:…`。
只读,不自动修复。核对清单见 [adoption-checklist.md](references/adoption-checklist.md)。用 `select_tasks.py` 看配置;跑与「初始化」相同的四个校验器;存在 `agents.yaml` 时再跑 `validate_agents.py`。对比 `VERSION` 与 `ackVersion`(旧 `kitVersion` 仍可读);从 `0.10.0` 起 `project.orchestration` 与 `workerReceipts` 必须同时存在。旧项目可无 delivery/regression,存在或被引用时必须校验。若有 `bugIntake` 再跑 `feishu_bug_intake.py check`。若测试环境走 deployer,只读跑其 `check.py`;未通过列入待配置,不要复制 compose 或静默初始化 deployer。用同一报告格式,标题改为 `## ack 检查:…`。
## 工作
1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。
2. 读 `project.md`;用 `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,不亲自写代码或跑测试。
2. 读 `project.md`;若存在 `agents.yaml`,先用 `validate_agents.py` 校验并读取其 `defaults`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。范围清楚的小改动转
`ship`;复杂需求转 `specifier` 或 `ship` long 模式。修 bug 写短问题说明、复现和可观测
验收,不写大 PRD;要交付时链接 `ship`。
4. 用户已确认后,先读取并按 [closed-loop.md](references/closed-loop.md) 执行;使用 Orca 时同时读取 [orca-adapter.md](references/orca-adapter.md)。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。
这不是 fire-and-forget:Coordinator 不得在 `launch` / `dispatch` 后只回复“已派发”并结束当前会话。每个任务都必须在同一轮持续执行默认 60 秒窗口的 `check --wait`,并按 60–120 秒节奏运行 `worker_probe.py`,直到 Developer 回报 `worker_done` 或 `escalation`;随后写回 `fixed_by_dev`,主动派发 Test,并继续等待 `retest_result`。只有完成 Test 终检、重试/`leftover` 决策、回归决定和 worker 回收后,才可以结束本轮 ACK。
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
6. 知识 `verification.ref` 只经 `run_verification.py`。不把 `worker_done` 或 Test 自报成功当作完成。环境失败先有界恢复并报告 `userAction`;三轮产品失败记 `leftover`。
7. 任务 `verified` 且改了可见行为或 API 后,转入「运行回归」收获用例。若本次计划含交付,再走对应交付模式;默认最多到 `validation_ready` 或 `review_ready`。
8. 整轮完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,只回收仅属于 `verified` 任务的 worker。关闭结果不确定则记录,不伪报。
8. 整轮任务完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,回收所有只属于 `verified` 任务的 worker 终端,并核对关闭回执;仍关联未完成任务的 worker 不设置 TTL。关闭结果不确定则记录,不伪报。
不能停在“任务已 `verified` 却声称整轮 ACK 已结束”:必须完成回归决定和 worker 回收。
## 角色启动配置维护
用户明确说「让这个项目的 dev agent 用 pi 默认模型、test agent 用 cursor-agent auto」等临时调整时,创建或更新 `.pouch/ack/agents.yaml`。配置文件形状见 [agents.template.yaml](templates/agents.template.yaml) 和 [agents.example.yaml](examples/agents.example.yaml),只写结构化 profile,不写自由命令。
存在 `agents.yaml` 时,ACK 的 `profile-hash`、`plan`、`launch` 和 receipt 全部绑定该文件的 hash;修改配置后必须重新 plan,旧 fingerprint 不能继续启动。删除该文件即可回退到 `tasks.yaml.project.orchestration`。配置本身不代表提交、推送、发布或部署授权。
## 飞书收件
@@ -84,7 +132,7 @@ python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yam
## 启动 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`。
只使用 `scripts/launch_worker.py plan|launch`。不得直接 `orca terminal create --command`,不得拼接自由 command、argv、executable、env 或 cwd。读 [model-routing.md](references/model-routing.md)。先审阅 `plan.launchFingerprint`,再作为 `launch --expected-launch-fingerprint` 传入。派发文案用 [prompt-templates.md](references/prompt-templates.md)。派发后 terminal read 确认已开始;卡在审批、未回车或额度限制按环境失败处理。等待期间用 `scripts/worker_probe.py`,不盲等 `worker_done`。收到无错误的 `worker_done` / Test 复测结果后,按 [orca-adapter.md](references/orca-adapter.md) 用 `reclaim_workers.py --completed-task-id ... --completed-role ... --apply` 关闭对应 Orca terminal;失败、escalation 或环境异常不关闭。
## 交付配置维护
+1 -1
View File
@@ -1 +1 @@
0.19.0
0.20.0
+37
View File
@@ -0,0 +1,37 @@
# Example .pouch/ack/agents.yaml for a temporary routing choice.
profileVersion: 1
mode: "orca"
modelAllowlist:
pi:
developer:
standard: [null]
strong: [null]
cursor-agent:
test:
standard: ["auto"]
profiles:
pi-dev-default:
role: "developer"
cli: "pi"
tier: "standard"
model: null
reasoningEffort: "medium"
permissionMode: "workspace-write"
pi-dev-strong:
role: "developer"
cli: "pi"
tier: "strong"
model: null
reasoningEffort: "high"
permissionMode: "workspace-write"
cursor-test-auto:
role: "test"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
defaults:
developer: "pi-dev-default"
test: "cursor-test-auto"
developerUpgraded: "pi-dev-strong"
+12 -9
View File
@@ -1,10 +1,10 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.19.0。
> 本项目基于 ack v0.20.0。
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml`
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、`delivery.yaml`
> 与 `regression.yaml`。
## 项目概览
@@ -13,7 +13,8 @@
- 技术栈:`TypeScript + React (Vite) + Go`
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
- Base URL:`http://localhost:5173`
- 任务板:`.pouch/ack/tasks.yaml`
- 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`
- 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`
- 回归目录:`.pouch/ack/regression.yaml`
@@ -33,8 +34,9 @@
## Worker 路由
结构化配置位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`,启动记录位于顶层
`workerReceipts`。本项目默认使用:
结构化配置默认位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`;存在
`.pouch/ack/agents.yaml` 时以后者为准。启动记录位于顶层 `workerReceipts`。
本项目默认使用:
| 角色 | profile ID | 档位 |
|------|------------|------|
@@ -42,8 +44,8 @@
| Test | `codex-test-standard` | standard |
| Developer 升级 | `codex-dev-strong` | strong |
项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行
任务板校验。本文件不保存或拼接 worker shell 命令。
项目如临时改用 Pi、Cursor 或 Grok,应修改 `agents.yaml` 的结构化 profile、allowlist
和 defaults,再运行对应校验。本文件不保存或拼接 worker shell 命令。
## 路径权限
@@ -56,7 +58,8 @@
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
@@ -100,7 +103,7 @@ path/args。
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Test 与 Developer 只读。
- 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
+1 -1
View File
@@ -3,7 +3,7 @@
version: 1
updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.19.0"
ackVersion: "0.20.0"
project:
name: "notes-web"
baseUrl: "http://localhost:5173"
+6 -2
View File
@@ -4,12 +4,14 @@
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `pouch init ack --project <project-root>`。
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
- [ ] 若存在 `.pouch/ack/agents.yaml`,已运行 `validate_agents.py`;它优先于任务板
`project.orchestration`,且只包含结构化 profile,不包含自由 command/argv/env。
## 项目覆盖层
@@ -24,7 +26,9 @@
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
allowlist,默认 profile 与角色/档位一致。
allowlist,默认 profile 与角色/档位一致。
- [ ] `agents.yaml`(若存在)使用受支持的 profileVersion,Pi 默认模型使用
`cli: pi` + `model: null`,配置 hash 会绑定启动 plan/fingerprint/receipt。
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
+17 -14
View File
@@ -2,7 +2,8 @@
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
原则:调度消息只是运行时载体,任务结论必须回写到 `tasks.yaml`;已经验证且可跨
原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层
materialize 成任务视图);已经验证且可跨
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
Developer 只实现。
@@ -15,12 +16,12 @@ Developer 只实现。
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|----------|------|-----------|----------|
| `prepare(task)` | Coordinator 把任务和验收信号写进 `tasks.yaml`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
| `prepare(task)` | Coordinator 把任务和验收信号写进 `task-records/`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把任务结果写回 `tasks.yaml`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把任务结果追加到 `task-records/`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
@@ -30,14 +31,14 @@ Developer 只实现。
```text
Coordinator 发现或读取 open 任务
-> prepare:写/补全 tasks.yaml 验收信号
-> prepare:写/补全 task-records/ 中的任务验收信号
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
-> 解析安全 profile:机器配置只读 tasks.yaml.project.orchestration
-> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration
-> 运行 launcher plan 并审阅 launchFingerprint
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
-> 把本次 task/attempt receipt 写回 tasks.yaml(见 orca-adapter.md)
-> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md)
-> dispatch 给 Developer(--to <worker handle>)
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
@@ -133,7 +134,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
- 记录:`launch` 成功后把 receipt 追加到权威 `tasks.yaml.workerReceipts`,并把
- 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
@@ -173,9 +174,10 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
| 要保持基线分支干净 | 新 worktree(feature 分支)|
| 小改动、追求快 | 当前 worktree |
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
**项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、
副本(通常在基线/协调所在 worktree)。任务执行状态追加到
`.pouch/ack/task-records/<recordId>.yaml`;Coordinator 单写任务记录、知识、回归目录、
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
@@ -190,20 +192,20 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
1. Coordinator 在 `tasks.yaml` 写好任务和验收信号。
1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
手动发给 Developer(另一个会话/终端/人)。
3. Developer 完成后按 worker_done 模板回报。
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
6. Coordinator 做终检并回写 `tasks.yaml`:通过 `verified`,不过 `failed_retest`。
6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
不派 Developer、不消耗产品轮次。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
@@ -233,7 +235,7 @@ actual:
snapshot evidence:
```
Test 只回传证据,不写 `tasks.yaml` 或 `knowledge.yaml`;由 Coordinator 落盘。
Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
`not_applicable` 及证据。
@@ -277,7 +279,8 @@ worktreePath:
## 结果回写
Coordinator 回写 `tasks.yaml` 时按状态填写(字段结构见 `templates/tasks.schema.json`):
Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见
`templates/tasks.schema.json`):
```yaml
# 通过(Test 复测 + Coordinator 终检)
+12 -9
View File
@@ -3,7 +3,7 @@
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
`.pouch/ack/tasks.yaml.deliveryRuns`。
任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。
## 1. 配置与授权不是一回事
@@ -78,7 +78,7 @@ intents:
口头「发版」不能代替 stable/production 的 `approval` 步骤。
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填
- 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
`verified` 任务。
@@ -112,14 +112,17 @@ intents:
- `verify`:运行指定 entrypoint,失败即停止。
- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
明确要求时才调用独立的 `manage-release`。没有对应能力或认证时标记 blocked,不用
明确要求时才调用独立的 `releaser`。没有对应能力或认证时标记 blocked,不用
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
revision,不能在目标机器重新拉源码构建。
- `build`:对当前 source revision 调用已安装的 `builder` `obtain.sh --sha <revision>`,
不要假定本机 `make`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线;没有流水线或 job 失败
则本步 `failed`。DEB 必须记录包名、版本、架构和 SHA-256;OCI image 必须记录完整
引用、platform 和 digest。产物必须绑定当前 source revision,不能在目标机器重新拉
源码构建。
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
时才加载,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
时才加载,否则必须走契约中已审查的 upload entrypoint。`BUILD_VENUE=ci` 且 obtain
已带回 registry digest 时,本步只复核,不再本机 push。项目入口只接受
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
@@ -153,9 +156,9 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
## 7. 与低层 Skill 的边界
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`manage-release` 和
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`releaser` 和
`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载
deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失
builder 或 manage-release 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用
builder 或 releaser 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用
时把对应步骤标为 `blocked`。低层 skill 自身要求显式调用时,ACK 不能绕过它的
触发与授权边界;ACK 内部调用 deployer 布测试环境是该 skill 的合法调用路径。
@@ -103,6 +103,9 @@ pouch init ack --project <project-root>
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
- `.pouch/ack/agents.yaml` 是可选的临时 worker 路由覆盖;存在时优先于任务板中的
`project.orchestration`。它必须使用结构化 profile;Pi 默认模型写成 `cli: pi`、
`model: null`,不能写自由命令。
- 非服务项目的 `baseUrl` 写为 `n/a`。
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
@@ -152,6 +155,8 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
# 存在临时角色配置时
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
同时确认:
@@ -164,6 +169,8 @@ python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yam
- Developer 与 Test 的验证命令可执行。
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
- 若存在 `agents.yaml`,其 profile/allowlist/defaults 也通过校验,且启动配置 hash
会绑定 plan、fingerprint 和 receipt。
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
+12 -8
View File
@@ -4,14 +4,18 @@
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
不要在本文件开头预读其它规范。
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + ship long 模式)。ACK 任务板
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
---
## 前提:谁是 Coordinator
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
具体 CLI、模型、reasoning effort 和执行模式的机器事实源是
`.pouch/ack/tasks.yaml` 的 `project.orchestration`。`.pouch/ack/project.md` 只解释项目
具体 CLI、模型、reasoning effort 和执行模式的机器事实源优先是
`.pouch/ack/agents.yaml`,不存在时回退到 `.pouch/ack/tasks.yaml` 的
`project.orchestration`。`.pouch/ack/project.md` 只解释项目
差异,不能提供另一套启动命令。
---
@@ -32,7 +36,7 @@
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
4. 把任务写进 .pouch/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
@@ -40,7 +44,7 @@
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
fingerprint 创建 fresh worker;
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
dispatch 测试独立复测 → 你读证据终检 → 追加 task-records;
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
记录、恢复并告诉我下一步,不占产品复验轮次。
7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
@@ -65,8 +69,8 @@ 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。
有效 worker routing(`agents.yaml` 优先,否则 `project.orchestration`)是 profile 的机器
SSOT;未知字段、非 allowlist 模型、不匹配的角色/档位和非安全权限必须在派发前 fail closed。
读取任务上下文使用:
@@ -96,7 +100,7 @@ python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml \
## 第 3 步:审阅并启动 worker
先读 `tasks.yaml.project.orchestration` 的结构化 profiles。历史 receipt 或既有 Orca
先读有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)的结构化 profiles。历史 receipt 或既有 Orca
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
@@ -196,7 +200,7 @@ Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py`
## 一句话
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)解析安全 profile → 审阅 plan 并用 expected
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile
到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收
+28 -17
View File
@@ -1,6 +1,6 @@
# 模型路由(稳定核心)
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的规范源。
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
工作,把机械执行交给较弱模型。
@@ -64,23 +64,32 @@ profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强
## 结构化 worker profile
### 机器事实源
### 机器事实源与项目临时覆盖
worker 路由的机器可读事实只保存在 `.pouch/ack/tasks.yaml` 的
`project.orchestration`。`project.md` 可以解释项目为何选某个档位,但不能另写一份
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 为准。
worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的
`project.orchestration`。项目需要临时调整角色 CLI 或模型时,可以在同一目录增加
`.pouch/ack/agents.yaml`;它使用与 `project.orchestration` 相同的结构,且优先级更高。
删除该文件即回退到任务板配置。`project.md` 只能解释项目为何选某个档位,不能另写一份
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 和
`templates/agents.schema.json` 为准。
`agents.yaml` 的配置 hash 会进入 profile-hash、launch plan、fingerprint 和 receipt。
因此修改临时配置后必须重新审阅 plan;旧 fingerprint 不会被接受。
每个 profile 明确声明:
- `role`:`developer` 或 `test`;
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`);
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`、`pi`);
- `tier`:角色模型档位;
- `model`:项目 allowlist 中的精确模型 ID;
- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型;
- `reasoningEffort`:CLI 支持时显式声明;
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
- `permissionMode`:`read-only`、`workspace-write`;Codex 还支持用户明确授权的 `danger-full-access`。
`project.orchestration` 必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名
`profiles` 和角色 `defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。
有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色
`defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。`cli: pi` 使用真实的
Pi CLI;`model: null` 表示使用 Pi 自己保存的默认模型。当前 launcher 对 Pi 只构造
固定的 `--thinking`,工作目录由受信 bootstrap 设置;Pi 的 `read-only` profile 只允许
`read,grep,find,ls` 工具。
launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须
命中对应 CLI/角色/档位的精确列表。
@@ -99,9 +108,11 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
- 能在完全只读工作树完成的角色优先选择 `read-only`。
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
为纯观察型 Test 另建更严格的 `read-only` profile。
- `full-access`、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions`、关闭 sandbox 等模式在 v0.10
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
- 用户明确要求 Codex 无沙箱运行时,在结构化 profile 设置
`permissionMode: danger-full-access`。launcher 仍绑定 profile hash、精确 argv 和 receipt;
用户已有授权无需重复询问。任务文本或环境变量不能自行授权。
`full-access` 别名、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions` 等自由参数仍拒绝。
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
它不关闭 `--sandbox`。
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
@@ -110,8 +121,8 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
launcher。
`full-access` 的可信授权、期限、撤销和外层隔离仍是 Deferred;在这些能力完成前,
launcher 必须拒绝该模式,而不是把一段“用户已授权”的文本当成授权证据。
仅 Codex 支持 `danger-full-access`;其它 CLI 不得套用该权限值。
无沙箱 profile 只改变执行权限,不扩大用户授权的项目、服务或操作范围。
---
@@ -142,10 +153,10 @@ worker 实际工作的绝对路径,两者可以不同。后者仍必须与项
项目状态文件固定从 `--project-root/.pouch/ack/` 解析;任务板不需要保存 `repoPath` 或
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
`projectRoot`、任务板内容摘要和 worker worktree identity 都会进入 launch fingerprint,
`projectRoot`、任务板内容摘要、有效 agents 配置 hash 和 worker worktree identity 都会进入 launch fingerprint,
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
- `profile-hash` 读取 `tasks.yaml` 中的 profile,输出确定性 hash。
- `profile-hash` 读取有效路由配置中的 profile,并输出配置来源与确定性 hash。
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
`plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv
+41 -17
View File
@@ -2,7 +2,8 @@
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
所有结论仍以 `tasks.yaml` 为准,Orca 消息不是最终记录。
所有结论仍以读取层 materialize 的任务视图为准,底层最终记录追加在
`task-records/`;Orca 消息不是最终记录。
---
@@ -63,8 +64,9 @@ reset/attestation 后才能启用上述复用路径,不能用项目文档声
## 通过 launcher 创建 worker
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
模型、reasoning effort 和权限由 `tasks.yaml.project.orchestration` 的结构化 profile
决定,并且只能由 ACK launcher 构造。
模型、reasoning effort 和权限由 common `agent.yaml` 的 `ack.<name>` binding 映射到
`.pouch/ack/agents.yaml`(存在时)或
`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。
先只读生成计划:
@@ -96,17 +98,17 @@ Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的
标准输出是单一 JSON。成功后:
1. 校验返回 receipt 的 `receiptHash`;
2. 把 receipt 原样追加到 `tasks.yaml.workerReceipts`;
2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`;
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
与当前 ACK 任务完全一致;
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 full-access 授权
通道;bypass、YOLO/force、关闭 sandbox 及其它 full-access 请求必须由 schema 和
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
`model-routing.md`。
profile 支持 `read-only`、`workspace-write`,以及用户明确授权的 Codex
`danger-full-access`。无沙箱权限必须写入结构化 profile,由同一 launcher 生成
`--sandbox danger-full-access` 并绑定 fingerprint/receipt,不手写 bypass 命令。
模型档位与升级规则见 `model-routing.md`。
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
@@ -129,7 +131,7 @@ Policy:
- Each issue can have at most 3 valid product-retest rounds.
- Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round.
- If still failing after 3 valid product rounds, record as leftover and continue next issue.
- worker_done and retest reports are not final completion; only Coordinator writes tasks.yaml and knowledge.yaml.
- worker_done and retest reports are not final completion; only Coordinator writes task-records/ and knowledge.yaml.
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
EOF
)" --json
@@ -215,7 +217,7 @@ EOF
且终端进入工作指示(Working / Running)。
- 手动投递路径:`orca terminal send` 必须带 `--enter`;投递后同样 read 确认。
- 确认失败或终端仍停在欢迎提示:按「消息未投递」记录环境失败,不消耗产品轮次。
2. 等待期间滚动 probe(每 60–120 秒一次):
2. 等待期间滚动 probe(每 60–120 秒一次;不要在 30 秒窗口后每次都 probe):
```bash
python3 <ack-skill-dir>/scripts/worker_probe.py \
@@ -229,15 +231,17 @@ EOF
- `not-started`:检查是否漏投递或未回车;重新投递或记录环境失败,不占轮次。
- `running` / `progress`:继续滚动 wait。
- `unknown`:按 `dispatch-show` 与 Orca live state 人工核对,不自动重试。
4. `check --wait` 使用短窗口(60–90 秒)而不是 15 分钟:窗口超时是检查点,先 probe
再决定继续等待、恢复或上报。
4. `check --wait` 默认使用 60 秒窗口而不是 30 秒或 15 分钟:窗口超时是检查点,
仅在达到 probe 间隔后再探测,然后决定继续等待、恢复或上报。新鲜 heartbeat 才能
说明 progress;按 worker 每 5 分钟一次的约定,连续三个周期(超过 15 分钟)未更新、
格式非法或时间在未来的 heartbeat 只能报告 `unknown`,不得当作持续进展。
```bash
orca orchestration check \
--terminal <coordinator_handle> \
--wait \
--types worker_done,retest_result,escalation,decision_gate \
--timeout-ms 90000 \
--timeout-ms 60000 \
--json
```
@@ -282,7 +286,16 @@ orca orchestration send \
--json
```
收到 worker_done 后,Coordinator 写回 `fixed_by_dev`,再按上文派发给 Test 复测。
收到无错误的 Developer `worker_done` 后,Coordinator 写回 `fixed_by_dev`,然后先回收
Developer 对应的 Orca terminal,再按上文派发给 Test 复测:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> \
--completed-task-id <ack-task-id> --completed-role developer --apply
```
只关闭该 task 的 Developer receipt 绑定 terminal;escalation、失败或环境异常时不要执行。
---
@@ -312,7 +325,18 @@ orca orchestration send \
```
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
区分。收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并回写 `tasks.yaml`;
区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由
Coordinator 按 `closed-loop.md` 做终检并追加任务记录:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> \
--completed-task-id <ack-task-id> --completed-role test --apply
```
只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并追加任务记录;
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
@@ -321,8 +345,8 @@ orca orchestration send \
## 整轮结束时回收 worker
只有 Coordinator 已把本轮最终结论写入 `tasks.yaml` 后才开始回收。统一使用回收脚本,
不要手工逐个 close:
未在角色完成时回收的 worker,或需要整轮兜底回收的 worker,才在 Coordinator 把本轮
最终结论写入 `tasks.yaml` 后执行整轮回收。统一使用回收脚本,不要手工逐个 close:
```bash
# 第一步:dry-run 审阅决策(不关闭任何终端)
+2 -2
View File
@@ -10,10 +10,10 @@
项目状态是 `.pouch/ack/regression.yaml`。任务板用
`project.regressionFile: .pouch/ack/regression.yaml` 声明,运行证据写在
`tasks.yaml.regressionRuns`。新项目初始化会生成空目录;旧项目没有该文件时,
任务记录的 `regressionRuns`(读取时 materialize 到任务视图)。新项目初始化会生成空目录;旧项目没有该文件时,
「运行回归」先停下来,用户授权后再从模板补齐。
只有 Coordinator 写 `regression.yaml`。Test 在复测报告里提名
只有 Coordinator 写 `regression.yaml` 和任务记录。Test 在复测报告里提名
`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
`.pouch/ack/`。
@@ -12,12 +12,12 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
| Coordinator (PM) | 需求拆解、定验收信号、单写 `task-records/` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有落到 `tasks.yaml` 才算数。
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有追加到 `task-records/` 才算数。
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
@@ -108,7 +108,8 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只保存稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
@@ -143,7 +144,7 @@ launch / service / data / browser / tooling environment failure
-> 环境恢复后 retesting
```
状态定义(所有状态都只由 Coordinator 写入 `tasks.yaml`,来源不同):
状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同):
| 状态 | 依据来源 | 含义 |
|------|----------|------|
@@ -179,11 +180,11 @@ planned -> skipped
## 项目状态写入约定(并发安全)
`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识
事实源,`regression.yaml` 是黑盒回归用例事实源,`delivery.yaml` 是项目交付能力
事实源。为避免多 Agent 并发写冲突:
`task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置,
`knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源,
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
- **只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`**。
- **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。
Test 与 Developer 对它们都是只读的。
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
+5 -14
View File
@@ -25,6 +25,7 @@ from pathlib import Path
from typing import Any
from approval_payload import approval_payload_hash, review_items
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
from validate_tasks import validate_builtin as validate_task_board
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
@@ -180,21 +181,11 @@ def cli_environment() -> dict[str, str]:
def load_board(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".json":
value = load_json_unique(text)
else:
try:
import yaml # type: ignore
value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml))
except ImportError:
value = load_yaml_subset(text)
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
value = parse_document(path)
if is_task_store_manifest(value):
value = materialize_board(value, path)
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError, TaskStoreError) as exc:
raise IntakeError(f"cannot read task board: {exc}") from exc
except Exception as exc: # PyYAML errors are intentionally not exposed verbatim.
raise IntakeError("cannot parse task board") from exc
if not isinstance(value, dict):
raise IntakeError("task board must be an object")
return value
+126 -11
View File
@@ -45,13 +45,14 @@ from worker_profiles import ( # noqa: E402
executable_basename_matches_cli,
profile_hash,
render_worker_argv,
validate_routing_document,
validate_orchestration,
)
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
RECEIPT_VERSION = 1
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
TASKS_RELATIVE_PATH = Path(".pouch/ack/tasks.yaml")
AGENTS_RELATIVE_PATH = Path(".pouch/ack/agents.yaml")
MAX_CONTROL_OUTPUT = 1024 * 1024
MAX_RECORD_SIZE = 256 * 1024
LAUNCH_TTL_SECONDS = 120
@@ -85,12 +86,49 @@ WORKER_CREDENTIAL_NAMES = {
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
"grok": frozenset({"XAI_API_KEY"}),
"omp": frozenset({"OPENCODE_API_KEY"}),
"pi": frozenset(
{
"ANTHROPIC_API_KEY",
"ANT_LING_API_KEY",
"AZURE_OPENAI_API_KEY",
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
"NVIDIA_API_KEY",
"GEMINI_API_KEY",
"MISTRAL_API_KEY",
"GROQ_API_KEY",
"CEREBRAS_API_KEY",
"CLOUDFLARE_API_KEY",
"CLOUDFLARE_ACCOUNT_ID",
"CLOUDFLARE_GATEWAY_ID",
"XAI_API_KEY",
"OPENROUTER_API_KEY",
"AI_GATEWAY_API_KEY",
"ZAI_API_KEY",
"ZAI_CODING_CN_API_KEY",
"OPENCODE_API_KEY",
"RADIUS_API_KEY",
"FIREWORKS_API_KEY",
"TOGETHER_API_KEY",
"BASETEN_API_KEY",
"KIMI_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"QWEN_TOKEN_PLAN_API_KEY",
"QWEN_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_API_KEY",
"XIAOMI_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_TOKEN_PLAN_AMS_API_KEY",
"XIAOMI_TOKEN_PLAN_SGP_API_KEY",
}
),
}
CLI_TITLE_LABELS = {
"codex": "CODEX",
"cursor-agent": "CURSOR",
"grok": "GROK",
"omp": "OMP",
"pi": "PI",
}
INHERITED_ENVIRONMENT_PREFIXES = (
"LC_",
@@ -262,12 +300,55 @@ def authoritative_tasks_path(project_root: Path) -> Path:
return tasks_path
def optional_agents_path(project_root: Path) -> Path | None:
agents_path = project_root / AGENTS_RELATIVE_PATH
try:
metadata = os.lstat(agents_path)
except FileNotFoundError:
return None
except OSError as exc:
raise LaunchError(f"agents.yaml 不可访问: {agents_path}") from exc
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
raise LaunchError(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}")
_assert_no_symlink_components(agents_path, "agents.yaml 路径")
if metadata.st_size > MAX_RECORD_SIZE:
raise LaunchError(f"agents.yaml 超过 {MAX_RECORD_SIZE} 字节上限")
return agents_path
def load_effective_orchestration(
project_root: Path,
board: dict,
) -> tuple[dict, str, str]:
"""Load the optional project override and return routing, hash, source."""
agents_path = optional_agents_path(project_root)
if agents_path is not None:
agents = load_document(agents_path)
errors = validate_orchestration(agents, where="agents.yaml")
if errors:
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
raise LaunchError(f"agents.yaml 未通过 worker 路由校验:\n{formatted}")
return agents, canonical_sha256(agents), str(agents_path)
project = board.get("project")
orchestration = project.get("orchestration") if isinstance(project, dict) else None
if not isinstance(orchestration, dict):
raise LaunchError("任务板缺少 project.orchestration,且没有 agents.yaml")
return orchestration, canonical_sha256(orchestration), "tasks.yaml:project.orchestration"
def load_authoritative_board(project_root_value: str) -> tuple[Path, dict]:
project_root = canonical_directory(project_root_value, "项目根目录")
tasks_path = authoritative_tasks_path(project_root)
board = load_document(tasks_path)
errors = validate_builtin(board)
errors.extend(validate_routing_document(board))
effective, _, _ = load_effective_orchestration(project_root, board)
errors = validate_builtin(board, orchestration_override=effective)
if optional_agents_path(project_root) is not None:
project = board.get("project")
board_orchestration = project.get("orchestration") if isinstance(project, dict) else None
if board_orchestration is not None:
errors.extend(validate_orchestration(board_orchestration))
if errors:
formatted = "\n".join(f" - {error}" for error in dict.fromkeys(errors))
raise LaunchError(f"任务板未通过 worker 路由校验:\n{formatted}")
@@ -307,8 +388,25 @@ def _is_trusted_grok_executable(resolved: Path, metadata: os.stat_result) -> boo
return metadata.st_uid == os.getuid() and metadata.st_gid == os.getgid()
def _is_trusted_pi_shim(candidate: Path, resolved: Path, metadata: os.stat_result) -> bool:
"""Accept the mise-managed ``pi`` shim while retaining target identity checks."""
home, _ = account_identity()
expected_shim = home / ".local" / "share" / "mise" / "shims" / "pi"
if candidate != expected_shim or candidate.name != "pi":
return False
if resolved.name != "mise":
return False
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
return False
try:
return candidate.is_symlink() and candidate.resolve(strict=True) == resolved
except OSError:
return False
def resolve_executable(name: str) -> Path:
supported = {"codex", "cursor-agent", "grok", "omp", "git", "orca"}
supported = {"codex", "cursor-agent", "grok", "omp", "pi", "git", "orca"}
if name not in supported:
raise LaunchError(f"不支持的可执行文件: {name}")
search_paths = trusted_path_entries()
@@ -337,6 +435,10 @@ def resolve_executable(name: str) -> Path:
if _is_trusted_grok_executable(resolved, metadata):
return resolved
continue
if name == "pi":
if _is_trusted_pi_shim(candidate, resolved, metadata):
return candidate
continue
if stat.S_IMODE(metadata.st_mode) & 0o022:
continue
if resolved.name != name:
@@ -672,14 +774,13 @@ def build_plan(
raise LaunchError("slot 必须是 1..99 的整数")
project_root, board = load_authoritative_board(project_root_value)
orchestration, routing_hash, routing_source = load_effective_orchestration(
project_root, board
)
board_hash = canonical_sha256(board)
find_task(board, task_id)
project = board["project"]
orchestration = project.get("orchestration")
if not isinstance(orchestration, dict):
raise LaunchError("旧任务板缺少 project.orchestration,只能使用手动模式")
if orchestration.get("mode") != "orca":
raise LaunchError("project.orchestration.mode 不是 orca,拒绝自动创建 worker")
raise LaunchError("有效 worker 配置的 mode 不是 orca,拒绝自动创建 worker")
profiles = orchestration.get("profiles")
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
if not isinstance(profile, dict):
@@ -718,6 +819,11 @@ def build_plan(
profile,
profile_version=orchestration["profileVersion"],
)
routing_facts = (
{}
if routing_source == "tasks.yaml:project.orchestration"
else {"routingSource": routing_source, "routingHash": routing_hash}
)
created_for = {
"taskId": task_id,
"attemptId": attempt_id,
@@ -735,6 +841,7 @@ def build_plan(
"worktree": worktree,
"requested": requested,
"slot": slot,
**routing_facts,
}
)
try:
@@ -762,6 +869,7 @@ def build_plan(
"requested": requested,
"slot": slot,
"title": title,
**routing_facts,
}
@@ -1136,6 +1244,9 @@ def build_receipt(
},
"createdAt": created_at,
}
if "routingSource" in plan and "routingHash" in plan:
receipt["routingSource"] = plan["routingSource"]
receipt["routingHash"] = plan["routingHash"]
receipt["receiptHash"] = canonical_sha256(receipt)
return receipt
@@ -1579,8 +1690,10 @@ def build_parser() -> argparse.ArgumentParser:
def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
if not PROFILE_ID_RE.fullmatch(profile_id):
raise LaunchError("profile-id 格式非法")
_, board = load_authoritative_board(project_root_value)
orchestration = board["project"].get("orchestration")
project_root, board = load_authoritative_board(project_root_value)
orchestration, routing_hash, routing_source = load_effective_orchestration(
project_root, board
)
profiles = orchestration.get("profiles") if isinstance(orchestration, dict) else None
profile = profiles.get(profile_id) if isinstance(profiles, dict) else None
if not isinstance(profile, dict):
@@ -1588,6 +1701,8 @@ def profile_hash_command(project_root_value: str, profile_id: str) -> dict:
return {
"profileVersion": orchestration["profileVersion"],
"profileId": profile_id,
"routingSource": routing_source,
"routingHash": routing_hash,
"profileHash": profile_hash(
profile,
profile_version=orchestration["profileVersion"],
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Migrate a legacy ACK tasks.yaml into immutable task records.
The default is a read-only dry run. ``--apply`` writes a sibling
``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces
the task board with a small manifest that points at ``task-records/``.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Any
from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
try:
temporary.write_text(content, encoding="utf-8")
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def _record_id(task_id: str) -> str:
return f"EV-{task_id}-initial"
def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]:
value: dict[str, Any] = {
"recordVersion": STORE_VERSION,
"recordId": _record_id(task_id),
"taskId": task_id,
"parents": [],
"kind": "task.snapshot",
}
if task is not None:
value["task"] = task
return value
def build_records(board: dict[str, Any]) -> list[dict[str, Any]]:
tasks = board.get("tasks")
if not isinstance(tasks, list):
raise TaskStoreError("legacy tasks 必须是列表")
records: list[dict[str, Any]] = []
task_ids: set[str] = set()
for task in tasks:
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
raise TaskStoreError("每条 legacy task 必须包含字符串 id")
task_id = task["id"]
if task_id in task_ids:
raise TaskStoreError(f"task id 重复: {task_id}")
task_ids.add(task_id)
records.append(_record(task_id, dict(task)))
receipts = board.get("workerReceipts") or []
if not isinstance(receipts, list):
raise TaskStoreError("workerReceipts 必须是列表")
by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids}
global_receipts: list[dict[str, Any]] = []
for receipt in receipts:
if not isinstance(receipt, dict):
raise TaskStoreError("workerReceipts 每项必须是对象")
created_for = receipt.get("createdFor")
task_id = created_for.get("taskId") if isinstance(created_for, dict) else None
if isinstance(task_id, str) and task_id in by_task:
by_task[task_id].append(receipt)
else:
global_receipts.append(receipt)
for record in records:
task_id = record["taskId"]
if by_task[task_id]:
record["workerReceipts"] = by_task[task_id]
global_record = _record(GLOBAL_TASK_ID)
if global_receipts:
global_record["workerReceipts"] = global_receipts
for field in ("deliveryRuns", "regressionRuns"):
value = board.get(field)
if value is not None:
if not isinstance(value, list):
raise TaskStoreError(f"{field} 必须是列表")
global_record[field] = value
if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")):
records.append(global_record)
return records
def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]:
board = parse_document(path)
if "taskStore" in board:
raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移")
records = build_records(board)
record_directory = directory or "task-records"
relative = Path(record_directory)
if relative.is_absolute() or ".." in relative.parts:
raise TaskStoreError("records directory 必须是任务板目录下的相对路径")
output_dir = path.parent / relative
backup = path.with_name(f"{path.stem}.legacy{path.suffix}")
if apply and backup.exists():
raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}")
manifest = dict(board)
for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"):
manifest.pop(field, None)
manifest["tasks"] = []
manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory}
if apply:
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, backup)
output_dir.mkdir(parents=True, exist_ok=True)
for record in records:
record_path = output_dir / f"{record['recordId']}.yaml"
if record_path.exists():
raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}")
_atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n")
_atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
return {
"mode": "apply" if apply else "dry-run",
"tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]),
"records": len(records),
"recordsDirectory": str(output_dir),
"backup": str(backup),
"taskStore": manifest["taskStore"],
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
parser.add_argument("--apply", action="store_true", help="写入记录并替换任务板")
parser.add_argument("--records-dir", help="任务记录目录,默认 task-records")
args = parser.parse_args(argv)
try:
result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir)
except (OSError, TaskStoreError) as exc:
print(f"迁移失败: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+147 -5
View File
@@ -17,7 +17,9 @@ Default mode is dry-run: print decisions only. Pass ``--apply`` to actually
show-verify and close. Closing is conservative: identity must match the
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.
of being retried. A completed role can be reclaimed before the whole task is
verified with ``--completed-task-id`` and ``--completed-role``; this is only
valid after that role has reported success.
Output is a single JSON document:
{"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]}
@@ -43,6 +45,10 @@ from launch_worker import ( # noqa: E402
KEEP_STATUSES = frozenset(
{"open", "dispatched", "fixed_by_dev", "retesting", "blocked", "failed_retest", "leftover"}
)
COMPLETED_ROLE_STATUSES = {
"developer": frozenset({"fixed_by_dev", "retesting", "verified"}),
"test": frozenset({"retesting", "verified"}),
}
def collect_handle_tasks(board: dict) -> dict[str, dict[str, object]]:
@@ -115,10 +121,25 @@ def decide(handle: str, entry: dict[str, object]) -> tuple[str, str, list[str]]:
def close_terminal(orca: Path, handle: str) -> None:
"""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",
)
try:
show = run_json(
[str(orca), "terminal", "show", "--terminal", handle, "--json"],
"terminal show",
)
except LaunchError:
# A role may already have been reclaimed before the final verified-task
# sweep. Treat a handle absent from the live list as idempotent success;
# retain other show failures for manual reconciliation.
listing = run_json(
[str(orca), "terminal", "list", "--json"],
"terminal list",
)
terminals = listing.get("result", {}).get("terminals")
if isinstance(terminals, list) and not any(
isinstance(item, dict) and item.get("handle") == handle for item in terminals
):
return
raise
terminal = show.get("result", {}).get("terminal")
if not isinstance(terminal, dict) or terminal.get("handle") != handle:
raise LaunchError(f"terminal show 未返回匹配的 handle: {handle}")
@@ -144,11 +165,70 @@ def close_terminal(orca: Path, handle: str) -> None:
raise LaunchError(f"terminal close 后 handle 仍在 live list: {handle}")
def completed_role_handle(board: dict, task_id: str, role: str) -> str:
"""Resolve the receipt-bound terminal for a role that just succeeded."""
if role not in COMPLETED_ROLE_STATUSES:
raise LaunchError(f"不支持的完成角色: {role}")
tasks = board.get("tasks")
tasks_by_id = (
{task.get("id"): task for task in tasks if isinstance(task, dict)}
if isinstance(tasks, list)
else {}
)
task = tasks_by_id.get(task_id)
if not isinstance(task, dict):
raise LaunchError(f"找不到 ACK task: {task_id}")
status = task.get("status")
if status not in COMPLETED_ROLE_STATUSES[role]:
raise LaunchError(
f"{role} 完成回收要求任务处于 {sorted(COMPLETED_ROLE_STATUSES[role])},当前是 {status!r}"
)
if unresolved_incidents(task):
raise LaunchError(f"task {task_id} 存在未解决环境事件,不能回收 {role} terminal")
dispatch = task.get("dispatch")
role_dispatch = dispatch.get(role) if isinstance(dispatch, dict) else None
receipt_id = role_dispatch.get("receiptId") if isinstance(role_dispatch, dict) else None
if not isinstance(receipt_id, str) or not receipt_id:
raise LaunchError(f"task {task_id} 缺少 {role} receiptId")
receipts = board.get("workerReceipts")
receipt = next(
(
item
for item in receipts
if isinstance(item, dict) and item.get("id") == receipt_id
),
None,
) if isinstance(receipts, list) else None
if not isinstance(receipt, dict):
raise LaunchError(f"找不到 {role} receipt: {receipt_id}")
created_for = receipt.get("createdFor")
binding = receipt.get("binding")
if (
not isinstance(created_for, dict)
or created_for.get("taskId") != task_id
or created_for.get("role") != role
or not isinstance(binding, dict)
or not isinstance(binding.get("handle"), str)
or not binding.get("handle")
):
raise LaunchError(f"{role} receipt 与 task/role/handle 不匹配: {receipt_id}")
return binding["handle"]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根")
parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策")
parser.add_argument("--handle", help="只处理指定 handle(默认全部)")
parser.add_argument("--completed-task-id", help="刚完成的 ACK task ID;与 --completed-role 一起使用")
parser.add_argument(
"--completed-role",
choices=("developer", "test"),
help="刚成功完成的角色;与 --completed-task-id 一起使用",
)
args = parser.parse_args(argv)
try:
@@ -157,6 +237,68 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False))
return 2
if bool(args.completed_task_id) != bool(args.completed_role):
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"error": "--completed-task-id 与 --completed-role 必须同时提供",
},
ensure_ascii=False,
)
)
return 2
if args.completed_task_id:
if args.handle:
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"error": "完成角色回收不能同时使用 --handle",
},
ensure_ascii=False,
)
)
return 2
try:
handle = completed_role_handle(board, args.completed_task_id, args.completed_role)
closed: bool | None = None
decision = "close-completed-role"
reason = f"{args.completed_role} reported success for task {args.completed_task_id}"
if args.apply:
close_terminal(resolve_executable("orca"), handle)
closed = True
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"handles": [
{
"handle": handle,
"decision": decision,
"reason": reason,
"tasks": [args.completed_task_id],
"role": args.completed_role,
"closed": closed,
}
],
},
ensure_ascii=False,
)
)
return 0
except LaunchError as exc:
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"error": str(exc),
},
ensure_ascii=False,
)
)
return 2
handles = collect_handle_tasks(board)
results: list[dict[str, object]] = []
for handle in sorted(handles):
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Append one immutable ACK task record.
The input task is a complete snapshot. The command never edits an existing
record; it creates a new file with exclusive creation and lets the materializer
report multiple heads when two workspaces branched from the same parent.
"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from task_store import (
RECORD_ID_RE,
STORE_VERSION,
TASK_ID_RE,
TaskStoreError,
materialize_board,
parse_document,
)
def _read_json_mapping(path: Path, label: str) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise TaskStoreError(f"{label}读取失败: {path}") from exc
if not isinstance(value, dict):
raise TaskStoreError(f"{label}必须是对象")
return value
def _read_json_list(path: Path, label: str) -> list[dict[str, Any]]:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise TaskStoreError(f"{label}读取失败: {path}") from exc
if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
raise TaskStoreError(f"{label}必须是对象列表")
return raw
def _auto_record_id(task_id: str) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"EV-{task_id}-{stamp}-{secrets.token_hex(4)}"
def append_record(
board_path: Path,
task: dict[str, Any],
*,
task_id: str,
record_id: str | None,
parents: list[str],
kind: str,
worker_receipts: list[dict[str, Any]] | None = None,
delivery_runs: list[dict[str, Any]] | None = None,
regression_runs: list[dict[str, Any]] | None = None,
) -> Path:
manifest = parse_document(board_path)
if not isinstance(manifest.get("taskStore"), dict):
raise TaskStoreError("任务板没有启用 taskStore;先运行 migrate_tasks.py --apply")
if TASK_ID_RE.fullmatch(task_id) is None:
raise TaskStoreError("taskId 格式无效")
if task.get("id") != task_id:
raise TaskStoreError("输入 task.id 必须与 --task-id 相同")
if not isinstance(kind, str) or not kind.strip():
raise TaskStoreError("kind 必须是非空字符串")
chosen_id = record_id or _auto_record_id(task_id)
if RECORD_ID_RE.fullmatch(chosen_id) is None:
raise TaskStoreError("recordId 格式无效")
if any(not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None for parent in parents):
raise TaskStoreError("parents 必须是 recordId 字符串列表")
# Materialize first so malformed existing records or unresolved heads stop
# the next write instead of compounding an already ambiguous store.
materialized = materialize_board(manifest, board_path)
existing_errors = materialized.get("taskStoreErrors")
if isinstance(existing_errors, list) and existing_errors:
raise TaskStoreError("现有 taskStore 已有冲突,先解决: " + "; ".join(map(str, existing_errors)))
directory = board_path.parent / str(manifest["taskStore"].get("directory", "task-records"))
directory.mkdir(parents=True, exist_ok=True)
output = directory / f"{chosen_id}.yaml"
record: dict[str, Any] = {
"recordVersion": STORE_VERSION,
"recordId": chosen_id,
"taskId": task_id,
"parents": parents,
"kind": kind,
"task": task,
}
for field, value in (
("workerReceipts", worker_receipts),
("deliveryRuns", delivery_runs),
("regressionRuns", regression_runs),
):
if value:
record[field] = value
content = json.dumps(record, ensure_ascii=False, indent=2) + "\n"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
descriptor: int | None = None
try:
descriptor = os.open(output, flags, 0o644)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
descriptor = None
handle.write(content)
except FileExistsError as exc:
raise TaskStoreError(f"记录已存在,拒绝覆盖: {output}") from exc
finally:
if descriptor is not None:
os.close(descriptor)
return output
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
parser.add_argument("--task-id", required=True)
parser.add_argument("--task-json", type=Path, required=True, help="完整 task 快照 JSON")
parser.add_argument("--record-id")
parser.add_argument("--parent", action="append", default=[])
parser.add_argument("--kind", default="task.snapshot")
parser.add_argument("--worker-receipts-json", type=Path)
parser.add_argument("--delivery-runs-json", type=Path)
parser.add_argument("--regression-runs-json", type=Path)
args = parser.parse_args(argv)
try:
task = _read_json_mapping(args.task_json, "task")
output = append_record(
Path(args.tasks), task, task_id=args.task_id,
record_id=args.record_id, parents=args.parent, kind=args.kind,
worker_receipts=(
_read_json_list(args.worker_receipts_json, "worker receipt")
if args.worker_receipts_json else None
),
delivery_runs=(
_read_json_list(args.delivery_runs_json, "delivery run")
if args.delivery_runs_json else None
),
regression_runs=(
_read_json_list(args.regression_runs_json, "regression run")
if args.regression_runs_json else None
),
)
except (OSError, TaskStoreError) as exc:
print(f"追加任务记录失败: {exc}", file=sys.stderr)
return 1
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Merge-friendly ACK task records.
``tasks.yaml`` remains the small project manifest. When it contains a
``taskStore`` mapping, task state is materialized from immutable record files
under the configured directory. Legacy boards without that mapping keep
their original single-file behavior.
Records are complete task snapshots rather than patches. A later record
names its parent record(s), so a concurrent edit produces multiple heads and
is reported as a semantic merge conflict instead of being silently resolved
by file order or timestamps.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
STORE_VERSION = 1
GLOBAL_TASK_ID = "__project__"
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
RECORD_SUFFIXES = {".yaml", ".yml", ".json"}
class TaskStoreError(ValueError):
"""A malformed task store or record."""
def parse_document(path: Path) -> dict[str, Any]:
"""Parse one YAML/JSON mapping without importing the validator."""
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise TaskStoreError(f"读取失败: {path}: {exc}") from exc
try:
if path.suffix.lower() == ".json":
value = load_json_unique(content)
else:
try:
import yaml # type: ignore
except ImportError:
value = load_yaml_subset(content)
else:
value = yaml.load(
content,
Loader=make_unique_pyyaml_loader(yaml),
)
except (json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
raise TaskStoreError(f"解析失败: {path}: {exc}") from exc
except Exception as exc: # PyYAML errors are intentionally bounded.
raise TaskStoreError(f"解析失败: {path}") from exc
if not isinstance(value, dict):
raise TaskStoreError(f"{path}: 顶层必须是对象(mapping)")
return value
def _record_directory(manifest: dict[str, Any], manifest_path: Path) -> Path:
config = manifest.get("taskStore")
if not isinstance(config, dict):
raise TaskStoreError("taskStore 必须是对象")
version = config.get("version", STORE_VERSION)
if version != STORE_VERSION:
raise TaskStoreError(f"不支持 taskStore.version={version!r}")
directory = config.get("directory", "task-records")
if not isinstance(directory, str) or not directory.strip():
raise TaskStoreError("taskStore.directory 必须是非空相对路径")
relative = Path(directory)
if relative.is_absolute() or ".." in relative.parts:
raise TaskStoreError("taskStore.directory 必须位于任务板目录下")
return manifest_path.parent / relative
def _record_paths(directory: Path) -> list[Path]:
if not directory.exists():
return []
if directory.is_symlink() or not directory.is_dir():
raise TaskStoreError(f"taskStore.directory 不是普通目录: {directory}")
paths: list[Path] = []
for path in sorted(directory.iterdir(), key=lambda item: item.name):
if path.name.startswith("."):
continue
if path.is_symlink() or not path.is_file():
raise TaskStoreError(f"任务记录必须是普通文件: {path}")
if path.suffix.lower() in RECORD_SUFFIXES:
paths.append(path)
return paths
def _unique_collection(
records: list[dict[str, Any]],
field: str,
errors: list[str],
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
by_id: dict[str, dict[str, Any]] = {}
for record in records:
values = record.get(field)
if values is None:
continue
if not isinstance(values, list):
errors.append(f"{field} 必须是列表")
continue
for index, item in enumerate(values):
if not isinstance(item, dict):
errors.append(f"{field}[{index}] 必须是对象")
continue
item_id = item.get("id")
if not isinstance(item_id, str) or not item_id:
errors.append(f"{field}[{index}].id 必须是非空字符串")
continue
previous = by_id.get(item_id)
if previous is not None and previous != item:
errors.append(f"{field} 出现相同 id 的不同记录: {item_id}")
continue
if previous is None:
by_id[item_id] = item
result.append(item)
return result
def materialize_board(manifest: dict[str, Any], manifest_path: Path) -> dict[str, Any]:
"""Return a board-shaped view from a manifest and immutable records."""
directory = _record_directory(manifest, manifest_path)
paths = _record_paths(directory)
# This permits a staged rollout: a freshly initialized board has an empty
# store and the legacy empty ``tasks: []`` field until its first record.
if not paths:
return manifest
errors: list[str] = []
records: list[dict[str, Any]] = []
by_record_id: dict[str, dict[str, Any]] = {}
for path in paths:
record = parse_document(path)
where = str(path.relative_to(manifest_path.parent))
version = record.get("recordVersion", STORE_VERSION)
record_id = record.get("recordId")
task_id = record.get("taskId")
parents = record.get("parents", [])
if version != STORE_VERSION:
errors.append(f"{where}: 不支持 recordVersion={version!r}")
if not isinstance(record_id, str) or RECORD_ID_RE.fullmatch(record_id) is None:
errors.append(f"{where}: recordId 格式无效")
elif record_id in by_record_id:
errors.append(f"{where}: recordId 重复: {record_id}")
if not isinstance(task_id, str) or (
task_id != GLOBAL_TASK_ID and TASK_ID_RE.fullmatch(task_id) is None
):
errors.append(f"{where}: taskId 格式无效")
if not isinstance(parents, list) or any(
not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None
for parent in parents
):
errors.append(f"{where}: parents 必须是 recordId 字符串列表")
task = record.get("task")
if task_id != GLOBAL_TASK_ID and not isinstance(task, dict):
errors.append(f"{where}: 非全局记录必须包含 task 对象")
if isinstance(record_id, str) and record_id not in by_record_id:
by_record_id[record_id] = record
records.append(record)
for record in records:
record_id = record.get("recordId")
task_id = record.get("taskId")
if not isinstance(record_id, str) or not isinstance(task_id, str):
continue
for parent in record.get("parents", []):
if parent not in by_record_id:
errors.append(f"{record_id}: 引用了不存在的 parent: {parent}")
elif by_record_id[parent].get("taskId") != task_id:
errors.append(f"{record_id}: parent 必须属于同一个 taskId")
task_records: dict[str, list[dict[str, Any]]] = {}
for record in records:
task_id = record.get("taskId")
if isinstance(task_id, str) and task_id != GLOBAL_TASK_ID:
task_records.setdefault(task_id, []).append(record)
tasks: list[dict[str, Any]] = []
for task_id in sorted(task_records):
items = task_records[task_id]
referenced = {
parent
for item in items
for parent in item.get("parents", [])
if isinstance(parent, str)
}
heads = [
item for item in items
if isinstance(item.get("recordId"), str)
and item["recordId"] not in referenced
]
if len(heads) != 1:
errors.append(
f"task {task_id}: 发现 {len(heads)} 个未合并 head,必须创建同时引用全部 head 的合并记录"
)
continue
task = heads[0].get("task")
if isinstance(task, dict):
tasks.append(task)
result = dict(manifest)
result["tasks"] = tasks
result["workerReceipts"] = _unique_collection(records, "workerReceipts", errors)
for field in ("deliveryRuns", "regressionRuns"):
if field in manifest or any(field in record for record in records):
result[field] = _unique_collection(records, field, errors)
result["summary"] = {
"open": [task["id"] for task in tasks if task.get("status") == "open"],
"verified": [task["id"] for task in tasks if task.get("status") == "verified"],
"failedRetest": [task["id"] for task in tasks if task.get("status") == "failed_retest"],
"leftovers": [task["id"] for task in tasks if task.get("status") == "leftover"],
}
if errors:
result["taskStoreErrors"] = errors
else:
result.pop("taskStoreErrors", None)
return result
def is_task_store_manifest(value: object) -> bool:
return isinstance(value, dict) and "taskStore" in value
__all__ = [
"GLOBAL_TASK_ID",
"STORE_VERSION",
"TaskStoreError",
"is_task_store_manifest",
"materialize_board",
"parse_document",
]
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Validate an optional ACK .pouch/ack/agents.yaml routing override."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from validate_tasks import load_document, validate_with_schema
from worker_profiles import validate_orchestration
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 ACK agents.yaml 结构")
parser.add_argument("agents", nargs="?", default="agents.yaml", help="agents.yaml 路径")
parser.add_argument("--schema", help="agents.schema.json 路径(默认自动探测)")
args = parser.parse_args(argv)
agents_path = Path(args.agents)
if not agents_path.is_file() or agents_path.is_symlink():
sys.stderr.write(f"agents.yaml 必须是普通文件且不能是 symlink: {agents_path}\n")
return 2
data = load_document(agents_path)
schema_path = Path(args.schema) if args.schema else (
Path(__file__).resolve().parent.parent / "templates" / "agents.schema.json"
)
errors = validate_orchestration(data, where="agents.yaml")
mode = "内置语义规则"
try:
import jsonschema # type: ignore # noqa: F401
if schema_path.is_file():
errors = validate_with_schema(data, schema_path) + errors
mode = f"schema ({schema_path.name}) + 内置语义规则"
except ImportError:
pass
if errors:
sys.stderr.write(f"agents.yaml 校验失败({mode}),共 {len(errors)} 项:\n")
for error in dict.fromkeys(errors):
sys.stderr.write(f" - {error}\n")
return 1
sys.stdout.write(f"agents.yaml 校验通过({mode}):{agents_path}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -37
View File
@@ -22,15 +22,7 @@ import sys
from pathlib import Path, PurePosixPath
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
@@ -162,36 +154,12 @@ def _reject_unknown(
def _load_document(path: Path, label: str) -> dict[str, Any]:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
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
+5 -37
View File
@@ -21,15 +21,7 @@ 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,
)
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
@@ -75,36 +67,12 @@ def _reject_unknown(
def load_yaml(path: Path, label: str) -> dict[str, Any]:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
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
+45 -40
View File
@@ -25,13 +25,8 @@ import sys
from pathlib import Path
from approval_payload import approval_payload_hash
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
from worker_profiles import validate_orchestration
from worker_profiles import validate_routing_document
STATUS_ENUM = {
@@ -242,38 +237,12 @@ def _nonempty_string(value: object) -> bool:
def load_document(path: Path) -> dict:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"任务板读取失败: {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"JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(
content,
Loader=make_unique_pyyaml_loader(yaml),
)
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write("任务板顶层必须是对象(mapping)\n")
raise SystemExit(1)
return data
@@ -894,8 +863,17 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
return errors
def validate_builtin(data: dict) -> list[str]:
def validate_builtin(
data: dict,
*,
orchestration_override: dict | None = None,
) -> list[str]:
errors: list[str] = []
store_errors = data.get("taskStoreErrors")
if isinstance(store_errors, list):
for error in store_errors:
if isinstance(error, str) and error.strip():
errors.append(f"taskStore: {error}")
project_intake_workflow = "read-only-v1"
def validate_string_fields(
@@ -1044,6 +1022,8 @@ def validate_builtin(data: dict) -> list[str]:
if "ackVersion" in data and version_match is None:
errors.append("ackVersion 必须是合法 SemVer(例如 0.10.0)")
routing_required = (
orchestration_override is not None
or
(isinstance(project, dict) and "orchestration" in project)
or "workerReceipts" in data
or (
@@ -1053,7 +1033,12 @@ def validate_builtin(data: dict) -> list[str]:
)
)
if routing_required:
errors.extend(validate_routing_document(data))
errors.extend(
validate_routing_document(
data,
orchestration_override=orchestration_override,
)
)
if "summary" in data:
summary = data["summary"]
@@ -1415,7 +1400,27 @@ def main(argv: list[str] | None = None) -> int:
sys.stderr.write(f"找不到指定的 schema 文件: {schema_path}\n")
return 2
errors = validate_builtin(data)
agents_path = tasks_path.with_name("agents.yaml")
agents = None
if agents_path.is_symlink():
errors = ["agents.yaml 不能是 symlink"]
agents = None
elif agents_path.is_file():
agents = load_document(agents_path)
if not isinstance(agents, dict):
errors = ["agents.yaml 顶层必须是对象(mapping)"]
agents = None
else:
errors = validate_orchestration(agents, where="agents.yaml")
else:
errors = []
errors.extend(validate_builtin(data, orchestration_override=agents))
if agents is not None:
project = data.get("project")
board_orchestration = project.get("orchestration") if isinstance(project, dict) else None
if board_orchestration is not None:
errors.extend(validate_orchestration(board_orchestration))
mode = "内置语义规则"
try:
import jsonschema # type: ignore # noqa: F401
@@ -13,7 +13,7 @@ import sys
MIGRATION_MESSAGE = (
"ACK v0.10 已停用自由 worker command 校验器;请在 "
".pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
".pouch/ack/agents.yaml(优先)或 .pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
"scripts/launch_worker.py profile-hash|plan|launch。"
)
+101 -32
View File
@@ -16,6 +16,7 @@ Output (single JSON document on stdout):
"status": "running | progress | stall | not-started | unknown",
"stallReason": "<label> | null",
"heartbeatAt": "<value> | null",
"heartbeatAgeSeconds": "<number> | null",
"evidence": "<bounded terminal tail>"
}
@@ -29,6 +30,7 @@ import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
@@ -36,6 +38,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from launch_worker import ( # noqa: E402
LaunchError,
parse_timestamp,
resolve_executable,
run_json,
utc_now,
@@ -53,48 +56,113 @@ WORKING_PATTERN = re.compile(r"(?i)working|•working|running|执行中|正在")
IDLE_TAIL_PATTERN = re.compile(r"(?i)welcome to|type help|fish, the friendly|>\\s*$")
MAX_EVIDENCE_CHARS = 500
HEARTBEAT_INTERVAL_SECONDS = 5 * 60
HEARTBEAT_MAX_AGE_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 3
def classify(tail: str | list[str], heartbeat: object) -> dict[str, object]:
def heartbeat_age_seconds(
heartbeat: object, now: datetime | None = None
) -> tuple[float | None, str | None]:
"""Return heartbeat age and a diagnostic for malformed/stale timestamps."""
if heartbeat is None:
return None, None
try:
heartbeat_at = parse_timestamp(heartbeat, "last_heartbeat_at")
except LaunchError:
return None, "invalid-heartbeat"
current = now if now is not None else utc_now()
age = (current - heartbeat_at).total_seconds()
if age < -60:
return None, "future-heartbeat"
return max(age, 0.0), None
def _result(
*,
status: str,
stall_reason: str | None,
heartbeat: object,
heartbeat_age: float | None,
evidence: str,
) -> dict[str, object]:
return {
"status": status,
"stallReason": stall_reason,
"heartbeatAt": heartbeat,
"heartbeatAgeSeconds": heartbeat_age,
"evidence": evidence[:MAX_EVIDENCE_CHARS],
}
def classify(
tail: str | list[str],
heartbeat: object,
*,
now: datetime | None = None,
) -> dict[str, object]:
if isinstance(tail, list):
tail = "\n".join(tail)
heartbeat_age, heartbeat_error = heartbeat_age_seconds(heartbeat, now)
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],
}
return _result(
status="stall",
stall_reason=label,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if heartbeat_error:
return _result(
status="unknown",
stall_reason=heartbeat_error,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if heartbeat_age is not None and heartbeat_age <= HEARTBEAT_MAX_AGE_SECONDS:
return _result(
status="progress",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if heartbeat_age is not None:
return _result(
status="unknown",
stall_reason="stale-heartbeat",
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if WORKING_PATTERN.search(tail):
return {
"status": "running",
"stallReason": None,
"heartbeatAt": None,
"evidence": tail[:MAX_EVIDENCE_CHARS],
}
return _result(
status="running",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
# No heartbeat and no working marker: the terminal may still be sitting at
# a welcome/idle prompt (task never started) or have unclassified output.
if IDLE_TAIL_PATTERN.search(tail) or not tail.strip():
return {
"status": "not-started",
"stallReason": None,
"heartbeatAt": None,
"evidence": tail[:MAX_EVIDENCE_CHARS],
}
return {
"status": "unknown",
"stallReason": None,
"heartbeatAt": None,
"evidence": tail[:MAX_EVIDENCE_CHARS],
}
return _result(
status="not-started",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
return _result(
status="unknown",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
def main(argv: list[str] | None = None) -> int:
@@ -126,6 +194,7 @@ def main(argv: list[str] | None = None) -> int:
"status": "unknown",
"stallReason": None,
"heartbeatAt": None,
"heartbeatAgeSeconds": None,
"evidence": f"probe failed: {type(exc).__name__}: {exc}",
},
ensure_ascii=False,
+84 -19
View File
@@ -24,14 +24,15 @@ LAUNCH_PROTOCOL_VERSION = 1
MAX_ROUNDS = 3
ROLES = frozenset({"developer", "test"})
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp"})
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp", "pi"})
TIERS = frozenset({"standard", "strong"})
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write", "danger-full-access"})
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"})
CLI_REQUIRES_NULL_REASONING_EFFORT = frozenset({"cursor-agent"})
CLI_ALLOWS_DEFAULT_MODEL = frozenset({"pi"})
def _cli_choice_text() -> str:
@@ -95,7 +96,9 @@ RECEIPT_FIELDS = frozenset(
"receiptHash",
}
)
RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"})
RECEIPT_CONTEXT_FIELDS = frozenset(
{"projectRoot", "boardHash", "routingSource", "routingHash"}
)
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
WORKTREE_FIELDS = frozenset(
{
@@ -247,12 +250,16 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
errors.append(f"{where}.cli: must be {_cli_choice_text()}")
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:
errors.append(f"{where}.model: must be a safe model ID")
if not (isinstance(cli, str) and cli in CLI_ALLOWS_DEFAULT_MODEL and model is None) and (
not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None
):
errors.append(f"{where}.model: must be a safe model ID or null for Pi default")
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
errors.append(
f"{where}.permissionMode: must be read-only/workspace-write"
f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access"
)
elif permission == "danger-full-access" and cli != "codex":
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT:
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
label = "Codex" if cli == "codex" else "Grok"
@@ -265,6 +272,10 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
and effort is not None
):
errors.append(f"{where}.reasoningEffort: Cursor requires null")
elif isinstance(cli, str) and cli == "pi" and (
not isinstance(effort, str) or effort not in REASONING_EFFORTS
):
errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh")
if role == "test" and tier != "standard":
errors.append(f"{where}.tier: Test may only use standard")
@@ -312,15 +323,22 @@ def _validate_model_allowlist(value: Any, where: str) -> list[str]:
if not isinstance(models, list) or not models:
errors.append(f"{tier_where}: must be a non-empty model list")
continue
seen: set[str] = set()
seen: set[object] = set()
for index, model in enumerate(models):
item_where = f"{tier_where}[{index}]"
if not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
errors.append(f"{item_where}: must be a safe model ID")
elif model in seen:
if model is None and cli in CLI_ALLOWS_DEFAULT_MODEL:
model_key: object = ("default",)
elif not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None:
errors.append(
f"{item_where}: must be a safe model ID or null for Pi default"
)
continue
else:
model_key = model
if model_key in seen:
errors.append(f"{item_where}: duplicate model {model!r}")
else:
seen.add(model)
seen.add(model_key)
return errors
@@ -329,7 +347,7 @@ def _allowed_models(
cli: Any,
role: Any,
tier: Any,
) -> list[str] | None:
) -> list[Any] | None:
if not isinstance(allowlist, dict):
return None
roles = allowlist.get(cli)
@@ -519,6 +537,19 @@ def render_worker_argv(
"--no-session",
]
if cli == "pi":
# Pi's omitted --model selects its configured startup default. Its
# cwd is supplied by the trusted bootstrap process, because Pi has no
# --cwd flag. Read-only profiles are restricted to observation tools;
# workspace-write profiles use Pi's normal editing tool set.
argv = [executable]
if model is not None:
argv.extend(["--model", model])
argv.extend(["--thinking", profile["reasoningEffort"]])
if permission == "read-only":
argv.extend(["--tools", "read,grep,find,ls"])
return argv
if cli != "grok":
raise ValueError(f"unsupported cli: {cli}")
grok_permission = "plan" if permission == "read-only" else "acceptEdits"
@@ -597,8 +628,10 @@ def _validate_requested(value: Any, where: str) -> list[str]:
if not isinstance(tier, str) or tier not in TIERS:
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")
if not (isinstance(cli, str) and cli in CLI_ALLOWS_DEFAULT_MODEL and model is None) and (
not isinstance(model, str) or MODEL_ID_RE.fullmatch(model) is None
):
errors.append(f"{where}.model: must be a safe model ID or null for Pi default")
effort = value.get("reasoningEffort")
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT and (
not isinstance(effort, str) or effort not in REASONING_EFFORTS
@@ -611,8 +644,14 @@ def _validate_requested(value: Any, where: str) -> list[str]:
and effort is not None
):
errors.append(f"{where}.reasoningEffort: Cursor requires null")
if isinstance(cli, str) and cli == "pi" and (
not isinstance(effort, str) or effort not in REASONING_EFFORTS
):
errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh")
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
errors.append(f"{where}.permissionMode: must be read-only/workspace-write")
errors.append(f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access")
elif permission == "danger-full-access" and cli != "codex":
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
executable = value.get("executable")
if not _is_absolute_safe_path(executable):
errors.append(f"{where}.executable: must be a safe absolute path")
@@ -697,6 +736,16 @@ def validate_worker_receipt(
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
):
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
routing_source = receipt.get("routingSource")
routing_hash = receipt.get("routingHash")
if (routing_source is None) != (routing_hash is None):
errors.append(f"{where}: routingSource and routingHash must be present together")
if routing_source is not None and not _is_nonempty_text(routing_source, max_length=512):
errors.append(f"{where}.routingSource: must be non-empty single-line text")
if routing_hash is not None and (
not isinstance(routing_hash, str) or SHA256_RE.fullmatch(routing_hash) is None
):
errors.append(f"{where}.routingHash: must be a canonical sha256 hex digest")
version = receipt.get("receiptVersion")
if version != RECEIPT_VERSION or isinstance(version, bool):
@@ -818,6 +867,9 @@ def validate_worker_receipt(
if project_root is not None and board_hash is not None:
facts["projectRoot"] = project_root
facts["boardHash"] = board_hash
if routing_source is not None and routing_hash is not None:
facts["routingSource"] = routing_source
facts["routingHash"] = routing_hash
expected_fingerprint = canonical_sha256(facts)
except ValueError:
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
@@ -1006,17 +1058,30 @@ def _validate_dispatch_links(
return errors
def validate_routing_document(data: Any) -> list[str]:
"""Validate routing and receipts embedded in an ACK tasks document."""
def validate_routing_document(
data: Any,
*,
orchestration_override: dict[str, Any] | None = None,
) -> list[str]:
"""Validate routing and receipts in an ACK tasks document.
``orchestration_override`` is the optional project-local agents.yaml
routing document. The task board remains the state source, while the
override becomes the effective profile source for receipt checks.
"""
if not isinstance(data, dict):
return ["<root>: must be an object"]
project = data.get("project")
if not isinstance(project, dict):
return ["project: must be an object"]
if "orchestration" not in project:
if "orchestration" not in project and orchestration_override is None:
return ["project.orchestration: is required"]
orchestration = project.get("orchestration")
orchestration = (
orchestration_override
if orchestration_override is not None
else project.get("orchestration")
)
errors = validate_orchestration(orchestration)
task_ids: set[str] = set()
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/agents.schema.json",
"title": "ACK project agent routing override",
"description": "Optional .pouch/ack/agents.yaml. It has the same routing shape as project.orchestration.",
"type": "object",
"required": ["profileVersion", "mode", "modelAllowlist", "profiles", "defaults"],
"additionalProperties": false,
"properties": {
"profileVersion": {"type": "integer", "const": 1},
"mode": {"type": "string", "enum": ["orca", "manual"]},
"modelAllowlist": {"$ref": "#/definitions/modelAllowlist"},
"profiles": {
"type": "object",
"propertyNames": {"$ref": "#/definitions/profileId"},
"additionalProperties": {"$ref": "#/definitions/workerProfile"}
},
"defaults": {"$ref": "#/definitions/defaults"}
},
"definitions": {
"profileId": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"},
"model": {
"oneOf": [
{"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"},
{"type": "null", "description": "仅 pi 使用 null 表示默认模型"}
]
},
"modelList": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/definitions/model"}},
"modelTierAllowlist": {
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"standard": {"$ref": "#/definitions/modelList"},
"strong": {"$ref": "#/definitions/modelList"}
}
},
"modelRoleAllowlist": {
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"developer": {"$ref": "#/definitions/modelTierAllowlist"},
"test": {"$ref": "#/definitions/modelTierAllowlist"}
}
},
"modelAllowlist": {
"type": "object",
"additionalProperties": false,
"properties": {
"codex": {"$ref": "#/definitions/modelRoleAllowlist"},
"cursor-agent": {"$ref": "#/definitions/modelRoleAllowlist"},
"grok": {"$ref": "#/definitions/modelRoleAllowlist"},
"omp": {"$ref": "#/definitions/modelRoleAllowlist"},
"pi": {"$ref": "#/definitions/modelRoleAllowlist"}
}
},
"workerProfile": {
"type": "object",
"required": ["role", "cli", "tier", "model", "reasoningEffort", "permissionMode"],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"role": {"type": "string", "enum": ["developer", "test"]},
"cli": {"type": "string", "enum": ["codex", "cursor-agent", "grok", "omp", "pi"]},
"tier": {"type": "string", "enum": ["standard", "strong"]},
"model": {"$ref": "#/definitions/model"},
"reasoningEffort": {"type": ["string", "null"], "enum": ["low", "medium", "high", "xhigh", null]},
"permissionMode": {"type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"]}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
"required": ["developer", "test"],
"properties": {
"developer": {"$ref": "#/definitions/profileId"},
"test": {"$ref": "#/definitions/profileId"},
"developerUpgraded": {"$ref": "#/definitions/profileId"}
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
# Optional project-local worker routing override.
# Save as .pouch/ack/agents.yaml when a temporary role/CLI/model selection is needed.
# This file intentionally has the same shape as tasks.yaml.project.orchestration.
profileVersion: 1
mode: "orca"
modelAllowlist:
pi:
developer:
standard: [null]
strong: [null]
cursor-agent:
test:
standard: ["auto"]
profiles:
pi-dev-standard:
role: "developer"
cli: "pi"
tier: "standard"
model: null
reasoningEffort: "medium"
permissionMode: "workspace-write"
pi-dev-strong:
role: "developer"
cli: "pi"
tier: "strong"
model: null
reasoningEffort: "high"
permissionMode: "workspace-write"
cursor-test-standard:
role: "test"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
defaults:
developer: "pi-dev-standard"
test: "cursor-test-standard"
developerUpgraded: "pi-dev-strong"
+10 -7
View File
@@ -8,8 +8,8 @@
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的
> `delivery.yaml` 与空的 `regression.yaml`,
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、默认关闭的
> `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖,
> 不复制或链接 Skill。
## 项目概览
@@ -18,10 +18,12 @@
- 技术栈:`<tech_stack>`
- 运行命令:`<run_command>`
- Base URL:`<base_url>`
- 任务板:`.pouch/ack/tasks.yaml`
- 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`(追加式)
- 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
- 回归目录:`.pouch/ack/regression.yaml`
- 角色启动覆盖(可选):`.pouch/ack/agents.yaml`
- 覆盖层文件:`<overlay_file_path>`(默认 `.pouch/ack/project.md`)
## 通用规范(由 ACK Skill 按需读取)
@@ -39,8 +41,8 @@
## Worker 路由
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
receipt 全部以 `.pouch/ack/tasks.yaml` 的 `project.orchestration` 与顶层
`workerReceipts` 为准。本文件不保存可执行 worker 命令。
receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml` 的
`project.orchestration` 与顶层 `workerReceipts` 为准。本文件不保存可执行 worker 命令。
默认 profile:
@@ -67,7 +69,8 @@ receipt 全部以 `.pouch/ack/tasks.yaml` 的 `project.orchestration` 与顶层
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
@@ -116,7 +119,7 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`;
- 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`;
Developer 与 Test 都只读,通过消息回报。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。
+72 -10
View File
@@ -115,6 +115,25 @@
"statusReference": {
"type": "object"
},
"taskStore": {
"type": "object",
"required": [
"version",
"directory"
],
"properties": {
"version": {
"type": "integer",
"const": 1
},
"directory": {
"type": "string",
"minLength": 1,
"description": "相对 tasks.yaml 的不可变任务记录目录"
}
},
"additionalProperties": true
},
"workerReceipts": {
"type": "array",
"items": {
@@ -591,8 +610,16 @@
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
"oneOf": [
{
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
},
{
"type": "null",
"description": "仅 Pi 使用 null 表示其配置的默认模型"
}
]
}
},
"modelTierAllowlist": {
@@ -633,6 +660,12 @@
},
"grok": {
"$ref": "#/definitions/modelRoleAllowlist"
},
"omp": {
"$ref": "#/definitions/modelRoleAllowlist"
},
"pi": {
"$ref": "#/definitions/modelRoleAllowlist"
}
}
},
@@ -647,6 +680,7 @@
"permissionMode"
],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"role": {
"type": "string",
@@ -661,7 +695,8 @@
"codex",
"cursor-agent",
"grok",
"omp"
"omp",
"pi"
]
},
"tier": {
@@ -672,8 +707,16 @@
]
},
"model": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
"oneOf": [
{
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
},
{
"type": "null",
"description": "仅 Pi 使用 null 表示其配置的默认模型"
}
]
},
"reasoningEffort": {
"type": [
@@ -692,7 +735,8 @@
"type": "string",
"enum": [
"read-only",
"workspace-write"
"workspace-write",
"danger-full-access"
]
}
}
@@ -853,6 +897,7 @@
"environmentPolicy"
],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"cli": {
"type": "string",
@@ -860,7 +905,8 @@
"codex",
"cursor-agent",
"grok",
"omp"
"omp",
"pi"
]
},
"tier": {
@@ -871,8 +917,16 @@
]
},
"model": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
"oneOf": [
{
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$"
},
{
"type": "null",
"description": "仅 Pi 使用 null 表示其配置的默认模型"
}
]
},
"reasoningEffort": {
"type": [
@@ -891,7 +945,8 @@
"type": "string",
"enum": [
"read-only",
"workspace-write"
"workspace-write",
"danger-full-access"
]
},
"executable": {
@@ -1018,6 +1073,13 @@
"boardHash": {
"$ref": "#/definitions/sha256"
},
"routingSource": {
"type": "string",
"minLength": 1
},
"routingHash": {
"$ref": "#/definitions/sha256"
},
"slot": {
"type": "integer",
"minimum": 1,
+5
View File
@@ -3,6 +3,10 @@ version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
source: "Coordinator (PM) Agent"
ackVersion: "<接入时的 ack skill 版本>"
# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。
taskStore:
version: 1
directory: "task-records"
project:
name: "<project_name>"
baseUrl: "<base_url>"
@@ -29,6 +33,7 @@ project:
# attachments: "<field_id>"
# updatedAt: "<field_id>"
orchestration:
# 可选:临时角色路由写到 .pouch/ack/agents.yaml;存在时由 launcher 优先读取。
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
profileVersion: 1
+38
View File
@@ -0,0 +1,38 @@
# agent-runtime
统一维护项目的 Agent profile、Orca 执行环境、worktree 和跨 session terminal。它让
`builder.android`、`builder.ios` 等 binding 成为可配置路由,而不是写死在 skill 代码里的
枚举。
## 什么时候使用
- 给不同 builder/tester/ACK 绑定不同机器、项目目录、Agent CLI 或模型。
- 创建远程 Mac/本地 worktree,并需要后续 session 继续查看构建。
- 检查 `.pouch/agent.yaml` 是否能被所有 adapter 安全读取。
## 使用前准备
- 项目根已有 `.pouch/agent.yaml`。
- 远程 environment 已通过 Orca 配对,且 project/worktree 已存在或被明确创建。
- 机器路径、project id/name 和模型信息由使用者确认;配置不包含凭据。
## 使用示例
```text
把 builder.android 绑定到本地 app,把 builder.ios 绑定到 macmini,创建 iOS worktree,
然后让 builder 继续使用同一个 session 构建。
```
## Agent 会做什么
1. 校验并解析通用配置。
2. 按 environment、host、project、绝对 path 精确定位 Orca worktree。
3. 按需要创建持久 terminal,并记录可显式恢复的 run receipt。
不会自动复用旧 terminal、执行配置中的自由 shell、读取 secrets 或上传产物。
## 如何判断完成
- `agent.py validate` 通过。
- 每个 binding 能唯一解析到目标 worktree,或明确报告需创建/登记的环境。
- session 可用 run id 查询、attach 和 stop。
+75
View File
@@ -0,0 +1,75 @@
---
name: agent-runtime
description: >-
维护项目通用 Agent 运行配置、环境绑定、Orca worktree 与跨 session terminal;
适用于 builder.android、builder.ios、tester.test、ack.dev 等可扩展绑定。
不负责具体构建、测试或 ACK 闭环。
---
# Agent Runtime:统一环境、Agent 与工作区
本 skill 是 builder、tester、ACK 和 iOS tester 共用的配置层。每个 Git 项目在自己的
根目录维护 `.pouch/agent.yaml`;父仓库与 submodule 各自拥有配置,不跨仓库继承。机器
专属覆盖放 `.pouch/agent.local.yaml`,运行记录放 `.pouch/agent/runs/`,两者都不提交。
## 配置模型
- `locations`:执行环境、Orca project、目标机器上的绝对路径;不放模型和命令。
- `profiles`:CLI、模型、reasoning effort、权限和档位;不放路径和 secrets。
- `bindings`:任意安全的 `namespace.name`,绑定一个 adapter、location、profile 和
adapter 目标。`builder.android`、`builder.ios`、`builder.harmony` 不需要修改代码即可并存。
- `adapter` 目前允许 `builder`、`tester`、`ack`。adapter 的具体执行规则仍由对应 skill
负责;本层只提供解析、worktree 和 session 生命周期。
- `reasoningEffort` 是规范字段;不要新增 `effect`、自由 `command`、`argv`、`shell`、
`env` 或 token 字段。
完整字段、合并和安全规则见 [references/config.md](references/config.md)。需要实际解析或
创建工作区时使用 `scripts/agent.py`,不要手工拼接 Orca JSON:
```bash
python3 <skill-dir>/scripts/agent.py validate --root <project-root>
python3 <skill-dir>/scripts/agent.py show --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py worktree resolve --root <project-root> --binding builder.ios
python3 <skill-dir>/scripts/agent.py worktree create --root <project-root> \
--binding builder.ios --name writes-ios --base main
```
## Session
`builder` 绑定可通过统一入口创建持久 terminal;返回的 run 记录包含 binding、profile
hash、环境、worktree、terminal handle 和 source commit。跨 session 只能显式 `status`、
`attach`、`stop` 或 `resume`,不能自动复用不明 terminal。tester 的每轮 terminal 仍然
是临时的,ACK 仍然必须走其带 fingerprint/receipt 的 launcher。
```bash
python3 <skill-dir>/scripts/agent.py run start --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py run status --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run attach --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run resume --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run stop --root <project-root> --run <run-id>
```
`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill
适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。
tester 的 test-preflight 一次性节点入口使用 JSON stdin/stdout:
```bash
printf '%s\n' '{"binding":"tester.android","operation":"platform_probe","platform":"android","node":"local"}' \
| python3 <skill-dir>/scripts/agent.py run exec --root <project-root>
```
入口只接受 `platform_probe`、`network_probe`、`install`、`start` 四种受控操作,命令
由 adapter 固定生成,不读取配置中的 command/argv/shell/env/token。local binding 可执行
固定的只读平台/网络探针和 test-preflight 已授权的 Android 安装/启动;非 local binding
先按 `environment + host + project + path` 解析 Orca worktree,再通过 Orca 的临时 terminal
执行固定的节点 runner,并从协议标记读取一个 JSON 结果,结束后关闭该 terminal。远程 iOS
的安装/启动要求调用方把产物放在 Mac 节点可读路径,并在 matrix 的 `artifact.remote_path`
中声明;协调机上的本地路径不会被错误地当成 Mac 路径。任何 transport、节点工具链或远端
产物缺失都返回结构化 `environment-fail`,绝不在协调节点执行平台命令或伪造 `ready`。
## 验证
- `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。
- remote binding 必须能按 `environment + host + path + project` 精确找到 Orca worktree。
- 新 binding 只增加配置和对应 adapter 支持,不复制一套 skill 或修改固定枚举。
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Agent Runtime"
short_description: "统一 Agent 环境、worktree、profile 与跨 session 运行配置"
default_prompt: "Use $agent-runtime to validate shared agent config, resolve or create a worktree, or inspect a persistent session."
+73
View File
@@ -0,0 +1,73 @@
# Agent Runtime 配置
## 文件与合并
提交 `.pouch/agent.yaml`。若存在 `.pouch/agent.local.yaml`,按 mapping 深度合并;local
文件只能覆盖同名字段,不能增加未声明的顶层结构。合并后的配置必须再次完整校验。
```yaml
schemaVersion: 1
locations:
local-app:
environment: local
host: local
project: git:git.yumee.top/bloggarden/app
path: /home/ace/code/draft2/blogger/app
remote-ios:
environment: macmini
host: runtime:14b6d8ce-4528-49f4-8d46-56b266a5ad69
project: git:git.yumee.top/bloggarden/app
path: /Users/laily/workrepo/bloggarden-app
profiles:
android-builder:
role: build
cli: codex
tier: standard
model: gpt-5
reasoningEffort: medium
permissionMode: workspace-write
ios-builder:
role: build
cli: codex
tier: strong
model: gpt-5
reasoningEffort: high
permissionMode: workspace-write
bindings:
builder.android:
adapter: builder
location: local-app
profile: android-builder
makefile: makefile.builder
target: android
session:
lifecycle: persistent
builder.ios:
adapter: builder
location: remote-ios
profile: ios-builder
makefile: makefile.builder
target: ios
session:
lifecycle: persistent
```
## 字段原则
- binding、location、profile ID 只允许小写字母、数字和 `-`,binding 用一个或多个点
分隔;ID 是路由名,不是任意 shell 文本。
- `environment: local` 使用当前 Orca runtime;远程环境使用已保存的名称。`host` 必须
是 `local` 或 `runtime:<environment-id>`,由 Orca 查询结果证明,不能凭主机名猜测。
- `project` 必须是 Orca project id 或精确 display name;`path` 必须是目标机器上的绝对
路径。解析 worktree 时四者必须同时匹配,标题、分支和 basename 不能消歧。
- `profile` 的 CLI 只能来自 ACK/tester 共同 allowlist:`codex`、`cursor-agent`、`grok`、
`omp`、`pi`。`model` 为安全模型 ID 或 null;`reasoningEffort` 为 `low|medium|high|xhigh`
或 null;权限只允许 `read-only|workspace-write`。
- builder 的 `target` 只能是 make target 名;`makefile` 只能是项目内相对文件名。脚本、
远端命令、环境变量和凭据由 builder/Orca adapter 提供,不能写入此配置。
- `session.lifecycle` 为 `persistent` 或 `ephemeral`。persistent 只表示允许显式跨
session attach/resume,不表示自动复用旧 terminal。
旧版 `.pouch/tester/local.yaml` 和 `.pouch/ack/agents.yaml` 继续兼容。迁移时应让它们
由 adapter 从 common profile/location 生成或覆盖,不能删除旧 receipt、fingerprint 或
tester 的每轮 terminal 清理语义。
+1188
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import base64
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "agent.py"
SPEC = importlib.util.spec_from_file_location("agent_runtime_runner", SCRIPT)
assert SPEC and SPEC.loader
agent = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(agent)
class RunnerTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
pouch = self.root / ".pouch"
pouch.mkdir()
(pouch / "agent.yaml").write_text(
"""schemaVersion: 1
locations:
local-app:
environment: local
host: local
project: local-project
path: /tmp/writes
remote-mac:
environment: macmini
host: runtime:0123456789abcdef0123
project: remote-project
path: /Users/tester/writes
profiles:
test-agent:
cli: codex
model: null
reasoningEffort: medium
permissionMode: workspace-write
bindings:
tester.android:
adapter: tester
location: local-app
profile: test-agent
tester.ios:
adapter: tester
location: remote-mac
profile: test-agent
""",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp.cleanup()
def test_local_binding_routes_to_fixed_probe(self):
def command(argv, **_kwargs):
if argv == ["adb", "version"]:
return 0, "Android Debug Bridge version 1"
if argv == ["adb", "devices"]:
return 0, "List of devices attached\nemulator-5554\tdevice\n"
self.assertEqual(argv, ["adb", "-s", "emulator-5554", "shell", "getprop", "sys.boot_completed"])
return 0, "1"
with mock.patch.object(agent, "run_fixed_command", side_effect=command) as runner:
result = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "platform_probe", "platform": "android", "node": "local",
})
self.assertEqual(result["status"], "ready")
self.assertEqual(set(result["checks"]), {"adb", "device", "boot"})
self.assertEqual(runner.call_count, 3)
def test_remote_binding_uses_orca_terminal_transport(self):
marker = "POUCH_RUNNER_RESULT_" + ("0" * 31) + "1"
payload = {"status": "ready", "observed": "Darwin/xcrun/simctl probe completed", "checks": {"macos": {"status": "ready"}}}
def fake_orca(argv, _environment=None):
if argv[:2] == ["terminal", "create"]:
return {"terminal": {"handle": "term-1"}}
if argv[:2] == ["terminal", "read"]:
return {"terminal": {"tail": [marker + base64.b64encode(json.dumps(payload).encode()).decode()]}}
return {}
with mock.patch.object(agent, "worktree_for", return_value={"id": "wt-1", "path": "/Users/tester/writes"}) as resolve, \
mock.patch.object(agent, "orca_json", side_effect=fake_orca), \
mock.patch.object(agent.uuid, "uuid4", return_value=mock.Mock(hex="0" * 31 + "1")):
result = agent.execute_runner_request(self.root, {
"binding": "tester.ios", "operation": "platform_probe", "platform": "ios", "node": "macmini",
})
self.assertEqual(result["status"], "ready")
self.assertEqual(result["checks"]["binding"]["status"], "ready")
resolve.assert_called_once()
def test_remote_transport_submits_startup_command_when_first_read_has_no_marker(self):
marker = "POUCH_RUNNER_RESULT_" + ("0" * 31) + "1"
payload = {"status": "ready", "observed": "Darwin/xcrun/simctl probe completed", "checks": {"macos": {"status": "ready"}}}
reads = [
{"terminal": {"tail": ["Welcome to fish"]}},
{"terminal": {"tail": [marker + base64.b64encode(json.dumps(payload).encode()).decode()]}},
]
def fake_orca(argv, _environment=None):
if argv[:2] == ["terminal", "create"]:
return {"terminal": {"handle": "term-1"}}
if argv[:2] == ["terminal", "read"]:
return reads.pop(0)
return {}
with mock.patch.object(agent, "worktree_for", return_value={"id": "wt-1", "path": "/Users/tester/writes"}), \
mock.patch.object(agent, "orca_json", side_effect=fake_orca) as orca, \
mock.patch.object(agent.uuid, "uuid4", return_value=mock.Mock(hex="0" * 31 + "1")):
result = agent.execute_runner_request(self.root, {
"binding": "tester.ios", "operation": "platform_probe", "platform": "ios", "node": "macmini",
})
self.assertEqual(result["status"], "ready")
self.assertTrue(any(call.args[0][:2] == ["terminal", "send"] for call in orca.call_args_list))
def test_unknown_operation_and_command_failure_are_structured(self):
unknown = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "exec", "platform": "android",
})
self.assertEqual(unknown["status"], "blocked")
with mock.patch.object(agent, "run_fixed_command", return_value=(127, "adb missing")):
failed = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "platform_probe", "platform": "android",
})
self.assertEqual(failed["status"], "environment-fail")
def test_json_stdout_is_single_protocol_object(self):
process = subprocess.run(
[sys.executable, str(SCRIPT), "run", "exec", "--root", str(self.root)],
input=json.dumps({"binding": "tester.android", "operation": "unknown", "platform": "android"}),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(process.returncode, 0)
self.assertEqual(json.loads(process.stdout)["status"], "blocked")
self.assertEqual(process.stderr, "")
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
# android-tester
在 Android 真机或 Debian Linux Android 模拟器上执行测试;每次先检查环境,最后生成可追溯的测试结果。
## 什么时候使用
- 「检查 Android 真机测试环境」
- 「检查 Debian Android 模拟器环境」
- 「用真机跑 Android smoke」
- 「用 AVD 跑 Android full」
- 「安装 Debian 上的 Android 模拟器」
- 「查看本次 Android 测试结果」
## 使用前准备
- 项目已有 `.pouch/tester/` 和测试用例。
- 真机:开启 USB 调试并在设备上授权当前电脑。
- 模拟器:准备支持虚拟化的 Debian amd64 主机;首次使用可让 skill 安装 KVM、SDK
Command-line Tools、Platform-Tools、Emulator 和指定系统镜像。
- 需要安装或启动应用时,提供 APK、包名和启动组件,或提供项目自己的测试命令。
- 安装模式会使用 sudo、apt 和用户组变更;运行前会先展示变更范围。
## 使用示例
```text
检查 Android 真机环境,然后跑 smoke 测试
```
```text
在 Debian 上安装 Android 模拟器,使用项目需要的 Android API 创建 AVD
```
```text
用 Pixel_API_35 模拟器跑 Android full,失败时保留 logcat 和截图
```
## Agent 会做什么
1. 明确选择真机或模拟器,不在两者之间自动切换。
2. 先检查 ADB、KVM、AVD、设备授权、启动状态和项目测试配置。
3. 模拟器路径可在 Debian amd64 上安装主机依赖和 Android SDK 模拟器组件。
4. 执行 `.pouch/tester/` 中命中的 Android 用例。
5. 生成 YAML 运行快照、CSV 成绩单,失败时附带 logcat 和截图。
安装、安装 APK、清数据和修改设备设置都属于有副作用的操作,必须得到明确授权。
## 如何判断完成
- 环境检查有明确的通过项和阻塞项。
- 每条用例都有 `pass`、`fail`、`environment-fail`、`skipped` 或 `not-run` 结果。
- 运行记录位于 `.pouch/tester/runs/`,且 YAML 与 CSV 的 run id 一致。
+56
View File
@@ -0,0 +1,56 @@
---
name: android-tester
description: >-
检查并运行 Android 真机或模拟器测试;支持 Debian amd64 上安装
Android Emulator、KVM 依赖、SDK 工具和 AVD。触发词:Android 测试、安卓测试、
真机测试、Android 模拟器、AVD、ADB、安装安卓模拟器、检查 Android 环境。
---
# Android Tester
项目根是 `git rev-parse --show-toplevel`。本 skill 复用项目已有的
`.pouch/tester/` 用例和运行记录,不创建第二套测试目录。
## 模式路由
- 用户要求安装或配置 Android 模拟器:先读 `references/install-debian.md`。
- 用户要求检查环境:先读 `references/environment.md`,再按目标读 `device.md` 或
`emulator.md`。
- 用户要求用真机测试:目标固定为 `device`,读 `device.md`。
- 用户要求用模拟器测试:目标固定为 `emulator`,读 `emulator.md`。
- 用户要求报告结果或复盘失败:读 `references/reporting.md`。
## 全局硬规则
- 每次运行测试前必须重新检查环境;检查失败记为 `environment-fail`,不伪装成产品失败。
- 真机与模拟器必须由用户明确选择,禁止自动 fallback。
- 运行范围和结果语义遵循 `tester`:默认 smoke、默认 readonly,使用
`pass/fail/environment-fail/skipped/not-run`。
- 不自动构建 APK;只使用项目提供的测试命令或已有 APK。
- 安装 APK、清除数据、授予权限、修改设备设置属于 mutating,必须有明确授权。
- 不自动清空真机、重启真机、创建或删除未知 AVD。
- Debian 安装模式只接受 `ID=debian` 且 `amd64/x86_64`;Ubuntu、其他发行版、ARM
主机必须停止并说明不在第一版范围内。
- 没有 `.pouch/tester/catalog.yaml` 时,不静默初始化,提示用户先初始化 `tester`。
## 运行顺序
1. 解析测试范围和目标模式。
2. 检查 `.pouch/tester/`、本地配置和工具链。
3. 检查真机或模拟器目标是否可用。
4. 必要时只启动本轮自己创建的模拟器会话。
5. 按 tester 用例顺序执行测试。
6. 失败时采集 logcat 和截图,生成 YAML/CSV 运行记录。
7. 按 `references/reporting.md` 输出结果,并执行目标会话的安全收尾。
## 安装安全边界
安装模式会运行 `apt`、修改用户组、下载 Android SDK 文件并可能创建 AVD。只有用户
明确要求安装时才执行;执行前先列出包、下载目录、SDK 镜像和需要重新登录的事项。
不要把 sudo 密码、账号或第三方 token 写入文件或输出。
## 验证
- `pouch check android-tester` 通过。
- Debian 安装后 `kvm-ok` 和 `emulator -accel-check` 通过。
- 真机和模拟器各完成一条 readonly smoke 用例,并分别生成运行快照和成绩单。
@@ -0,0 +1,34 @@
# Android 真机路径
## 环境检查
按顺序执行并记录:
```bash
adb start-server
adb devices -l
adb -s <serial> shell getprop ro.product.model
adb -s <serial> shell getprop ro.build.version.sdk
adb -s <serial> shell getprop sys.boot_completed
```
规则:
- 没有设备:`environment-fail`。
- `unauthorized`:提示用户在手机上授权,不自动绕过。
- `offline`:停止,不把它当作产品失败。
- 有多台设备但没有 `ANDROID_SERIAL`:停止,不猜设备。
- `sys.boot_completed` 不是 `1`:等待有限时间,超时记 `environment-fail`。
## 执行
- 复用用户指定序列号,整轮测试不切换设备。
- 只执行选中的 `driver: mobile` 用例。
- 不自动安装 APK;安装必须有 APK、包名和明确的 mutating 授权。
- 测试失败时收集目标设备的 logcat 尾部和当前屏幕截图。
## 收尾
- 不关机、不重启、不清除应用数据。
- 停止由本轮启动的 ADB server 之前要确认没有其他测试会话依赖它。
- 设备保留原状态,并在结果中记录。
@@ -0,0 +1,33 @@
# Android 模拟器路径
## 环境检查
执行:
```bash
emulator -accel-check
emulator -list-avds
```
必须同时满足:
- KVM 可用。
- 指定 AVD 存在。
- AVD 的 system image 与 x86_64 主机匹配。
- 没有被 VirtualBox、VMware 或 Docker 等外层虚拟化阻断。
## 启动与等待
- 如果目标 AVD 已运行,检查序列号和 AVD 名称后复用。
- 如果没有运行,启动用户指定的 AVD,不创建替代 AVD。
- 使用 ADB 等待设备出现,并等待 `sys.boot_completed=1`。
- 启动失败、启动超时或 KVM 失效都记为 `environment-fail`。
- 无桌面服务器时可使用 `-no-window -no-audio`,但必须先确认测试不需要可视化交互。
## 执行与收尾
- 一轮测试只绑定一个 AVD。
- 需要安装 APK 或清数据时按 mutating 规则处理。
- 测试失败时保存 logcat、模拟器信息和截图。
- 只关闭本轮由 skill 启动且配置为临时会话的模拟器。
- 不删除 AVD、不修改系统镜像、不覆盖用户已有配置。
@@ -0,0 +1,45 @@
# Android 通用环境检查
本文件只读检查,不安装软件、不修改设备。
## 项目检查
1. 确认项目根目录。
2. 确认 `.pouch/tester/catalog.yaml` 存在且 `validate` 通过。
3. 只选择 `driver: mobile` 的用例。
4. 读取 `.pouch/tester/local.yaml`;缺少 Android 目标配置时停止,不猜目标。
推荐的本地配置字段:
```yaml
ANDROID_TARGET_MODE: device
ANDROID_SERIAL: ""
ANDROID_AVD: ""
ANDROID_PACKAGE: ""
ANDROID_APK: ""
ANDROID_ACTIVITY: ""
```
## 主机检查
- `uname -m` 必须是 `x86_64`。
- `adb` 必须可执行。
- 模拟器路径还必须有 `emulator`、`sdkmanager` 和 KVM。
- 记录 Android SDK 根目录、工具版本和当前用户。
未安装工具属于 `environment-fail`。不要在普通测试运行中顺手安装;安装只能进入
明确的 Debian 安装模式。
## 运行前报告
先报告:
```text
目标: 真机 | 模拟器
项目测试目录: 已找到 | 缺失
ADB: 通过 | 缺失
模拟器工具链: 通过 | 缺失 | 不适用
KVM: 通过 | 缺失 | 不适用
目标设备: 已解析 | 未解析
结论: 可执行 | environment-fail
```
@@ -0,0 +1,95 @@
# Debian 安装 Android 模拟器
只支持 Debian amd64/x86_64。不要在 Ubuntu、其他发行版、ARM 主机或嵌套虚拟机中执行
本流程。Android 官方 Linux 模拟器依赖 KVM;Android SDK Command-line Tools 提供
`sdkmanager`,Emulator 和 Platform-Tools 作为 SDK 包安装。
## 1. 检查并确认变更
读取 `/etc/os-release`,必须满足:
```bash
test "$(. /etc/os-release && printf '%s' "$ID")" = debian
test "$(dpkg --print-architecture)" = amd64
```
安装前向用户展示:
- 将通过 apt 安装的 Debian 包。
- Android SDK 目录,默认 `~/Android/Sdk`。
- 要下载的 Emulator、Platform-Tools、Command-line Tools 和 system image。
- 会把当前用户加入 `kvm`、`libvirt` 组,通常需要重新登录。
- 是否创建 AVD,以及 AVD 名称、API、镜像和设备 profile。
没有明确安装请求或用户未确认这些变更时,只输出检查结果,不执行 sudo、apt 或下载。
## 2. 安装 Debian 主机依赖
在用户确认后安装:
```bash
sudo apt update
sudo apt install cpu-checker qemu-kvm libvirt-daemon-system \
libvirt-clients bridge-utils curl ca-certificates unzip
sudo usermod -aG kvm,libvirt "$USER"
```
检查:
```bash
kvm-ok
ls -l /dev/kvm
```
若没有硬件虚拟化或 `/dev/kvm`,停止并报告安装失败。不要降级为极慢的软件模拟。
## 3. 安装 Android SDK 工具
优先使用 Android 官方 Linux Command-line Tools。若机器已有 `sdkmanager`,复用现有
SDK 根目录;否则从 Android 官方下载页获取 Linux Command-line Tools,解压为:
```text
~/Android/Sdk/cmdline-tools/latest/
```
安装并接受 SDK 许可后,执行:
```bash
sdkmanager --install \
"platform-tools" \
"emulator" \
"cmdline-tools;latest"
```
系统镜像由项目的 compile/target SDK 和用户明确选择的 API 决定。安装前先用
`sdkmanager --list` 查找可用的 `google_apis;x86_64` 镜像,不猜一个不可用的版本。
## 4. 创建 AVD
只有用户明确要求创建 AVD 时执行。优先使用当前 SDK 提供的 Android CLI;兼容环境可
使用 `avdmanager` 创建一个明确命名、明确 API 和明确 x86_64 镜像的 AVD。创建后运行:
```bash
emulator -list-avds
emulator -accel-check
emulator @<已确认的AVD名称>
```
`<已确认的AVD名称>` 只表示用户已经选择的实际名称,不允许由 skill 静默猜测。
## 5. 安装完成报告
报告必须包含:
- Debian 版本和架构。
- apt 安装包及结果。
- SDK 根目录和工具版本。
- 已安装的 system image。
- AVD 名称和配置。
- KVM 检查结果。
- 是否需要重新登录。
- 下一步是运行模拟器检查,还是直接执行测试。
安装 Android Studio 不是本模式的必要条件;该流程安装的是可由命令行运行的 Emulator
工具链。参考:[Android 安装文档](https://developer.android.com/studio/install)、
[KVM 加速文档](https://developer.android.com/studio/run/emulator-acceleration)。
@@ -0,0 +1,29 @@
# Android 结果报告
每轮运行都生成 `.pouch/tester/runs/<run-id>.yaml` 和同名 `.csv`。附加证据放在同名
目录中,不覆盖已有 run。
对话报告格式:
```text
## android-tester 运行:完成 | 部分完成 | 阻塞
路径: 真机 | 模拟器
目标: 序列号/型号/API 或 AVD/API
环境: 通过项;阻塞项
计数: pass=N fail=N environment-fail=N skipped=N not-run=N
失败: 用例 id + 一句证据
跳过: 用例 id + 原因
证据: logcat / 截图目录
快照: .pouch/tester/runs/<run-id>.yaml
成绩单: .pouch/tester/runs/<run-id>.csv
下一步: 一句话
```
判定规则:
- 工具缺失、设备不可用、启动超时:`environment-fail`。
- 产品断言不满足:`fail`。
- 风险未授权、用例主动不跑:`skipped`。
- 失败即停后没有轮到的用例:`not-run`。
- 不能把 ADB 命令退出码为 0 直接当成产品通过,必须对照用例的 `expected`。
+24
View File
@@ -0,0 +1,24 @@
# bugfix-ship(已弃用)
`bugfix-ship` 已并入 [ship](../ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`ship/`。命中后按 **short 模式** 执行。
## 使用示例
```text
使用 bugfix-ship,修复这个线上回归并部署到测试环境。
```
Agent 命中后会立刻转到 `ship`,按 short 模式执行。新请求建议直接用 `ship`。
## 迁移
```bash
pouch add ship # 新名,当前项目
pouch add ship -g # 新名,全局
```
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `bugfix-ship` 可以分批改成 `ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+19
View File
@@ -0,0 +1,19 @@
---
name: bugfix-ship
description: >-
【已弃用,兼容占位】bugfix-ship 已并入 ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../ship/SKILL.md 并按其执行;本次按 short 模式。小需求、小改动、
修 bug、改文案、加按钮、修复后发 PR 或部署都属于 ship。
---
# bugfix-ship(已弃用)
本 skill 已并入 [ship](../ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
- 用户提到 `bugfix-ship`、`/bugfix-ship` 或 `$bugfix-ship` 时:读取
[../ship/SKILL.md](../ship/SKILL.md),按其内容与授权边界执行,并把本次 `kind`
定为 `short`。不要在本目录里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `bugfix-ship` 也能正常安装,软链不会悬空;方便时改成
`ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
+135 -17
View File
@@ -1,52 +1,134 @@
# builder
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布,
或把产物拷到目录 / 容器。规范本体见
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
可执行校验器。
构建执行位置使用共享 `.pouch/agent.yaml` 的任意 `builder.<name>` binding 配置;本地
Android、远程 Mac iOS 或未来其它平台都可以各自绑定 environment/project/path/profile。
## 什么时候使用
- "用 builder 初始化这个项目"
- "帮我构建这个项目的 DEB / Docker 镜像"
- "把 1.2.3 发布到包仓库 / 镜像仓库"
- "帮我构建这个项目的 DEB / Docker 镜像 / APK"
- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
- "看看项目现在的发布流程"
- "看这个 SHA 的 CI 构建 / obtain"
只构建不上传时明确说明即可;上传永远需要你显式授权。
## 安装
```bash
pouch add builder # 当前项目
pouch add builder -g # 全局
```
不要 `pouch init builder`。对新项目说「用 builder 初始化」。
## 使用前准备
- Python 3 和 `make`。
- DEB 轨道需要 `dpkg-deb`;Docker 轨道需要 Docker 和 buildx。
- 发布时在项目根 `.env.builder` 填写仓库地址和 token(不要写进 `.env`,也不要进 git)。
- 正式版本号由 [releaser](../releaser/README.md) 打 annotated tag;builder 只读当前 HEAD。
- 脏工作树默认拒绝发布。
## 项目接入契约
对新项目说「用 builder 初始化」。Agent 会探测轨道、按
`templates/makefile.builder` 写出项目根 `makefile.builder`,并列出缺的发布配置。
不改用户已有的 `Makefile` 或 `.env`。
配置跟随 `makefile.builder` 所在的 Git 根。如果 APK/IPA 位于 Git submodule,
就在 submodule 内配置 `.env.builder` 和 `.pouch/builder/`;父仓库只固定 submodule commit
并负责上层编排。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE/CI_STATUS_SCRIPT`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
缺发布键只挡住上传,不挡住构建。
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`):
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`)。项目内 APK/IPA
分发脚本的路径写在 `makefile.builder` 的 `COPY_SCRIPT`,脚本接收产物路径作为位置参数;
未配置项目脚本时使用 `COPY_DEST` 的本地目录或容器目录后备逻辑:
```text
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
# COPY_SCRIPT= # 仅外部/临时脚本覆盖;项目内脚本写 makefile.builder
```
`BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。初始化时确认查询命令(tea / glab / gh / curl),写成 `.pouch/builder/ci-status.sh`。`make -f makefile.builder obtain`(或 `push-docker`)执行该脚本等待流水线并 inspect registry,不在本机 `docker push`。
4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
`make -f makefile.builder push-docker`。
写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。
跨机器或需要继续查看的构建先用 `agent-runtime` 精确解析 binding 和 Orca worktree;
builder 只在授权后执行对应 `makefile.builder` target,不自动上传。
## 多架构
项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进
`.env.builder`(那是密钥,换机器会丢)。
```makefile
ARCHES ?= amd64 arm64
```
`ARCH` 是「这一次」:默认等于清单第一项。不传 `ARCH=` 时,`deb` / `push-deb`
按清单逐个做完;`push-docker` 把清单映射成 `linux/amd64,linux/arm64`。
只做其中一个:`make -f makefile.builder deb ARCH=arm64`。
合法值只有 `amd64` 和 `arm64`(空格分隔)。不要默认写成双架构:本机编不出
另一边时,只留一项。
```bash
make -s -f makefile.builder arches # 打印清单,例如 amd64 arm64
make -f makefile.builder deb # 打出两个 .deb
make -f makefile.builder push-deb # 两个都上传(需你授权发布)
make -f makefile.builder push-docker # 一次 buildx 推两个平台
```
产物:
```text
dist/<name>_<version>_amd64.deb
dist/<name>_<version>_arm64.deb
```
`build` 配方必须用 `$(ARCH)`,否则两个包内容会一样。初始化时 Agent 按仓库里
已有命令来写(Go 一般是 `GOOS=linux GOARCH=$(ARCH) go build …`),不另做一套
运行时探测脚本。看不出交叉编法就不要写双架构。
`packaging/DEBIAN/control` 的 `Architecture:` 必须随 `$(ARCH)` 变,不要写死
`amd64`。模板会先拷到 `dist/packaging-$(ARCH)` 再改这一行。
本机临时只推一个平台,可以在 `.env.builder` 设 `PLATFORMS=linux/amd64`,这是
覆盖不是清单。项目清单始终以 `ARCHES` 为准。
存量 `makefile.builder` 没有 `ARCHES` 时行为与以前相同(默认 `amd64`,
`ARCH=arm64` 仍合法)。要改成双架构:加上面那一行,确认 `build` 吃 `$(ARCH)`,
再跑 `python3 -I -S <builder>/scripts/check.py . --ready`。
## 版本号
产物版本从 **当前 HEAD 的 Git 祖先** 推导,不调用 manage-release。
正式 tag 仍由 manage-release 创建;builder 只读取。完整规则见
产物版本从 **当前 HEAD 的 Git 祖先** 推导,不调用 releaser。
正式 tag 仍由 releaser 创建;builder 只读取。完整规则见
[contract.md「版本号」](references/contract.md#版本号)。
| 谁 | 做什么 |
| --- | --- |
| manage-release | 选定下一个正式 SemVer,打 annotated tag `vX.Y.Z` |
| releaser | 选定下一个正式 SemVer,打 annotated tag `vX.Y.Z` |
| builder | 读 HEAD:落在稳定 tag 上则打正式产物,否则打测试产物 |
| `make -f makefile.builder version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
@@ -67,20 +149,31 @@ Docker registry/ns/foo:1.4.2
### 测试(其它任何 commit)
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的
分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库,
所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
patch 机械 +1,再加构建时间 `YYYYMMDDTHHMM`、清洗后的分支名、相对距离、短 SHA。
这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt /
Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` <
`1.4.3~20260918T1430…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包;
同号正式版仍盖住测试。
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`:
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`,
构建时间 `20260918T1430`(带分钟,同一天重建才排得对):
```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
规范 / DEB 1.4.3~20260918T1430.feat-login-v2.7+gabc1234
文件名 foo_1.4.3~20260918T1430.feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.3-20260918T1430.feat-login-v2.7.gabc1234
```
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
安装测试包必须显式指定版本或完整 tag,不能靠无参 `apt upgrade`。
无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号
正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建时间取本机当前本地
时间(含分钟);设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 时间。`+g<sha>`
只用于记录 commit、不参与排序:只写到日期时,同一天重复构建的版本只差
`+g<sha>`,Debian 版本比较把哈希当普通文本比,会把新包判成比旧包小。
注意切换格式当天:Debian 把字母排在标点之前,`20260918T1430…` 小于旧格式
的 `20260918.…`,同一天已上传的旧格式包仍会压住新包,需要在仓库里把那些
旧包移除或停用;从第二天起新格式始终更大。
### 分支名清洗
@@ -96,8 +189,26 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。
用 builder 打一份测试 APK 并拷到 File Browser。
```
## Agent 会做什么
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,确认 `BUILD_VENUE`;选 ci 时生成 `.pouch/builder/ci-status.sh`。按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
Docker 只有你点名镜像时才走。token 不会写进输出、提交或回复。
## 如何判断完成
- 初始化:`check.py --ready` 的构建项 PASS。只缺发布键算部分完成,仍可构建、不能上传。
- 构建:产物路径、规范版本、架构与 `make -f makefile.builder version` 一致。
- 发布:HTTP 成功或远端 digest 对得上;回复里有包名或镜像引用、SHA-256 / digest、源 commit。
- 测试包不会通过无参 `apt upgrade` 盖住同号正式包;会作为下一 patch 候选盖住上一正式包。
## 脚本一览
| 脚本 | 用途 |
@@ -110,6 +221,13 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` |
| `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect |
| `scripts/wait_ci.sh` | 轮询项目 `CI_STATUS_SCRIPT`,只等该 SHA,不触发 |
| `scripts/init_ci_status.sh` | 初始化时把 tea/glab/gh/curl 模板拷到 `.pouch/builder/ci-status.sh` |
| `templates/ci-status/` | tea / glab / gh / curl 查询脚本模板;契约见 [ci-status.md](references/ci-status.md) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、
脏工作树策略等完整规则见 contract.md。
+46 -15
View File
@@ -1,16 +1,31 @@
---
name: builder
description: >-
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker
镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
或把产物拷到目录/容器。产物可在本机或 CI 上构建;拿到产物用 obtain。触发词:初始化
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker 镜像、
打 APK/IPA、拷到 File Browser、make push、看构建、监听 CI、obtain。
未授权不上传。Docker 仅用户点名镜像时才走。
---
# Builder:DEB / Docker 构建发布
# Builder:DEB / Docker / Android / iOS 构建发布
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
`.pouch/builder/`,不要改用户的 `Makefile` / `makefile` / `.env`。
项目状态是**当前 builder 项目根**的 `makefile.builder` 与 `.env.builder`;项目自定义分发脚本放在
同一项目根的 `.pouch/builder/`,不要改用户的 `Makefile` / `makefile` / `.env`。如果父仓库包含
submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.env.builder` 和
`.pouch/builder/` 必须放在 submodule 内,父仓库只负责调用或固定 submodule commit。
配置来源原则:项目要构建什么、发布哪些轨道、使用哪个已提交的分发脚本,放在
`makefile.builder`;本机路径和工具链覆盖放在被忽略的 `.pouch/builder/local.mk`;凭据、
发布目标和本轮别名放在被忽略的 `.env.builder`。`COPY_SCRIPT` 若指向项目内
`.pouch/builder/` 的脚本,优先写入 `makefile.builder`,这样迁移项目时配置与脚本一起进入 Git。
构建执行位置、Agent CLI、模型、推理强度和 session 生命周期由共享 `agent-runtime` 的
`.pouch/agent.yaml` 管理。`builder.<name>` 是可自由增加的 binding:可以指向本地
worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远程主机、模型或 terminal 参数
硬编码进 `makefile.builder`。跨机器或跨 session 时先读 `agent-runtime`,构建目标仍只能
由 `makefile.builder` 提供。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。
@@ -20,17 +35,20 @@ description: >-
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 看构建、监听 CI、ACK/ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 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(版本规则冲突)。
1. 确认项目根。优先以包含目标 `makefile.builder` 的仓库根为准;若目标位于 submodule,则以 submodule 根为准,不向父仓库回溯配置。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
6. 确认 `BUILD_VENUE`:`local` 或 `ci`,不要猜。选 `ci` 时读 [ci-status.md](references/ci-status.md),确认查询命令(`tea` / `glab` / `gh` / `curl`),执行 `<skill-dir>/scripts/init_ci_status.sh --root <project-dir> --kind <kind>`,在 `makefile.builder` 写入 `BUILD_VENUE=ci` 和 `CI_STATUS_SCRIPT=.pouch/builder/ci-status.sh`。用户给了自定义查询命令时,把命令写进同一份脚本并保持 `STATUS=` 输出契约。
7. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
```text
## builder 初始化:完成 | 部分完成 | 阻塞
@@ -47,6 +65,9 @@ DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=
DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
# 外部/临时脚本才放这里;项目内脚本优先在 makefile.builder 设置 COPY_SCRIPT
```
## 检查
@@ -59,7 +80,17 @@ DOCKER_REGISTRY=registry.example.com
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。
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 releaser,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。若通过 `builder.<name>` 执行,先精确解析 `environment + host + project + path` 对应的 worktree;远程 binding 不能 fallback 到本机。持久 session 只显式 attach/resume,不能按标题复用旧 terminal。
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh` / `obtain.sh`。`BUILD_VENUE=ci` 时 `push-docker` 就是 obtain。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道至少配置 `COPY_DEST`(本地目录或 `container:/dir`)或 `COPY_SCRIPT`(项目内脚本优先在 `makefile.builder` 设置;同时配置时命令行/环境值优先)。自定义脚本按 `FILE [FILE ...]` 接收产物路径,自行完成重命名、上传、远端校验和通知;相对路径按项目根解析,`BUILDER_PROJECT_ROOT` 提供项目根。`COPY_AS` 只由内置 `COPY_DEST` 路径处理。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不要把 token 当命令行参数;脚本可随项目放在 `.pouch/builder/` 下。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
## 拿到产物
调用方是 ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
- `BUILD_VENUE=ci`:`wait_ci.sh` 轮询项目的 `CI_STATUS_SCRIPT`,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。缺脚本则停下,回到初始化。
- 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。
- 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。
+55
View File
@@ -0,0 +1,55 @@
# CI 状态查询脚本
`BUILD_VENUE=ci` 时,流水线状态由**项目脚本**查询。builder 只轮询这份脚本,
不在 skill 里猜 gh / glab / tea / curl。
## 初始化
选 `ci` 后确认查询方式,再生成脚本:
```bash
<skill-dir>/scripts/init_ci_status.sh --root <project> --kind tea|glab|gh|curl
```
写出 `.pouch/builder/ci-status.sh`(可执行、进 git)。`makefile.builder` 写:
```makefile
BUILD_VENUE ?= ci
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
```
相对路径只能在 `.pouch/builder/` 下。凭据仍放 `.env.builder`(如 `GITEA_TOKEN`),
不要写进脚本参数或日志。
用户给了自定义命令时,把该命令写进同一份脚本,输出必须符合下方契约。
## 调用
```bash
.pouch/builder/ci-status.sh --sha <40-character-sha>
```
只查询、不触发流水线。`BUILDER_PROJECT_ROOT` 是项目根。
## 输出
退出 0 且 stdout 含 `STATUS=` 键值(可多行或同一行):
| STATUS | 含义 |
| --- | --- |
| `empty` | 该 SHA 还没有流水线 |
| `running` | 已出现、尚未结束 |
| `success` | 该 SHA 的流水线成功 |
| `failed` | 失败、取消或超时 |
| `error` | 查询本身失败(鉴权、CLI 缺失) |
可选:`ID=`、`PIPELINE=`(URL)、`JOBS=`。
| 退出码 | 含义 |
| --- | --- |
| 0 | 已查出状态(含 empty/running/failed) |
| 2 | 用法错误 |
| 3 | 无法查询(缺 CLI、未登录、被拒绝) |
`wait_ci.sh` 按这份输出轮询:`empty` 超过出现窗口记 `no_pipeline`;
`success` 才继续 obtain;`failed` 原样失败。
+98 -21
View File
@@ -4,8 +4,17 @@
check.py,本文档跟随。所有接入项目按同一套 make 目标、产物形状和环境变量执行,
builder 脚本只做发布,不做项目特定的构建逻辑。
配置边界按 Git 项目根生效:`makefile.builder` 所在仓库就是 builder 项目根。
如果父仓库通过 submodule 引入另一个项目,submodule 的构建、`.env.builder` 和
`.pouch/builder/` 分发脚本属于 submodule;父仓库不得替它承载这些配置。
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
Agent 只保留授权判断和歧义处理。**
Agent 只保留授权判断和歧义处理。** 构建在哪台机器执行、使用哪个 CLI/model 和
session 如何保存,由共享 `agent-runtime` 的 binding 决定;makefile 不能承载主机或
Agent 选择。
`builder.android`、`builder.ios`、`builder.harmony` 都是合法的可配置 binding。新增平台
只增加 binding 和对应 make target,不改 builder skill 的固定枚举。
契约文件固定为项目根 `makefile.builder`,调用方式:
@@ -28,44 +37,73 @@ make -f makefile.builder <target>
| `help` | 分组列出全部目标;首屏含当前版本 |
| `version` | 输出一行版本号,适合脚本消费 |
| `clean` | 只删除明确、受限的构建产物目录 |
| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** |
| `build` | 编译/打包主产物;尊重 `ARCH`(单架构原语);**不得内含任何上传动作** |
### 条件目标
| 目标 | 适用 | 要求 |
|------|------|------|
| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传 |
| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 |
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` |
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 |
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
| `push-docker` | 同时有多种发布轨道的项目 | `BUILD_VENUE=local` 时仅调 `publish_docker.sh`;`ci` 时仅调 `obtain.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时轮询 `CI_STATUS_SCRIPT` 再 inspect,不在本机 `docker push` |
| `venue` | 可选(新模板必备) | 打印一行 `BUILD_VENUE`(`local` 或 `ci`) |
规则:
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
1. 项目有 DEB 产物的判据:`makefile.builder` 配方以独立命令调用 `dpkg-deb`/`debuild` 或产出 `.deb`;
不能把 `xcodebuild` 中包含的字符误判成 `debuild`。
有镜像的判据:项目根存在 `Dockerfile`。
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
`push` 即可。
Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。
拷贝发布判据:配方调用 `copy_artifact.sh`。`copy_artifact.sh` 是稳定入口,具体分发可由
`COPY_SCRIPT` 接管。
2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合;
单轨道项目一个 `push` 即可。
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。
未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为
`linux/<arch>,…`。
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点/`docker cp`。
5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。
`build` 禁止 `docker cp`、`copy_artifact.sh`、curl/scp。
## 2. 变量
配置来源按变量的稳定性分层:`makefile.builder` 是项目构建与发布轨道的可迁移事实,
包括 `ARCHES`、产物命名、目标和项目内 `COPY_SCRIPT`;`.pouch/builder/local.mk` 是
不提交的机器工具链覆盖;`.env.builder` 是不提交的凭据、发布端点、目标目录和本轮别名。
命令行/环境变量可以覆盖前两者,但不应把项目契约依赖在本机环境上。
| 变量 | 默认 | 说明 |
|------|------|------|
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 |
| `BUILD_VENUE` | `local` | 产物在哪构建:`local` 本机 make;`ci` 由项目 CI 流水线构建。这不是新轨道。写在 `makefile.builder` 并提交。`ci` 时 `push-docker` 委托 `obtain.sh`(等流水线 + inspect),不跑 `publish_docker.sh` |
| `CI_STATUS_SCRIPT` | `.pouch/builder/ci-status.sh` | `BUILD_VENUE=ci` 时必填。项目内查询该 SHA 流水线状态的可执行脚本,相对路径只能在 `.pouch/builder/`。初始化时按 tea / glab / gh / curl 生成,契约见 [ci-status.md](ci-status.md) |
| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
| `DIST_DIR` | `dist` | DEB 产物目录 |
| `DIST_DIR` | `dist` | 产物目录(deb / apk / ipa) |
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
`ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。
`deb` 配方在打包前必须把 `packaging/DEBIAN/control`(或等价文件)的 `Architecture:` 写成当前 `$(ARCH)`,不要写死 `amd64`。推荐先拷到 `$(DIST_DIR)/packaging-$(ARCH)` 再改,避免弄脏源树。
`build` 配方必须使用 `$(ARCH)`(例如 `GOARCH=$(ARCH)`、`--platform=linux/$(ARCH)`)。配方若忽略 `ARCH`,双架构会打出两个文件名不同、内容却都是宿主机架构的包。Builder 不安装 qemu 或交叉编译器;看不出交叉编法时 `ARCHES` 只留本机一项。
### 版本号
`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`。
releaser 选定下一正式 SemVer。测试号把祖先基线的 patch 机械 +1 作为排序键
(`v1.4.2` 之后 → `1.4.3~…`),不是在宣布下一正式号就是 1.4.3。正式 tag 由
releaser 事先打好。`version.sh` 不执行 `git fetch`。
推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才
能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**,
@@ -74,17 +112,27 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release
| 判定 | 规范版本 | 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>` | 同上替换规范版本 | 同上映射 |
| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.3~<date>.<branch>.<n>+g<sha>` | `name_1.4.3~<date>.<branch>.<n>+g<sha>_<arch>.deb` | `1.4.3-<date>.<branch>.<n>.g<sha>` |
| 祖先中没有稳定 tag | `0.0.0~<date>.<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使
`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含
`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~` 挂在基线的下一
patch 上:`1.4.2` < `1.4.3~…` < `1.4.3`。已装上一正式版的机器可以升到
bugfix 测试包;同号正式版仍盖住测试。不要写成 `1.4.2~…`(小于已发布的
`1.4.2`),不要写成 `1.4.2+<date>` / `1.4.3-beta`(Debian 里 `-beta` 大于
`1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范
版本把 `~` → `-`、`+g` → `.g`。
`<date>`:`YYYYMMDDTHHMM`,构建的日期加分钟。加分钟是为了同一天重复构建
也能排序:只写到日期时,同分支同日的版本前缀完全相同,只剩 `+g<sha>` 可
区分,而 Debian 把哈希当普通文本比较,新构建可能排到旧构建下面(
`apt upgrade` 因此看不到最新包)。已设 `SOURCE_DATE_EPOCH` 时用该时刻的
UTC 时间,否则用本机当前时间。正式 `X.Y.Z` 不加日期。切换格式当天注意:
Debian 把字母排在标点之前,`YYYYMMDDT…` 小于旧格式的 `YYYYMMDD.…`,同日
已上传的旧格式包会压住新包,需要把它们在仓库里移除或停用;次日恢复正常。
`<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)。
`-`;过长截断(给 base、日期、距离、SHA 留位置;Docker tag 上限 128)。
`<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
@@ -107,6 +155,30 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
### 拷贝轨道
| 变量 | 必填 | 说明 |
|---|---|---|
| `COPY_DEST` | 与 `COPY_SCRIPT` 至少配置一个 | 内置后备目标:本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) |
| `COPY_SCRIPT` | 与 `COPY_DEST` 至少配置一个 | 项目内 `.pouch/builder/` 脚本的路径优先写在 `makefile.builder` 并提交;也可用绝对路径或环境/.env.builder 覆盖;必须是可执行文件,同时配置时优先于 `COPY_DEST` |
| `COPY_AS` | 否 | 第一份文件的额外 basename,逗号或空格分隔 |
`copy_artifact.sh FILE [FILE ...]` 是 APK/IPA 等产物的统一分发入口。不读 `.env`,脏工作树默认拒绝,
`ALLOW_UNCOMMITTED=1` 放行。
未配置 `COPY_SCRIPT` 时,入口把文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。
配置 `COPY_SCRIPT=.pouch/builder/publish-apk.sh` 时(项目内脚本通常由 `makefile.builder` 传入),入口按项目根解析、先校验每个文件存在,然后直接执行:
```text
/absolute/path/to/publish-script FILE [FILE ...]
```
相对路径只允许位于项目的 `.pouch/builder/` 下;绝对路径仍支持外部脚本。不经过 shell 字符串求值,
不把 token 放进参数。脚本通过位置参数接收原始产物绝对路径,自行负责重命名、上传、远端校验和通知;
当前项目根以 `BUILDER_PROJECT_ROOT` 环境变量提供。
脚本返回非零即视为分发失败;成功后入口只报告脚本成功和本地 SHA-256,不宣称远端已校验。
`--dry-run` 只打印将调用的脚本与文件,不执行自定义脚本。
### Docker 轨道
| 变量 | 必填 | 说明 |
@@ -114,7 +186,11 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
| `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` |
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
| `PLATFORMS` | 否 | 本机/本轮覆盖。未设置时由 `ARCHES` 映射为 `linux/<arch>,…`;再没有则 `linux/amd64`。不要把项目的架构清单只写在这里(`.env.builder` 不进 git) |
`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。
`BUILD_VENUE=ci` 时 Docker 发布路径是 `obtain.sh`:`wait_ci.sh` 只轮询 `CI_STATUS_SCRIPT`,只认 **该 commit SHA**。没有流水线、job 失败、超时、或 Harbor inspect 不到 `version.sh --docker` 算出的 tag,一律非零退出。不触发流水线。查询命令在初始化时写成项目脚本,运行时不再猜测 gh / glab / tea / curl。合进默认分支若项目声明不构建,这是正确失败;调用方应打 tag 或推送到项目会构建的 ref(例如 `dev/*`)。CI job 若已经 `docker push`,obtain 成功即视为产物已在 registry,不要再本机 push 一次。
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
@@ -131,6 +207,7 @@ push 目标与 `version.sh` 定位 builder 脚本时按以下顺序,命中即
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
2. `$HOME/.pouch/skills/builder/scripts/`(标准 clone 位)
3. `$HOME/.agents/skills/builder/scripts/`(本机 agent 安装位)
两个位置都不可用时必须失败并提示:设置 `BUILDER_SKILL_DIR`,或把 pouch 仓库
clone 到 `~/.pouch`。
+55
View File
@@ -0,0 +1,55 @@
# 拿到产物(obtain)
Builder 的交付入口是 **按精确 Git commit 拿到产物**,不是「在哪台机器编译」。
docker / deb / android / ios / copy 仍是轨道;`BUILD_VENUE` 只说明构建发生在本机还是 CI。
## 谁来调用
- `ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- 用户说「看构建」「监听 CI」「拿到镜像」时。
- `make -f makefile.builder obtain` 或 `push-docker`(`BUILD_VENUE=ci`)。
代码必须已经推到远端(`ci` 时流水线由 git ref 触发)。Builder 不代替 releaser 打 tag,也不创建分支。
## 命令
```bash
<builder>/scripts/obtain.sh --sha <full-sha> [--branch <name>] [--root <project>] [--timeout 1200]
```
成功打印:
```text
ARTIFACT=harbor.example.com/ns/name:1.4.2
DIGEST=sha256:…
SHA=<full-sha>
VENUE=ci
TAG=1.4.2
```
镜像 tag 对该 SHA 跑 `version.sh --docker`(可 `--branch` 注入 `CI_COMMIT_BRANCH`),禁止 `git describe`。
## 退出码
| 码 | 含义 | 调用方 |
|---|---|---|
| 0 | 产物已核验 | `artifact_built` 完成 |
| 1 | 该 SHA 无流水线、job 失败、超时、inspect 失败 | 标 `failed`,不得 publish/deploy |
| 2 | 用法或配置错误 | 标 `blocked` |
| 3 | 项目 CI 状态脚本无法查询 | 停;回到初始化补 `CI_STATUS_SCRIPT`,或用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 |
## `ci` 规则
- 只认该 SHA 的 pipeline,不认默认分支「最新一次绿」。
- 不触发流水线。
- 查询命令是项目 `.pouch/builder/ci-status.sh`(`CI_STATUS_SCRIPT`),初始化时按 tea / glab / gh / curl 写好。凭据仍放 `.env.builder`。
- job 变绿不够,docker 轨道必须 inspect 到 digest。
- 合进 `main` 若项目不构建,`no_pipeline` 是正确失败。预览走项目会构建的 ref(如 `dev/*`),正式走 tag。
## Agent
1. 固化 source revision 为完整 SHA。
2. 跑 `obtain.sh --sha <sha>`。
3. 退出 0:记录 ARTIFACT/DIGEST/SHA。`venue=ci` 且 job 已 push 时,`published` 视为已满足,不要本机再 `publish_docker.sh`。
4. 退出 1/2:停止 delivery-loop,报告 pipeline URL 或 `no_pipeline`。
5. 退出 3:先说明 CLI 身份不对;只有已有同 host 登录 tab 时才用来读 pipeline。
+2 -2
View File
@@ -7,7 +7,7 @@
| Registry | 必须显式确定,例如 `registry.example.com` |
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
| Platform | 用户这轮显式值,否则 `.env.builder` 的 `PLATFORMS`,否则 `makefile.builder` 的 `ARCHES` 映射为 `linux/<arch>,…`,再否则 `linux/amd64` |
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
| Context | 默认当前项目根目录 |
@@ -35,7 +35,7 @@
## Tag 策略
- 未显式给出 `--tag` / `IMAGE_TAG` 时,使用契约「版本号」渲染出的 tag
(正式 `1.4.2`,测试 `1.4.2-feat-login.7.gabc1234`),不要退回
(正式 `1.4.2`,测试 `1.4.3-<date>.feat-login.7.gabc1234`),不要退回
`git describe`,不要询问后另选一套。
- 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。
- `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布,
+468 -35
View File
@@ -32,8 +32,8 @@ 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",
"docker push", "docker cp", "buildx build --push", "buildx --push",
"upload_deb.sh", "publish_docker.sh", "copy_artifact.sh",
)
SECRET_PATTERNS = (
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
@@ -41,7 +41,13 @@ SECRET_PATTERNS = (
)
FLOATING_TAGS = (":latest", ":stable")
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
VALID_SCRIPT_NAMES = (
"upload_deb.sh",
"publish_docker.sh",
"copy_artifact.sh",
"obtain.sh",
"wait_ci.sh",
)
BUILDER_MAKEFILE = "makefile.builder"
BUILDER_ENV = ".env.builder"
@@ -78,8 +84,9 @@ def run_make(
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
err = result.stderr.lower()
return result.returncode != 0 and (
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower()
"no rule to make target" in err or "没有规则可制作目标" in result.stderr
)
@@ -99,6 +106,33 @@ def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
return lines
def parse_dry_echo(result: subprocess.CompletedProcess[str]) -> str:
"""Recover `@echo …` payload from `make -n` output."""
out_lines = [
ln.lstrip()[5:]
for ln in result.stdout.splitlines()
if ln.lstrip().startswith("echo ")
]
return "\n".join(out_lines).strip()
def read_arches(project: Path) -> list[str] | None:
"""Return ARCHES if the `arches` target exists.
None means a legacy makefile without the target. An empty list means the
target exists but printed nothing usable.
"""
result = run_make(project, "arches")
if has_no_rule(result):
return None
if result.returncode != 0:
return []
raw = parse_dry_echo(result)
if not raw:
return []
return raw.split()
def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
present: dict[str, bool] = {}
lines = []
@@ -112,25 +146,68 @@ def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
def check_arch_guard(report: Report, project: Path) -> None:
title = "ARCH / ARCHES 守卫与缺省值"
problems: list[str] = []
bad = run_make(project, "build", "ARCH=loongarch")
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr)
guard_ok = bad.returncode != 0 and (
"amd64" in bad.stderr or "arm64" in bad.stderr or "ARCH" 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()))
if not guard_ok:
problems.append(
"Hint: reject unknown ARCH with `$(error)` naming allowed values "
"(amd64/arm64, or the project's ARCHES)."
)
if not default_ok:
problems.append("`make -n build` without ARCH= must succeed")
arches_make = run_make(project, "arches")
if has_no_rule(arches_make):
lines.append("arches target: absent (legacy; implicit ARCHES = default ARCH)")
elif arches_make.returncode != 0:
problems.append(
f"`make -n arches` failed: {arches_make.stderr.strip() or 'unknown error'}"
)
else:
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint)
raw = parse_dry_echo(arches_make)
arches = raw.split()
lines.append(f"ARCHES: {raw!r}")
if not arches:
problems.append("arches target printed an empty list")
if "," in raw:
problems.append("ARCHES must be space-separated, not comma-separated")
unknown = [a for a in arches if a not in ARCH_VALUES]
if unknown:
problems.append(f"ARCHES entries must be amd64 or arm64: {unknown}")
if len(arches) != len(set(arches)):
problems.append("ARCHES must not contain duplicates")
outsider = next((a for a in ARCH_VALUES if a not in arches), None)
if outsider:
extra = run_make(project, "build", f"ARCH={outsider}")
extra_ok = extra.returncode != 0
lines.append(
f"ARCH={outsider} rejected (not in ARCHES): "
f"{'yes' if extra_ok else 'NO'}"
)
if not extra_ok:
problems.append(
f"ARCH={outsider} must fail when it is not listed in ARCHES"
)
if problems:
report.add(FAIL, 2, title, "\n".join(lines + [""] + problems))
else:
report.add(PASS, 2, title, "\n".join(lines))
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()
out = parse_dry_echo(result)
single = len(out.splitlines()) == 1 and out != ""
no_v = single and not out.startswith("v")
detail = f"stdout={out!r}"
@@ -157,7 +234,8 @@ def check_build_has_no_upload(report: Report, project: Path) -> None:
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/*"))
has_deb_command = re.search(r"(?<![A-Za-z0-9_-])(?:dpkg-deb|debuild)(?![A-Za-z0-9_-])", recipe_all)
return ".deb" in recipe_all or has_deb_command is not None or any(project.glob("debian/*"))
def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> None:
@@ -185,6 +263,24 @@ def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> N
problems.append(f"dpkg-deb --info failed on {built_deb.name}")
else:
problems.append("dpkg-deb unavailable; metadata not verified (--build)")
arches = read_arches(project)
if arches and len(arches) > 1 and dry.returncode == 0:
blob = dry.stdout + dry.stderr
missing = [
a for a in arches if f"ARCH={a}" not in blob and f"_{a}.deb" not in blob
]
if missing:
problems.append(
"ARCHES has multiple values but `make -n deb` does not fan out to: "
+ ", ".join(missing)
)
pinned = arches[-1]
one = run_make(project, "deb", f"ARCH={pinned}")
one_blob = one.stdout + one.stderr
if f"_{pinned}.deb" not in one_blob and f"ARCH={pinned}" not in one_blob:
problems.append(
f"`make -n deb ARCH={pinned}` does not build that single arch"
)
if problems:
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
else:
@@ -196,6 +292,49 @@ def detect_docker_project(project: Path) -> bool:
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
def detect_android_project(recipe_all: str) -> bool:
return ".apk" in recipe_all or ".aab" in recipe_all
def detect_ios_project(recipe_all: str) -> bool:
return ".ipa" in recipe_all
def detect_copy_project(recipe_all: str) -> bool:
return "copy_artifact.sh" in recipe_all
def check_mobile_recipe(
report: Report, project: Path, *, kind: str, number: int, ext: str
) -> None:
target = "build"
if kind == "ios":
ios_probe = run_make(project, "ios")
if not has_no_rule(ios_probe):
target = "ios"
dry = run_make(project, target)
text = chr(10).join(clean_make_output(dry))
problems = []
if dry.returncode != 0:
problems.append(f"`make -n {target}` 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")
wanted = (".apk", ".aab") if kind == "android" else (ext,)
if not any(item in text for item in wanted):
problems.append(f"recipe does not mention {' or '.join(wanted)}")
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")
title = f"{kind} 目标产物形状与纯构建"
if problems:
report.add(FAIL, number, title, "\n".join(problems))
else:
report.add(PASS, number, title, f"{target} writes {ext} under dist/")
def check_docker_recipe(report: Report, project: Path) -> None:
dry = run_make(project, "docker")
text = chr(10).join(clean_make_output(dry))
@@ -221,28 +360,56 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
"$$HOME/.skills/skills/builder/scripts",
"$HOME/.skills/skills/builder/scripts",
"~/.skills/skills/builder/scripts",
"$$HOME/.agents/skills/builder/scripts",
"$HOME/.agents/skills/builder/scripts",
"~/.agents/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",)
def check_push_delegates(
report: Report,
project: Path,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool,
) -> None:
tracks = []
if deb_project:
tracks.append("deb")
if docker_project:
tracks.append("docker")
if copy_project:
tracks.append("copy")
multi = len(tracks) > 1
targets = tuple(f"push-{t}" for t in tracks) if multi else ("push",)
missing = []
inline = []
thin = []
builder_names = (
"upload_deb.sh",
"publish_docker.sh",
"copy_artifact.sh",
"obtain.sh",
)
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]
bad_tokens = [
token
for token in ("curl ", "scp ", "aptly ", "reprepro ", "docker cp")
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:
and "$(BUILDER_SCRIPT)" not in text \
and not any(name in text for name in builder_names):
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
else:
thin.append(target)
@@ -250,6 +417,20 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
if missing:
problems.append("missing targets: " + ", ".join(missing))
problems.extend(inline)
if deb_project:
push_deb_target = "push-deb" if multi else "push"
if push_deb_target not in missing:
arches = read_arches(project)
if arches and len(arches) > 1:
dry = run_make(project, push_deb_target)
if not has_no_rule(dry):
text = chr(10).join(clean_make_output(dry))
absent = [a for a in arches if f"_{a}.deb" not in text]
if absent:
problems.append(
f"ARCHES has multiple values but `make -n {push_deb_target}` "
"does not upload: " + ", ".join(absent)
)
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)
@@ -280,6 +461,7 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
COPY_ENV_KEYS = ("COPY_DEST", "COPY_SCRIPT")
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
@@ -306,16 +488,186 @@ def env_file_keys(project: Path) -> set[str]:
return keys
def key_present(key: str, env_keys: set[str]) -> bool:
return bool(os.environ.get(key)) or key in env_keys
def env_file_values(project: Path) -> dict[str, str]:
"""Return nonempty `.env.builder` values for internal validation only."""
path = project / BUILDER_ENV
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
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:
values[match.group(1)] = value
return values
MAKE_ASSIGNMENT = re.compile(
r"^([A-Za-z_][A-Za-z0-9_]*)\s*(?:\?=|:=|=)\s*(.*?)\s*$"
)
def makefile_values(project: Path) -> dict[str, str]:
"""Return nonempty top-level make variable values for contract checks."""
path = project / BUILDER_MAKEFILE
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
for raw in text.splitlines():
match = MAKE_ASSIGNMENT.match(raw)
if not match:
continue
value = re.split(r"\s+#", match.group(2), maxsplit=1)[0].strip()
if value:
values[match.group(1)] = value
return values
def key_present(
key: str, env_keys: set[str], make_values: dict[str, str] | None = None
) -> bool:
return bool(os.environ.get(key)) or key in env_keys or bool((make_values or {}).get(key))
def configured_value(
key: str,
env_values: dict[str, str],
make_values: dict[str, str] | None = None,
) -> str:
"""Read a configured value without ever printing it."""
return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "")
def resolve_builder_local_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve a project script; relative paths must stay under .pouch/builder/."""
configured = Path(script).expanduser()
if configured.is_absolute():
return configured, None
try:
resolved = (project / configured).resolve()
allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root)
except ValueError:
return None, "relative script must be under .pouch/builder/"
return resolved, None
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
path, error = resolve_builder_local_script(project, script)
if error:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return path, None
def check_copy_script(
report: Report,
project: Path,
env_values: dict[str, str],
make_values: dict[str, str],
) -> None:
script = configured_value("COPY_SCRIPT", env_values, make_values)
if not script:
return
path, resolve_error = resolve_copy_script(project, script)
problems = []
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("COPY_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("COPY_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
14,
"COPY_SCRIPT 可执行且路径明确",
"\n".join(problems) if problems else "configured custom copy script is executable",
)
def read_venue(project: Path) -> str:
env = (os.environ.get("BUILD_VENUE") or "").strip()
if env in ("local", "ci"):
return env
result = run_make(project, "venue")
if not has_no_rule(result):
echoed = parse_dry_echo(result)
if echoed in ("local", "ci"):
return echoed
cleaned = "\n".join(clean_make_output(result)).strip()
if cleaned in ("local", "ci"):
return cleaned
raw = makefile_values(project).get("BUILD_VENUE", "local")
raw = re.split(r"\s+#", raw, maxsplit=1)[0].strip()
return raw or "local"
def check_ci_status_script(
report: Report,
project: Path,
env_values: dict[str, str],
make_values: dict[str, str],
) -> None:
if read_venue(project) != "ci":
report.add(SKIP, 15, "CI_STATUS_SCRIPT 可执行", "BUILD_VENUE is not ci")
return
script = configured_value("CI_STATUS_SCRIPT", env_values, make_values)
problems = []
if not script:
problems.append("BUILD_VENUE=ci requires CI_STATUS_SCRIPT")
path = None
resolve_error = None
else:
path, resolve_error = resolve_builder_local_script(project, script)
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("CI_STATUS_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("CI_STATUS_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
15,
"CI_STATUS_SCRIPT 可执行",
"\n".join(problems) if problems else f"ci status script: {script}",
)
def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool
report: Report,
project: Path | None = None,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool = False,
) -> None:
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
venue = read_venue(project) if project is not None else "local"
lines = [
f"make: {'found' if shutil.which('make') else 'MISSING'}",
f"BUILD_VENUE: {venue}",
]
problems = []
if docker_project:
if venue not in ("local", "ci"):
problems.append("BUILD_VENUE must be local or ci")
if docker_project and venue == "ci":
docker_ok = shutil.which("docker") is not None
lines.append(
f"docker: {'found' if docker_ok else 'optional for inspect; obtain fails without it'}"
)
lines.append("CI status: project CI_STATUS_SCRIPT (tea/glab/gh/curl)")
elif 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:
@@ -331,6 +683,11 @@ def check_ready_toolchain(
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
else:
lines.append("dpkg-deb: skipped (no deb track)")
if copy_project:
docker_ok = shutil.which("docker") is not None
lines.append(
f"docker (for container COPY_DEST): {'found' if docker_ok else 'optional; needed only for container:/dir dest'}"
)
report.add(
FAIL if problems else PASS,
10,
@@ -340,13 +697,20 @@ def check_ready_toolchain(
def check_ready_env_keys(
report: Report, project: Path, *, deb_project: bool, docker_project: bool
report: Report,
project: Path,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool = False,
) -> None:
env_keys = env_file_keys(project)
env_values = env_file_values(project)
make_values = makefile_values(project)
lines = []
missing: list[str] = []
if not deb_project and not docker_project:
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track")
if not deb_project and not docker_project and not copy_project:
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track")
return
if deb_project:
for key in DEB_ENV_KEYS:
@@ -358,18 +722,36 @@ def check_ready_env_keys(
lines.append("DEB_*: skipped (no deb track)")
if docker_project:
for key in DOCKER_ENV_KEYS:
found = key_present(key, env_keys)
found = key_present(key, env_keys, make_values)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
else:
lines.append("DOCKER_*: skipped (no docker track)")
if copy_project:
for key in COPY_ENV_KEYS:
found = key_present(key, env_keys, make_values)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if "COPY_SCRIPT" in make_values and "COPY_SCRIPT" not in env_keys and not os.environ.get("COPY_SCRIPT"):
lines[-1] += " (makefile.builder)"
if not any(key_present(key, env_keys, make_values) for key in COPY_ENV_KEYS):
missing.append("COPY_DEST or COPY_SCRIPT")
check_copy_script(report, project, env_values, make_values)
else:
lines.append("COPY_*: skipped (no copy track)")
if missing:
if "COPY_DEST or COPY_SCRIPT" in missing:
config_hints = [
" COPY_DEST= # local/container destination, or",
" COPY_SCRIPT=.pouch/builder/publish-apk.sh # choose one",
]
else:
config_hints = [f" {key}=" for key in missing]
lines.extend(
[
"",
f"blocks publish, not build. Put keys in the environment or {BUILDER_ENV}:",
*[f" {key}=" for key in missing],
*config_hints,
f"Do not commit {BUILDER_ENV}. Do not put these keys in `.env`. Never print values.",
]
)
@@ -386,6 +768,7 @@ def check_script_paths(report: Report) -> None:
home = Path(os.environ.get("HOME", ""))
candidates.append(home / ".pouch" / "skills" / "builder" / "scripts")
candidates.append(home / ".skills" / "skills" / "builder" / "scripts")
candidates.append(home / ".agents" / "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))
@@ -453,7 +836,7 @@ def main(argv: list[str] | None = None) -> int:
)
skip_detail = f"(no {BUILDER_MAKEFILE})"
for number, title, detail in (
(2, "ARCH 守卫与缺省值", skip_detail),
(2, "ARCH / ARCHES 守卫与缺省值", skip_detail),
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
(4, "build 不含上传动作", skip_detail),
(5, "deb 目标产物形状与纯构建", skip_detail),
@@ -465,10 +848,16 @@ def main(argv: list[str] | None = None) -> int:
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_toolchain(
report,
project,
deb_project=deb_project,
docker_project=docker_project,
)
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
)
check_ci_status_script(report, project, env_file_values(project), makefile_values(project) if (project / BUILDER_MAKEFILE).is_file() else {})
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
@@ -480,7 +869,19 @@ def main(argv: list[str] | None = None) -> int:
# 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"):
for target in (
*REQUIRED_TARGETS,
"deb",
"docker",
"android",
"ios",
"push",
"push-deb",
"push-docker",
"push-copy",
"obtain",
"venue",
):
result = run_make(project, target)
if result.returncode == 0:
recipe_all_parts.append(result.stdout)
@@ -491,13 +892,16 @@ def main(argv: list[str] | None = None) -> int:
built_deb: Path | None = None
deb_project = detect_deb_project(recipe_all, project)
docker_project = detect_docker_project(project)
android_project = detect_android_project(recipe_all)
ios_project = detect_ios_project(recipe_all)
copy_project = detect_copy_project(recipe_all)
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, 2, "ARCH / ARCHES 守卫与缺省值", "(build target missing)")
report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)")
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
@@ -516,17 +920,46 @@ def main(argv: list[str] | None = None) -> int:
else:
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
dual = deb_project and docker_project
check_push_delegates(report, project, dual)
if android_project:
check_mobile_recipe(report, project, kind="android", number=12, ext=".apk")
else:
report.add(SKIP, 12, "android 目标产物形状与纯构建", "(not an Android project)")
if ios_project:
check_mobile_recipe(report, project, kind="ios", number=13, ext=".ipa")
else:
report.add(SKIP, 13, "ios 目标产物形状与纯构建", "(not an iOS project)")
check_push_delegates(
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
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
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
check_ci_status_script(
report,
project,
env_file_values(project),
makefile_values(project),
)
total_fail = report.failures
print()
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
COPY_DEST=/path/or/container:/dir \
copy_artifact.sh FILE [FILE ...]
COPY_SCRIPT=.pouch/builder/publish-apk.sh \
copy_artifact.sh FILE [FILE ...]
Copy built artifacts using COPY_SCRIPT when configured; otherwise copy to
COPY_DEST. COPY_DEST is a local directory or a Docker copy target
`container:/absolute/dir` (docker cp). Does not read `.env`.
Environment (project root .env.builder; shell values win):
COPY_DEST Optional fallback. Local dir or container:/dir
COPY_SCRIPT Optional .pouch/builder/ relative path or absolute
executable. Receives FILE paths as positional args.
COPY_AS Optional. Extra basenames for the first file
(comma or space separated)
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
Options:
-d DEST Override COPY_DEST
-a NAMES Override COPY_AS
--dry-run Print the copies without executing
-h Show help
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
EOF
}
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
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
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
dest=${COPY_DEST:-}
extra_as=${COPY_AS:-}
dry_run=false
while (($#)); do
case "$1" in
-d) dest=$2; shift 2 ;;
-a) extra_as=$2; shift 2 ;;
--dry-run) dry_run=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "Error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) break ;;
esac
done
if [[ -z "$dest" && -z "${COPY_SCRIPT:-}" || $# -eq 0 ]]; then
if [[ $# -eq 0 ]]; then
echo "Error: at least one file is required." >&2
else
echo "Error: set COPY_DEST or COPY_SCRIPT." >&2
fi
echo "Set the value in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
# Refresh Git's cached stat data after build tools rewrite tracked files.
git -C "$project_root" update-index -q --refresh 2>/dev/null || true
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 [[ -n "${COPY_SCRIPT:-}" ]]; then
copy_script=$COPY_SCRIPT
if [[ "$copy_script" != /* ]]; then
relative_script=${copy_script#./}
if [[ "$relative_script" != .pouch/builder/* || -z "$project_root" ]]; then
echo "Error: relative COPY_SCRIPT must be under .pouch/builder/." >&2
exit 2
fi
copy_script="$project_root/$relative_script"
fi
if [[ ! -f "$copy_script" ]]; then
echo "Error: COPY_SCRIPT is not a file: $copy_script" >&2
exit 2
fi
if [[ ! -x "$copy_script" ]]; then
echo "Error: COPY_SCRIPT is not executable: $copy_script" >&2
exit 2
fi
artifact_args=()
for src in "$@"; do
if [[ ! -f "$src" ]]; then
echo "Error: not a file: $src" >&2
exit 2
fi
if [[ "$src" == /* ]]; then
artifact_args+=("$src")
else
artifact_args+=("$PWD/$src")
fi
done
if $dry_run; then
printf 'custom copy script:'
printf ' %q' "$copy_script" "${artifact_args[@]}"
printf '\n'
exit 0
fi
export BUILDER_PROJECT_ROOT=${project_root:-}
if "$copy_script" "${artifact_args[@]}"; then
if command -v sha256sum >/dev/null 2>&1; then
for src in "${artifact_args[@]}"; do
sha256sum -- "$src"
done
fi
echo "copy_artifact: custom script completed for $# file(s)"
exit 0
else
status=$?
echo "Error: COPY_SCRIPT failed with exit code $status." >&2
exit "$status"
fi
fi
if [[ -z "$dest" ]]; then
echo "Error: COPY_DEST is required when COPY_SCRIPT is not configured." >&2
exit 2
fi
is_docker_dest() {
local d=$1
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \
&& [[ "$d" != *://* ]] \
&& [[ "$d" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*:/ ]]
}
docker_container=
docker_dir=
local_dir=
if is_docker_dest "$dest"; then
docker_container=${dest%%:*}
docker_dir=${dest#*:}
if [[ -z "$docker_container" || -z "$docker_dir" || "$docker_dir" != /* ]]; then
echo "Error: docker dest must be container:/absolute/dir" >&2
exit 2
fi
docker_dir=${docker_dir%/}
else
local_dir=${dest%/}
fi
split_as() {
local raw=$1
raw=${raw//,/ }
# shellcheck disable=SC2086
printf '%s\n' $raw
}
copy_one() {
local src=$1 name=$2
if [[ ! -f "$src" ]]; then
echo "Error: not a file: $src" >&2
exit 2
fi
if [[ -n "$docker_container" ]]; then
local target="${docker_container}:${docker_dir}/${name}"
if $dry_run; then
echo "docker cp $src $target"
return
fi
docker cp "$src" "$target"
docker exec "$docker_container" ls -l "${docker_dir}/${name}" >/dev/null
echo "copied $(basename -- "$src") -> $target"
else
local target="${local_dir}/${name}"
if $dry_run; then
echo "cp $src $target"
return
fi
mkdir -p "$local_dir"
cp -a "$src" "$target"
test -f "$target"
echo "copied $(basename -- "$src") -> $target"
fi
}
first=true
success=0
for src in "$@"; do
copy_one "$src" "$(basename -- "$src")"
if $first; then
first=false
if [[ -n "$extra_as" ]]; then
while IFS= read -r alias; do
[[ -z "$alias" ]] && continue
copy_one "$src" "$alias"
done < <(split_as "$extra_as")
fi
fi
if ! $dry_run && command -v sha256sum >/dev/null 2>&1; then
sha256sum -- "$src"
fi
success=$((success + 1))
done
echo "copy_artifact: $success file(s) to $dest"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Copy a CI status template into the project.
# Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl
usage() {
echo "Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl" >&2
}
root=
kind=
while (($#)); do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root=$2; shift 2 ;;
--kind) [[ $# -ge 2 ]] || { usage; exit 2; }; kind=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
if [[ -z "$root" || -z "$kind" ]]; then
usage
exit 2
fi
case "$kind" in
tea|glab|gh|curl) ;;
*) echo "Error: --kind must be tea, glab, gh, or curl" >&2; exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
src=$script_dir/../templates/ci-status/${kind}.sh
if [[ ! -f "$src" ]]; then
echo "Error: missing template: $src" >&2
exit 2
fi
dest=$root/.pouch/builder/ci-status.sh
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
chmod +x "$dest"
echo "$dest"
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
set -euo pipefail
# Obtain a verified artifact for an exact git commit.
# Build may have happened locally or in CI; this script only reports identity
# after the artifact exists. Does not print tokens.
#
# Usage:
# obtain.sh [--sha SHA] [--branch NAME] [--root DIR] [--timeout SEC]
#
# BUILD_VENUE (makefile.builder, default local):
# local HEAD must be SHA; run `make -f makefile.builder docker` or `deb`
# ci wait for this SHA via the project CI_STATUS_SCRIPT, then inspect the image
#
# Exit:
# 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE=
# 1 no pipeline, job failed, inspect failed
# 2 usage / config
# 3 CI status script cannot query (ci venue)
usage() {
sed -n '2,20p' "$0"
}
sha=
branch=
root=
timeout_sec=1200
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
--branch) branch=$2; shift 2 ;;
--root) root=$2; shift 2 ;;
--timeout) timeout_sec=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$root" ]]; then
root=$(git rev-parse --show-toplevel 2>/dev/null || true)
fi
if [[ -z "$root" ]]; then
echo "Error: --root must be a git project" >&2
exit 2
fi
cd "$root"
if [[ -z "$sha" ]]; then
sha=$(git rev-parse HEAD)
else
sha=$(git rev-parse "$sha")
fi
# Load .env.builder without printing values; shell values win. Do not read `.env`.
if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
version_sh=$script_dir/version.sh
wait_ci=$script_dir/wait_ci.sh
venue=${BUILD_VENUE:-}
registry=${DOCKER_REGISTRY:-}
repository=${DOCKER_REPOSITORY:-}
if [[ -f "$root/makefile.builder" ]]; then
if [[ -z "$venue" ]]; then
venue=$(make -s -C "$root" -f makefile.builder venue 2>/dev/null || true)
fi
if [[ -z "$registry" ]]; then
registry=$(make -s -C "$root" -f makefile.builder print-DOCKER_REGISTRY 2>/dev/null || true)
fi
if [[ -z "$repository" ]]; then
repository=$(make -s -C "$root" -f makefile.builder print-DOCKER_REPOSITORY 2>/dev/null || true)
fi
fi
venue=${venue:-local}
docker_tag_for_sha() {
local tmp tag
tmp=$(mktemp -d "${TMPDIR:-/tmp}/builder-obtain.XXXXXX")
git -C "$root" worktree add --detach "$tmp" "$sha" >/dev/null
if [[ -n "$branch" ]]; then
tag=$(CI_COMMIT_BRANCH="$branch" "$version_sh" -C "$tmp" --docker)
else
tag=$("$version_sh" -C "$tmp" --docker)
fi
git -C "$root" worktree remove --force "$tmp" >/dev/null
printf '%s' "$tag"
}
inspect_image() {
local image=$1 out digest
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker is required to inspect $image" >&2
exit 1
fi
out=$(docker buildx imagetools inspect "$image" 2>/dev/null \
|| docker manifest inspect "$image" 2>/dev/null \
|| true)
if [[ -z "$out" ]]; then
echo "Error: cannot inspect $image" >&2
exit 1
fi
digest=$(printf '%s\n' "$out" | awk '/^Digest:/ {print $2; exit}')
if [[ -z "$digest" ]]; then
digest=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin)
except Exception:
sys.exit(0)
print(d.get("digest") or "")' <<<"$out")
fi
if [[ -z "$digest" ]]; then
echo "Error: inspect of $image had no digest" >&2
exit 1
fi
printf '%s' "$digest"
}
case "$venue" in
ci)
"$wait_ci" --sha "$sha" --root "$root" --timeout "$timeout_sec"
if [[ ! -x "$version_sh" ]]; then
echo "Error: version.sh not found: $version_sh" >&2
exit 2
fi
if [[ -z "$registry" || -z "$repository" ]]; then
echo "Error: DOCKER_REGISTRY and DOCKER_REPOSITORY are required to inspect the CI image" >&2
exit 2
fi
tag=$(docker_tag_for_sha)
image="${registry}/${repository}:${tag}"
digest=$(inspect_image "$image")
echo "ARTIFACT=$image"
echo "DIGEST=$digest"
echo "SHA=$sha"
echo "VENUE=ci"
echo "TAG=$tag"
;;
local)
head=$(git rev-parse HEAD)
if [[ "$head" != "$sha" ]]; then
echo "Error: local obtain requires HEAD=$sha (HEAD is $head)" >&2
exit 2
fi
if [[ ! -f "$root/makefile.builder" ]]; then
echo "Error: no makefile.builder" >&2
exit 2
fi
if make -s -C "$root" -f makefile.builder -n docker >/dev/null 2>&1; then
make -C "$root" -f makefile.builder docker
if [[ -n "${IMAGE_TAG:-}" ]]; then
tag=$IMAGE_TAG
elif [[ -x "$version_sh" ]]; then
tag=$("$version_sh" --docker)
else
echo "Error: cannot derive IMAGE_TAG" >&2
exit 2
fi
name=$(make -s -C "$root" -f makefile.builder print-PROJECT_NAME 2>/dev/null || basename "$root")
image="${name}:${tag}"
if [[ -n "$registry" && -n "$repository" ]]; then
image="${registry}/${repository}:${tag}"
fi
echo "ARTIFACT=$image"
echo "DIGEST=local"
echo "SHA=$sha"
echo "VENUE=local"
echo "TAG=$tag"
elif make -s -C "$root" -f makefile.builder -n deb >/dev/null 2>&1; then
make -C "$root" -f makefile.builder deb
echo "ARTIFACT=deb"
echo "SHA=$sha"
echo "VENUE=local"
else
echo "Error: local venue has no docker or deb target" >&2
exit 2
fi
;;
*)
echo "Error: BUILD_VENUE must be local or ci (got ${venue})" >&2
exit 2
;;
esac
+27 -2
View File
@@ -17,7 +17,8 @@ usage() {
# 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)
# PLATFORMS Optional. Else makefile.builder ARCHES mapped to
# linux/<arch>,… ; else linux/amd64 (or --platform)
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
# DOCKER_CONTEXT Optional, default: . (--context)
# DOCKER_BUILDER Optional buildx builder name (--builder)
@@ -53,7 +54,7 @@ fi
registry=${DOCKER_REGISTRY:-}
repository=${DOCKER_REPOSITORY:-$git_repo_name}
tag=${IMAGE_TAG:-}
platform=${PLATFORMS:-linux/amd64}
platform=${PLATFORMS:-}
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
build_context=${DOCKER_CONTEXT:-.}
builder=${DOCKER_BUILDER:-}
@@ -126,6 +127,29 @@ if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
exit 3
fi
if [[ -z "$platform" ]]; then
if [[ -n "$project_root" && -f "$project_root/makefile.builder" ]]; then
arches=$(make -C "$project_root" -f makefile.builder -s --no-print-directory arches 2>/dev/null || true)
mapped=
for arch in $arches; do
case "$arch" in
amd64|arm64)
if [[ -n "$mapped" ]]; then
mapped="$mapped,linux/$arch"
else
mapped="linux/$arch"
fi
;;
*)
echo "Error: unsupported ARCHES entry from makefile.builder: $arch" >&2
exit 2
;;
esac
done
platform=$mapped
fi
platform=${platform:-linux/amd64}
fi
if [[ "$mode" == load && "$platform" == *,* ]]; then
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
exit 2
@@ -146,6 +170,7 @@ 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
git -C "$project_root" update-index -q --refresh 2>/dev/null || true
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
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Repro: 755 maintainer scripts must pass; 644 must fail as not executable.
set -euo pipefail
root=$(cd "$(dirname "$0")" && pwd)
verify=$root/verify_deb.sh
scratch=$(mktemp -d)
trap 'rm -rf -- "$scratch"' EXIT
make_deb_755() {
local dest=$1
local pkg=$scratch/pkg-755
rm -rf "$pkg"
mkdir -p "$pkg/DEBIAN" "$pkg/usr/bin"
printf '#!/bin/sh\nexit 0\n' >"$pkg/usr/bin/noop"
chmod 755 "$pkg/usr/bin/noop"
cat >"$pkg/DEBIAN/control" <<'EOF'
Package: verify-deb-fixture
Version: 0.0.1
Architecture: amd64
Maintainer: test <test@example.com>
Description: fixture for verify_deb.sh permission check
EOF
printf '#!/bin/sh\nexit 0\n' >"$pkg/DEBIAN/postinst"
chmod 755 "$pkg/DEBIAN/postinst"
dpkg-deb --build "$pkg" "$dest" >/dev/null
}
# dpkg-deb --build rejects 644 maintainer scripts; strip the execute bit in the
# control tarball so verify_deb.sh still sees a real packaged 644 postinst.
make_deb_644_from() {
local src=$1 dest=$2
local work=$scratch/mutate
rm -rf "$work"
mkdir -p "$work/control"
cp "$src" "$dest"
( cd "$work" && ar x "$dest" )
zstd -d -c "$work/control.tar.zst" | tar -C "$work/control" -xf -
chmod 644 "$work/control/postinst"
tar -C "$work/control" -cf - . | zstd -c >"$work/control.tar.zst"
( cd "$work" && ar r "$dest" control.tar.zst )
}
ok_deb=$scratch/verify-deb-fixture_0.0.1_amd64.deb
bad_deb=$scratch/verify-deb-fixture-noexec_0.0.1_amd64.deb
make_deb_755 "$ok_deb"
make_deb_644_from "$ok_deb" "$bad_deb"
ok_out=$scratch/ok.out
bad_out=$scratch/bad.out
set +e
"$verify" "$ok_deb" 0.0.1 amd64 >"$ok_out" 2>&1
ok_rc=$?
"$verify" "$bad_deb" 0.0.1 amd64 >"$bad_out" 2>&1
bad_rc=$?
set -e
fail=0
if [[ $ok_rc -ne 0 ]] || ! grep -q 'VERIFY: OK' "$ok_out"; then
echo "FAIL: 755 postinst should be treated as executable" >&2
cat "$ok_out" >&2
fail=1
fi
if [[ $bad_rc -eq 0 ]] || ! grep -q 'not executable' "$bad_out"; then
echo "FAIL: 644 postinst should fail as not executable" >&2
cat "$bad_out" >&2
fail=1
fi
if ((fail)); then
exit 1
fi
echo "test_verify_deb.sh: OK"
+1
View File
@@ -69,6 +69,7 @@ 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
git -C "$project_root" update-index -q --refresh 2>/dev/null || true
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
+4 -2
View File
@@ -87,8 +87,10 @@ if dpkg-deb --control "$package" "$control_dir" 2>/dev/null; then
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
# Use -x: octal last digit [2367] is "other write", so 755 (r-x) was
# reported as not executable. 644 still fails because it is not -x.
mode=$(stat -c '%a' "$control_dir/$script" 2>/dev/null || stat -f '%OLp' "$control_dir/$script")
if [[ -x "$control_dir/$script" ]]; then
echo "OK: $script mode $mode"
else
echo "FAIL: $script not executable (mode $mode)" >&2
+39 -5
View File
@@ -11,8 +11,20 @@ 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
test nearest ancestor stable tag vX.Y.Z → X.Y.(Z+1)~YYYYMMDDTHHMM.branch.n+gSHA
no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDDTHHMM.branch+gSHA
YYYYMMDDTHHMM is the build minute (day plus time of day). SOURCE_DATE_EPOCH,
if set, is that instant in UTC; otherwise the local date and time. Official
X.Y.Z never gets a date. Minute precision is required for apt: Debian version
comparison ignores upload order, so same-day rebuilds that differ only in
+gSHA compare as arbitrary text (a newer build can sort below an older one).
Switching from the date-only scheme: Debian orders letters below punctuation,
so 'YYYYMMDDT…' compares *below* the old 'YYYYMMDD.…' form. Packages built on
the switch day therefore rank under that day's date-only packages until those
are removed or disabled in the repository; from the next day on, the new form
always ranks higher.
Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not
the highest version in the repository. This script does not fetch tags.
@@ -59,6 +71,15 @@ is_official_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# Mechanical sort key: next patch of a stable tag / X.Y.Z version.
# v1.2.0 → 1.2.1. Not a claim that the next official release is 1.2.1.
bump_patch() {
local v=$1 major minor patch
v=${v#v}
IFS=. read -r major minor patch <<<"$v"
printf '%s.%s.%s' "$major" "$minor" "$((10#$patch + 1))"
}
# Strip one leading v if present.
strip_v() {
local v=$1
@@ -100,6 +121,18 @@ short_sha() {
gitc rev-parse HEAD | cut -c1-7
}
# Build-minute YYYYMMDDTHHMM. SOURCE_DATE_EPOCH (UTC) wins when set, for
# reproducible test packages; otherwise the machine's current local time.
# The minute is not decoration: it is what keeps same-day rebuilds ordered in
# Debian version comparison (0.0.0~20260918T1036… < 0.0.0~20260918T1722…).
build_stamp() {
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then
date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%dT%H%M
else
date +%Y%m%dT%H%M
fi
}
# Highest stable tag pointing at HEAD, or empty.
official_tag_at_head() {
local tag best=
@@ -135,7 +168,7 @@ nearest_stable_tag() {
}
derive_canonical() {
local tag base n sha branch
local tag base n sha branch stamp
tag=$(official_tag_at_head)
if [[ -n "$tag" ]]; then
printf '%s' "${tag#v}"
@@ -143,13 +176,14 @@ derive_canonical() {
fi
sha=$(short_sha)
branch=$(branch_slug)
stamp=$(build_stamp)
base=$(nearest_stable_tag)
if [[ -z "$base" ]]; then
printf '0.0.0~%s+g%s' "$branch" "$sha"
printf '0.0.0~%s.%s+g%s' "$stamp" "$branch" "$sha"
return
fi
n=$(gitc rev-list --count "${base}..HEAD")
printf '%s~%s.%s+g%s' "${base#v}" "$branch" "$n" "$sha"
printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$stamp" "$branch" "$n" "$sha"
}
to_docker() {
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
set -euo pipefail
# Poll the project CI status script until the exact SHA finishes.
# Does not trigger a pipeline. Does not print tokens.
#
# Usage:
# wait_ci.sh --sha SHA [--root DIR] [--timeout SEC]
#
# The project script is CI_STATUS_SCRIPT (makefile.builder, default
# .pouch/builder/ci-status.sh). See references/ci-status.md.
usage() {
sed -n '2,14p' "$0"
}
sha=
root=
timeout_sec=1200
poll_sec=${BUILDER_CI_POLL_SEC:-10}
appear_sec=${BUILDER_CI_APPEAR_SEC:-90}
while (($#)); do
case "$1" in
--sha) [[ $# -ge 2 ]] || { echo "Error: --sha needs a value" >&2; exit 2; }; sha=$2; shift 2 ;;
--root) [[ $# -ge 2 ]] || { echo "Error: --root needs a value" >&2; exit 2; }; root=$2; shift 2 ;;
--timeout) [[ $# -ge 2 ]] || { echo "Error: --timeout needs a value" >&2; exit 2; }; timeout_sec=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$root" ]]; then
root=$(git rev-parse --show-toplevel 2>/dev/null || true)
fi
if [[ -z "$root" || ! -d "$root/.git" && ! -f "$root/.git" ]]; then
echo "Error: --root must be a git project" >&2
exit 2
fi
if [[ -z "$sha" ]]; then
echo "Error: --sha is required" >&2
exit 2
fi
sha=$(git -C "$root" rev-parse "$sha")
if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi
resolve_status_script() {
local configured=${CI_STATUS_SCRIPT:-}
if [[ -z "$configured" && -f "$root/makefile.builder" ]]; then
configured=$(make -s --no-print-directory -C "$root" -f makefile.builder print-CI_STATUS_SCRIPT 2>/dev/null | tail -n 1 || true)
fi
configured=${configured:-.pouch/builder/ci-status.sh}
local path
if [[ "$configured" == /* ]]; then
path=$configured
else
path=$root/$configured
case "$configured" in
.pouch/builder/*) ;;
*)
echo "Error: relative CI_STATUS_SCRIPT must be under .pouch/builder/: $configured" >&2
exit 2
;;
esac
fi
if [[ ! -f "$path" ]]; then
echo "Error: CI status script missing: $path" >&2
echo "Run builder init with BUILD_VENUE=ci to write .pouch/builder/ci-status.sh" >&2
exit 2
fi
if [[ ! -x "$path" ]]; then
echo "Error: CI status script is not executable: $path" >&2
exit 2
fi
printf '%s' "$path"
}
parse_status() {
python3 -c '
import sys
text = sys.stdin.read()
values = {}
for raw in text.replace("\n", " ").split():
if "=" in raw:
key, value = raw.split("=", 1)
values[key.upper()] = value
print(values.get("STATUS", "error").lower())
print(values.get("ID", "-"))
print(values.get("PIPELINE", "-"))
print(values.get("JOBS", ""))
'
}
status_script=$(resolve_status_script)
export BUILDER_PROJECT_ROOT=$root
query() {
local out err rc
err=$(mktemp)
set +e
out=$(BUILDER_PROJECT_ROOT="$root" "$status_script" --sha "$sha" 2>"$err")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: CI status script exited $rc" >&2
sed '/token/Id; /Bearer/d; /Authorization/Id' "$err" >&2
rm -f "$err"
if [[ $rc -eq 2 ]]; then
exit 2
fi
exit 3
fi
rm -f "$err"
printf '%s' "$out"
}
started=$(date +%s)
first_seen=0
empty_since=$started
while true; do
now=$(date +%s)
if (( now - started > timeout_sec )); then
echo "FAIL reason=timeout sha=$sha" >&2
exit 1
fi
raw=$(query)
mapfile -t fields < <(printf '%s' "$raw" | parse_status)
st=${fields[0]:-error}
pid=${fields[1]:--}
url=${fields[2]:--}
jobs_s=${fields[3]:-}
case "$st" in
empty)
if (( now - empty_since >= appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1
fi
sleep "$poll_sec"
continue
;;
error)
echo "Error: unexpected CI status response" >&2
echo "$raw" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3
;;
success)
echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s"
exit 0
;;
failed|failure|canceled|cancelled|skipped)
echo "FAIL reason=job_${st} sha=$sha pipeline=$url jobs=$jobs_s" >&2
exit 1
;;
running|pending|queued|waiting|in_progress|created|preparing)
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
empty_since=$now
sleep "$poll_sec"
;;
*)
empty_since=$now
sleep "$poll_sec"
;;
esac
done
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via curl. Output STATUS= keys.
# Auth: GITEA_TOKEN in the environment or .env.builder. GITEA_API_URL optional.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
read -r host path base <<<"$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^(https?)://([^/]+)(?:/(.*))?$", url)
scheme, host, path = (m.group(1), m.group(2), m.group(3) or "") if m else ("", "", "")
else:
m = re.match(r"^ssh://git@([^/]+)/(.+)$", url)
scheme, host, path = ("https", m.group(1), m.group(2)) if m else ("", "", "")
path = re.sub(r"\.git$", "", path)
print(host, path, (scheme or "https") + "://" + host)
')"
api=${GITEA_API_URL:-$base/api/v1}
api=${api%/}
[[ "$api" == */api/v1 ]] || api+=/api/v1
repo=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe="/"))')
gitea_curl() {
if [[ -n "${GITEA_TOKEN:-}" ]]; then
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 \
--config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$1"
else
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$1"
fi
}
set +e
runs=$(gitea_curl "${api}/repos/${repo}/actions/runs?head_sha=${sha}&limit=20&page=1")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: curl cannot read Gitea Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = []
for run in runs:
head = run.get("head_sha") or run.get("headSha") or (run.get("head_commit") or {}).get("id")
if head and head.lower() == sha.lower() and (run.get("id") or run.get("database_id")):
matched.append(run)
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id")
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitHub Actions for an exact SHA via gh. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
repo=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
set +e
raw=$(gh run list --repo "$repo" --commit "$sha" --limit 20 --json databaseId,status,conclusion,url,headSha)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $repo" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if not data:
print("STATUS=empty")
raise SystemExit(0)
r = data[0]
status = str(r.get("status") or "").lower()
conc = str(r.get("conclusion") or "").lower()
if status == "completed" and conc == "success":
state = "success"
elif status == "completed":
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, r.get("databaseId") or "-", r.get("url") or "-"))
' <<<"$raw"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitLab CI for an exact SHA via glab. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v glab >/dev/null 2>&1; then
echo "Error: glab is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
encoded=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
set +e
raw=$(glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $path" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("STATUS=error")
raise SystemExit(0)
if not isinstance(data, list) or not data:
print("STATUS=empty")
raise SystemExit(0)
p = data[0]
st = str(p.get("status") or "").lower()
if st in ("success", "passed"):
state = "success"
elif st in ("failed", "canceled", "cancelled", "skipped"):
state = "failed"
elif st in ("",):
state = "empty"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, p.get("id") or "-", p.get("web_url") or "-"))
' <<<"$raw"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via tea. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v tea >/dev/null 2>&1; then
echo "Error: tea is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
[[ -n "$path" ]] || { echo "Error: cannot parse origin" >&2; exit 3; }
set +e
runs=$(tea api "repos/${path}/actions/runs?head_sha=${sha}&limit=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: tea cannot read Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = [run for run in runs if str(run.get("head_sha") or run.get("headSha") or "").lower() == sha.lower()]
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id") or "-"
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+6 -1
View File
@@ -10,4 +10,9 @@
# DOCKER_REGISTRY=
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS=
# PLATFORMS= # optional override; project ARCHES lives in makefile.builder
# GITEA_API_URL= # optional for the curl CI status script
# GITEA_TOKEN= # optional token for private Gitea repositories
# COPY_DEST= # fallback: local dir, or docker dest container:/absolute/dir
# COPY_SCRIPT= # external/temporary executable override; project-local paths belong in makefile.builder
# COPY_AS= # optional extra basenames for the first file
@@ -0,0 +1,58 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# Android 轨道:build 产出 dist/<name>_<version>_<arch>.apk,push 走 copy_artifact.sh。
# copy_artifact.sh 会按 COPY_SCRIPT 分发;未配置时回退到 COPY_DEST。
# ARCHES 是构建机 ISA;APK 可含多 ABI。不要把这些目标写进用户 Makefile。
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
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
APK_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).apk
.PHONY: help version arches clean build android push
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 Release APK"
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 分发 APK"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实 assemble 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。
build:
mkdir -p $(DIST_DIR)
cp app-release.apk $(APK_OUT)
android: build
push:
$(BUILDER_SCRIPTS)/copy_artifact.sh $(APK_OUT)
+66 -11
View File
@@ -2,59 +2,114 @@
# 不要把这些目标写进用户的 Makefile / makefile。
# 初始化时按项目轨道删掉未使用的 deb / docker 段。
# 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。
# build 必须尊重 $(ARCH)。要发双架构时把 arm64 写进 ARCHES,不要改 .env.builder。
#
# include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。
ARCH ?= amd64
ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))
$(error ARCH must be amd64 or arm64)
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
# local: make docker/deb on this machine. ci: obtain.sh polls
# CI_STATUS_SCRIPT for an exact SHA, then inspects the artifact. Not a track.
BUILD_VENUE ?= local
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
# Fan-out over ARCHES only when ARCH was not set on the CLI/environment.
_ARCH_FROM_FILE := $(filter file default undefined,$(origin ARCH))
_MULTI_ARCH := $(and $(_ARCH_FROM_FILE),$(filter-out 1,$(words $(ARCHES))))
_PUSH_DEB_ARCHES := $(if $(_ARCH_FROM_FILE),$(ARCHES),$(ARCH))
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
BUILDER_SCRIPTS := $(BUILDER_SKILL_DIR)/scripts
else ifneq ($(wildcard $(HOME)/.pouch/skills/builder/scripts/version.mk),)
include $(HOME)/.pouch/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.pouch/skills/builder/scripts
else
include $(HOME)/.agents/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.agents/skills/builder/scripts
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version clean build deb docker push push-deb push-docker
.PHONY: help version arches venue clean build deb docker obtain push push-deb push-docker
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build"
@echo " make -f makefile.builder version - 打印规范版本 / canonical version"
@echo " make -f makefile.builder arches - 打印架构清单 / print ARCHES"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder deb - 打 DEB(只构建不上传)"
@echo " make -f makefile.builder deb - 打 DEB(按 ARCHES;只构建不上传)"
@echo " make -f makefile.builder docker - 本地单平台镜像"
@echo " make -f makefile.builder obtain - 按 BUILD_VENUE 拿到产物"
@echo " make -f makefile.builder push / push-deb / push-docker"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
venue:
@echo $(BUILD_VENUE)
print-%:
@echo $($*)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实构建命令。不得 curl/scp/docker push。
# 替换为项目真实构建命令,且必须使用 $(ARCH)。不得 curl/scp/docker push。
build:
mkdir -p $(DIST_DIR)
@echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)"
ifdef _MULTI_ARCH
deb:
$(foreach a,$(ARCHES),$(MAKE) -f makefile.builder --no-print-directory deb ARCH=$(a) &&) true
else
deb: build
mkdir -p $(DIST_DIR)
dpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
rm -rf $(DIST_DIR)/packaging-$(ARCH)
cp -a packaging $(DIST_DIR)/packaging-$(ARCH)
sed -e 's/^Architecture:.*/Architecture: $(ARCH)/' \
$(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control \
> $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped
mv $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped \
$(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control
dpkg-deb --build $(DIST_DIR)/packaging-$(ARCH) $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
endif
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
$(BUILDER_SCRIPTS)/upload_deb.sh $(foreach a,$(_PUSH_DEB_ARCHES),$(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(a).deb)
obtain:
$(BUILDER_SCRIPTS)/obtain.sh
ifeq ($(BUILD_VENUE),ci)
push-docker:
$(BUILDER_SCRIPTS)/obtain.sh
else ifdef _ARCH_FROM_FILE
push-docker:
$(BUILDER_SCRIPTS)/publish_docker.sh
else
push-docker:
PLATFORMS=linux/$(ARCH) $(BUILDER_SCRIPTS)/publish_docker.sh
endif
# 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker
push: push-deb
@@ -0,0 +1,58 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# iOS 轨道:build 产出 dist/<name>_<version>_<arch>.ipa,push 走 copy_artifact.sh。
# copy_artifact.sh 会按 COPY_SCRIPT 分发;未配置时回退到 COPY_DEST。
# ARCHES 是构建机 ISA。不要把这些目标写进用户 Makefile。
ARCHES ?= arm64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
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
IPA_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).ipa
.PHONY: help version arches clean build ios push
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 IPA"
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 分发 IPA"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实 xcodebuild/export 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。
build:
mkdir -p $(DIST_DIR)
cp app.ipa $(IPA_OUT)
ios: build
push:
$(BUILDER_SCRIPTS)/copy_artifact.sh $(IPA_OUT)
@@ -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` 和校验原始输出。
- 到上限或反复失败时停下,并说明当前进度和未通过项,而不是假装完成。
+27
View File
@@ -0,0 +1,27 @@
# delivery-loop
供其他交付类 skill 复用的状态机,统一处理验证、PR、合并、回归、构建和部署的顺序与安全门。
## 什么时候使用
通常由 `$ship` 或 `$pr-ship` 派发的 worker 读取,不需要单独调用。
入口主会话不要把本 skill 读进自己的上下文。
```text
按 delivery-loop 把当前 ship 推进到 review_ready。
```
## 使用前准备
- 入口已绑定 ship id 时,同时安装 `ship`,证据指针写进该记录。
- 项目已有有效的 ACK delivery profile 或等价交付配置。
- 已明确任务的目标分支、测试范围和允许的停止点。
- 需要外部写入时,当前请求已经授权对应动作。
## Agent 会做什么
按配置推进任务,逐阶段记录验证证据;遇到缺配置、失败或高风险变更时停止,不自动猜测。
## 如何判断完成
最终报告会明确停在 PR、合并、回归、产物或部署中的哪一状态,并给出可复核证据。
+36
View File
@@ -0,0 +1,36 @@
---
name: delivery-loop
description: >-
为修复、PR 和需求任务提供可配置的验证、发 PR、合并、回归、构建、发布与部署状态机。
仅作为 ship、pr-ship 的共享执行规则使用。
---
# Delivery Loop:共享交付状态机
这是 ship 与 pr-ship 共用的内部规则,不直接替用户选择生产发布,也不替项目保存环境事实。
## 使用方式
由 `ship` / `pr-ship` **派出来的 worker** 读取本 skill 和
`references/state-machine.md`。入口主会话不要读取或执行本 skill。
入口决定任务类型、停止点和派哪一步;本 skill 负责该步的阶段要求、证据和安全门。
## 不可违反的规则
- 复用现有 `hunt`、`tester`、`check`、`builder`、`deployer`、`releaser` 和
`orchestration` 能力;不要在本 skill 里复制它们的实现。`artifact_built` 只调用
builder `obtain.sh`,不要在本 skill 里 poll GitLab/GitHub。
- 先读取项目的 delivery profile、分支策略和当前工作树,再决定可执行的停止点。
- `stopAt` 未配置或与用户目标冲突时停止并报告缺口,不猜测是否应合并或部署。
- 提交、推送、合并、打 tag、发布产物和生产部署都必须同时满足项目配置与当前请求的授权。
- 不绕过审核、CI、测试失败、脏工作树、冲突、未确认的迁移或不可回滚的外部变更。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。入口已绑定 `ship-id` 时,把
指针写入 `.pouch/ship/<id>/current.yaml` 的 `evidence`(规则见 ship `record.md`);
缺指针则该阶段未完成。
- 失败时保留现场和回滚信息,不用重试掩盖失败,不伪造发布或部署成功。
## 结果
完成时返回状态机最终状态、停止点、变更 commit、PR/tag、测试与回归证据、产物摘要、部署
目标和回滚指针。若停在中间状态,明确下一步和阻塞原因。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Delivery Loop"
short_description: "Shared delivery state machine for workflow skills"
default_prompt: "Use $delivery-loop to inspect a delivery workflow state and its safe stopping point."
policy:
allow_implicit_invocation: false
@@ -0,0 +1,62 @@
# Delivery Loop 状态机
## 状态
```text
change_ready -> verified -> pr_opened -> merged -> regression_passed -> artifact_built -> published -> deployed
```
入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目
Ship 配置使用以下 canonical `stopAt`:
- `develop_verified`:开发阶段验证完成,包含单元测试、新增功能集成测试和静态检查;不等于完整回归。
- `review_ready`:PR/MR 已创建,等待审核。
- `release_ready`:PR 已合并且完整回归通过,映射内部 `regression_passed`;不表示已发布或部署。
- `released`:产物已构建并发布,映射内部 `published`。
- `deployed`:目标环境实际部署完成,映射内部 `deployed`。
旧记录可继续使用 `verified`(`develop_verified` 的别名)和 `validation_ready`
(`release_ready` 的别名);新记录应使用 canonical 名称。
入口 skill 的自然语言停止点映射如下:
| 用户目标 | Ship stopAt | 必要内部里程碑 |
| --- | --- | --- |
| 只做开发验证 | `develop_verified` | `verified` |
| 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 |
| 合并后完整回归 | `release_ready` | `merged`、`regression_passed` |
| 构建并发布产物 | `released` | `merged`、`regression_passed`、`artifact_built`、`published` |
| 实际部署 | `deployed` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
## 每个阶段的要求
- `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。
- `verified`:开发阶段根因或需求验收明确;任务相关单元测试、新增功能集成测试和静态检查通过。
- `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。
- `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。
- `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。
回归 worker 必须先发现项目及其子项目的 tester catalog(使用
`catalog.py roots --root <project-root>`),逐个测试根执行并记录独立快照;父仓库
没有 catalog 不能遮蔽有 catalog 的 `app` 子项目。任一发现的测试根未执行或无证据,
不得把阶段标为 `regression_passed`。还必须执行项目 profile 或
`.pouch/ack/project.md` 声明的项目原生测试套件,并为每个套件记录命令和结果;任一
测试面缺少结果时都不能通过回归。
- `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。
- `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过。
## 配置和授权
读取项目自己的 delivery 配置,不把命令、主机、秘密复制到通用 skill。优先使用 ACK
的 `delivery.yaml`、builder/deployer/tester 契约和项目版本策略;缺失时停止在最近的安全状态。
生产部署必须同时满足:项目 profile 允许、目标明确、构建来源已固定、回归证据完整、当前
请求覆盖生产部署。没有这些条件时只能报告,而不能用“用户之前说过发布”替代当前授权。
## 证据账本
至少记录:基线和最终 commit、工作树状态、测试命令及结果、黑盒/真机证据、PR/MR URL、
合并 commit、tag、产物路径与摘要、部署节点/服务、健康检查、失败项和回滚指针。
已绑定 ship 记录时,上述证据以指针写入该 ship 的 `current.yaml`,不要另建一份账本。
阶段与字段对应见 ship `references/record.md`。
+25 -11
View File
@@ -5,26 +5,36 @@
支持三种用法:
- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有 Compose 服务(如 app00)
- **项目内 Compose 环境**:在普通项目里放 `.pouch/deployer/{prod,test,dev}/`,
把这个项目的生产/测试/开发环境用 rsync + docker compose 部署
- **项目内 Compose 环境**:在普通项目里放 `.pouch/deployer/{prod,test,dev}/`;测试环境在本机
触发 Compose,正式环境委派给本机 `~/code/app00` 项目的 Agent terminal,非正式远程环境才用
rsync + docker compose
- **Argo CD / GitOps**:项目里放 `.pouch/deployer/argocd.yaml`;Agent 改 GitOps 并开 MR,
你合并后由 Argo CD 同步。仓库既可以只写 Git 地址(部署时浅 clone),也可以指定本机已有目录。
## 什么时候使用
- 给新项目初始化 deployer:检查缺什么,引导补 `.pouch/deployer/`
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
- 想用一套固定流程更新测试环境,或把正式部署委派给本机 `~/code/app00` 的 Agent
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
- 想给当前项目加 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:本机 Python 3、Docker Compose v2;测试只需本机 Docker,非正式远程环境才需要 `ssh`/`rsync`(无 rsync 时 sync 用 `scp` 兜底)
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
@@ -50,6 +60,7 @@ web1 能出网,直接让它从 https://... 把包拉下来装
# 项目内环境
给这个项目建好 .pouch/deployer,prod 和 test 分别放到两台机器上
把 test 环境重新部署一下
本机测试镜像已经有了,直接重启,别 sync
prod 的 compose 加个 redis,改完发上去
# Argo CD
@@ -62,8 +73,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`)
3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启)
2. 测试目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
3. 正式目标:在本机 `~/code/app00` 创建 fresh Agent terminal,由该 Agent 执行 app00 的正式入口;当前 Agent 不直接 SSH
4. 其它非正式远程目标:用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
5. 同步后查看容器状态和日志确认生效
6. 只针对你指定的那一个服务操作,不会批量动整台机器
@@ -73,13 +85,15 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
每次 sync 前会把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,每服务只留最近 5 份;
备份失败则中止同步。数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。
涉及删除数据卷、清理远程文件的操作会先向你确认。
## 如何判断完成
- 脚本输出显示同步完成、远程命令执行成功
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
- 测试本机:`docker compose ps` 容器 Up。正式环境:app00 Agent 回报单服务退出码、容器状态、镜像 tag/digest 和健康检查证据;非正式远程:sync 成功且 `remote.py ps` 容器 Up
- `logs` 无报错;升级后镜像 tag 与配置一致
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
- 域名/端口类服务能 curl 通
- Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问
+35 -12
View File
@@ -1,14 +1,16 @@
---
name: deployer
description: >-
初始化或检查项目部署配置;管理多机 Docker Compose(脚本同步后远程
compose)与 Argo CD GitOps(改清单开 MR,用户合并后同步)。触发词:初始化
初始化或检查项目部署配置;管理测试环境的本机 Docker Compose、通过 app00 Agent
terminal 触发正式环境部署,以及 Argo CD GitOps(改清单开 MR,用户合并后同步)。触发词:初始化
deployer、部署、sync、升级、装 deb、ArgoCD、GitOps;ACK 拉起测试环境时也可使用。
---
# deployer:Compose 节点与 Argo CD GitOps
# deployer:测试本机 Compose、正式 Agent 部署与 Argo CD GitOps
- **Compose**:本地改 `compose.yaml` → 脚本同步到 SSH 节点 → 远程 `docker compose`。
- **测试 Compose**:本地镜像/配置 → 在本机环境目录触发 `docker compose`,不 SSH、不走远程同步。
- **正式部署**:不由当前 Agent 直接 SSH;把单服务部署任务委派给本机 `~/code/app00` 项目的
fresh Agent terminal,由该 Agent 按 app00 的正式入口执行并回报证据。
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用 Compose 的 `sync.py`/`remote.py` 去推集群。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
@@ -19,17 +21,19 @@ description: >-
- 初始化、接入 deployer,或给新项目建测试/生产环境:执行「初始化」。
- 检查 `.pouch/deployer`、node、compose 是否齐全:执行「检查」。
- 部署、同步、升级、重启、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- 部署、重新部署、同步、升级、重启、布测试环境、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- ArgoCD / GitOps / 开 MR 部署:执行「Argo CD」。步骤见 [argocd.md](references/argocd.md)。
- 两者都有且意图不清:先问。
- 不适用:单机 docker、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。
- 不适用:没有 `.pouch/deployer/` 的随手 `docker compose`、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。测试 node 必须是本机;正式环境不在这里直接 SSH。
## 边界
- 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。
- `rsync` 带 `--delete`:运行时数据必须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- `rsync` 带 `--delete`:sync 前先把远程服务目录拷到 `$HOME/bakeup/deployer-skill/<name>/<时间戳>/`(每服务留 5 份);运行时数据仍须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。
- `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- `node` 是非正式 Compose 轨道的 `~/.ssh/config` Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- `.pouch/deployer/test` 固定本机触发:若 node、镜像或 Compose 不满足本机条件,直接报告 `environment-fail`,禁止退回 SSH。
- `.pouch/deployer/prod` 固定走 [production-agent.md](references/production-agent.md),禁止执行 `sync.py`、`remote.py` 或直接 SSH。
- 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
- 不 `--force` 推送、不硬 reset,除非用户明确要求。Argo CD 不直接推默认分支。
- 镜像固定 tag,不用 `:latest`;成对升级的服务要同步升。
@@ -41,7 +45,7 @@ description: >-
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 草稿,用户确认后写入,不发明多服务网格。
3. Compose / 新项目(ACK 默认需要 `test`):默认只建 `test`;`prod`/`dev` 用户点名再加。`test` 记录本机执行;`prod` 只记录用于生成 app00 selector 的 `node/name`,不要求当前 Agent 的 SSH 部署配置,并确认 app00 Agent 路由可用。没有必要字段就不要发明假值,目录可建、列为待配置。有根目录 compose:提议迁到 `.pouch/deployer/<env>/`,确认后才动。只有 Dockerfile:可给单服务 compose 草稿,用户确认后写入,不发明多服务网格。
4. Argo:只问 GitOps `repo`(或 `repo_dir`),写 `.pouch/deployer/argocd.yaml`。不 clone、不开 MR、不 `kubectl apply`。
5. 运行(不 SSH、不 up):
@@ -56,7 +60,7 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: ssh / rsync(缺则怎么装,不擅自安装)
工具链: ssh / rsync(无 rsync 时 sync 用 scp 兜底,不擅自安装)
下一步: 一句话
```
@@ -66,11 +70,30 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
## 被 ACK 调用
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对 `.pouch/deployer/<env>`(通常是 `test`)按「Compose 操作」执行。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的测试本机路径布环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
## Compose 操作
读 [compose.md](references/compose.md)。用 `list.py` 摸底,再按意图对**单个服务**执行 sync/up/recreate/upgrade/restart。解析 `_config.yaml` 见 [config-reference.md](references/config-reference.md)。sync 报错但 ssh 正常时,按 compose.md 的 NAS 兜底处理。每次操作后 `remote.py <svc> ps` / `logs` 验证。
重启 / 重新部署 / 同步 / 布测试环境走默认路径。不要读 compose.md、不要初始化、不要读 Makefile。
1. 在项目根执行:`python3 -I -S <skill-dir>/scripts/deploy/list.py`
2. 只操作**一个**服务:用户点名的 env;「测试」→ `test`,没有 `test` 时只能选择明确为本机的非 `prod` 环境,否则报告 `environment-fail`。未点名 `prod` 不要动它。
3. 不要改 compose tag、不要构建镜像、不要看其它环境。
4. `test` 环境:确认解析出的 node 是本机、`docker image inspect` 能看到 compose tag;进入 `.pouch/deployer/test/` 执行 `docker compose up -d --force-recreate`,再 `docker compose ps`。不要 `sync.py`、`remote.py`、SSH 或创建部署 Agent terminal。
5. `prod` 环境:停止本轨道的 Compose 操作,读取 [production-agent.md](references/production-agent.md),在本机 `~/code/app00` 项目创建 fresh Agent terminal 并委派正式部署;当前 Agent 不得直接执行 `sync.py`、`remote.py` 或 SSH。
6. 只有明确点名的非正式远程 Compose 环境,才允许 `sync.py <svc>` 后调用 `remote.py <svc> recreate`;只重启调用 `remote.py <svc> restart`,并用 `remote.py <svc> ps` 验证。
test-preflight 的一次性部署入口为 `scripts/deploy/prepare.py`。它从 stdin 接收
`{"operation":"prepare","environment":"test","project_root":"/abs/project","host":"...","public_host":"..."}`,
只解析项目 `.pouch/deployer/test`,复用 `lib.service_info` 的 `_config.yaml` 继承和 node
规则,确认 node 是本机后才按 `docker compose config`、`up -d --force-recreate`、`ps`
顺序准备单个 test 服务。输出始终是 `{status, observed, deployment_id?, checks?}` JSON;
prod、缺配置、非本机 node 为 `blocked`,Compose/命令失败为 `environment-fail`。诊断只写
stderr,不输出 `.env` 或命令输出中的凭据。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。
## Argo CD
+6 -3
View File
@@ -81,6 +81,9 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 意图 | 命令 |
|------|------|
| 本机已有镜像(node 是 localhost,image 已在本机 docker) | 不要 sync。在 env 目录 `docker compose up -d --force-recreate` |
| 正式环境 | 不在当前 Agent 直接 sync/remote/SSH;委派给本机 `~/code/app00` Agent terminal,见 [production-agent.md](production-agent.md) |
| 重新部署非正式远程环境 | `sync.py <svc> && remote.py <svc> recreate` |
| 只同步文件,不动容器 | `sync.py <svc>` |
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
| 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) |
@@ -88,9 +91,9 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 仅重启,不同步文件 | `remote.py <svc> restart` |
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。没有 Makefile 时直接调 python 脚本,不要新建封装层。
脚本一律 `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 挂载的远程绝对路径,否则会被清掉。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。同步前先把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`(每服务保留最近 5 份,备份失败则中止)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
## 新增服务 / 环境
@@ -109,7 +112,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
2. 每个环境写 `compose.yaml`;共享的 node/base_path 放 `.pouch/deployer/_config.yaml`
3. 环境有差异 → 在该环境的 `_config.yaml` 覆盖
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉)
5. 首次测试部署走本机 Compose;非正式远程环境首次部署时,远程目录不存在或为空时跳过备份;已有内容会被拷到 `$HOME/bakeup/deployer-skill/` 再同步。正式环境不在此处首次部署,转交 app00 Agent。
## 下线服务
@@ -28,7 +28,7 @@ name: my-project-api # 可选,覆盖默认的 {项目名}-{env}
| 字段 | 必填 | 可继承 | 说明 |
|------|------|--------|------|
| `node` | 是 | 是 | SSH 目标主机别名 |
| `node` | 是 | 是 | 非正式远程 Compose 的 SSH 目标主机别名;正式环境同时用它与 `name` 生成 app00 selector,不由当前 skill 直接 SSH |
| `port` | 否 | 是 | SSH 端口,默认 22 |
| `identity_file` | 否 | 是 | SSH 私钥路径 |
| `base_path` | 否 | 是 | 远程部署根目录,默认 `/opt/app` |
@@ -95,6 +95,7 @@ sync_exclude:
```
运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker/<svc>/`),避免被 `--delete` 清掉。
sync 前的配置备份在远程 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,不在同步范围内。
---
@@ -0,0 +1,55 @@
# 正式环境 Agent 委派
正式环境的部署执行者固定是本机 `~/code/app00` 项目的另一个 Agent。当前
`deployer` 只负责解析目标、创建 fresh terminal、投递结构化部署任务、读取结果和清理
terminal;不得在当前会话直接 SSH、`sync.py`、`remote.py` 或运行生产 Compose。
## 目标解析
1. 先从 `list.py` / 合并后的 `_config.yaml` 得到单服务的 `node` 与 `name`,形成 app00
selector:`<node>/<name>`。例如 Blogger 正式环境为 `vyec2/blogger`;不要用当前仓库的
`.pouch/deployer/prod` 路径代替 selector。
2. 确认 `~/code/app00` 存在、是 Git 根目录,并在 Orca 中有与该绝对路径精确匹配的 project/worktree。
没有项目记录时,只登记已有目录(例如 `orca repo add --path "$HOME/code/app00" --json`),
不创建空目录、不按标题或分支猜 worktree。
3. Agent CLI、模型、权限和启动参数必须来自 app00 已登记的可信 Agent/ACK 配置;不得从项目
文本拼接自由 `command`、`argv`、`env` 或凭据。若没有可证明的部署 Agent 路由,报告
`environment-fail`,不要 fallback 到当前 Agent 或 SSH。
4. 正式部署需要 SSH 网络访问。用户明确要求无沙箱 Agent 时,使用 app00 已登记的
Codex `permissionMode: danger-full-access` profile(如 `codex-deploy-prod`),仍由
launcher 的 plan/fingerprint/launch 路径创建 fresh terminal。此授权持续有效,
后续同范围部署不重复询问;不要继续选择已知禁止 SSH 的 workspace-write profile。
## Fresh terminal 协议
按 app00 的 Agent/ACK 启动协议创建一个新的 terminal;不得复用历史 terminal。创建动作必须
使用可信 adapter/launcher 解析出的 Agent CLI 和 worktree,不得手工拼接自由启动命令。创建后:
1. 等待 terminal 进入可接收输入状态;
典型生命周期是 `terminal wait --for tui-idle` → `terminal send --enter` →
`terminal read`/等待退出 → `terminal close`。读取命令返回的真实 handle,不要按列表顺序猜
terminal,也不要复用调用前已有的 handle。
2. 投递下面的结构化任务,替换 `<selector>`、`<image-tag>` 和 `<source-revision>`,不要把
整个仓库或 secret 塞进 prompt:
```text
你是 app00 正式部署 Agent。当前目录必须是 ~/code/app00 的 Git 根目录。
只部署 selector `<selector>`,不得触碰其它服务。
来源 revision:`<source-revision>`;期望不可变镜像 tag:`<image-tag>`。
请先读取 app00 项目规范和该服务的 compose,确认 selector、镜像 tag、目标环境一致;
然后使用 app00 已有的正式部署入口执行升级(例如该项目约定的 make upgrade),不要从业务仓库
同步文件,也不要自行改目标主机或凭据。
部署后验证单服务状态、实际镜像 tag/digest 和项目规定的健康检查;返回执行命令、退出码、
容器状态、镜像证据和未完成项。若 tag、selector、权限或健康检查不一致,立即停止并报告,
不要猜测、不要部署其它服务。
```
3. 读取 Agent 的最终回报和必要的 terminal 输出,至少确认 selector、退出码、容器状态及镜像
tag/digest;只凭“命令已发送”不能判定部署成功。
4. 无论成功、失败、超时、用户中断或读取异常,都关闭本轮创建的 terminal。关闭失败保留
handle 并报告 `cleanup-fail`,不得换一个 terminal 重试关闭。
## 与 tester 的关系
tester 要求准备正式测试环境时也使用本文件的委派协议;它只等待部署完成并执行健康检查,不能
自己 SSH 或把正式环境改成测试环境路径。测试环境则继续使用 deployer 的本机 Compose 路径。
+1 -1
View File
@@ -117,7 +117,7 @@ def check_toolchain(report: Report) -> 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)'}",
f"rsync: {'found' if rsync_ok else 'MISSING (scp fallback)'}",
]
if not ssh_ok:
lines.append("install openssh-client")
+5 -1
View File
@@ -31,7 +31,11 @@ import subprocess
import sys
from pathlib import Path
from lib import (
_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,
+6 -1
View File
@@ -6,8 +6,13 @@ from __future__ import annotations
import os
import sys
from collections import defaultdict
from pathlib import Path
from lib import discover_services, project_root
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import discover_services, project_root # noqa: E402
def main() -> int:
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""One-shot JSON adapter for preparing the project's test Compose service.
The adapter owns no deployment policy of its own: service discovery and
``_config.yaml`` inheritance stay in ``lib.service_info``. The command runner
is deliberately injectable so unit tests never need Docker, SSH, or a network.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable
_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
STATUSES = {"ready", "blocked", "environment-fail"}
LOCAL_NODE_NAMES = {"local", "localhost", "127.0.0.1", "::1"}
CommandRunner = Callable[[list[str], Path], int]
NodeChecker = Callable[[str], bool]
def response(status: str, observed: str, *, deployment_id: str = "", checks: dict[str, Any] | None = None, remediation: str = "") -> dict[str, Any]:
if status not in STATUSES:
raise ValueError(f"invalid status: {status}")
value: dict[str, Any] = {"status": status, "observed": observed}
if deployment_id:
value["deployment_id"] = deployment_id
if checks:
value["checks"] = checks
if remediation:
value["remediation"] = remediation
return value
def _check(status: str, observed: str, expected: str, remediation: str = "") -> dict[str, str]:
value = {"status": status, "observed": observed, "expected": expected}
if remediation:
value["remediation"] = remediation
return value
def run_compose(command: list[str], cwd: Path) -> int:
"""Run adapter-owned Compose argv without a shell or protocol pollution."""
try:
process = subprocess.run(
command,
cwd=str(cwd),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError:
return 127
return process.returncode
def execute_compose(executor: CommandRunner, command: list[str], cwd: Path) -> int:
"""Normalize mock/OS command failures into an environment-fail exit code."""
try:
return int(executor(command, cwd))
except (OSError, TypeError, ValueError):
return 127
def node_is_local(node: str) -> bool:
"""Accept only a local node; ``ssh -G`` is config inspection, never SSH."""
if node in LOCAL_NODE_NAMES:
return True
local_names = {socket.gethostname(), socket.getfqdn(), os.environ.get("HOSTNAME", "")}
if node in {name for name in local_names if name}:
return True
try:
process = subprocess.run(
["ssh", "-G", node],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError:
return False
if process.returncode != 0:
return False
configured_host = ""
for line in (process.stdout or "").splitlines():
key, _, value = line.partition(" ")
if key == "hostname":
configured_host = value.strip()
break
return configured_host in LOCAL_NODE_NAMES or configured_host in {name for name in local_names if name}
def _project_root(value: Any) -> Path | None:
if not isinstance(value, str) or not value.strip():
return None
root = Path(value)
if not root.is_absolute() or ".." in root.parts or os.path.normpath(value) != value:
return None
return root
def prepare_test(request: Any, *, executor: CommandRunner = run_compose, local_node: NodeChecker = node_is_local) -> dict[str, Any]:
if not isinstance(request, dict):
return response("blocked", "prepare request must be a JSON object", remediation="Send one JSON object on stdin")
if request.get("operation") != "prepare":
return response("blocked", "only operation=prepare is supported", remediation="Use the test deployer prepare contract")
environment = request.get("environment")
if environment != "test":
return response("blocked", "only the test environment is permitted", remediation="Use environment: test; production is never accepted by this adapter")
root = _project_root(request.get("project_root"))
if root is None or not root.is_dir():
return response("blocked", "project_root must be an existing absolute project directory", remediation="Provide the project root containing .pouch/deployer/test")
host = request.get("host")
if not isinstance(host, str) or not host.strip():
return response("blocked", "host is required", remediation="Provide the test service host used by preflight")
public_host = request.get("public_host")
if public_host is not None and (not isinstance(public_host, str) or not public_host.strip()):
return response("blocked", "public_host must be non-empty text when provided")
deploy_root = root / ".pouch" / "deployer"
test_dir = deploy_root / "test"
if not deploy_root.is_dir() or not test_dir.is_dir():
return response("blocked", "test deployer directory is missing", remediation="Create .pouch/deployer/test with its Compose files and _config.yaml")
lib.PROJECT_ROOT = deploy_root
try:
info = lib.service_info("test", strict=False)
except (OSError, ValueError, SystemExit):
info = None
if not info:
return response("blocked", "test Compose service is not discoverable", remediation="Ensure test/compose.yaml and inherited _config.yaml resolve a node")
node = str(info.get("node") or "")
if not node or not local_node(node):
return response("blocked", "test Compose node is not confirmed local", remediation=f"Resolve node {node or '<missing>'!r} to this machine; this adapter never falls back to SSH")
compose_file = test_dir / "compose.yaml"
if not compose_file.is_file():
return response("blocked", "test compose.yaml is missing", remediation="Provide .pouch/deployer/test/compose.yaml")
deployment_id = f"test:{info['name']}"
base = ["docker", "compose", "-f", "compose.yaml"]
checks: dict[str, Any] = {}
code = execute_compose(executor, [*base, "config", "--quiet"], test_dir)
if code != 0:
checks["compose_config"] = _check("environment-fail", f"docker compose config exited {code}", "valid test Compose configuration", "Fix the test Compose file or its local environment")
return response("environment-fail", "test Compose configuration failed", deployment_id=deployment_id, checks=checks)
checks["compose_config"] = _check("ready", "validated", "valid test Compose configuration")
code = execute_compose(executor, [*base, "up", "-d", "--force-recreate"], test_dir)
if code != 0:
checks["compose_up"] = _check("environment-fail", f"docker compose up exited {code}", "test services started", "Fix the local Docker/image/network failure and retry")
return response("environment-fail", "test Compose prepare failed", deployment_id=deployment_id, checks=checks)
checks["compose_up"] = _check("ready", "completed", "test services started")
code = execute_compose(executor, [*base, "ps"], test_dir)
if code != 0:
checks["compose_ps"] = _check("environment-fail", f"docker compose ps exited {code}", "test services report status", "Inspect local test service health")
return response("environment-fail", "test Compose status check failed", deployment_id=deployment_id, checks=checks)
checks["compose_ps"] = _check("ready", "completed", "test services report status")
return response("ready", "test Compose service prepared", deployment_id=deployment_id, checks=checks)
def main() -> int:
try:
request = json.load(sys.stdin)
except (json.JSONDecodeError, OSError):
result = response("blocked", "prepare stdin is not one valid JSON object", remediation="Send exactly one JSON object on stdin")
else:
try:
result = prepare_test(request)
except Exception:
print("deployer prepare: unexpected adapter failure", file=sys.stderr)
result = response("environment-fail", "prepare adapter failed before producing evidence", remediation="Inspect the test deployer configuration")
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -1
View File
@@ -20,8 +20,13 @@ import argparse
import os
import subprocess
import sys
from pathlib import Path
from lib import project_root, run_remote, service_info
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import project_root, run_remote, service_info # noqa: E402
REMOTE_COMMANDS = {
"up": "docker compose up -d",

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