142 Commits

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

- test_ack_skill: template must NOT contain allowedWorktrees: anymore,
  only the deprecation comment
- test_ack_worker_profiles: legacy allowlist field is tolerated (no
  'requires at least one path'); receipt no longer validated against it
- test_ack_launch_worker: capture_worktree_identity dropped the
  allowlist arg; assert registered-worktree pass, symlink impostor and
  unregistered-rejection under the new signature
2026-08-24 15:40:02 +08:00
laily a8efa5f359 feat(skiff): replace opencode target with agents standard dir
- targets: cursor, claude, codex, agents (~/.agents/skills, .agents/skills)
- agents dir covers OMP native discovery; drop omp/opencode targets
- project-level agents/cursor/codex share .agents/skills via idempotent symlinks
- migrate local state: remove ~/.config/opencode/skills links, reinstall into ~/.agents/skills
2026-08-24 14:25:08 +08:00
ace 47bd454fa3 feat(builder): merge deb-publisher + publish-docker-image into contract-driven builder skill
- skills/builder: SKILL.md, README.md, references/contract.md (make/publish
  contract v1), references/registry.md
- scripts/check.py: executable contract checker (make dry-run probes, secret
  scan, push thin-wrapper and script path checks; --build verifies real .deb)
- scripts/upload_deb.sh: migrated from deb-publisher, adds project .env
  auto-load and dirty-worktree publish gate
- scripts/publish_docker.sh: migrated from publish-docker-image publish.sh,
  now env-first (DOCKER_REGISTRY/REPOSITORY/IMAGE_TAG/PLATFORMS), refuses
  floating latest and multi-platform --load
- scripts/verify_deb.sh: metadata/content/sha256 verification with v-prefix
  normalization
- orc: deb+docker stages both route to $builder; routing table, DAGs,
  README, config untouched stage names; tests updated
- ack delivery.md + skiff source-model.md: reference builder
- remove skills/deb-publisher and skills/publish-docker-image
2026-08-24 12:52:53 +08:00
laily e7a139e2cb feat(deployer): add deb package distribution and install via scp/apt 2026-08-24 11:25:43 +08:00
laily e4d4319919 feat: add deployer 2026-08-24 09:44:59 +08:00
laily 31bc5f45ce feat: add memory-distill skill
Task-end session distill into general and per-skill project memory stores via memories/manifest.md.
2026-08-24 00:46:20 +08:00
laily 1c52a3996c feat(ack): reclaim verified worker terminals via script after round end 2026-08-23 23:14:29 +08:00
laily 5a906f24fa feat(ack): supervise worker liveness after dispatch instead of blind wait 2026-08-23 23:06:22 +08:00
laily d33bc3ccaf refactor(ack): default OMP workers to yolo approval, drop approvalMode switch 2026-08-23 22:32:30 +08:00
laily e0fffd6845 feat(ack): make OMP yolo config-driven via approvalMode 2026-08-23 22:28:58 +08:00
laily b7b6d814b2 docs(ack): clarify OMP yolo as authorized approval mode 2026-08-23 22:25:36 +08:00
laily 1a74b15ed5 feat: update 2026-08-23 22:02:06 +08:00
laily 02fafb4bf6 feat(ack): support OMP workers 2026-08-23 22:00:32 +08:00
laily 7dfdf80e9e feat(ack): add grok workers and allow --always-approve
Grok is a first-class worker CLI. Launcher argv includes --always-approve so
unattended tool calls are not blocked; sandbox stays required.
2026-08-23 19:08:54 +08:00
laily c113f68bf4 feat: add draw-prototype-flow 2026-08-04 13:55:32 +08:00
laily 5ff8899b48 feat: update manage-release 2026-08-04 13:55:16 +08:00
laily 5018a1801d feat(ack): refine intake and validation workflow 2026-08-04 10:52:37 +08:00
laily b9c82b5520 feat: update 2026-08-03 17:07:21 +08:00
laily 0954ad542a feat(ack): add Feishu bug review approval gate 2026-08-03 15:26:02 +08:00
laily 4d078e8258 docs(ack): sync version references and research sources 2026-08-03 00:41:07 +08:00
laily 6855ac7078 feat: update feishu intake 2026-08-02 14:08:11 +08:00
laily e9b34747cc fix(orc): support trusted release shell workflow 2026-08-01 21:06:42 +08:00
laily f5bf35c722 feat(orc): centralize host-aware routing 2026-08-01 20:04:55 +08:00
laily 34bbb97406 Merge pull request 'feat: add ORC engineering orchestration' (#6) from support-release into main
Reviewed-on: laily/.skills#6
2026-08-01 18:01:22 +08:00
laily 337f1a9098 feat(orc): add tiered engineering orchestration 2026-08-01 17:49:25 +08:00
laily f02a34e751 revert(ack): restore three-role collaboration scope
This reverts commit a0f1c15b85.
2026-08-01 17:48:33 +08:00
laily e7f42e835a Merge pull request 'feat(ack): add one-off delivery routing' (#5) from support-release into main
Reviewed-on: laily/.skills#5
2026-08-01 15:33:23 +08:00
laily a0f1c15b85 feat(ack): add one-off delivery routing 2026-08-01 15:29:34 +08:00
laily f8d03fad4d feat(ack): add Feishu bug intake 2026-08-01 14:37:34 +08:00
laily 5b0e41199d Merge pull request 'feat(ack): add project delivery workflow' (#4) from add-extro-info into main
Reviewed-on: laily/.skills#4
2026-08-01 14:24:32 +08:00
laily 2c3d91c75c feat(ack): add project delivery workflow 2026-08-01 12:35:42 +08:00
laily f08edb6452 Merge pull request 'feat: add manage-release skill' (#3) from release into main
Reviewed-on: laily/.skills#3
2026-08-01 10:19:39 +08:00
laily d7997091bd feat: add manage-release skill 2026-07-31 23:58:47 +08:00
laily ae3bce7b5d feat(ack): add structured worker model routing 2026-07-31 23:34:13 +08:00
laily ee66dbe9ce feat(ack): add project knowledge guardrails 2026-07-31 21:09:36 +08:00
laily 7d1994cf93 refactor: unify skill source model 2026-07-30 12:11:25 +08:00
laily 076d87e303 refactor: fold ack kit into skill 2026-07-30 11:54:02 +08:00
laily e308d6a04c feat: add reusable deb upload script 2026-07-30 10:19:28 +08:00
laily e667f58bf1 add README guidance to skill workflow 2026-07-29 21:08:04 +08:00
laily 262c138bee feat: add shallow fetch and grouped skill selection 2026-07-29 10:57:53 +08:00
laily 18ebb70911 feat: add self-update and improve skill selector 2026-07-29 10:29:48 +08:00
laily afbdde157f feat: add explicit ack workflow skill 2026-07-28 17:08:33 +08:00
laily 5f90758e22 feat: add kit project initializer 2026-07-28 16:41:41 +08:00
laily 983183d5b3 feat: support registry collections and docker publishing 2026-07-27 23:25:46 +08:00
laily b78126830b merge: custom skill sources
# Conflicts:
#	skiff/README.md
#	skiff/cli.py
#	skiff/project.py
2026-07-27 18:11:31 +08:00
laily 3411e54b13 feat: support custom skill sources 2026-07-27 18:09:13 +08:00
laily b9963b66de feat: add interactive skill selector 2026-07-27 18:07:39 +08:00
laily 86cd1fa36d feat: add skill draft workflow 2026-07-27 14:03:19 +08:00
laily 8b82539f6f fix: 复用 agent 2026-07-15 01:29:56 +08:00
laily b3765ecad6 feat(ack): enforce worker launch policy 2026-07-12 17:19:46 +08:00
laily ae05845ee7 docs(ack): add Codex model routing 2026-07-12 14:53:50 +08:00
laily af823e867f feat: add init-new-project 2026-07-07 09:50:04 +08:00
laily 61ef55c514 feat: modify ack 2026-07-07 09:44:55 +08:00
laily d11f90fd66 feat(ack): worker 模型固定与 worktree 决策说明 (v0.6.2) 2026-07-07 01:45:55 +08:00
laily f33992a0aa feat: rename 2026-07-07 01:33:48 +08:00
laily 6bd03c2f6d feat: 2026-07-07 00:53:54 +08:00
laily d7a74578dc refactor(agent-collaboration-kit): split stable core from project overrides
- Reorganize flat files into core/ templates/ examples/ scripts/ with VERSION
- Deduplicate: single-source state machine, three-round policy, acceptance signals
- Abstract orchestration in closed-loop.md with manual mode; move Orca commands to orca-adapter.md
- Add tasks.schema.json + validate_tasks.py (jsonschema or builtin rules, no hard deps)
- Add filled examples, concurrency write rule, kitVersion tracking; unify to Chinese

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:01:09 +08:00
laily 58de2650c2 docs: separate agent collaboration kit from skills
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:32:50 +08:00
laily c4c68572c3 feat: add agent-collaboration-kit 2026-07-06 09:24:22 +08:00
laily f1ed320991 feat(skiff): align CLI with Vercel skills and fix entry symlink
Replace install/uninstall with add/remove, add publish for git ops in
~/.skills, and resolve bin/skiff symlinks so commands work from any cwd.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 12:11:53 +08:00
laily 9a3285fba6 Merge pull request 'feat: add skiff' (#2) from feat/discussion-notes into main
Reviewed-on: laily/.skills#2
2026-07-04 10:52:53 +08:00
laily af0efc7870 Merge pull request 'feat: add discussion-notes skill' (#1) from feat/discussion-notes into main
Reviewed-on: laily/.skills#1
2026-07-03 00:36:22 +08:00
315 changed files with 59297 additions and 946 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
# Python(skiff CLI 运行时产物) # Python(pouch CLI 运行时产物)
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
@@ -31,3 +31,4 @@ Thumbs.db
# 本地环境配置(勿提交密钥) # 本地环境配置(勿提交密钥)
.env .env
.env.* .env.*
.pouch
+9
View File
@@ -0,0 +1,9 @@
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
version: 1
updatedAt: "2026-08-01T12:43:06+08:00"
project:
name: "skills"
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
verificationRegistry: {}
entries: []
+71
View File
@@ -0,0 +1,71 @@
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
> 本项目基于 ACK Skill v0.19.0。通用规范由 `/ack` 从 Skill 自身的
> `references/` 读取;本文件只保存当前仓库差异。
## 项目概览
- 项目:`skills`
- 技术栈:Python 3、Markdown、JSON Schema
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
- Base URL:`n/a`
- 任务板:`.pouch/ack/tasks.yaml`
- 项目知识:`.pouch/ack/knowledge.yaml`
- 覆盖层文件:`.pouch/ack/project.md`
## 通用规范(由 ACK Skill 按需读取)
- 开始需求:`references/kickoff.md`
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/orca-adapter.md`
## Worker 路由
机器可校验的 worker profile、允许 worktree 和 receipt 只以
`.pouch/ack/tasks.yaml` 为准。本次 Developer 使用当前项目根 `/home/ace/.skills`;Test
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
## 路径权限
| 路径 | Coordinator | Test | Developer | 说明 |
|------|:-----------:|:----:|:---------:|------|
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
| `.pouch/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 |
| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 |
| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
| 其它 `tests/test_ack*.py` | Read-only | Read-only | Read-only | 既有 ACK 回归测试 |
| 临时测试目录 | Read-only | R/W | R/W | 必须由 `tempfile`/`mktemp -d` 创建,不保存凭据 |
| `~/.lark-cli/` | Read-only | Read-only | Read-only | 不读取、不修改真实飞书 profile;测试只能使用假 CLI |
## 命令
Developer 白盒验证:
```bash
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'
python3 -m unittest tests.test_ack_skill tests.test_ack_tasks_validation
python3 -m compileall -q skills/ack/scripts
```
Test 黑盒复测:
```bash
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
python3 -m unittest discover -s tests -p 'test_ack*.py'
python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 skills/ack/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
```
## 本次任务硬规则
- 只支持官方 `lark-cli`,所有读取命令必须显式携带项目配置的 `--profile`,不能依赖当前 active profile。
- 项目只保存 profile 名称、Base/table/view 标识和字段映射;App Secret、token 与用户凭据不得进入仓库、任务板或日志。
- 飞书接入第一版只读,不更新或删除表格记录,不切换 CLI active profile。
- 外部 CLI 缺失、profile 不存在、返回结构异常、附件下载失败时 fail closed,不回退到网页抓取。
- 只有 `ACK Ready` 视图中的记录可进入整理;每条记录保留稳定 `record_id` 来源引用,重复读取不得制造重复 ACK 来源。
- 不修改 `AGENTS.md`、`CLAUDE.md`,不提交或推送。
+839
View File
@@ -0,0 +1,839 @@
version: 1
updatedAt: "2026-08-23T20:30:26+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.19.0"
project:
name: "skills"
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
baseUrl: "n/a"
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
overlayFile: ".pouch/ack/project.md"
knowledgeFile: ".pouch/ack/knowledge.yaml"
orchestration:
profileVersion: 1
mode: "orca"
allowedWorktrees:
- "/home/ace/orca/workspaces/.skills/record-bug"
- "/home/ace/.skills"
modelAllowlist:
codex:
developer:
standard: ["gpt-5.6-terra"]
strong: ["gpt-5.6-sol"]
test:
standard: ["gpt-5.6-luna"]
omp:
developer:
standard: ["opencode-go/gpt-5.6-luna"]
strong: ["opencode-go/gpt-5.6-luna"]
test:
standard: ["opencode-go/gpt-5.6-luna"]
profiles:
codex-dev-standard:
role: "developer"
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
codex-test-standard:
role: "test"
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
codex-dev-strong:
role: "developer"
cli: "codex"
tier: "strong"
model: "gpt-5.6-sol"
reasoningEffort: "high"
permissionMode: "workspace-write"
omp-dev-standard:
role: "developer"
cli: "omp"
tier: "standard"
model: "opencode-go/gpt-5.6-luna"
reasoningEffort: "medium"
permissionMode: "workspace-write"
omp-test-standard:
role: "test"
cli: "omp"
tier: "standard"
model: "opencode-go/gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
omp-dev-strong:
role: "developer"
cli: "omp"
tier: "strong"
model: "opencode-go/gpt-5.6-luna"
reasoningEffort: "high"
permissionMode: "workspace-write"
defaults:
developer: "codex-dev-standard"
test: "codex-test-standard"
developerUpgraded: "codex-dev-strong"
workerReceipts:
- receiptVersion: 1
id: "WR-1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
launchId: "1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:7de2f9ebf3065e7c044150fa87aed39582a8018b63a0dabac22194bf52ad52b7"
slot: 1
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A1"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_4d6278c9-83ac-4876-a29d-022adc346e6c"
incarnationId: "6daffb4c-e705-47f3-b7c2-d1ee1fb16c56"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T04:46:51.629061Z"
createdAt: "2026-08-01T04:46:42.869861Z"
receiptHash: "sha256:93e7e4454438a4d1c9a64eb7429563bb4b71348715cc4cb3e42c96cb9ca75fa4"
- receiptVersion: 1
id: "WR-035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
launchId: "035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:981fe3c23594573f564de9ab321e724324962958ed689ad3c0c98d7d165c1aa9"
slot: 2
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A1"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_ab858ba7-b972-45fb-bf50-61e96ced2830"
incarnationId: "9e20c158-7ac3-4a18-8dca-f9fec50126d6"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T04:57:33.909375Z"
createdAt: "2026-08-01T04:57:25.086609Z"
receiptHash: "sha256:1245532d66814f5d3b2a6a3b22293ead597f04390e628333569a7ba8763d0a84"
- receiptVersion: 1
id: "WR-3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
launchId: "3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:8264af6531e9828c0f994845b3a24931e22abea72d8a1d54a7c764577e544873"
slot: 3
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A2"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c"
incarnationId: "f77f05ff-fb2b-4099-ab03-070f61671673"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:01:26.824435Z"
createdAt: "2026-08-01T05:01:17.965038Z"
receiptHash: "sha256:c20c47c7491b5b399d2c29fce67a7035b432969bda0ccb74948eb03c3d085380"
- receiptVersion: 1
id: "WR-12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
launchId: "12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:621c7d5d6b7182d9a1ebf211fa11a80d7b100a3b682f8800f61031a651f5bf2a"
slot: 4
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A2"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_1ec9d2e1-a1b6-45db-8477-097e39a93ee4"
incarnationId: "a6f79ac3-9cc6-40a1-a38a-e7b7c2a49b02"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:05:36.189001Z"
createdAt: "2026-08-01T05:05:27.500696Z"
receiptHash: "sha256:824227bda0e78558807e10da9d89ec32ee4d7f37d782b866bca4629c5ac9e8fb"
- receiptVersion: 1
id: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
launchId: "90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:97ef498caf4a5f0d48fef22773c942a5065dfcf5cecefa22a6ad21df8d04bcd1"
slot: 5
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A3"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_88079d90-29a3-48ac-9b97-bec011abc249"
incarnationId: "5d4f785f-e0b5-4ab3-9de3-72ebc2da3b06"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:16:40.565911Z"
createdAt: "2026-08-01T05:16:31.788668Z"
receiptHash: "sha256:c62417b2ee8e9815228a18b2ee1be84b2844dada50eb9703a061e08e51e57eb7"
- receiptVersion: 1
id: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
launchId: "8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:6c7a45f7dd119bb6ef3bfaed7961c30f257285d940f248e0b16678dcad1bd19d"
slot: 6
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A3"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
incarnationId: "6e37ac75-5725-410c-87af-63f8bb42a911"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:25:08.564556Z"
createdAt: "2026-08-01T05:24:59.720042Z"
receiptHash: "sha256:dcbed5b2a3fa99312deba61951253992741eca885068e8f649613bd97eb3f59f"
- {"binding":{"boundAt":"2026-08-23T12:28:51.112660Z","connected":true,"handle":"term_10563050-a88d-4b59-b5c5-78c734185605","incarnationId":"b7dee6bb-8b2f-4385-8592-bc9bf44c1f5e","observedWorktreePath":"/home/ace/.skills","orchestrator":"orca","runtimeId":"76fbdbba-9fef-42d8-97be-94c9892b859c","writable":true},"boardHash":"sha256:88926bb895a370b2f6fcf7a3f7945fd00c40ad9e719e7da47b7d65605f63f16d","createdAt":"2026-08-23T12:28:43.628960Z","createdFor":{"attemptId":"ACK-OMP-001-A1","role":"developer","taskId":"ACK-OMP-001"},"id":"WR-446b1eb450c160509eea68eae4ad3aedcc730876cb5cbcfa5097ddd739c97212","launchFingerprint":"sha256:4e9bb7e8ec6035d175429f7b728ea45e01018b54d834950c6d2f6eacf8962d2a","launchId":"446b1eb450c160509eea68eae4ad3aedcc730876cb5cbcfa5097ddd739c97212","profileHash":"sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b","profileId":"codex-dev-standard","projectRoot":"/home/ace/.skills","receiptHash":"sha256:2caf851ffbf84c82352915068d9740ebf2868aadbe083475b025f0e627c69e27","receiptVersion":1,"requested":{"argv":["/home/ace/.codex/packages/standalone/releases/0.149.0-x86_64-unknown-linux-musl/bin/codex","--strict-config","--model","gpt-5.6-terra","--config","model_reasoning_effort=medium","--sandbox","workspace-write","--ask-for-approval","never","--cd","/home/ace/.skills"],"argvHash":"sha256:81ef379bd5cfad3e178e2936873d4ed342c2b33bbf05feb2d47da38dc20fe829","cli":"codex","cliVersion":"codex-cli 0.149.0","environmentPolicy":"per-cli-allowlist-v1","executable":"/home/ace/.codex/packages/standalone/releases/0.149.0-x86_64-unknown-linux-musl/bin/codex","executableDevice":2049,"executableInode":1979460,"model":"gpt-5.6-terra","permissionMode":"workspace-write","reasoningEffort":"medium","tier":"standard"},"slot":3,"worktree":{"device":2049,"gitCommonDevice":2049,"gitCommonDir":"/home/ace/.skills/.git","gitCommonInode":558841,"inode":558835,"path":"/home/ace/.skills"}}
summary:
verified: ["FEISHU-001"]
open: ["ACK-OMP-002", "ACK-OMP-003"]
failedRetest: []
leftovers: []
tasks:
- id: "FEISHU-001"
type: "feature"
title: "ACK 从飞书多维表格按项目 profile 读取并整理 Bug"
priority: "P1"
status: "verified"
assignee: "test"
component: "skills/ack"
specRefs:
- "docs/ack-feishu-bug-intake.md"
testRefs:
- "tests/test_ack_feishu_intake_unit.py"
- "tests/test_ack_feishu_intake_e2e.py"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
扩展完整 ACK Skill,使项目可以在 tasks.yaml 中声明飞书多维表格 Bug 收件箱和
lark-cli profile。ACK 通过可信、无 shell 的读取器读取 ACK Ready 视图、按需
下载截图并输出可幂等整理的标准化记录;不同项目不会串用飞书租户。
stepsToReproduce:
- "创建两个假 lark-cli profile:tenant-a 为 active,项目配置 tenant-b"
- "在 tenant-b 的 ACK Ready 视图返回一条带截图附件的 Bug 记录"
- "运行 ACK 飞书读取器 check/fetch,并重复提交同一标准化结果"
expected: >
读取器每次显式使用 tenant-b,仅读取配置 view,正确标准化文字与截图附件,
对所有错误 fail closed;ACK 按稳定 source.ref 整理,重复来源不产生新任务,
未配置飞书的旧项目保持兼容。
actual: >
当前 ACK 没有飞书 Bug 数据源、CLI profile 配置、附件读取或来源幂等规则。
evidence:
browser: "飞书多维表格记录可包含文字字段和截图附件;本次使用假 CLI 离线复现"
api: "官方 lark-cli base +record-list 与 +record-download-attachment"
logs: "本机当前没有 lark-cli,验证必须通过隔离假 CLI 完成"
verification:
commands:
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'"
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'"
- "python3 -m unittest discover -s tests -p 'test_ack*.py'"
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
browser:
page: "n/a"
checks:
- "假 CLI 记录证明所有数据命令显式使用 tenant-b profile"
- "标准化输出包含唯一 sourceRef 与本地截图路径"
- "重复来源整理结果只对应一个 ACK 任务"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
attemptId: "FEISHU-001-A3"
taskId: "task_9bc1dba1e21a"
dispatchId: "ctx_0791582a526f"
test:
profileId: "codex-test-standard"
receiptId: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
attemptId: "FEISHU-001-A3"
taskId: "task_4f70d2496d13"
dispatchId: "ctx_66ab3460dda2"
rounds:
- round: 1
attemptId: "FEISHU-001-A1"
result: failed
evidence: >
Isolated fake tenant and screenshot intake passed profile isolation,
download containment, fail-closed behavior, organization rules and
legacy validation, but normalized output leaked baseToken inside
sourceRef and exposed the attachment file token.
- round: 2
attemptId: "FEISHU-001-A2"
result: failed
evidence: >
Strengthened isolated E2E passed after the secret-output fix, but
Coordinator final gate compared the adapter with official lark-cli
source: profile list returns a raw JSON array and has no --format
flag, while the adapter requires an object and sends --format.
Executable lookup also trusts arbitrary PATH instead of the fixed
trusted directories required by the product spec; template source
reference still documents the obsolete raw form.
- round: 3
attemptId: "FEISHU-001-A3"
result: passed
evidence: >
Fresh Test ran a file-backed fake Feishu submission containing all
Bug text fields and a screenshot through the production check/fetch
entry points. Six focused E2E tests and five unit tests passed. The
run verified the official raw profile array and data.data matrix,
explicit tenant-b selection, trusted executable resolution,
screenshot containment, stable opaque sourceRef deduplication and
fail-closed handling for malformed, ambiguous, non-progressing and
over-limit responses. Validators, pouch check, compileall and diff
checks also passed.
resolution:
fixedBy: "term_88079d90-29a3-48ac-9b97-bec011abc249"
verifiedBy: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
verifiedAt: "2026-08-01T13:31:40+08:00"
leftoverReason: null
evidence:
developer: >
FEISHU-001-A1 implemented ACK 0.12.0 Feishu Base intake. Focused unit
tests, ACK contract tests, compileall, task validation and diff check
passed. The fresh worker could not deliver worker_done through its
workspace-write sandbox because Orca relay/FUSE was unavailable;
Coordinator preserved the terminal evidence in Orca task
task_ed87b3386a80 and did not treat it as final verification.
testRound1: >
Fresh Test term_ab858ba7-b972-45fb-bf50-61e96ced2830 created the
isolated E2E fixture and found secret-safe output failing while all
other targeted signals passed. Evidence is retained in Orca task
task_251a9b5265cd; Test relay was unavailable for the same sandbox
reason, so this remains a failed retest rather than verification.
developerRound2: >
Fresh Developer term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c changed
sourceRef to a domain-separated SHA-256 digest and kept file tokens
internal to download calls. Focused unit, ACK contract, read-only E2E,
compileall and diff checks passed; independent round-2 Test is still
required.
coordinatorGateRound2: >
Targeted Test evidence was green, but final source-contract review
against official larksuite/cli cmd/profile/list.go rejected the round:
actual profile list output is a JSON array, the command exposes no
format flag, and the current reader resolves lark-cli through caller
PATH rather than fixed trusted directories.
developerRound3: >
Fresh Developer term_88079d90-29a3-48ac-9b97-bec011abc249 added the
trusted resolver, official raw profile-list and data.data parsing,
strict envelopes, sanitized execution PATH, revised unit coverage and
complete setup/source examples. Five focused unit tests, 34 ACK
contracts (one skipped), compileall, task validation and diff check
passed; final independent Test remains required.
testRound3: >
Fresh Test term_44ab2675-445a-4f51-a0f5-83960bc1ca3b passed six
isolated E2E cases and all five Feishu unit tests. It exercised the
production entry points with a real fake-CLI subprocess and a separate
submitted-bug JSON file, downloaded a screenshot into a temporary
directory, repeated the fetch to prove a stable opaque sourceRef, and
simulated ACK source.ref organization without duplicates. The four
failures in the wider legacy suite reproduce unchanged on base commit
f08edb6 and are classified as existing environment/layout failures.
Orca worker_done transport was unavailable inside the worker sandbox
because FUSE was absent; the full fresh-terminal evidence is retained
in Orca task task_4f70d2496d13.
- id: "ACK-OMP-001"
type: "feature"
title: "支持 OMP worker CLI"
priority: "P1"
status: "blocked"
assignee: "developer"
component: "skills/ack/scripts"
specRefs:
- "docs/PRD-ack-omp-worker.md#范围"
testRefs:
- "tests/test_ack_omp_worker.py"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
ACK 当前 worker CLI allowlist 只支持 codex、cursor-agent 和 grok,无法使用当前
Oh My Pi(OMP)CLI 启动 Developer 或 Test worker。增加 omp CLI 支持,Orca 继续
负责 terminal orchestration,模型仍由 profile 明确指定。
fixLogic: >
扩展结构化 CLI allowlist、模型 allowlist 校验、可信 executable 解析、按 CLI
隔离的 worker 环境和固定 argv 渲染。OMP argv 使用精确 provider/model、thinking、
approval mode、绝对 cwd 和 no-session;workspace-write 映射 yolo,read-only
映射 always-ask(用户已明确授权),禁止 auto-approve、plan-yolo 与会话复用。CLI、模型、版本、
审批模式和 worktree 继续进入 launch fingerprint 与 receipt。
acceptanceCriteria:
- "合法 omp profile 可通过路由校验,模型必须命中 omp/role/tier allowlist"
- "OMP worker argv 固定包含 model、thinking、approval-mode、cwd 和 no-session"
- "auto-approve、plan-yolo、会话复用和不可信 executable 均 fail closed;workspace-write yolo 需显式授权"
- "既有 codex、cursor-agent、grok 的路由与 receipt 行为保持不变"
stepsToReproduce:
- "在 ACK profile 中声明 cli 为 omp,模型为 opencode-go/gpt-5.6-luna"
- "运行 worker profile 校验或 launcher plan"
- "观察当前实现将 omp 判定为不支持的 CLI"
expected: >
ACK 可以通过结构化 profile 安全选择 OMP worker,并为 Orca terminal 生成可核对的
交互式启动计划;不允许借配置注入任意命令或绕过审批。
actual: >
当前 worker_profiles.CLIS、launcher 支持集合和凭据策略没有 omp,launcher 会拒绝
OMP worker。
evidence:
browser: "n/a"
api: "本机 omp 17.2.11 支持 --model、--thinking、--cwd、--approval-mode 和 --no-session;当前 ACK 不接受 omp"
logs: "当前 ACK 路由单元测试 44 项通过,但只覆盖 codex/cursor-agent/grok"
verification:
commands:
- "python3 -m unittest tests.test_ack_omp_worker tests.test_ack_worker_profiles tests.test_ack_launch_worker"
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
browser:
page: "n/a"
checks:
- "OMP profile plan 输出 cli、model、版本、审批模式和固定 argv"
- "危险参数与不可信 executable 不会进入启动计划"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: "WR-446b1eb450c160509eea68eae4ad3aedcc730876cb5cbcfa5097ddd739c97212"
attemptId: "ACK-OMP-001-A1"
taskId: "task_35bbcdbeeb1f"
dispatchId: "ctx_abeb35c059a8"
test:
profileId: "codex-test-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
rounds: []
environmentIncidents:
- id: "ACK-OMP-001-ENV-1"
attemptId: "ACK-OMP-001-A1"
role: "coordinator"
phase: "launch"
status: "resolved"
summary: "配置中的旧 worker worktree 已不存在,Developer 启动计划被拒绝"
evidence: "/home/ace/orca/workspaces/.skills/record-bug 不存在;git worktree list 仅列出 /home/ace/.skills 和 agent-swarm"
impact: "首次 Developer 派发未创建终端,不消耗产品复验轮次"
recoveryAction: "核对 Git worktree,登记当前项目根 /home/ace/.skills 到 allowedWorktrees,并重新校验任务板"
userAction: "无需操作;Coordinator 将使用当前已登记项目根重新生成并审阅启动计划"
reportedAt: "2026-08-23T20:27:41+08:00"
resolvedAt: "2026-08-23T20:27:41+08:00"
- id: "ACK-OMP-001-ENV-2"
attemptId: "ACK-OMP-001-A1"
role: "coordinator"
phase: "launch"
status: "resolved"
summary: "移除不存在的旧 worktree 会使历史 receipt 无法通过 allowlist 校验"
evidence: "validate_tasks 报告 6 个历史 receipt.worktree.path 不在 allowlist;历史 receipt 均绑定 /home/ace/orca/workspaces/.skills/record-bug"
impact: "配置收紧后 Developer 计划仍无法生成,未创建终端且不消耗产品复验轮次"
recoveryAction: "按历史 receipt 恢复旧路径为同一 Git 仓库的 detached worktree,并保留当前项目根作为本轮 worker 路径"
userAction: "无需操作;旧路径仅为历史 receipt 校验保留,本轮 worker 使用 /home/ace/.skills"
reportedAt: "2026-08-23T20:27:41+08:00"
resolvedAt: "2026-08-23T20:27:41+08:00"
- id: "ACK-OMP-001-ENV-3"
attemptId: "ACK-OMP-001-A1"
role: "developer"
phase: "tooling"
status: "open"
summary: "Codex Developer worker 命中 OpenAI 使用额度限制,无法执行任务"
evidence: "worker terminal 显示 You've hit your usage limit;仅提供切换到 gpt-5.6-luna 或等待额度恢复,当前 profile 固定为 gpt-5.6-terra"
impact: "Developer 尚未修改代码或运行验证;任务未产生产品失败,不消耗复验轮次"
recoveryAction: "停止当前阻塞等待,保留 terminal 与 dispatch 供协调;需改用已授权且有额度的 profile 后重新 plan/launch"
userAction: "请决定等待额度恢复,或授权 Coordinator 将本次 Developer profile 切换到可用的已配置 CLI/model;不能把当前 worker 自行改成 Luna"
reportedAt: "2026-08-23T20:30:26+08:00"
resolvedAt: null
resolution:
fixedBy: null
verifiedBy: null
verifiedAt: null
leftoverReason: null
- id: "ACK-OMP-002"
type: "feature"
title: "补充 OMP 路由回归测试"
priority: "P1"
status: "open"
assignee: "developer"
component: "tests/test_ack_omp_worker.py"
specRefs:
- "docs/PRD-ack-omp-worker.md#可观测验收标准"
testRefs:
- "tests/test_ack_omp_worker.py"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
新增 OMP profile、argv、可信 executable、审批模式和环境隔离的白盒覆盖,并确保
现有 CLI 路由回归测试继续通过。
fixLogic: >
测试合法与非法 OMP profile、provider/model 精确 allowlist、thinking 映射、审批
模式边界、no-session、恶意参数、可信路径、版本记录和凭据隔离;只测试可观察
行为,不测试源代码文本或偶然默认值。
acceptanceCriteria:
- "测试覆盖 OMP 的合法 profile、模型 allowlist、thinking、argv 和环境策略"
- "测试覆盖危险审批模式、非法模型、恶意 executable 和自由参数拒绝"
- "既有 ACK worker profile、launcher 与任务板测试全部通过"
stepsToReproduce:
- "运行现有 ACK worker profile 和 launcher 测试"
- "检查测试没有 OMP profile、argv 或环境断言"
expected: >
OMP 支持具有可执行的回归护栏,后续路由或安全边界回退会被测试捕获。
actual: >
当前测试矩阵只包含 codex、cursor-agent 和 grok,没有 OMP 覆盖。
evidence:
browser: "n/a"
api: "n/a"
logs: "现有 worker profile 测试 44 项通过"
verification:
commands:
- "python3 -m unittest tests.test_ack_omp_worker tests.test_ack_worker_profiles tests.test_ack_launch_worker tests.test_ack_tasks_validation"
- "python3 -m compileall -q skills/ack/scripts"
browser:
page: "n/a"
checks:
- "白盒测试对每个 OMP 安全边界给出确定 pass/fail"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
test:
profileId: "codex-test-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
rounds: []
environmentIncidents: []
resolution:
fixedBy: null
verifiedBy: null
verifiedAt: null
leftoverReason: null
- id: "ACK-OMP-003"
type: "verification"
title: "独立复测 OMP worker 派发"
priority: "P1"
status: "open"
assignee: "test"
component: "skills/ack"
specRefs:
- "docs/PRD-ack-omp-worker.md#可观测验收标准"
testRefs:
- "tests/test_ack_omp_e2e.py"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
在独立临时项目和隔离 OMP 配置目录中验证 fresh OMP worker 的 Orca terminal 绑定、
任务注入、工作目录对齐和 lifecycle 回报。
fixLogic: >
Test 只使用隔离配置和受控测试入口,不读取真实凭据;先核对 worktree、CLI 版本、
profile 和服务状态,再验证任务输入确实被 OMP worker 接收。协议、审批或环境阻塞
必须记录为环境事件,不转写成产品失败。
acceptanceCriteria:
- "fresh OMP worker 绑定到指定 worktree 且 receipt 记录 cli、model、版本和 argv"
- "Orca dispatch 的任务输入被 OMP worker 接收并产生可核对 lifecycle 证据"
- "测试不读取或持久化真实 OMP 凭据,协议/环境失败有明确证据"
stepsToReproduce:
- "创建临时项目、隔离 OMP 配置目录和受控 profile"
- "按 ACK launcher plan/launch 创建 OMP worker"
- "通过 Orca dispatch 注入任务并观察 worker 回报"
expected: >
OMP worker 可在 ACK 安全边界内完成一次独立派发和回报;若当前 Orca 注入协议或
OMP 审批模式无法完成,则明确暴露阻塞点而不伪报通过。
actual: >
当前尚未有 OMP worker profile、launcher 分支或独立黑盒演练。
evidence:
browser: "n/a"
api: "n/a"
logs: "OMP CLI 17.2.11 支持交互式启动参数;实际 Orca 注入兼容性待验证"
verification:
commands:
- "python3 -m unittest discover -s tests -p 'test_ack_omp_e2e.py'"
- "python3 skills/ack/scripts/validate_tasks.py .pouch/ack/tasks.yaml"
- "python3 skills/ack/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml"
browser:
page: "n/a"
checks:
- "独立测试记录 worker worktree、模型、argv、注入结果和 lifecycle 证据"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
test:
profileId: "codex-test-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
rounds: []
environmentIncidents: []
resolution:
fixedBy: null
verifiedBy: null
verifiedAt: null
leftoverReason: null
+154 -93
View File
@@ -1,31 +1,27 @@
# Agent Skills 仓库 # pouch
自研 Agent Skills 的单一事实来源(SSOT)。Skill 内容在本仓库维护;安装与管理由 [skiff](https://git.yumee.top/laily/skiff) CLI 负责。 自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。仓库名、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。旧名是 `skills` / `pouch`。
| 仓库 | 地址 | 职责 | | 仓库 | 地址 | 职责 |
| --------------- | ------------------------------------------------------------------------ | --------------- | | --------------- | ------------------------------------------------------------------------ | -------------------------- |
| **skills**(本仓库) | [https://git.yumee.top/laily/skills](https://git.yumee.top/laily/skills) | skill 内容与规范 | | **pouch**(本仓库) | [https://git.yumee.top/laily/pouch](https://git.yumee.top/laily/pouch) | skill、规范包、pouch CLI 的当前 SSOT |
| **skiff** | [https://git.yumee.top/laily/skiff](https://git.yumee.top/laily/skiff) | 安装、symlink、健康检查 |
--- ---
## 快速开始 ## 快速开始
需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时:pouch skill 已安装则走其「安装 pouch」模式;否则读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行。不要只用 `pouch select`。
```bash ```bash
# 1. 克隆并关联 git clone https://git.yumee.top/laily/pouch.git ~/.pouch
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills cd ~/.pouch && ./install.sh
git clone https://git.yumee.top/laily/skiff.git ~/code/gitea/skiff
cd ~/code/gitea/skiff && ./install.sh
skiff setup ~/code/gitea/skills
# 2. 全局安装 skill # install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
skiff install declarative-openspec-loop pouch select -g
pouch list
# 3. 查看状态 pouch status
skiff list
skiff status
``` ```
--- ---
@@ -33,51 +29,105 @@ skiff status
## 仓库结构 ## 仓库结构
``` ```
skills/ skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类)
├── _template/ # 新建 skill 的脚手架 pouch/ # CLI 源码(Python 3)
├── declarative-openspec-loop/ # 自研 skill bin/pouch # CLI 入口
│ ├── SKILL.md catalog.yaml # pouch 预置 Skill 来源目录
│ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md
│ └── reference.md
registry.yaml # 外部 Git skill 来源目录
AGENTS.md # 本文档 AGENTS.md # 本文档
``` ```
**本仓库包含**:`skills/`、`registry.yaml`、`AGENTS.md` **本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
**本仓库不包含**:CLI 代码、各项目的 skill 启用清单 **本仓库不包含**:各项目的 skill 启用清单
--- ---
## Skill 目录 ## Skill 目录
### 自研(Owned) ### 内置(Builtin)
| Skill | 说明 | | Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- | | --- | --- |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 | | [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md | | [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 通过 Tailscale Serve 将静态文件暴露为 tailnet 内 HTTP 预览 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug,一直交到停点 |
| [long-ship](skills/long-ship/README.md) | 复杂需求:specifier PRD 批准后拆任务、集成回归并交付 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
改名后旧名保留一个纯占位 skill,避免已装软链悬空和旧项目 `.pouch.yaml` 失效。确认没有
项目再按旧名启用后,删除对应占位目录即可,不丢任何内容。
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。 新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
### 外部(External Git) Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
项目状态,不复制或链接 Skill 内容:
在 `registry.yaml` 中注册,通过 skiff 拉取安装: ```bash
pouch init ack
```
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(检查配置并引导补齐),
不要 `pouch init builder` / `pouch init deployer`。
### 预置目录(Catalog)
在 `catalog.yaml` 中预置,通过 pouch 拉取安装:
| Skill | 来源 | | Source | 来源 |
| ----------- | -------------------------------------------------------------------------- | | --- | --- |
| superpowers | [https://github.com/obra/superpowers](https://github.com/obra/superpowers) | | waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) |
| taste-skill | [https://github.com/Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) |
```bash ```bash
skiff fetch superpowers pouch fetch waza
skiff install-external superpowers pouch add waza/think -g
pouch add taste-skill/taste-skill -g
``` ```
### 自定义仓库(Custom Sources)
公司或团队维护、且一个仓库中包含多个 skill 时,使用命名 custom source:
```bash
pouch source add company \
git@git.company.com:platform/agent-skills.git \
--skills-path skills
pouch list --source company
pouch add company/internal-review -g
```
也可以接入已有本地 checkout:
```bash
pouch source add company --local ~/code/company-skills --skills-path skills
```
配置保存在 `~/.config/pouch/config.yaml`,Git source 默认 clone 到
`~/.local/share/pouch/sources/<source>/`。项目 `.pouch.yaml` 只记录逻辑
source 名称,每台机器独立配置实际仓库地址。
### 社区(External NPM / GitHub) ### 社区(External NPM / GitHub)
推荐使用 Vercel CLI 安装第三方 skill: 推荐使用 Vercel CLI 安装第三方 skill:
@@ -92,26 +142,29 @@ npx skills find typescript
## 设计原则 ## 设计原则
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建 1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`,不在 Agent 目录直接创建
2. **项目自治** — 每个项目自己维护 `.skills.yaml`,本仓库不维护项目清单 2. **项目自治** — 每个项目自己维护 `.pouch.yaml`,本仓库不维护项目清单
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT 3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **仓库分离** — skill 内容与 CLI 工具分仓库,互不影响更新 4. **能力内聚** — Skill 使用的规范、模板和脚本与 `SKILL.md` 同目录维护
5. **一体维护** — skill 与 CLI 同仓库维护
--- ---
## 架构 ## 架构
``` ```
skills 仓库(本仓库) skiff CLI pouch 仓库(本仓库) pouch CLI
skills/<name>/ ←── skiff install / enable skills/<name>/ ←── pouch install / enable
registry.yaml ←── skiff add / fetch pouch/ ←── python3 -m pouch
catalog.yaml ←── pouch add / fetch
↑ ↑
~/.skills(symlink) ~/.pouch(symlink)
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
~/.cursor/skills/ project/.agents/skills/ ~/.cursor/skills/ project/.agents/skills/
~/.claude/skills/ project/.claude/skills/ ~/.claude/skills/ project/.claude/skills/
~/.codex/skills/ project/.skills.yaml ~/.codex/skills/ project/.pouch.yaml
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
``` ```
### Skill 三层分类 ### Skill 三层分类
@@ -119,8 +172,9 @@ registry.yaml ←── skiff add / fetch
| 层级 | 位置 | 维护方式 | | 层级 | 位置 | 维护方式 |
| ---------------- | ---------------------------------- | ------------------------------- | | ---------------- | ---------------------------------- | ------------------------------- |
| **Owned** | `skills/<name>/` | 本仓库 commit | | **Builtin** | `skills/<name>/` | 本仓库 commit |
| **External Git** | `~/.local/share/skills/externals/` | `skiff fetch` | | **Catalog** | `catalog.yaml` + checkout 缓存 | `pouch catalog add / fetch` |
| **Custom Source** | `~/.local/share/pouch/sources/` 或本地路径 | `pouch source add/fetch` |
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` | | **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
@@ -132,21 +186,24 @@ registry.yaml ←── skiff add / fetch
| Cursor | `~/.cursor/skills/` | `.agents/skills/` | | Cursor | `~/.cursor/skills/` | `.agents/skills/` |
| Claude Code | `~/.claude/skills/` | `.claude/skills/` | | Claude Code | `~/.claude/skills/` | `.claude/skills/` |
| Codex | `~/.codex/skills/` | `.agents/skills/` | | Codex | `~/.codex/skills/` | `.agents/skills/` |
| Agents 标准 | `~/.agents/skills/` | `.agents/skills/`(与 cursor/codex 共用;覆盖 OMP) |
--- ---
## 项目级启用 ## 项目级启用
每个项目**自己维护** `.skills.yaml`,不由本仓库管理: 每个项目**自己维护** `.pouch.yaml`,不由本仓库管理:
```yaml ```yaml
# .skills.yaml(在项目根目录) # .pouch.yaml(在项目根目录)
skills: skills:
- declarative-openspec-loop - name: declarative-openspec-loop
- name: superpowers source: builtin
source: registry - name: think
ref: main source: catalog:waza
- name: internal-review
source: company
targets: # 可选,默认 all targets: # 可选,默认 all
- cursor - cursor
@@ -157,49 +214,50 @@ targets: # 可选,默认 all
| 概念 | 类比 | | 概念 | 类比 |
| -------------- | --------------------------- | | -------------- | --------------------------- |
| skills 仓库 | npm registry | | pouch 仓库 | npm registry |
| `.skills.yaml` | `package.json` dependencies | | `.pouch.yaml` | `package.json` dependencies |
| `skiff enable` | `npm install` | | `pouch enable` | `npm install` |
| `skiff sync` | `npm ci` | | `pouch sync` | `npm ci` |
项目级命令(skiff 待实现): 项目级命令:
```bash ```bash
cd ~/code/my-app cd ~/code/my-app
skiff enable declarative-openspec-loop pouch enable declarative-openspec-loop
skiff disable declarative-openspec-loop pouch disable declarative-openspec-loop
skiff sync pouch sync
``` ```
--- ---
## skiff 命令 ## pouch 命令
详见 [skiff README](https://git.yumee.top/laily/skiff)。 详见 [pouch README](https://git.yumee.top/laily/pouch)。
### 已实现 ### 已实现
| 命令 | 说明 | | 命令 | 说明 |
| -------------------------------------- | ------------------- | | -------------------------------------- | ------------------- |
| `skiff setup <path>` | 关联 `~/.skills` 到本仓库 | | `pouch bootstrap` | 将 pouch 项目 skill 全局安装到所有 Agent |
| `skiff list` | 列出所有 skill | | `pouch list` | 列出所有 skill |
| `skiff status` | 安装状态总览 | | `pouch status` | 安装状态总览 |
| `skiff install <name>` | 全局安装(symlink) | | `pouch add <name> [-g]` | 项目或全局安装(symlink) |
| `skiff uninstall <name>` | 移除 symlink | | `pouch remove <name> [-g]` | 移除 symlink |
| `skiff add / fetch / install-external` | 外部 Git skill | | `pouch catalog add` / `pouch fetch` | 管理和拉取 catalog source |
| `pouch update` | 更新 pouch 自身和已拉取的 catalog / custom source |
### 待实现 ### 草稿与健康检查
| 命令 | 说明 | | 命令 | 说明 |
| ------------------------ | --------------------------- | | --- | --- |
| `skiff enable / disable` | 项目级启用/关闭 | | `pouch create <name> --idea TEXT [--from-project PATH]` | 从 `_template/` 创建草稿 |
| `skiff sync` | 按 `.skills.yaml` 重建 symlink | | `pouch check <name>` | 校验草稿或正式 skill |
| `skiff create` | 从 `_template/` 脚手架创建 skill | | `pouch finalize <name>` | 校验并将草稿转为正式 skill |
| `skiff doctor` | symlink 健康检查 | | `pouch doctor [--fix]` | symlink 健康检查与修复 |
--- ---
@@ -210,7 +268,8 @@ skiff sync
``` ```
skill-name/ skill-name/
├── SKILL.md # 必需 ├── SKILL.md # 必需,给 Agent
├── README.md # 必需,给人类
├── reference.md # 可选 ├── reference.md # 可选
├── examples.md # 可选 ├── examples.md # 可选
└── scripts/ # 可选 └── scripts/ # 可选
@@ -234,11 +293,12 @@ description: >-
### 新建流程 ### 新建流程
1. `cp -r skills/_template skills/my-skill` 1. `pouch create my-skill --idea "..." --from-project .`
2. 编辑 `skills/my-skill/SKILL.md` 2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md` 和 `README.md`
3. `skiff install my-skill --target cursor` 验证 3. `pouch check my-skill`
4. 在本仓库 commit 4. 用户确认后执行 `pouch finalize my-skill`
5. 各项目 `skiff enable my-skill`(待实现) 5. `pouch add my-skill -a cursor -g -y` 验证
6. 在本仓库 commit;需要的项目再用 `pouch add my-skill` 启用
**禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。 **禁止**在 `~/.cursor/skills/` 或项目 Agent 目录直接创建非 symlink 的 skill。
@@ -250,7 +310,7 @@ symlink 正确时,Agent 在项目里改 skill 文件 = 直接改 SSOT:
``` ```
project/.agents/skills/foo/SKILL.md project/.agents/skills/foo/SKILL.md
→ ~/.skills/skills/foo/SKILL.md → ~/.pouch/skills/foo/SKILL.md
→ 在本仓库 commit → 在本仓库 commit
``` ```
@@ -265,7 +325,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| -------------- | ----------------------------------------------------- | | -------------- | ----------------------------------------------------- |
| Cursor / Codex | symlink,正常 | | Cursor / Codex | symlink,正常 |
| Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` | | Claude Code | symlink 单个 skill 目录,不要 symlink 整个 `~/.claude/skills/` |
| symlink 被替换 | `skiff doctor`(待实现)→ 重建 symlink | | symlink 被替换 | `pouch doctor --fix` → 重建 symlink |
--- ---
@@ -275,7 +335,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| 场景 | 工具 | | 场景 | 工具 |
| -------------- | ---------------------------------------------------- | | -------------- | ---------------------------------------------------- |
| 自研 skill 安装/管理 | **skiff** | | 自研 skill 安装/管理 | **pouch** |
| 社区 skill 安装 | **Vercel `npx skills add`** | | 社区 skill 安装 | **Vercel `npx skills add`** |
| NPM 包内 skill | **skills-npm** / **skill-indexer** | | NPM 包内 skill | **skills-npm** / **skill-indexer** |
| 搜索发现 | **npx skills find** / [skills.sh](https://skills.sh) | | 搜索发现 | **npx skills find** / [skills.sh](https://skills.sh) |
@@ -288,13 +348,15 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| 我要… | 命令 | 在哪 | | 我要… | 命令 | 在哪 |
| ---------- | --------------------------------- | ---- | | ---------- | --------------------------------- | ---- |
| 首次 setup | `skiff setup ~/code/gitea/skills` | 任意 | | 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
| 新建 skill | 复制 `_template/` → 编辑 → commit | 本仓库 | | 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 |
| 全局启用 | `skiff install <name>` | 任意 | | 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 |
| 项目启用 | `skiff enable <name>` | 项目目录 | | 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
| 看状态 | `skiff status` | 任意 | | 全局启用 | `pouch add <name> -g` | 任意 |
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
| 看状态 | `pouch status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 | | 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `skiff fetch <name>` | 任意 | | 更新外部 skill | `pouch fetch <name>` 或日常命令自动更新 | 任意 |
--- ---
@@ -302,8 +364,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
## 参考 ## 参考
- [Agent Skills 开放标准](https://agentskills.io) - [Agent Skills 开放标准](https://agentskills.io)
- [skiff CLI](https://git.yumee.top/laily/skiff) - [pouch CLI](https://git.yumee.top/laily/pouch)
- [Vercel skills CLI](https://github.com/vercel-labs/skills) - [Vercel skills CLI](https://github.com/vercel-labs/skills)
- [skills.sh](https://skills.sh) - [skills.sh](https://skills.sh)
- [Cursor Skills 文档](https://cursor.com/docs/context/skills) - [Cursor Skills 文档](https://cursor.com/docs/context/skills)
+145 -44
View File
@@ -1,71 +1,158 @@
# Agent Skills # pouch
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。Skill 内容与 **skiff** CLI 在本仓库一并维护。 自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。
给同事看怎么装、怎么打版本、怎么构建,读 [docs/getting-started.md](docs/getting-started.md)。人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。
## 前置
- Python 3、git
- Cursor、Claude Code、Codex,或其它兼容 Agent Skills 的 Agent
- `~/.local/bin` 需要在 `PATH` 里(`install.sh` 会写入 bash / zsh / fish 的 rc,当前 shell 要重开或 `source` 一次才生效)
## 快速开始 ## 快速开始
```bash ```bash
git clone https://git.yumee.top/laily/skills.git ~/code/gitea/skills git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/code/gitea/skills cd ~/.pouch
./install.sh ./install.sh
skiff setup ~/code/gitea/skills
skiff install declarative-openspec-loop # install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
skiff list # 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
skiff status
pouch select # 交互选择要装的 skill(默认当前项目)
pouch select -g # 或装到全局,所有项目都能用
pouch list
pouch status
```
从旧的 `~/.skills` / `pouch` 迁移:
```bash
mv ~/.skills ~/.pouch
~/.pouch/install.sh
```
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
## 让 Agent 代装
对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。步骤只维护在
[skills/pouch/references/install.md](skills/pouch/references/install.md):
确认 python3/git → 处理 `~/.pouch` → `install.sh` → 用短清单介绍 skill → 问要装哪些。
不要用 `pouch select`。不要在未确认时覆盖已有的 `~/.pouch`。
自己装仍看上面的「快速开始」。
## 装完之后怎么用
1. 打开已安装目标的 Agent(Cursor / Claude Code / Codex 等)。
2. 项目级安装:打开那个项目再说话。全局安装(`-g`):任意项目都可以。
3. 按对应 skill 的 README,把示例请求发给 Agent。
`./install.sh` 只全局安装 `pouch`。其它 skill 都要 `pouch add` 或 `pouch select`。
```bash
cd ~/code/my-app
pouch add ack # 当前项目
pouch add discussion-notes -g # 全局
``` ```
## 仓库结构 ## 仓库结构
``` ```
skills/ # 自研 skill(SSOT) skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
skiff/ # CLI 源码(Python 3) pouch/ # CLI 源码(Python 3)
bin/skiff # CLI 入口 bin/pouch # CLI 入口
registry.yaml # 外部 Git skill 目录 catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 详细规范与架构说明 AGENTS.md # 详细规范与架构说明
``` ```
| 路径 | 说明 | | 路径 | 说明 |
|------|------| | ---------------------------- | ----------------------------------------------------- |
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md` | | [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
| [skiff/](skiff/README.md) | 安装、软链、健康检查 CLI | | [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
| [registry.yaml](registry.yaml) | 外部 Git skill 注册表 | | [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 | | [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
## 自研 Skill ## 自研 Skill
| Skill | 说明 | | Skill | 说明 |
|-------|------| | ----------------------------------------------------------------------- | ------------------------------------------- |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 | | [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 | | [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
| [previewer](skills/previewer/README.md) | 用 Node 渲染 HTML/静态文件,并通过 Tailscale 返回访问 IP 和 HTTP 预览 |
| [pr-ship](skills/pr-ship/README.md) | 把已有 PR 从检查、合并推进到回归、构建和部署 |
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [ship](skills/ship/README.md) | short-ship / long-ship / pr-ship 共用的交付记录格式 |
| [short-ship](skills/short-ship/README.md) | 范围清楚的单次变更:小功能、文案、已确认 bug |
| [long-ship](skills/long-ship/README.md) | 复杂需求:PRD 批准后拆任务、集成回归并交付 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 long-ship |
| [delivery-loop](skills/delivery-loop/README.md) | 三个交付入口共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`short-ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`long-ship` 的旧名兼容占位,不含流程 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
```bash
pouch init ack
pouch init ack --project ~/app
```
初始化会生成默认关闭的 `.pouch/ack/delivery.yaml` 和空的 `.pouch/ack/regression.yaml`。
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
验证通过后收获回归用例。
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
新建 skill: 新建 skill:
```bash ```bash
skiff create my-skill # 从 _template/ 脚手架创建 pouch create my-skill --idea "描述要解决的重复问题" --from-project .
# 编辑 skills/my-skill/SKILL.md # 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md 和 README.md
skiff install my-skill # 全局安装验证 pouch check my-skill
pouch finalize my-skill
pouch add my-skill -g # 全局安装验证
``` ```
项目里使用 skill 发现通用问题或优化时,让 Agent 按 `pouch` skill 收集实际结果与期望结果,修改 `~/.pouch/skills/<name>/` 的 SSOT,并执行 `pouch check <name>`。项目专属规则保留在项目内,不回流到通用 skill。
## 安装方式 ## 安装方式
### 全局(用户级) ### 全局(用户级)
```bash ```bash
skiff install <name> # 安装到 ~/.cursor/skills/ 等 pouch add <name> -g # 安装到 ~/.cursor/skills/ 等
skiff install <name> --target cursor pouch add <name> -g -a cursor
``` ```
### 项目级 ### 项目级
在项目根目录维护 `.skills.yaml`: 在项目根目录维护 `.pouch.yaml`:
```yaml ```yaml
skills: skills:
- declarative-openspec-loop - name: ack
- name: superpowers source: builtin
source: registry - name: think
ref: main source: catalog:waza
targets: # 可选,默认 all targets: # 可选,默认 all
- cursor - cursor
@@ -74,18 +161,27 @@ targets: # 可选,默认 all
``` ```
```bash ```bash
skiff enable declarative-openspec-loop pouch add ack
skiff sync pouch sync
skiff disable declarative-openspec-loop pouch remove ack
``` ```
## 外部 Skill ## Catalog 与 Custom Source
**Git 来源**(经 skiff 管理): 安装 catalog 中预置的来源:
```bash ```bash
skiff fetch superpowers pouch fetch waza
skiff install-external superpowers pouch add waza/think -g
pouch add taste-skill/taste-skill -g # 只要核心 frontend skill
pouch add taste-skill -g # 或装整个 Taste Skill collection
```
接入团队自己的本地目录或 Git 仓库:
```bash
pouch source add company --local ~/code/company-skills --skills-path skills
pouch add company/internal-review -g
``` ```
**社区来源**(Vercel CLI): **社区来源**(Vercel CLI):
@@ -99,30 +195,34 @@ npx skills find typescript
``` ```
本仓库 本仓库
├── skills/<name>/ ←── skiff install / enable ├── skills/<name>/ ←── pouch add / select
├── registry.yaml ←── skiff add / fetch ├── catalog.yaml ←── 预置来源发现与 fetch
└── skiff/ ←── python3 -m skiff └── pouch/ ←── python3 -m pouch
↑ ↑
~/.skills(symlink) ~/.pouch(symlink)
│ │
┌────┴────────────────┐ ┌────┴────────────────┐
▼ ▼ ▼ ▼
~/.cursor/skills/ project/.agents/skills/ ~/.cursor/skills/ project/.agents/skills/
~/.claude/skills/ project/.claude/skills/ ~/.claude/skills/ project/.claude/skills/
~/.codex/skills/ project/.skills.yaml ~/.codex/skills/ project/.pouch.yaml
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
``` ```
## 设计原则 ## 设计原则
1. **SSOT** — 自研 skill 只存在于 `skills/<name>/` 1. **SSOT** — 自研 skill 只存在于 `skills/<name>/`
2. **项目自治** — 各项目自行维护 `.skills.yaml` 2. **项目自治** — 各项目自行维护 `.pouch.yaml`
3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT 3. **软链优先** — 通过 symlink 映射到 Agent 目录,改 skill 即改 SSOT
4. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译 4. **能力内聚** — Skill 所需规范、模板和脚本与 `SKILL.md` 放在同一目录
5. **一体维护** — skill 与 CLI 同仓库,Python 3 直接运行,无需编译
## 文档 ## 文档
- [docs/getting-started.md](docs/getting-started.md):给同事的安装、打版本、构建教程
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定 - [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
- [skiff/README.md](skiff/README.md) — CLI 命令参考与开发说明 - [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
- 各 skill 的人类说明见上表 `README.md`
## 参考 ## 参考
@@ -130,3 +230,4 @@ npx skills find typescript
- [Vercel skills CLI](https://github.com/vercel-labs/skills) - [Vercel skills CLI](https://github.com/vercel-labs/skills)
- [skills.sh](https://skills.sh) - [skills.sh](https://skills.sh)
- [Cursor Skills 文档](https://cursor.com/docs/context/skills) - [Cursor Skills 文档](https://cursor.com/docs/context/skills)
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# 跟随软链,确保从 ~/.local/bin/pouch 调用时仍能找到仓库根目录
SCRIPT="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT" ]; do
link_dir="$(cd "$(dirname "$SCRIPT")" && pwd)"
SCRIPT="$(readlink "$SCRIPT")"
[[ "$SCRIPT" != /* ]] && SCRIPT="$link_dir/$SCRIPT"
done
REPO_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
exec python3 -m pouch "$@"
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:$PYTHONPATH}"
exec python3 -m skiff "$@"
+41
View File
@@ -0,0 +1,41 @@
# Preconfigured Skill source catalog
# Format:
# <name>:
# repo: <git-url>
# ref: <branch|tag> (default: main)
# path: <subpath> (default: .)
# description: <text> (optional, shown by `pouch select`)
# tags: (optional)
# - <tag>
#
# Single-skill example (`path` contains SKILL.md):
# example-skills:
# repo: https://github.com/example/skills
# ref: main
# path: .
#
# Collection example (`path` contains skill directories):
# example-collection:
# repo: https://github.com/example/skill-collection
# ref: main
# path: skills
waza:
repo: https://github.com/tw93/Waza.git
ref: main
path: skills
description: Waza engineering workflow skills for planning, UI, review, debugging, writing, research, reading, and agent health.
tags:
- engineering
- workflow
- skill-collection
taste-skill:
repo: https://github.com/Leonxlnx/taste-skill.git
ref: main
path: skills
description: Taste Skill collection for anti-slop frontend, redesign, image generation, brand kits, and output enforcement.
tags:
- frontend
- design
- skill-collection
+46
View File
@@ -0,0 +1,46 @@
# ACK OMP Worker 支持规格
## 目标
让 ACK 的 Developer/Test worker 可以通过当前 Oh My Pi(OMP)CLI 工作,同时保留现有的角色路由、模型 allowlist、审批模式、worktree 和 receipt 校验边界。
## 范围
1. ACK worker CLI allowlist 增加 `omp`,不增加 `opencode`。
2. 增加 OMP 的模型 allowlist 与 role/profile 配置能力;模型使用 OMP 要求的精确 `provider/model` 选择器,例如 `opencode-go/gpt-5.6-luna`,不从当前 Coordinator 会话自动推断。
3. launcher 在固定可信目录中解析 `omp`,探测并记录版本,生成确定性的启动 argv,并把 CLI、模型、thinking、审批模式、worktree 和版本纳入 fingerprint/receipt。
4. OMP worker 使用交互式 `omp` 命令,通过结构化参数指定模型、thinking、approval mode 和工作目录;Orca 仍负责 terminal orchestration 与任务 dispatch。
5. OMP profile 不允许写入自由 command、额外 argv、shell、环境变量或凭据值;不使用 `--auto-approve`、`--plan-yolo` 或会话复用;`--approval-mode yolo` 是 OMP workspace-write worker 的默认审批模式,规则层直接允许。
6. 为 OMP 增加 profile 校验、argv 渲染、环境凭据隔离、可信 executable 解析和 launcher plan 的白盒/黑盒测试。
7. 保持 Codex、Cursor、Grok 既有行为不变;不修改 ACK 的三角色职责或 delivery 流程。
## 非目标
- 不把 OMP 替换为新的编排后端;Orca 仍是 ACK 的 terminal orchestration backend。
- 不支持 OpenCode CLI;本需求只支持 `omp` 可执行文件。
- 不根据当前 Coordinator 的 provider、模型或环境变量自动选择 worker profile。
- 不实现 OMP ACP 协议;本轮使用 OMP 的交互式 CLI 入口。
- 不读取、写入或提交真实 OMP 凭据。
## 约束与关键假设
- `omp` 可执行文件必须通过 ACK 固定可信目录解析,不能从调用者 PATH 任意拾取。
- OMP 模型 ID 必须由项目 allowlist 明确声明;当前会话中的 `opencode-go/gpt-5.6-luna` 只有在 profile 明确配置后才可使用。
- 审批模式由 launcher 固定构造:`workspace-write` → `--approval-mode yolo`、`read-only` → `--approval-mode always-ask`。
- `workspace-write` 下的 exec 工具是否会因 OMP 审批提示阻塞,由独立 Test 在黑盒环境中验证;若阻塞,必须记录为环境/运行模式问题,不伪报成功。
## 可观测验收标准
1. `validate_orchestration` 接受合法 `omp` profile,拒绝未知 CLI、未在 `omp/role/tier` allowlist 中的模型、Test strong profile 和危险权限模式。
2. `render_worker_argv` 对 OMP 只生成固定的 `omp --model {provider/model} --thinking {level} --approval-mode {mode} --cwd {absolute-worktree} --no-session` 形状,并拒绝危险或会话复用参数。
3. `resolve_executable`/launcher plan 能在可信 OMP 安装下记录 `cli: omp`、版本、精确 argv、环境策略和 worktree identity;非可信同名 executable fail closed。
4. OMP worker 环境只获得基础运行时变量、代理/证书变量和明确允许的 OMP/provider credential 名称,不继承调用者的任意环境变量、PATH 或其它 CLI 凭据。
5. 现有 Codex/Cursor/Grok profile 的 argv、allowlist、权限拒绝和 receipt 校验回归测试继续通过。
6. 在独立临时项目和隔离 OMP 配置目录中,Test 能确认 fresh OMP worker 被正确绑定到指定 worktree,能接收 Orca dispatch 的任务输入,并能回报 ACK lifecycle 证据;无法完成时记录具体协议/环境证据。
7. 文档明确:OMP 是 worker CLI,Orca 是编排层;两者都不自行决定模型,模型由 `tasks.yaml.project.orchestration` profile 决定。
## 建议任务拆分
- `ACK-OMP-001`:扩展 ACK worker profile、launcher 与安全环境策略,支持 OMP interactive worker。
- `ACK-OMP-002`:补充 OMP profile/argv/launcher 白盒测试与既有 CLI 回归测试。
- `ACK-OMP-003`:在隔离临时环境完成 OMP worker 的 Orca dispatch 黑盒复测并记录证据。
+752
View File
@@ -0,0 +1,752 @@
---
title: ACK 设计评审记录
date: "2026-07-31T17:15:37+08:00"
updated: "2026-07-31T23:31:48+08:00"
---
# ACK 设计评审记录
> 这是对 ACK v0.8.1 的设计评审快照,用于推动后续版本改进。文中的文件行号对应
> 2026-07-31 的仓库状态,不作为 ACK 的运行规范。
本文同时记录后续方案和实施结果。带“建议”“目标”或“待验证”的内容默认是设计
方向,不代表当前版本已经具备;实际运行契约仍以 `skills/ack/SKILL.md`、schema、
校验器和测试为准。知识护栏方案的 v0.9.0 落地边界、model-routing v0.10.0 的
实施结论见下文状态表。
## 背景
本轮评审覆盖 `skills/ack/` 的入口、角色规范、闭环流程、项目模板、任务板
schema、校验脚本、Orca 适配器,以及 `pouch init ack` 的真实运行路径。
评审主要回答两个问题:
- ACK 的三角色协作方向是否合理。
- 当前实现是否已经能稳定完成初始化、派发、独立复测、状态恢复和结果审计。
## 结论
ACK 的核心方向合理。显式触发、三角色分权、可观测验收、用户确认、Test 独立
复测、Coordinator 单写任务板和三轮止损都值得保留。
当前版本适合描述为“任务级受监督验收闭环”。它可以组织单个工作空间里的实现和
复测,但还没有覆盖批准状态持久化、跨 worktree 交接、编排状态恢复、目标分支集成
和交付状态,因此暂时不应把 `verified` 等同于“已集成”或“已交付”。
概念设计约为 8/10,当前可操作性约为 5 至 6/10。下一阶段应该补齐控制面契约,
不需要推翻三角色模型。
## 值得保留的设计
- Coordinator、Developer 和 Test 的职责分开,Developer 不能给自己的实现做最终
判定。
- 新需求先写产品文档、任务拆分和可观测验收信号,用户确认后再派发。
- `tasks.yaml` 由 Coordinator 单写,worker 消息只负责传递证据。
- Test 在复测前核对 worktree、服务实例和构建产物,减少测错代码和旧进程造成的
假通过。
- 每个任务最多三轮,失败后进入 `leftover`,不会无限消耗同一个 worker。
- 通用规范留在 Skill,项目只保存覆盖层与任务状态。
- 闭环核心不依赖 Orca,手动模式仍能保留相同的角色与验收逻辑。
## P0:状态和证据必须先成为可验证协议
### `verified` 目前可以没有证据
`roles-and-permissions.md` 定义的完成条件包括 Developer 白盒证据、Test 独立证据、
环境对齐和 Coordinator 终检,但任务 schema 只强制 `id`、`title` 和 `status`。
内置校验器同样没有检查 `verified` 所需的证据。
本轮探针确认,下面这种任务可以通过内置校验:
```yaml
version: 1
project:
name: demo
tasks:
- id: T-1
title: hollow green
status: verified
```
相关位置:
- `skills/ack/templates/tasks.schema.json:76`
- `skills/ack/scripts/validate_tasks.py:70`
- `skills/ack/references/roles-and-permissions.md:140`
建议为不同状态定义语义约束。`verified` 至少要求:
- Developer 的修改文件、验证命令和结果。
- 独立 Test 的身份、逐条验收结果和原始证据。
- 被测代码或构建产物的不可变指纹。
- worktree、服务进程、Base URL 和测试时间。
- Coordinator 的 gate 结果与写入时间。
### 用户确认没有持久化
Skill 要求用户确认后再派发,但状态机从 `open` 直接进入 `dispatched`。会话在确认前
中断后,新的 Coordinator 无法知道任务是待确认,还是已经批准。
建议增加 `proposed` 和 `approved`,并记录 `approvedAt`、`approvedBy` 与验收版本。
### 推荐状态流
```text
proposed
-> approved
-> implementing
-> retesting
-> verified_in_workspace
-> integrated
-> verified
blocked
-> approved
-> cancelled
product_failed x 3
-> leftover
```
`verified_in_workspace` 只说明指定工作空间中的代码已经通过独立复测。只有目标分支
集成并完成集成后验证,才能进入最终 `verified`。
## P0:Orca 适配器需要改成双任务模型
当前文档让 Developer 对一个 Orca task 发送 `worker_done`,然后把同一个 task 再次
派给 Test。截至评审日期,当前 Orca 的 orchestration 规则会在收到有效
`worker_done` 后自动把 task 和 dispatch 标记为 completed。第二次派发同一个 task
缺少可靠的生命周期语义。
相关位置:
- `skills/ack/references/orca-adapter.md:144`
- `skills/ack/references/orca-adapter.md:164`
- `skills/ack/references/orca-adapter.md:199`
建议每个 ACK attempt 创建两个 Orca 子任务:
```text
ACK task
Developer task
Test task, depends on Developer task
```
任务板分别保存:
```yaml
devTaskId: null
devDispatchId: null
testTaskId: null
testDispatchId: null
```
Developer 和 Test 都对自己的 dispatch 发送 `worker_done`,Coordinator 最后只读
证据并执行 gate。
## P0:明确 worktree 和代码交接方式
当前流程先在 Coordinator worktree 写 PRD 和 `tasks.yaml`,随后才决定是否创建隔离
worktree。权威任务板只保留在 Coordinator worktree,但 worker prompt 又要求在自己
的 worktree 读取相对路径。Developer 的未提交代码如何传给独立的 Test worktree
也没有定义。
相关位置:
- `skills/ack/references/kickoff.md:30`
- `skills/ack/references/closed-loop.md:65`
- `skills/ack/references/closed-loop.md:87`
- `skills/ack/references/prompt-templates.md:31`
- `skills/ack/references/orca-adapter.md:74`
建议默认让 Developer 和 Test 串行使用同一个 worktree。必须隔离时,在 attempt 中
明确:
```yaml
transfer:
type: commit | patch | artifact
source: "<immutable reference>"
digest: "<sha256 or diff hash>"
```
派发内容应携带验收快照和规范的绝对路径,不能假设每个 worktree 都有同一份未提交
文档。
## P0:Worker 启动策略改为安全默认
> v0.10 已以结构化 profile 和可信 launcher 取代本节描述的自由 command。下方是
> 当时的评审记录,不再代表当前实现;其中“full-access 写入任务板授权”的建议已被
> v0.10 决策取代,当前明确为 Deferred。
当前校验器强制 Codex 使用 bypass、Cursor 使用 YOLO,并写死模型和 CLI。项目文档
允许收紧权限或替换模型,但合法的安全覆盖会被校验器拒绝。
校验器还只检查自由 shell 字符串里的部分 token。本轮探针确认,带有 shell 控制符
和额外命令的字符串仍可通过校验。这样的结果会给调用方错误的安全感。
相关位置:
- `skills/ack/scripts/validate_worker_command.py:49`
- `skills/ack/references/model-routing.md:98`
- `skills/ack/templates/project.template.md:31`
建议把项目覆盖层改成结构化配置:
```yaml
orchestration:
mode: orca
workers:
developer:
cli: codex
model: "<project-approved-model>"
effort: medium
permissionMode: sandbox
test:
cli: codex
model: "<project-approved-model>"
effort: low
permissionMode: sandbox
```
可信代码根据这些字段构造 argv,不接受自由 shell 拼接。安全模式默认通过。
原建议设想 full-access 由用户单独授权并写入任务板;v0.10 没有实现这条授权通道,
项目文件也不能充当授权证据。full-access 的可信授权、期限、撤销和外层隔离均为
Deferred,当前 launcher 必须 fail closed。
## P0:初始化需要原子化
`pouch init ack` 会先写 `project.md` 和 `tasks.yaml`,再运行任务板校验。缺少 PyYAML
时,命令会失败但保留两个文件。再次运行又会因为拒绝覆盖而失败。
相关位置:
- `requirements.txt:1`
- `skills/ack/scripts/validate_tasks.py:37`
- `pouch/cli.py:1144`
- `pouch/cli.py:1156`
本轮在不含第三方包的隔离 Python 环境中复现了这个状态。
建议:
- 在临时目录渲染和校验,全部通过后再原子 rename。
- 失败时只清理由本次调用创建的临时文件。
- 提供 `pouch init ack --repair` 或等价恢复路径。
- 默认模板使用 `tasks: []`,完整示例继续放在 `examples/`。
- CLI 输出“脚手架已创建,待配置”,检查通过后再称为“初始化完成”。
## P1:任务板改成追加式 attempts
当前 `dispatch.rounds` 只记录轮次、结果和一段证据,无法支持恢复、并发和审计。
建议改成追加式 `attempts[]`:
```yaml
attempts:
- id: "T-1-A1"
round: 1
idempotencyKey: "<stable key>"
startedAt: "<timestamp>"
workspace:
path: "<worktree>"
baseCommit: "<sha>"
diffHash: "<hash>"
developer:
worker: "<identity>"
taskId: "<runtime task id>"
dispatchId: "<runtime dispatch id>"
evidence: {}
test:
worker: "<independent identity>"
taskId: "<runtime task id>"
dispatchId: "<runtime dispatch id>"
evidence: {}
gate:
result: pending | passed | failed
failureKind: null
```
`failureKind` 建议区分:
- `product_failed`:实现不满足验收,消耗三轮预算。
- `environment_blocked`:环境或服务不可用,不消耗轮次。
- `needs_decision`:需要用户决定范围,不消耗轮次。
- `acceptance_invalid`:验收标准有误,返回 `proposed`。
- `worker_lost`:worker 消失,由 Coordinator 重新派发。
外部 dispatch 前先持久化 intent。Coordinator 重启后,用 `idempotencyKey` 和 runtime
ID 查询 Orca,再决定继续等待、恢复状态或重新派发。
## P1:统一 schema 和语义校验
安装 `jsonschema` 时,`validate_tasks.py` 只运行 schema;未安装时只运行内置规则。
两条路径的约束不同。schema 会放过重复 ID、四轮 dispatch 和空
`leftoverReason`,内置规则也会放过部分错误结构。
建议:
- 始终先执行 schema,再无条件执行语义 invariant。
- 为 `structural` 和 `ready` 提供两个显式模式。
- `ready` 模式检查状态对应的证据、环境、批准和轮次。
- 显式传入的 schema 路径不存在时直接失败,不能静默降级。
- `summary` 从 `tasks` 派生,避免双写。
- 收紧 `additionalProperties`,扩展字段统一放入 `extensions`。
## P1:补齐完成边界和批次验证
ACK 默认不提交、不推送。隔离 worktree 中的任务即使复测通过,也可能尚未进入目标
分支。最终报告需要明确区分:
- 已在指定工作空间验证。
- 已集成到目标分支。
- 已完成集成后回归。
- 已提交、已推送或已发布。
多个任务分别通过后,还需要一次批次级集成回归,避免组合后出现冲突或行为变化。
## P2:减少规范重复和模型漂移
- 保留一份权威状态机、一份角色权限表和一份模型档位规则。
- `kickoff.md` 只做一页运行手册,adapter 只保存工具命令。
- 具体模型名称放到可更新的映射或项目覆盖层,稳定核心只描述能力档位和升级条件。
- `ackVersion` 不能只记录旧版本。需要独立的 `schemaVersion`、兼容范围和迁移命令。
- 用真实运行数据观察首轮通过率、环境失败率、平均轮次、`leftover` 原因、耗时和
token,再决定三轮规则是否需要按任务风险调整。
- 为低风险文档或机械改动提供轻量模式,高风险和用户可见行为继续使用完整三角色
闭环。
## 低成本清理
- `skills/ack/SKILL.md:66` 的旧字段应为 `kitVersion`。
- `skills/ack/templates/project.template.md:8` 建议修改或替换 `AGENTS.md`,与 Skill
的禁止规则冲突。
- `skills/ack/references/orca-adapter.md:201` 应引用 prompt §4。
- `skills/ack/scripts/validate_worker_command.py:2` 残留 “Music Pilot”。
- `skills/ack/examples/tasks.example.yaml` 缺少 checklist 要求的 `overlayFile`。
- Test 负责黑盒与集成测试,Developer 负责单元测试;当前优化文档对单元测试所有权
的表述需要统一。
## 建议实施顺序
1. 收紧任务状态、批准状态和 `verified` 证据门。
2. 把 Orca adapter 改成每轮 Developer/Test 双任务。
3. 定义同 worktree 默认策略和跨 worktree transfer。
4. 改造 worker launcher 与安全授权。
5. 原子化 `pouch init ack`,空任务板作为默认模板。
6. 引入追加式 attempts、幂等恢复和失败分类。
7. 统一 schema 与语义校验,补齐对抗性 fixture。
8. 区分工作空间验证、集成验证和发布状态。
9. 去重文档,补版本迁移和运行指标。
## 验证记录
本轮执行了:
- `pouch check ack`:通过。这个命令只证明 Skill 的元数据和基础结构有效。
- `validate_tasks.py examples/tasks.example.yaml`:通过,当前机器使用内置规则。
- `validate_worker_command.py --self-test`:7 项通过。
- `python3 -m unittest discover -s tests -v`:58 项通过。
- 隔离全局安装 smoke:通过,安装结果为指向 ACK SSOT 的 symlink。
- 隔离初始化探针:缺少 PyYAML 时失败,留下半初始化文件,重试被拒绝。
- 对抗性任务板探针:无证据的 `verified` 可以通过。
- 对抗性 worker 命令探针:附加 shell 控制符的命令字符串可以通过。
- 当前 Orca orchestration 指南复核:有效 `worker_done` 会自动完成对应 task 和
dispatch。
现有 ACK 测试主要覆盖字符串、Skill 元数据和初始化冒烟,没有覆盖状态机语义、
schema 与 fallback 一致性、Orca 双阶段生命周期、Coordinator 崩溃恢复和跨 worktree
代码交接。
## 待验证
- [ ] 用真实项目跑一轮同 worktree 的 Developer/Test 闭环。
- [ ] 用独立 worktree 验证 commit、patch 和 artifact 三种交接方式。
- [ ] 验证 Coordinator 在 task-create、dispatch 和 Test 完成三个时间点崩溃后的
恢复行为。
- [ ] 为安全 worker profile、路径 containment 和 shell 注入增加对抗性测试。
- [ ] 定义 ACK v0.9 的 schema 迁移策略,再决定是否保留旧状态名称。
## 扩展方案:项目知识护栏库
### v0.9.0 落地边界
| 能力 | v0.9.0 状态 |
|------|-------------|
| `knowledge.yaml` 模板、schema、初始化和跨文件校验 | 已实现 |
| active/stale/superseded/archived、作用域、冲突、复查时间和来源校验 | 已实现 |
| 确定性选择、全项目规则优先、固定 revision 引用 | 已实现 |
| registry ID + 结构化 argv + fd 固定根目录的安全检查入口 | 已实现 |
| Developer candidate、Test check、Coordinator 单写的协作约定 | 已实现为协议和校验字段 |
| 不同 revision 的历史内容不可篡改或可独立恢复 | 延后;v0.9.0 没有外部不可变账本 |
| 依赖或路径变化后自动转 stale | 延后;当前由 `reviewAfter` 和人工复核驱动 |
| 在运行时强制只有获授权身份能写入或激活知识 | 延后;当前依赖 Coordinator 单写边界 |
| 自动识别任意提示注入、PII 或所有秘密 | 延后;当前只有结构禁区和常见秘密模式 |
| `approved`、追加式 attempts、崩溃恢复和最终集成 gate | 延后到控制面改造 |
### 定位
ACK 需要增加第三类项目事实,用来保存跨任务复用、会改变后续开发或验证行为的已
验证知识。它不保存聊天记忆,也不承担项目 Wiki 的职责。
| 项目事实 | 保存内容 | 写入者 |
|----------|----------|--------|
| `project.md` | 相对稳定的项目配置、路径和命令 | Coordinator 或项目维护者 |
| `tasks.yaml` | 当前任务状态、attempt 和执行证据 | Coordinator |
| `knowledge.yaml` | 跨任务复用的已验证经验 | Coordinator |
建议新增项目级 SSOT:`.pouch/ack/knowledge.yaml`。知识归项目所有,与 Orca 等编排
工具无关;ACK 负责在任务闭环中生产、选择和消费这些知识。
这项设计会修改当前“`.pouch/ack/` 只保存 `project.md` 与 `tasks.yaml`”的边界。
新增文件保存项目事实,不复制 ACK Skill 的通用规范,因此不违反 Skill 内容仍以
`skills/ack/` 为 SSOT 的原则。
### 知识类型
第一版只支持三类知识:
| 类型 | 含义 | 例子 |
|------|------|------|
| `guardrail` | 必须执行或明确禁止的项目约束 | 修改数据库迁移时必须验证回滚 |
| `pitfall` | 已证实的失败模式、触发条件和避免方法 | Test 连到了另一个 worktree 的旧服务 |
| `verification` | 特定条件下必须增加的检查 | 修改缓存键后执行跨版本兼容测试 |
架构决策正文继续写入 ADR 或正式规格,知识项只引用决策及其适用条件。任务进度和
单次失败证据继续留在 `tasks.yaml`。通用且可跨项目复用的规则应回流 ACK Skill,
不能作为某个项目的知识长期保存。
### 生命周期
```text
Developer / Test 发现经验
-> candidate
-> Test 独立验证 + Coordinator gate
-> active
-> stale
-> superseded | archived
```
- Developer 和 Test 只能提交 candidate,不能直接写入或激活知识。
- candidate 保存在当前 task 或 attempt 的证据中,不参与后续任务的自动选择。
- 根因得到证实、修复通过独立 Test、Coordinator 完成 gate 后,candidate 才能转为
`active`。
- 全项目范围的 `must`、`never` 或权限类规则需要 User 或 Decision Owner 确认。
- 依赖、配置、路径或适用版本发生变化后,相关知识转为 `stale`,默认不再派发。
- 新知识替代旧知识时必须记录 `supersedes`,不能静默改写历史。
- 临时 workaround 必须有复查时间和移除条件,不能无限期保持 `active`。
同一 Agent 不能把自己读到的旧知识直接作为新证据再次激活。新的 candidate 必须
包含当前任务产生的独立观测,避免形成自我强化的错误闭环。
### 最小数据结构
每条知识至少包含:
```yaml
id: K-001
revision: 1
kind: pitfall
status: active
title: "复测前确认服务来自当前 worktree"
scope:
components: [web]
paths: ["web/**"]
dependencies: []
versions: []
tags: [long-running-service]
appliesWhen: "修改常驻 Web 服务或前端构建产物"
directive: "复测前重启服务,并核对服务实例对应的 commit"
rationale: "曾因复测旧进程产生假通过"
verification:
ref: service-worktree-alignment
expected: "服务实例、worktree 和 commit 一致"
provenance:
taskId: BUG-017
attemptId: BUG-017-A2
codeRef: "<commit or tree hash>"
evidenceRef: "tasks.yaml#BUG-017"
owner: "<owner>"
author: "<candidate author>"
reviewer: "<independent reviewer>"
createdAt: "<timestamp>"
lastValidatedAt: "<timestamp>"
reviewAfter: "<timestamp or null>"
removalCondition: null
supersedes: []
conflictsWith: []
```
`revision` 是逻辑版本标识。修改知识的约束含义时必须新增 revision,并保留被引用
的旧条目;任务引用 `K-001@1` 后,可以说明当时选择的逻辑版本。v0.9.0 尚未用
content hash、签名或外部账本封存条目内容,因此不能只凭这个字符串证明历史字节
不可变;项目需要把状态纳入版本控制,强不可变审计留待后续实现。
`directive` 只能描述应采取的动作。需要执行命令时,`verification.ref` 应引用项目中
已审查的测试或检查 ID,由可信配置解析成结构化 argv。知识库不保存可自动执行的
自由 shell 命令。
### 与三角色闭环的集成
| 角色 | 权限和职责 |
|------|------------|
| Coordinator | 单写 `knowledge.yaml`;选择本轮适用知识;激活、废弃和处理冲突 |
| Developer | 只读本轮知识;回报遵守情况;提交 `knowledgeCandidates` |
| Test | 只读本轮知识;执行额外检查;独立验证 candidate |
| User / Decision Owner | 批准全项目强制规则、权限规则和无法通过测试证明的政策 |
`prepare(task)` 时,Coordinator 按 component、path、dependency、version、tag 和失败
特征筛选 `active` 知识。匹配结果只作为候选,Coordinator 确认后把固定版本引用
写入本轮上下文:
```yaml
knowledgeRefs:
- K-001@1
- K-014@2
```
引入前文建议的追加式 `attempts[]` 后,引用和回报建议放在:
```text
attempts[].context.knowledgeRefs
attempts[].developer.knowledgeApplied
attempts[].developer.knowledgeCandidates
attempts[].test.knowledgeChecks
```
派发时把已引用知识的必要内容内联到 Developer 和 Test 的 prompt,避免 worker 因
worktree 不同而读不到 Coordinator worktree 中未提交的知识文件。每次只派发当前
任务命中的少量条目,不全量注入知识库。
Developer 回报实际遵守了哪些知识,以及新发现的 candidate。Test 对每条适用的
`verification` 回报 pass、fail 和证据。要求执行的知识检查没有覆盖时,
Coordinator 不得把任务写成 `verified_in_workspace` 或 `verified`。
### 检索和冲突规则
第一版使用确定性匹配,不引入向量数据库、Embedding 或语义 RAG:
- 先按 path、component、dependency、version 和 tag 选择条目。
- 失败排查时可以额外匹配错误签名和相关 symbol。
- 只返回 `active` 且适用条件成立的条目。
- 设置条目数和上下文预算,详情按需读取。`scope.all=true` 的全项目规则优先占用
预算;全项目规则本身超过预算时显式失败,不能静默丢弃。
- `stale`、`superseded` 和 `archived` 默认不返回。
- 没有匹配结果只表示本轮没有找到知识,不能据此声称项目没有相关约束。
同一 subject 和 scope 不能存在互相矛盾的 `active` 条目。校验器发现冲突时应阻止
进入 ready 状态,由 Coordinator 或 Decision Owner 选择保留项并记录
`supersedes`。运行时不能使用“最后写入者获胜”解决冲突。
自动匹配只负责推荐。Coordinator 写入当前 attempt 的显式 `knowledgeRefs` 才是
本轮权威上下文。这个边界可以降低作用域标注不准造成的漏选和误选。
### 知识晋升后的去向
知识库不是所有经验的最终归宿。不同内容应继续进入对应的权威载体:
| 内容 | 最终位置 |
|------|----------|
| 当前任务状态和单次验证证据 | `tasks.yaml` |
| 稳定项目路径、命令和运行配置 | `project.md` |
| 架构或产品决策及其取舍 | ADR 或正式规格 |
| 安全、正确性和兼容性约束 | 测试、lint、CI 或正式规范 |
| 原始日志、截图和构建产物 | 受控 artifact 存储 |
| 跨项目通用规则 | ACK Skill |
| 项目特有且跨任务复用的经验 | `knowledge.yaml` |
关键 `verification` 应逐步转成可执行测试或 CI gate。知识项继续保存触发条件、原因
和证据引用,不能用自然语言规则替代可执行控制。
### 禁止写入的内容
- Token、密钥、Cookie、含凭据 URL、生产数据、PII、客户内容和未脱敏日志。
- 系统提示、开发者提示、角色或权限覆盖,以及绕过审批、测试和安全限制的指令。
- 可自动执行的破坏性命令或生产命令。
- 原始聊天、整段 issue 或网页内容、巨量日志、截图和构建产物。
- 未复现猜测、LLM 单方推断、一次性巧合和个人评价。
- PID、临时端口、个人 worktree 绝对路径等短命机器状态。
- 对代码、配置和正式文档的重复抄写。
- 没有作用域、证据和失效条件的 `always`、`never` 或 workaround。
- 需要权限隔离的漏洞 PoC、敏感内部拓扑和安全调查材料。
issue、日志和外部网页只能作为不可信 evidence。进入知识库前必须提炼为可审查的
项目结论,不能原样晋升,也不能获得高于用户指令、批准规格、当前代码、配置和测试
的优先级。
### 第一版范围
第一版只实现一个 `.pouch/ack/knowledge.yaml`,不拆目录。活跃条目达到几十条、单文件
开始影响审阅和选择时,再平滑迁移为 `.pouch/ack/knowledge/index.yaml` 加独立知识卡,
条目 schema 和引用格式保持不变。
第一版包括:
1. `knowledge.yaml` schema 与语义校验。
2. task 或 attempt 的 `knowledgeRefs`。
3. Developer 的 `knowledgeApplied` 与 `knowledgeCandidates`。
4. Test 的 `knowledgeChecks`。
5. Coordinator 在 prepare、gate 和任务收尾三个节点处理知识。
6. 基于 scope 的确定性匹配。
7. stale、冲突、revision 和 supersedes 校验。
第一版不包括:
- 自动抓取或总结全部对话。
- Agent 自动激活、删除或提交知识。
- 向量数据库、Embedding 和语义 RAG。
- 自动改写 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
- 从知识正文直接执行 shell 命令。
- 组织级、跨仓库知识同步。
### 目标验收与当前状态
| 目标 | v0.9.0 状态 |
|------|-------------|
| candidate 不会作为 active 派发 | 已实现 |
| 未授权 worker 不能激活、废弃或删除知识 | 协议约束;运行时身份授权延后 |
| path、component、version 只返回匹配条目 | 已实现确定性匹配;准确率仍取决于 scope 标注 |
| 非 active 条目默认不返回 | 已实现 |
| 冲突 active 规则被拒绝 | 已实现显式冲突和相同 subject/scope 检查 |
| 历史 attempt 的知识内容不可变 | 延后;当前只有 revision 约定,没有不可变账本 |
| `verification.ref` 无法解析时校验失败 | 已实现 |
| evidence 中的提示注入不会变成命令 | 部分实现;禁止自由执行字段,语义晋升仍需人工 gate |
| 秘密和生产数据不能进入知识库 | 部分实现;常见秘密模式会阻断,不能替代完整 DLP |
| 依赖、路径变化后自动转 stale | 延后;当前依赖复查日期和 Coordinator |
| 增长后仍在预算内且不漏全项目规则 | 已实现;全项目规则超预算时显式失败 |
### 与 ACK 改造顺序的关系
知识护栏库最终仍依赖可信的任务状态、attempt、证据来源和 ready 校验。v0.9.0 先
落地存储、选择和基础 gate,不能据此声称前述控制面问题已经解决。后续应继续:
1. 确定知识 schema、生命周期和角色权限。
2. 在 attempt 中固定 `knowledgeRefs` 和 candidate 证据。
3. 接入 Coordinator 的匹配、派发和晋升流程。
4. 增加 stale、冲突、权限、注入和敏感信息的对抗性 fixture。
5. 选一个真实项目试运行,再根据活跃条目数量决定是否拆分存储。
这套方案最依赖 scope 标注的质量。项目知识如果很难用组件、路径、依赖、版本或
触发条件限定,自动匹配会漏掉关键项或产生大量噪声。这类知识应继续由人维护在 ADR
或项目文档中,不能强行进入自动派发流程。
## v0.10.0 实施结论:结构化 model-routing
### 当前决策
旧的 `validate_worker_command.py --command '<shell>'` 只能检查少量 token,无法证明
整段 shell 没有追加命令、重复参数、环境注入或 cwd 漂移。v0.10 删除这条自由命令
配置面:旧脚本只保留 fail-closed 迁移提示;自动 worker 的唯一入口是
`launch_worker.py profile-hash|plan|launch`。
机器 SSOT 固定为 `tasks.yaml.project.orchestration`。profile 只声明 role、CLI、
tier、model、reasoning effort 和 permission mode;model 必须命中按
CLI/role/tier 分组的 allowlist。自动权限只允许 `read-only` 与
`workspace-write`,full-access、Codex bypass、Cursor YOLO/force 和关闭 sandbox
全部 fail closed。
### 已落地约束
| 控制面 | v0.10.0 实现 |
|--------|--------------|
| 自由 command / extra argv / env / cwd | schema 与语义校验拒绝;旧 validator fail closed |
| Agent argv | 由 `worker_profiles.py` 按 profile 生成唯一 argv;子进程 `shell=False` |
| plan → launch 漂移 | `launch` 强制接收刚审阅的 `--expected-launch-fingerprint`,slot 也纳入 hash |
| PATH / loader / 凭据串用 | 不按调用者 PATH 找可执行文件;固定可信目录;控制进程无供应商凭据,worker 使用 `per-cli-allowlist-v1` |
| worktree 冒充 | 绝对规范路径、无 symlink、同 Git common-dir,并命中 `git worktree list -z` |
| Orca command 注入 | `--command` 只含固定 bootstrap 和随机 launch ID |
| bootstrap 抢跑 | 先绑定 runtime/handle/incarnation/worktree,再用 terminal stdin nonce/proof 授权 |
| cleanup / bootstrap 竞态 | Popen 前在同一 record lock 内复核 awaiting 状态;cleanup 先发生则拒绝启动 Agent |
| create 部分失败 | 取得确定 handle 前的超时、transport/解码异常、中断、非零、畸形/缺字段响应均记为 indeterminate,禁止自动重试 |
| handle 后失败清理 | record 写失败不阻断 close;只有 `terminal close --tab --json` 完整匹配且最终 failed 状态可靠落盘才记普通失败,否则保持 indeterminate/reconcile-required |
| receipt 漂移 | 规范化 profileHash、argvHash、slot、launchFingerprint 与 receiptHash 交叉校验 |
| dispatch 旧 receipt 改挂 | 每个角色显式记录 attemptId;receipt 必须绑定当前 ACK task.id、同 role/profile/attempt,receiptId/attemptId 同空同填 |
| 配置兼容 | ackVersion 必须是合法 SemVer;v0.10+ 的 orchestration/workerReceipts 必须同时存在,v0.9 旧板仍可只读校验和手动协作 |
### 不能被弱证明掩盖的边界
`receiptHash` 是无密钥 checksum,不是签名。项目内可写方能修改 receipt 后重算
hash;而当前 Orca terminal metadata 不提供原始 Agent argv、模型或权限
attestation。因此 v0.10 **不根据持久化 receipt 自动复用旧终端**。每次自动派发都
重新执行 plan,并用 expected fingerprint 启动 fresh worker;持久化 receipt 只作
审计和 dispatch 关联。
`launchFingerprint` 只证明完整计划没有漂移,不是一次性授权或幂等键;同一计划重复
执行仍会创建新的 fresh terminal。成功后不得重放,结果不确定时必须先 reconcile。
自动防重放要依赖后端原子 idempotency/claim 或项目外可信 launch intent,不能由
checksum 伪装提供。
任务板里的 dispatch 关联也不是宽松的历史索引。`dispatch.<role>` 显式记录
`profileId`、`receiptId` 和 `attemptId`,校验器要求 receipt 的 `createdFor` 精确绑定
当前 ACK `tasks[].id`、同一角色和同一 attempt,并要求 profile 一致。该约束阻止把
旧任务、旧角色或旧轮次 receipt 改挂到当前 dispatch,但不会把无密钥 receipt 升级成
可信 attestation。
同样,receipt 证明的是 launcher 请求和本地 CLI/runtime 绑定,不证明模型供应商最终
执行的模型。开放自动复用需要 Orca/ACP 提供启动参数 attestation,或项目外可信签发
与校验通道。开放 full-access 还需要不可由项目文本伪造的用户授权、期限、撤销和外层
隔离。这两项均留待后续版本。
威胁模型也明确到 Coordinator 账户边界:v0.10 防止任务板、任务内容、普通环境和
受限 worker 把数据变成第二个启动命令入口;不抵御已经完全控制 Coordinator OS
账户、Orca runtime、受信 Agent/Orca 可执行文件或用户级 Agent 配置/插件/MCP 的
攻击者。`permissionMode` 也不是模型供应商或外部工具能力 attestation;后者必须依赖
独立 OS 身份、受控 Agent 配置或平台能力。
## 后续版本提案:ACP 与可替换编排后端
### 当前决策
ACK 当前版本继续以 **Orca** 作为唯一自动编排后端。闭环语义保持工具无关,但
worker 创建、终端身份绑定、dispatch 和 wait 的可执行适配仍由 Orca 完成。
ACP(Agent Client Protocol)作为后续版本的优先候选,当前只记录方案,不加入运行
时依赖、不新增半成品 adapter,也不让现有流程在 Orca 与 ACP 之间自动猜测。
| 能力 | 当前版本 | 后续方向 |
|------|----------|----------|
| 自动创建并监督 worker | Orca | ACP capability negotiation 后可增加 ACP adapter |
| 结构化模型与权限 profile | ACK 自己定义,Orca 只承载启动 | 保持为 ACK 稳定契约,不交给后端自由解释 |
| dispatch / wait / terminal identity | Orca runtime handle | 映射到 ACP session / request / event identity |
| 无自动后端时运行闭环 | 手动模式 | 可增加 PTY / Zellij 类低层 fallback,但不冒充语义协议 |
| 后端选择 | 项目显式配置 | 未来仍需显式配置,不按已安装命令静默切换 |
### 后端无关的最小语义
未来 adapter 只应实现以下能力,不应接管 ACK 的角色、状态机或安全策略:
1. `createWorker(profile, worktree)`:按 ACK 已验证的结构化 profile 创建 worker。
2. `inspectWorker(identity)`:返回可绑定的 runtime、session/incarnation 和活性。
3. `dispatch(identity, taskContext)`:把一个明确任务投递给已验证 worker。
4. `wait(identity, eventTypes)`:等待完成、复测、升级或 decision gate 事件。
5. `closeWorker(identity)`:只关闭本次创建且身份仍匹配的 worker。
模型、reasoning effort、权限模式、worktree 和 receipt hash 仍由 ACK 校验。adapter
不能重新开放自由 shell、任意 argv、任意环境变量或“后端默认模型”作为旁路。
### ACP 采用门槛
满足以下条件后再实现 ACP adapter:
- 目标 Agent CLI 对 ACP 的启动、会话身份、取消和事件语义足够稳定。
- 能把 Developer/Test 的独立身份、worktree 和结构化 profile 映射到可核对字段。
- 能区分“已请求的模型/权限”和“运行时可观测事实”,不把客户端请求冒充供应商证明。
- 超时、重连、重复 dispatch 和 runtime 重启具有明确的幂等或恢复语义。
- 与 Orca adapter 使用同一组 ACK conformance tests,且不会降低安全默认。
在这些门槛满足前,ACP 保持 **Deferred**。PTY 或 Zellij 一类方案只能作为较低层的
进程/终端承载,不提供 session 语义、模型证明或任务状态机;如果以后加入,也必须
经过独立 adapter,不能散落为文档中的自由命令。
### 参考依据
- [GitHub 仓库自定义指令](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions)
区分仓库级、路径级和就近生效的 Agent 指令。
- [Claude Code 项目记忆](https://code.claude.com/docs/zh-CN/memory)区分持久指令与
自动记忆,并建议把较大的内容按作用域拆分、按需加载。
- [Architectural Decision Records](https://adr.github.io/)用于保存单个重要决策的
理由、取舍和后果,适合承载不应混入项目知识库的决策正文。
+106
View File
@@ -0,0 +1,106 @@
# ACK 飞书多维表格 Bug 收件箱
## 目标
让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并
反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、
可幂等追踪的 ACK 任务并启动三角色闭环。
## 非目标
- 不用 Skill 承担人工记录入口。
- 不抓取公开网页或依赖浏览器登录态。
- 不把 App Secret、access token 或飞书用户凭据写入项目。
- 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书。
- 不把多条互不相关的 Bug 合成一次 Developer 派发。
## 项目配置契约
可选配置位于 `.pouch/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
配置存在时必须包含:
| 字段 | 约束 |
|------|------|
| `provider` | 固定为 `feishu-base` |
| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 |
| `profile` | `lark-cli` profile 名称;每次命令显式传入 |
| `baseToken` | 飞书 Base token |
| `tableId` | 数据表 ID |
| `viewId` | 只包含可导入记录的 `ACK Ready` 视图 ID |
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、
`acceptance`、`priority`、`attachments`、`updatedAt`。启用审核前协作时还必须映射
`fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。
## CLI 契约
- 可执行文件固定为可信路径中的 `lark-cli`。
- 允许官方 npm 包生成且 package/bin 映射校验通过的 `run.js` wrapper,并解析到包内
native binary 执行;拒绝缺少 native binary 或其它同名软链接目标。
- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。
- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。
- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。
- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改
配置映射的 `fixLogic` 与 `acceptance`。
- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务
校验器拒绝审核字段与该 hash 不一致的 reviewed task。
- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。
- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。
- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与
元数据一致。
- 标准输出必须是单一 JSON;CLI stderr 只作为错误摘要,不把可能的凭据写入结果。
## 标准化结果
读取器输出批次元数据和 `records`。每条记录至少包含:
- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256
后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本;
- `recordId` 与 `updatedAt`;
- 绑定来源事实、附件元数据和审核字段的 `draftRevision`;
- title、actual、expected、steps、可选 fixLogic、acceptance、priority;
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority;
- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用;
- 原始字段中无法映射但不影响导入的警告。
输出不得包含 App ID、App Secret、Base token、附件 file token、tenant/user access
token 或 CLI 配置文件内容。
## Coordinator 整理规则
1. 先运行读取器 `check` 确认 `lark-cli` 和项目 profile,再单独核对 app 已获得所需读写
scope;`check` 不把用户 token 状态当作 app/bot scope 证明。
2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift
整理动作;需要看截图时使用临时下载目录。
3. 将记录分类并在飞书补全 `fixLogic` 与 `acceptance`;用户反馈时继续写回同一记录。
4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改
应用代码。通过后每条导入任务保存 `source.kind=feishu-base`、`source.ref`、
`source.recordId`、`source.updatedAt`、`source.workflow`、`source.approvedRevision`、
`source.approvedPayloadHash`,并把截图观察转成文字证据。
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed;
steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文
补齐并写回飞书。只有批准后的最终版本才导入任务板。
9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分
来源事实的残缺行不得静默跳过。
## 可观测验收信号
1. 两个假租户 profile 同时存在时,项目指定 `tenant-b`,所有记录和附件命令都显式包含 `--profile tenant-b`,不会读取 active 的 `tenant-a`。
2. 只读取配置的 `ACK Ready` view ID,并投影配置字段;不查询整张表或其它视图。
3. 一条含截图附件的假记录被标准化为稳定 `sourceRef`、完整文字字段和本地附件路径,输出中不存在任何 secret/token 凭据。
4. CLI 缺失、profile 缺失、畸形 JSON、错位矩阵、分页越界、路径穿越或附件下载失败均返回非零退出码且不输出伪成功结果。
5. ACK 文档明确要求按 `source.ref` 幂等整理;同一读取结果重复提交不会生成第二个来源任务。
6. 现有无 `bugIntake` 的 ACK 项目仍能通过任务板校验并按原流程工作。
7. 缺少 steps、acceptance、priority 的记录仍生成 create/refresh 计划并列出
`enrichmentRequired`;全空误建行被跳过,缺 title/actual/expected 的部分记录失败。
## 最脆弱假设与降级
本设计假设官方 `lark-cli` 的 Base JSON 输出保持 `fields`、`record_id_list` 与行矩阵
对应关系。读取器必须校验三者长度和字段映射;若上游输出契约变化,立即失败并提示
升级适配器,不能错列生成 Bug。飞书或 CLI 不可用时,只停止新的同步,已经进入
`tasks.yaml` 的任务继续按 ACK 闭环执行。
+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)
+11 -4
View File
@@ -3,15 +3,22 @@ set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${HOME}/.local/bin" BIN_DIR="${HOME}/.local/bin"
PATH_MARKER="# skiff: ~/.local/bin" PATH_MARKER="# pouch: ~/.local/bin"
LEGACY_PATH_MARKER="# skiff: ~/.local/bin"
mkdir -p "$BIN_DIR" mkdir -p "$BIN_DIR"
ln -sf "$REPO/bin/skiff" "$BIN_DIR/skiff" ln -sf "$REPO/bin/pouch" "$BIN_DIR/pouch"
echo "已安装 skiff -> $BIN_DIR/skiff" echo "已安装 pouch -> $BIN_DIR/pouch"
if [[ ! -e "$BIN_DIR/skiff" || -L "$BIN_DIR/skiff" ]]; then
ln -sf "pouch" "$BIN_DIR/skiff"
echo "已保留 skiff -> pouch(旧命令名)"
fi
PYTHONPATH="$REPO${PYTHONPATH:+:$PYTHONPATH}" python3 -m pouch bootstrap
path_already_configured() { path_already_configured() {
local file="$1" local file="$1"
[[ -f "$file" ]] && grep -qF "$PATH_MARKER" "$file" [[ -f "$file" ]] && { grep -qF "$PATH_MARKER" "$file" || grep -qF "$LEGACY_PATH_MARKER" "$file"; }
} }
configure_bash() { configure_bash() {
-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
+284
View File
@@ -0,0 +1,284 @@
# pouch
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
## 安装
```bash
cd /path/to/pouch # 本仓库根目录
./install.sh # 软链到 ~/.local/bin/pouch
```
确保 `~/.local/bin` 在 `PATH` 中。
## 命令风格
接口对齐 [Vercel skills CLI](https://github.com/vercel-labs/skills) 的 `add` / `remove`,
统一管理 builtin skill、预置 catalog source 和用户命名的 custom source。
```bash
# 浏览可用自研 skill
pouch add --list
# 装到当前项目 / 全局
pouch add discussion-notes -a cursor -y
pouch add discussion-notes -a cursor -g -y
# 卸载
pouch remove discussion-notes -a cursor -y
pouch rm discussion-notes -g -y
# 改完 skill 后提交推送(在任意目录执行,操作 ~/.pouch)
pouch publish skills/discussion-notes -m "update discussion-notes" --push
```
开发时也可直接运行:
```bash
PYTHONPATH=/path/to/pouch python3 -m pouch <command>
```
## 首次安装
```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
~/.pouch/install.sh
```
`install.sh` 会安装 CLI,并自动执行 `pouch bootstrap`,将本仓库的 `pouch` skill 全局软链到 Cursor、Claude Code 和 Codex。也可以随时手动重跑:
```bash
pouch bootstrap
```
## 命令参考
### 查看
| 命令 | 说明 |
|------|------|
| `pouch list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
| `pouch status [--target all\|cursor\|claude\|codex\|agents]` | 安装状态总览 |
### 项目初始化
| 命令 | 说明 |
|------|------|
| `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
| `pouch update` | 更新 pouch 自身,以及已拉取的 catalog / custom source |
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态(目前用于 ack) |
### Skill 安装
| 命令 | 说明 |
|------|------|
| `pouch add <name> [--global] [-a AGENT...] [-y]` | 安装到 Agent 目录(软链) |
| `pouch select [--global] [-a AGENT...]` | 打开终端多选界面,批量安装 skill |
| `pouch remove <name> [--global] [-a AGENT...] [-y]` | 移除软链(`rm` / `r` 别名) |
| `pouch add --list` | 列出可用 builtin skill |
| `pouch publish [paths] -m MSG [--push]` | 在 ~/.pouch 内 git add/commit/push |
旧命令 `install` / `uninstall` 已移除,请改用 `add` / `remove`。
全局目标路径:
| Agent | 路径 |
|-------|------|
| cursor | `~/.cursor/skills/` |
| claude | `~/.claude/skills/` |
| codex | `~/.codex/skills/` |
| agents | `~/.agents/skills/`(agents 标准目录,覆盖 OMP) |
### 预置 Catalog Source
| 命令 | 说明 |
|------|------|
| `pouch catalog add <name> <repo-url> [--ref main] [--path .]` | 写入 `catalog.yaml` |
| `pouch fetch <name>` | 克隆或更新 catalog source checkout |
| `pouch add <name> [-g] [-a AGENT...]` | 安装 catalog 中的单个 skill 或完整 collection(缺失时自动 fetch) |
| `pouch add <collection>/<skill> [...]` | 只安装 collection 中指定的 skill |
`catalog.yaml` 条目可额外提供 `description` 和 `tags`。`description`
会显示在 `pouch select` 的候选列表中。`path` 可以直接指向含
`SKILL.md` 的单个 skill,也可以指向由多个 skill 目录组成的 collection。
collection 会自动发现下一层所有含 `SKILL.md` 的目录;`pouch add <name>`
安装全部,`pouch select` 则展开为 `<name>/<skill>` 供分别勾选。同一
`repo` 与 `ref` 共享一份 Git checkout。
预置 collection 示例:
```bash
pouch add waza/think -g
pouch add taste-skill/taste-skill -g
pouch add taste-skill -g # 安装 Taste Skill 全部子 skill
```
### 自动更新
日常执行 `pouch list`、`pouch add`、`pouch status` 等命令时,pouch 会按间隔
顺带更新自身和已经 clone 过的 catalog / custom Git source,不必单独跑
`pouch fetch` 或 `pouch update`。默认每 24 小时最多自动更新一次;网络失败或
工作区不干净时跳过,不打断当前命令。`pouch add <catalog>` 在安装前会再刷新
该来源,以便拿到最新子 skill。
在 `~/.config/pouch/config.yaml` 里关闭或改间隔:
```yaml
auto_update:
enabled: true
interval: 24h # 也可用 1d、60m、3600
```
本次跳过:`pouch --no-auto-update list`,或设置环境变量 `POUCH_NO_AUTO_UPDATE=1`。
立刻更新全部:`pouch update`。
### 交互式批量安装
```bash
pouch select # 当前项目,全部 Agent
pouch select -a codex # 当前项目,仅 Codex
pouch select -g # 全局安装
pouch select --project ~/code/app # 指定项目
```
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
取消。普通 `pouch select` 只安装到项目,并在每一项旁只读显示各 Agent 的
全局安装状态;`pouch select -g` 只安装到全局。已经安装到目标范围的 skill
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `pouch remove`。如果全局
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
项目模式会把成功选择的项目写入 `.pouch.yaml`。非交互环境请使用
`pouch add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
skill 条目中,后续 `pouch sync` 不会扩散到其他 Agent。
### Custom source(多-skill 仓库)
公司或团队维护的仓库通常包含多个 skill。使用命名 source 接入:
```bash
# Git 仓库,默认 clone 到 ~/.local/share/pouch/sources/company
pouch source add company \
git@git.company.com:platform/agent-skills.git \
--ref main \
--skills-path internal/skills
# 或接入已有本地仓库
pouch source add company \
--local ~/code/company-agent-skills \
--skills-path skills
pouch source list
pouch source fetch company
pouch list --source company
pouch add company/code-review -g -a codex
```
| 命令 | 说明 |
|------|------|
| `pouch source add <name> <repo> [--ref REF] [--checkout PATH] [--skills-path PATH]` | 注册并克隆 Git source |
| `pouch source add <name> --local PATH [--skills-path PATH]` | 接入已有本地仓库 |
| `pouch source list` / `show <name>` | 查看 source |
| `pouch source fetch <name>` / `fetch --all` | clone 或 fast-forward 更新 |
| `pouch source remove <name>` | 移除配置并保留 checkout |
配置保存在 `~/.config/pouch/config.yaml`。Git/SSH 认证复用本机 Git 配置,
pouch 不保存 token。可以使用 `company/code-review`,也可以使用
`pouch add code-review --source company`。多个来源包含同名 skill 时,必须明确来源。
### 项目级
| 命令 | 说明 |
|------|------|
| `pouch enable <name> [--target all] [--project <dir>]` | 写入 `.pouch.yaml` 并创建项目软链 |
| `pouch disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
| `pouch sync [--target all] [--project <dir>]` | 按 `.pouch.yaml` 重建软链 |
项目目标路径:
| Agent | 路径 |
|-------|------|
| cursor | `<project>/.agents/skills/` |
| claude | `<project>/.claude/skills/` |
| codex | `<project>/.agents/skills/` |
| agents | `<project>/.agents/skills/`(与 cursor/codex 共用路径,软链幂等) |
### 脚手架与健康检查
| 命令 | 说明 |
|------|------|
| `pouch create <name> [--idea TEXT] [--from-project PATH]` | 从模板创建含 `SKILL.md`、`README.md` 的草稿 |
| `pouch check <name>` | 校验草稿或正式 skill,包括人类使用说明 |
| `pouch finalize <name>` | 校验草稿并移动到正式 `skills/` |
| `pouch doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
## 常用工作流
### 新建并全局启用自研 skill
```bash
pouch create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善草稿中的 SKILL.md 和 README.md
pouch check my-skill
pouch finalize my-skill
pouch publish skills/my-skill -m "add my-skill" --push
pouch add my-skill -a cursor -g -y
pouch doctor -a cursor
```
### 在项目中启用 skill
```bash
cd ~/code/my-app
pouch add discussion-notes -a cursor -y
```
### 添加 Catalog Source
```bash
pouch catalog add my-ext https://github.com/org/repo --ref main
pouch fetch my-ext
pouch add my-ext -g
```
## 源码结构
```
pouch/
├── __init__.py # 版本号
├── __main__.py # python3 -m pouch 入口
├── cli.py # 命令定义与调度
├── paths.py # 路径常量与 Agent 目标
├── skills.py # builtin/catalog/custom 统一解析
├── catalog.py # catalog.yaml 读写与 Skill 发现
├── config.py # 用户配置与自动更新设定
├── auto_update.py # 命令执行时顺带更新 pouch 与已拉取 source
├── sources.py # custom source 配置、发现与 Git 管理
├── project.py # .pouch.yaml 管理
├── symlinks.py # 软链创建/检查/修复
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
```
入口脚本:[../bin/pouch](../bin/pouch)
## 路径约定
| 变量 | 路径 | 说明 |
|------|------|------|
| `POUCH_HOME` | `~/.pouch` | pouch 仓库(软链;兼容 `~/.skills`) |
| `SKILLS_DIR` | `~/.pouch/skills/` | builtin skill 目录 |
| `CATALOG_FILE` | `~/.pouch/catalog.yaml` | 预置 Skill 来源目录 |
| `CATALOG_CACHE_DIR` | `~/.local/share/pouch/externals/` | catalog checkout 缓存;按 repo/ref 共享 |
| `CONFIG_FILE` | `~/.config/pouch/config.yaml` | custom source 与 auto_update 配置 |
| `STATE_FILE` | `~/.local/share/pouch/state.yaml` | 自动更新时间戳 |
| `SOURCES_DIR` | `~/.local/share/pouch/sources/` | custom Git source 默认 checkout |
## 注意事项
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
- 若 Agent 将软链替换为普通目录,运行 `pouch doctor --fix` 重建
## 相关文档
- [项目 README](../README.md)
- [AGENTS.md](../AGENTS.md)
- 各 skill 的人类说明:`skills/<name>/README.md`
+3
View File
@@ -0,0 +1,3 @@
"""pouch — Agent Skills 安装与管理 CLI。"""
__version__ = "0.6.0"
+1 -1
View File
@@ -1,4 +1,4 @@
from skiff.cli import main from pouch.cli import main
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+40
View File
@@ -0,0 +1,40 @@
"""Agent 名称解析(兼容 Vercel skills CLI 别名)。"""
from __future__ import annotations
from pouch.paths import ALL_TARGETS
AGENT_ALIASES: dict[str, str] = {
"cursor": "cursor",
"claude": "claude",
"claude-code": "claude",
"codex": "codex",
"agents": "agents",
}
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
def flatten_agent_args(groups: list[list[str]] | None) -> list[str] | None:
if not groups:
return None
flat = [item for group in groups for item in group]
return flat or None
def resolve_agent_args(agents: list[str] | None) -> list[str]:
if not agents or agents == ["*"] or "*" in agents:
return list(ALL_TARGETS)
resolved: list[str] = []
for raw in agents:
key = raw.lower()
if key == "*":
return list(ALL_TARGETS)
target = AGENT_ALIASES.get(key)
if target is None:
choices = ", ".join(AGENT_CHOICES)
raise SystemExit(f"未知 agent: {raw!r},可选: {choices}")
if target != "*" and target not in resolved:
resolved.append(target)
return resolved
+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)
+105
View File
@@ -0,0 +1,105 @@
"""预置 Skill catalog 的配置、发现与本地 checkout 路径。"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
from pouch import yaml_io
from pouch.paths import CATALOG_FILE, LEGACY_REGISTRY_FILE
def load_catalog(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or (
CATALOG_FILE if CATALOG_FILE.is_file() else LEGACY_REGISTRY_FILE
)
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"catalog 格式错误: {path}")
return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")}
def save_catalog(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or CATALOG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def catalog_repo(entry: dict[str, Any]) -> str:
"""Expand a local home-relative repo while leaving remote URLs unchanged."""
repo = str(entry.get("repo", ""))
return str(Path(repo).expanduser()) if repo.startswith("~") else repo
def catalog_repo_path(entry: dict[str, Any]) -> Path:
"""Return the shared checkout path for a repo/ref pair."""
from pouch.paths import CATALOG_CACHE_DIR
repo = str(entry.get("repo", ""))
ref = str(entry.get("ref", "main"))
digest = hashlib.sha256(f"{repo}\0{ref}".encode()).hexdigest()[:16]
return CATALOG_CACHE_DIR / "_repos" / digest
def catalog_checkout_path(name: str, entry: dict[str, Any]) -> Path:
"""Use a local repo directly, otherwise return its external checkout."""
from pouch.paths import CATALOG_CACHE_DIR
configured_repo = str(entry.get("repo", ""))
local_repo = Path(catalog_repo(entry))
if configured_repo.startswith("~") and local_repo.is_dir():
return local_repo.resolve()
legacy = CATALOG_CACHE_DIR / name
return legacy if legacy.is_dir() else catalog_repo_path(entry)
def catalog_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
entry = entry or load_catalog().get(name, {})
subpath = entry.get("path", ".") or "."
checkout = catalog_checkout_path(name, entry).resolve()
skill_path = (checkout / subpath).resolve()
try:
skill_path.relative_to(checkout)
except ValueError as exc:
raise SystemExit(
f"catalog 条目 {name!r} 的 path 超出来源仓库: {subpath!r}"
) from exc
return skill_path
def discover_catalog_skills(
name: str,
entry: dict[str, Any] | None = None,
) -> dict[str, Path]:
"""Discover a catalog provider containing one or more skills."""
entry = entry or load_catalog().get(name, {})
root = catalog_skill_path(name, entry)
if (root / "SKILL.md").is_file():
return {name: root}
if not root.is_dir():
return {}
resolved_root = root.resolve()
skills: dict[str, Path] = {}
for item in sorted(root.iterdir()):
skill_md = item / "SKILL.md"
if (
item.name.startswith("_")
or item.is_symlink()
or not item.is_dir()
or skill_md.is_symlink()
or not skill_md.is_file()
):
continue
resolved_item = item.resolve()
resolved_skill_md = skill_md.resolve()
try:
resolved_item.relative_to(resolved_root)
resolved_skill_md.relative_to(resolved_item)
except ValueError:
continue
skills[item.name] = item
return skills
+1921
View File
File diff suppressed because it is too large Load Diff
+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)
+59
View File
@@ -0,0 +1,59 @@
"""~/.pouch 仓库内的 git 操作。"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from pouch.paths import POUCH_HOME
def _run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(repo), *args],
check=check,
text=True,
capture_output=not sys.stdout.isatty(),
)
def ensure_git_repo(repo: Path) -> None:
if not (repo / ".git").exists():
raise SystemExit(f"不是 git 仓库: {repo}")
def has_staged_changes(repo: Path) -> bool:
result = _run_git(repo, "diff", "--cached", "--quiet", check=False)
return result.returncode == 1
def publish(
*,
paths: list[str],
message: str | None,
push: bool,
no_commit: bool,
) -> None:
repo = POUCH_HOME.resolve()
ensure_git_repo(repo)
_run_git(repo, "add", "--", *paths)
if not has_staged_changes(repo):
print("没有可提交的变更", file=sys.stdout)
return
if no_commit:
print(f"已暂存变更: {', '.join(paths)}", file=sys.stdout)
return
if not message:
raise SystemExit("提交需要 -m/--message")
_run_git(repo, "commit", "-m", message)
print(f"已提交: {message}", file=sys.stdout)
if push:
_run_git(repo, "push")
print("已推送到远程", file=sys.stdout)
+110
View File
@@ -0,0 +1,110 @@
"""路径与 Agent 目标定义。"""
from __future__ import annotations
from pathlib import Path
HOME = Path.home()
PREFERRED_POUCH_HOME = HOME / ".pouch"
LEGACY_POUCH_HOME = HOME / ".skills"
PROJECT_MANIFEST = ".pouch.yaml"
LEGACY_PROJECT_MANIFEST = ".skills.yaml"
def _first_existing_dir(*candidates: Path, default: Path) -> Path:
for path in candidates:
if path.is_dir():
return path
return default
def _first_existing_file(*candidates: Path, default: Path) -> Path:
for path in candidates:
if path.is_file():
return path
return default
POUCH_HOME = _first_existing_dir(
PREFERRED_POUCH_HOME,
LEGACY_POUCH_HOME,
default=PREFERRED_POUCH_HOME,
)
SKILLS_DIR = POUCH_HOME / "skills"
TEMPLATE_DIR = SKILLS_DIR / "_template"
DRAFTS_DIR = POUCH_HOME / ".drafts"
CATALOG_FILE = POUCH_HOME / "catalog.yaml"
LEGACY_REGISTRY_FILE = POUCH_HOME / "registry.yaml"
CATALOG_CACHE_DIR = _first_existing_dir(
HOME / ".local" / "share" / "pouch" / "externals",
HOME / ".local" / "share" / "skills" / "externals",
default=HOME / ".local" / "share" / "pouch" / "externals",
)
CONFIG_FILE = _first_existing_file(
HOME / ".config" / "pouch" / "config.yaml",
HOME / ".config" / "skiff" / "config.yaml",
default=HOME / ".config" / "pouch" / "config.yaml",
)
DATA_DIR = _first_existing_dir(
HOME / ".local" / "share" / "pouch",
HOME / ".local" / "share" / "skiff",
default=HOME / ".local" / "share" / "pouch",
)
SOURCES_DIR = _first_existing_dir(
DATA_DIR / "sources",
HOME / ".local" / "share" / "pouch" / "sources",
HOME / ".local" / "share" / "skiff" / "sources",
default=HOME / ".local" / "share" / "pouch" / "sources",
)
STATE_FILE = DATA_DIR / "state.yaml"
AGENT_GLOBAL: dict[str, Path] = {
"cursor": HOME / ".cursor" / "skills",
"claude": HOME / ".claude" / "skills",
"codex": HOME / ".codex" / "skills",
# agents 标准目录:OMP 原生 canonical(agents provider),cursor/codex 项目级同路径
"agents": HOME / ".agents" / "skills",
}
AGENT_PROJECT: dict[str, str] = {
"cursor": ".agents/skills",
"claude": ".claude/skills",
"codex": ".agents/skills",
# 与 cursor/codex 共用 .agents/skills;软链幂等,同路径只写一次
"agents": ".agents/skills",
}
ALL_TARGETS = ("cursor", "claude", "codex", "agents")
def project_manifest(root: Path) -> Path:
"""项目清单路径:优先 .pouch.yaml,否则沿用 .skills.yaml。"""
preferred = root / PROJECT_MANIFEST
legacy = root / LEGACY_PROJECT_MANIFEST
if preferred.is_file():
return preferred
if legacy.is_file():
return legacy
return preferred
def resolve_targets(target: str | None) -> list[str]:
if target is None or target == "all":
return list(ALL_TARGETS)
if target not in ALL_TARGETS:
raise ValueError(f"未知 target: {target!r},可选: {', '.join(ALL_TARGETS)}, all")
return [target]
def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
if project_root is None:
return AGENT_GLOBAL[target]
return project_root / AGENT_PROJECT[target]
def ensure_pouch_home() -> None:
if not POUCH_HOME.is_dir():
raise SystemExit(
"~/.pouch 不存在。请将本仓库克隆到 ~/.pouch。"
"若仍是 ~/.skills,先执行: mv ~/.skills ~/.pouch"
)
+24 -19
View File
@@ -1,22 +1,22 @@
"""项目级 .skills.yaml 管理。""" """项目级 .pouch.yaml 管理。"""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from skiff import yaml_io from pouch import yaml_io
from skiff.paths import PROJECT_MANIFEST from pouch.paths import project_manifest
from skiff.skills import resolve_skill_source from pouch.skills import normalize_source, resolve_skill_source
def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]: def load_manifest(path: Path | None = None) -> tuple[Path, dict[str, Any]]:
path = path or Path.cwd() / PROJECT_MANIFEST path = path or project_manifest(Path.cwd())
if not path.is_file(): if not path.is_file():
return path, {"skills": []} return path, {"skills": []}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {} data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict): if not isinstance(data, dict):
raise SystemExit(f".skills.yaml 格式错误: {path}") raise SystemExit(f".pouch.yaml 格式错误: {path}")
if "skills" not in data: if "skills" not in data:
data["skills"] = [] data["skills"] = []
return path, data return path, data
@@ -31,11 +31,13 @@ def save_manifest(path: Path, data: dict[str, Any]) -> None:
def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]: def normalize_skill_entry(entry: str | dict[str, Any]) -> dict[str, Any]:
if isinstance(entry, str): if isinstance(entry, str):
return {"name": entry, "source": "owned"} return {"name": entry, "source": "builtin"}
name = entry.get("name") name = entry.get("name")
if not name: if not name:
raise SystemExit(f".skills.yaml 条目缺少 name: {entry}") raise SystemExit(f".pouch.yaml 条目缺少 name: {entry}")
source = entry.get("source", "owned") source = normalize_source(str(entry.get("source", "builtin")))
if source == "catalog" and entry.get("registry"):
source = f"catalog:{entry['registry']}"
return {"name": name, "source": source, **{k: v for k, v in entry.items() if k not in ("name", "source")}} return {"name": name, "source": source, **{k: v for k, v in entry.items() if k not in ("name", "source")}}
@@ -44,8 +46,6 @@ def manifest_skill_names(data: dict[str, Any]) -> list[str]:
def _entry_to_yaml(entry: dict[str, Any]) -> str | dict[str, Any]: def _entry_to_yaml(entry: dict[str, Any]) -> str | dict[str, Any]:
if entry.get("source", "owned") == "owned" and set(entry.keys()) <= {"name", "source"}:
return entry["name"]
return entry return entry
@@ -53,25 +53,28 @@ def add_skill_to_manifest(
manifest_path: Path, manifest_path: Path,
name: str, name: str,
*, *,
source: str = "owned", source: str = "builtin",
extra: dict[str, Any] | None = None, extra: dict[str, Any] | None = None,
) -> None: ) -> None:
path = manifest_path path = manifest_path
if path.is_dir(): if path.is_dir():
path = path / PROJECT_MANIFEST path = project_manifest(path)
file_path, data = load_manifest(path) if path.is_file() else (path, {"skills": []}) file_path, data = load_manifest(path) if path.is_file() else (path, {"skills": []})
if not path.is_file(): if not path.is_file():
file_path = path file_path = path
entries = [normalize_skill_entry(e) for e in data.get("skills", [])] entries = [normalize_skill_entry(e) for e in data.get("skills", [])]
if any(e["name"] == name for e in entries):
return
item: dict[str, Any] = {"name": name, "source": source} item: dict[str, Any] = {"name": name, "source": source}
if extra: if extra:
item.update(extra) item.update(extra)
existing_index = next((i for i, entry in enumerate(entries) if entry["name"] == name), None)
if existing_index is None:
entries.append(item) entries.append(item)
elif entries[existing_index] == item:
return
else:
entries[existing_index] = item
data["skills"] = [_entry_to_yaml(e) for e in entries] data["skills"] = [_entry_to_yaml(e) for e in entries]
save_manifest(file_path, data) save_manifest(file_path, data)
@@ -79,8 +82,8 @@ def add_skill_to_manifest(
def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool: def remove_skill_from_manifest(manifest_path: Path, name: str) -> bool:
path = manifest_path path = manifest_path
if not path.is_file(): if path.is_dir() or not path.is_file():
path = path / PROJECT_MANIFEST path = project_manifest(path if path.is_dir() else path.parent)
file_path, data = load_manifest(path) file_path, data = load_manifest(path)
original = data.get("skills", []) original = data.get("skills", [])
kept = [e for e in original if normalize_skill_entry(e)["name"] != name] kept = [e for e in original if normalize_skill_entry(e)["name"] != name]
@@ -97,5 +100,7 @@ def iter_manifest_skills(data: dict[str, Any]) -> list[dict[str, Any]]:
def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]: def resolve_manifest_skill(entry: dict[str, Any]) -> tuple[Path, str]:
name = entry["name"] name = entry["name"]
source = entry.get("source", "owned") source = normalize_source(entry.get("source", "builtin"))
if source == "catalog" and entry.get("registry"):
source = f"catalog:{entry['registry']}"
return resolve_skill_source(name, source=source) return resolve_skill_source(name, source=source)
+228
View File
@@ -0,0 +1,228 @@
"""Terminal multi-select UI for skills."""
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True)
class SkillChoice:
name: str
kind: str
description: str = ""
installed: bool = False
readonly_status: str = ""
children: tuple[str, ...] = ()
indent: int = 0
def fit_to_width(text: str, width: int) -> str:
"""Trim text to terminal display width without splitting wide characters."""
if width <= 0:
return ""
result: list[str] = []
used = 0
for char in text:
if unicodedata.combining(char):
char_width = 0
else:
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
if used + char_width > width:
break
result.append(char)
used += char_width
return "".join(result)
def display_width(text: str) -> int:
return sum(
0
if unicodedata.combining(char)
else 2
if unicodedata.east_asian_width(char) in {"W", "F"}
else 1
for char in text
)
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
needle = query.casefold().strip()
if not needle:
return list(choices)
return [
choice
for choice in choices
if needle in choice.name.casefold()
or needle in choice.kind.casefold()
or needle in choice.description.casefold()
or needle in choice.readonly_status.casefold()
]
def select_skills(
choices: list[SkillChoice],
*,
scope_label: str = "当前作用域",
wrapper: Callable[..., set[str] | None] | None = None,
) -> set[str] | None:
"""Open the selector. Return names, or None when cancelled."""
if not choices:
return set()
try:
import curses
except ImportError as exc:
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
selected = {
choice.name
for choice in choices
if choice.installed and not choice.children
}
def mark_for(choice: SkillChoice) -> str:
if not choice.children:
return "x" if choice.name in selected else " "
selected_children = selected.intersection(choice.children)
if len(selected_children) == len(choice.children):
return "x"
return "-" if selected_children else " "
def draw(stdscr: object) -> set[str] | None:
curses.curs_set(0)
stdscr.keypad(True)
current = 0
query = ""
source_attr = curses.A_NORMAL
status_attr = curses.A_NORMAL
description_attr = curses.A_DIM
try:
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
source_attr = curses.color_pair(1)
status_attr = curses.color_pair(2)
except curses.error:
pass
while True:
visible = filter_choices(choices, query)
current = min(current, max(0, len(visible) - 1))
stdscr.erase()
height, width = stdscr.getmaxyx()
text_width = max(0, width - 1)
stdscr.addnstr(
0,
0,
fit_to_width(f"安装目标: {scope_label}", text_width),
text_width,
)
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
if query:
stdscr.addnstr(
2,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
item_capacity = max(1, (height - 4) // 3)
start = max(0, current - item_capacity + 1)
for offset, choice in enumerate(visible[start : start + item_capacity]):
index = start + offset
row = 3 + offset * 3
mark = mark_for(choice)
description = f" {choice.description}" if choice.description else ""
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
parts = [
(f"{' ' * choice.indent}[{mark}] ", focus_attr),
(choice.name, focus_attr | curses.A_BOLD),
(f" {choice.kind}", focus_attr | source_attr),
]
if choice.readonly_status:
parts.append(
(f" {choice.readonly_status}", focus_attr | status_attr)
)
column = 0
for text, attr in parts:
available = text_width - column
if available <= 0:
break
fitted = fit_to_width(text, available)
stdscr.addnstr(row, column, fitted, available, attr)
column += display_width(fitted)
stdscr.addnstr(
row + 1,
0,
fit_to_width(description, text_width),
text_width,
focus_attr | description_attr,
)
footer = (
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
)
stdscr.addnstr(
height - 1,
0,
fit_to_width(footer, text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
if key in ("q", "Q", "\x1b"):
return None
if key in ("\n", "\r") or key == curses.KEY_ENTER:
return set(selected)
if key == curses.KEY_UP and visible:
current = (current - 1) % len(visible)
elif key == curses.KEY_DOWN and visible:
current = (current + 1) % len(visible)
elif key == " " and visible:
choice = visible[current]
if choice.children:
children = set(choice.children)
if children.issubset(selected):
selected.difference_update(children)
else:
selected.update(children)
else:
selected.symmetric_difference_update({choice.name})
elif key == "/":
curses.curs_set(1)
query = _read_query(stdscr, curses, width, row=2)
curses.curs_set(0)
current = 0
runner = wrapper or curses.wrapper
return runner(draw)
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
query = ""
while True:
stdscr.move(row, 0)
stdscr.clrtoeol()
text_width = max(0, width - 1)
stdscr.addnstr(
row,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
if key in ("\n", "\r") or key == curses.KEY_ENTER:
return query
if key == "\x1b":
return ""
if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE:
query = query[:-1]
elif isinstance(key, str) and key.isprintable():
query += key
+293
View File
@@ -0,0 +1,293 @@
"""自研 skill 发现与解析。"""
from __future__ import annotations
import re
from pathlib import Path
from pouch.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_pouch_home
from pouch.catalog import (
catalog_skill_path,
discover_catalog_skills,
load_catalog,
)
from pouch.sources import (
discover_source_skills,
list_source_skills,
load_sources,
)
def list_builtin_skills() -> list[str]:
ensure_pouch_home()
if not SKILLS_DIR.is_dir():
return []
names: list[str] = []
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
if entry.name.startswith("_"):
continue
if (entry / "SKILL.md").is_file():
names.append(entry.name)
return names
def builtin_skill_path(name: str) -> Path:
path = SKILLS_DIR / name
if not (path / "SKILL.md").is_file():
raise SystemExit(f"builtin skill 不存在: {name}")
return path
def normalize_source(source: str | None) -> str | None:
if source == "owned":
return "builtin"
if source == "registry":
return "catalog"
if source and source.startswith("registry:"):
return f"catalog:{source.split(':', 1)[1]}"
return source
def split_skill_spec(spec: str, source: str | None = None) -> tuple[str, str | None]:
if "/" not in spec:
return spec, normalize_source(source)
qualified_source, name = spec.split("/", 1)
if not qualified_source or not name or "/" in name:
raise SystemExit(f"skill 限定名称无效: {spec!r}(应为 source/name)")
qualified_source = normalize_source(qualified_source)
source = normalize_source(source)
if source and source != qualified_source:
raise SystemExit(
f"skill 来源冲突: {spec!r} 与 --source {source!r} 不一致"
)
return name, qualified_source
def list_custom_skills(source: str | None = None) -> dict[str, list[str]]:
sources = load_sources()
if source:
if source not in sources:
raise SystemExit(f"未配置 source: {source}")
return {source: list_source_skills(source, sources[source])}
return {name: list_source_skills(name, entry) for name, entry in sources.items()}
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
"""返回 (skill_path, source),未指定来源时拒绝同名歧义。"""
ensure_pouch_home()
name, source = split_skill_spec(name, source)
builtin = SKILLS_DIR / name
if source == "builtin":
if not (builtin / "SKILL.md").is_file():
raise SystemExit(f"builtin source 中找不到 skill: {name}")
return builtin, "builtin"
catalog = load_catalog()
sources = load_sources()
if source and source.startswith("catalog:"):
provider = source.split(":", 1)[1]
if provider not in catalog:
raise SystemExit(f"catalog 中找不到 source: {provider}")
skills = discover_catalog_skills(provider, catalog[provider])
if name not in skills:
available = ", ".join(skills) or "(无)"
raise SystemExit(
f"catalog source {provider!r} 中找不到 skill {name!r}。可用: {available}"
)
return skills[name], f"catalog:{provider}"
if source == "catalog":
if name not in catalog:
raise SystemExit(f"catalog 中找不到 skill source: {name}")
path = catalog_skill_path(name, catalog[name])
if not (path / "SKILL.md").is_file():
raise SystemExit(
f"catalog skill {name!r} 尚未 fetch 或 path 中缺少 SKILL.md。"
f"请运行: pouch fetch {name}"
)
return path, f"catalog:{name}"
if source:
if source not in sources:
raise SystemExit(
f"项目依赖 source {source!r},但本机尚未配置。"
f"请运行: pouch source add {source} <repo>"
)
skills = discover_source_skills(source, sources[source])
if name not in skills:
raise SystemExit(f"source {source!r} 中找不到 skill: {name}")
return skills[name], source
candidates: list[tuple[Path, str]] = []
if (builtin / "SKILL.md").is_file():
candidates.append((builtin, "builtin"))
if name in catalog:
path = catalog_skill_path(name, catalog[name])
if (path / "SKILL.md").is_file() or not path.exists():
candidates.append((path, f"catalog:{name}"))
for provider, entry in catalog.items():
if provider == name:
continue
skills = discover_catalog_skills(provider, entry)
if name in skills:
candidates.append((skills[name], f"catalog:{provider}"))
for source_name, entry in sources.items():
skills = discover_source_skills(source_name, entry)
if name in skills:
candidates.append((skills[name], source_name))
unique_candidates: list[tuple[Path, str]] = []
seen_paths: set[Path] = set()
for path, candidate_source in candidates:
resolved = path.resolve()
if resolved in seen_paths:
continue
seen_paths.add(resolved)
unique_candidates.append((path, candidate_source))
candidates = unique_candidates
if len(candidates) > 1:
choices = ", ".join(f"{candidate_source}/{name}" for _, candidate_source in candidates)
raise SystemExit(f"skill 名称存在多个来源,请明确指定: {choices}")
if candidates:
path, resolved_source = candidates[0]
if resolved_source.startswith("catalog:") and not path.exists():
provider = resolved_source.split(":", 1)[1]
raise SystemExit(f"catalog source {provider!r} 尚未 fetch。请先运行: pouch fetch {provider}")
return path, resolved_source
raise SystemExit(f"找不到 skill: {name}")
def read_skill_meta(skill_dir: Path) -> dict[str, str]:
skill_md = skill_dir / "SKILL.md"
if not skill_md.is_file():
return {}
text = skill_md.read_text(encoding="utf-8")
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
frontmatter = text[3:end]
meta: dict[str, str] = {}
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
if name_match:
meta["name"] = name_match.group(1).strip().strip("\"'")
desc_match = re.search(
r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)",
frontmatter,
re.MULTILINE,
)
if desc_match:
lines = [line.strip() for line in desc_match.group(1).splitlines() if line.strip()]
meta["description"] = " ".join(lines)
else:
inline = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE)
if inline:
meta["description"] = inline.group(1).strip().strip("\"'")
return meta
def skill_description(name: str) -> str | None:
meta = read_skill_meta(SKILLS_DIR / name)
desc = meta.get("description")
return desc.strip() if desc else None
def validate_skill_name(name: str) -> None:
import re
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
raise SystemExit(
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)"
)
if name == "_template":
raise SystemExit("不能使用保留名 _template")
_PLACEHOLDER_PATTERNS = (
(r"^name:\s*skill-name\s*$", "skill-name"),
(r"^#\s+Skill 名称\s*$", "Skill 名称"),
(r"简要描述 skill 做什么、何时触发", "简要描述 skill 做什么"),
(r"^-\s*触发场景 1\s*$", "触发场景 1"),
(r"^\d+\.\s*第一步\s*$", "第一步"),
)
def validate_skill_dir(skill_dir: Path, expected_name: str) -> list[str]:
"""返回 skill 目录中的校验问题;空列表表示通过。"""
issues: list[str] = []
skill_md = skill_dir / "SKILL.md"
readme = skill_dir / "README.md"
if not skill_md.is_file():
return ["缺少 SKILL.md"]
if not readme.is_file():
issues.append("缺少 README.md(面向人类的使用说明)")
else:
readme_text = readme.read_text(encoding="utf-8")
if not readme_text.strip():
issues.append("README.md 为空")
if re.search(r"^#\s+skill-name\s*$", readme_text, re.MULTILINE):
issues.append("README.md 存在模板占位内容: skill-name")
for placeholder in (
"用一句话告诉使用者这个 skill 能解决什么问题",
"给出用户可以直接说出的典型请求",
"给出一条可以直接交给 Agent 的示例请求",
):
if placeholder in readme_text:
issues.append(f"README.md 存在模板占位内容: {placeholder}")
text = skill_md.read_text(encoding="utf-8")
if not text.strip():
return ["SKILL.md 为空"]
if not text.startswith("---\n"):
return ["SKILL.md 缺少 YAML frontmatter"]
end = text.find("\n---", 4)
if end == -1:
return ["SKILL.md frontmatter 未闭合"]
frontmatter = text[4:end]
keys = re.findall(r"^([A-Za-z0-9_-]+):", frontmatter, re.MULTILINE)
unexpected = sorted(set(keys) - {"name", "description"})
missing = sorted({"name", "description"} - set(keys))
if missing:
issues.append(f"frontmatter 缺少字段: {', '.join(missing)}")
if unexpected:
issues.append(f"frontmatter 只允许 name、description,发现: {', '.join(unexpected)}")
meta = read_skill_meta(skill_dir)
if meta.get("name") != expected_name:
issues.append(
f"目录名与 frontmatter name 不一致: {expected_name} != {meta.get('name', '(缺失)')}"
)
description = meta.get("description", "").strip()
if not description:
issues.append("description 不能为空")
found_placeholders = [
label
for pattern, label in _PLACEHOLDER_PATTERNS
if re.search(pattern, text, re.MULTILINE | re.IGNORECASE)
]
if found_placeholders:
issues.append(f"存在模板占位内容: {', '.join(found_placeholders)}")
link_pattern = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
for target in link_pattern.findall(text):
target = target.strip().split("#", 1)[0]
if not target or "://" in target or target.startswith(("mailto:", "/")):
continue
if not (skill_dir / target).resolve().is_file():
issues.append(f"引用文件不存在: {target}")
for draft_file in ("brief.yaml",):
if (skill_dir / draft_file).exists():
issues.append(f"正式 skill 不应包含草稿文件: {draft_file}")
return issues
+217
View File
@@ -0,0 +1,217 @@
# Skill 来源模型
## 背景
pouch 当前使用 `owned` 表示本仓库 `skills/` 中维护的 Skill,同时还支持
`registry` 外部仓库和用户配置的 custom source。
这些名称混合了不同维度:
- `owned` 表示所有权。
- `registry` 表示来源配置存放在哪里。
- `external` 表示内容不在当前仓库。
- `collection` 表示一个来源中包含多个 Skill。
它们不能作为同一层级的互斥类型。例如 custom source 既可能来自外部 Git 仓库,
也可能是 collection;因此 `custom` 与 `external` 并不互斥。
本文建议将用户可见的来源注册方式统一为三类:
- `builtin`:随当前 pouch 仓库提供的 Skill。
- `catalog:<name>`:由 pouch 自带目录预先登记的来源。
- `custom:<name>`:用户在本机显式配置的命名来源。
来源注册方式与获取方式、仓库布局相互独立。builtin、catalog 和 custom 中的任意
来源都可以包含一个或多个 Skill;除 builtin 外,catalog 和 custom 都可以使用
Git 仓库或本地目录。
## 迁移前模型
```mermaid
flowchart TD
S[pouch 可发现的 Skills]
S --> O["owned<br/>~/.pouch/skills/*"]
S --> C["custom source<br/>~/.config/pouch/config.yaml"]
S --> R["registry<br/>registry.yaml"]
O --> O1["本仓库维护<br/>随 pouch 一起分发"]
C --> C1["本地目录<br/>--local PATH"]
C --> C2["指定 Git 仓库<br/>repo + skills_path"]
R --> R1["外部单 Skill 仓库"]
R --> R2["外部 Skill Collection"]
R1 --> E["~/.local/share/pouch/externals/"]
R2 --> E
```
迁移前实现中:
- `list` 和 `status` 支持 owned、registry 和 custom source。
- `resolve_skill_source` 可以解析三种来源并处理同名歧义。
- `select` 只组装 owned 和 registry 条目,尚未展示 custom source。
- `.pouch.yaml` 默认将未声明来源的 Skill 解释为 `owned`。
- custom source 的 `skills_path` 已经可以包含多个 Skill,本质上也是 collection。
## 现行模型
```mermaid
flowchart TD
A[Skill Provider]
A --> B["builtin<br/>仓库内隐式注册"]
A --> C["catalog:waza<br/>pouch 预置目录"]
A --> D["custom:company<br/>用户本机配置"]
B --> B1["skills/ack"]
B --> B2["skills/pouch"]
B --> B3["skills/builder"]
C --> C1["Git 或本地目录"]
C1 --> C2["单 Skill"]
C1 --> C3["Collection"]
C3 --> C4["waza/think"]
C3 --> C5["waza/check"]
D --> D1["Git 或本地目录"]
D1 --> D2["单 Skill"]
D1 --> D3["Collection"]
D3 --> D4["company/security-review"]
D3 --> D5["company/release"]
```
### 来源注册方式
| 类型 | 含义 | 配置来源 | 用户界面展示 |
| --- | --- | --- | --- |
| `builtin` | 随当前 pouch 仓库提供 | `skills/` | `builtin` |
| `catalog` | pouch 预先登记、所有用户可发现的来源 | `catalog.yaml` | `catalog:<name>` |
| `custom` | 用户在本机显式注册的命名来源 | `~/.config/pouch/config.yaml` | `custom:<name>` |
`builtin` 比 `owned` 更适合作为用户可见名称,因为它表达 Skill 的分发位置和可用
方式,而不是仓库的所有权关系。
`catalog` 比 `registry` 更准确:当前文件只是 pouch 随仓库维护的预置来源目录,
并不是远程注册中心,也不是一种 Skill 来源协议。
### 三个正交维度
每个 provider 应由三个维度描述:
```text
registration: builtin | catalog | custom
transport: bundled | local | git
layout: single | collection
```
custom source 示例:
```text
registration: custom
name: company
transport: local | git
layout: single | collection
```
catalog entry 示例:
```text
registration: catalog
name: waza
transport: local | git
layout: single | collection
```
`external` 只适合描述物理位置,可作为 catalog 和部分 custom provider 的统称,
不应成为与 builtin、custom 并列的持久化类型。
## `select` 展示
`pouch select` 应同时展示 builtin、catalog 和 custom Skill:
```text
[ ] ack builtin
[ ] builder builtin
[-] company custom source
[ ] company/release custom:company
[ ] company/security-review custom:company
[-] waza catalog source
[ ] waza/check catalog:waza
[ ] waza/think catalog:waza
```
建议遵循以下规则:
- builtin Skill 直接显示 Skill 名称。
- catalog 和 custom collection 都使用来源名称作为父节点。
- catalog 和 custom Skill 都使用 `<source>/<skill>` 作为选择器键。
- 单 Skill 来源可以省略父节点,但仍显示注册方式和来源名称。
- 不同来源安装后名称相同时,继续拒绝同时选择,避免覆盖同一安装路径。
## 配置表示
新写入的 `.pouch.yaml` 使用以下形式:
```yaml
skills:
- name: ack
source: builtin
- name: security-review
source: company
- name: think
source: catalog:waza
```
custom source 在 manifest 中继续保存其逻辑名称,例如 `company`。这样不同机器可以
独立配置仓库地址,而项目只依赖稳定的来源名称。
## 兼容策略
用户可见术语已经调整,并保留以下读取兼容,避免已有项目立即失效:
1. 对外文档、CLI 输出和 selector 统一使用 `builtin`、`catalog:<name>` 和
`custom:<name>`。
2. 新生成的 `.pouch.yaml` 对内置 Skill 写入 `source: builtin`。
3. 读取旧 manifest 时继续接受 `source: owned`,并在解析时归一化为 `builtin`。
4. CLI 参数在过渡期继续接受 `--source owned`,但帮助和输出只推荐 `builtin`。
5. 读取旧 manifest 中的 `source: registry` 和 `registry: <name>`,归一化为
`catalog:<name>`。
6. 主文件使用 `catalog.yaml`;不存在时兼容读取旧 `registry.yaml`。
7. custom source 的逻辑名称和现有 `config.yaml` 结构保持不变。
8. 将 `builtin`、`catalog` 和兼容别名 `owned`、`registry` 设为 custom source
保留字。
9. `select` 同步接入 custom Skill,并让 custom collection 与 catalog collection
使用相同的父子展示逻辑。
## 实现范围
当前实现覆盖:
- `pouch/skills.py`:来源解析、归一化和 builtin 命名。
- `pouch/project.py`:manifest 默认值、序列化与旧值兼容。
- `pouch/sources.py`:来源保留字。
- `pouch/catalog.py`:catalog 配置、checkout 与 Skill 发现。
- `pouch/cli.py`:`list`、`status`、`add`、`select` 和输出文案。
- `pouch/selector.py`:统一 catalog/custom collection 的父子展示。
- CLI 与来源解析测试。
- 根 README、`pouch/README.md`、`skills/pouch/SKILL.md` 和相关示例。
## 验证要求
实施完成后至少覆盖:
- builtin Skill 可以通过新名称列出、选择、安装和写入 manifest。
- 旧的 `source: owned` manifest 仍可同步。
- custom local source 和 custom Git source 都出现在 selector 中。
- catalog 单 Skill 和 Collection 都保持可选择。
- custom 单 Skill 和 Collection 都保持可选择。
- 多来源同名 Skill 必须显式指定来源。
- 两个选择最终安装为同一个名称时,selector 拒绝冲突。
- `list`、`status`、`select` 对同一来源使用一致术语。
## 设计前提
迁移前 `registry.yaml` 的真实职责只是维护 pouch 预置的来源目录,而不是提供远程
发布、版本解析或可信签名等注册中心能力,因此现已改为 `catalog.yaml`。如果未来
实现真正的远程 registry,应单独定义其协议和与 catalog 的同步关系,不复用旧名称。
+117
View File
@@ -0,0 +1,117 @@
"""命名 custom skill source 的配置、发现与 Git 管理。"""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any
from pouch.config import load_user_config, save_user_config
from pouch.paths import CONFIG_FILE, SOURCES_DIR
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
def validate_source_name(name: str) -> None:
from pouch.skills import validate_skill_name
validate_skill_name(name)
if name in RESERVED_SOURCES:
raise SystemExit(f"source 名称为保留字: {name}")
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
data = load_user_config(path)
raw = data.get("sources", {})
if raw is None:
return {}
if not isinstance(raw, dict):
raise SystemExit(f"pouch 配置 sources 格式错误: {path or CONFIG_FILE}")
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or CONFIG_FILE
data = load_user_config(path)
data["sources"] = sources
save_user_config(data, path)
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
local_path = entry.get("local_path")
if local_path:
return Path(str(local_path)).expanduser().resolve()
checkout = entry.get("checkout")
if checkout:
return Path(str(checkout)).expanduser().resolve()
return (SOURCES_DIR / name).resolve()
def source_skills_root(name: str, entry: dict[str, Any]) -> Path:
checkout = source_checkout(name, entry)
raw_subpath = str(entry.get("skills_path", "skills") or "skills")
subpath = Path(raw_subpath)
if subpath.is_absolute():
raise SystemExit(f"source {name!r} 的 skills_path 必须是仓库内相对路径")
root = (checkout / subpath).resolve()
try:
root.relative_to(checkout)
except ValueError:
raise SystemExit(f"source {name!r} 的 skills_path 不能超出仓库目录") from None
return root
def discover_source_skills(name: str, entry: dict[str, Any]) -> dict[str, Path]:
root = source_skills_root(name, entry)
if (root / "SKILL.md").is_file():
return {name: root}
if not root.is_dir():
return {}
return {
item.name: item
for item in sorted(root.iterdir())
if item.is_dir()
and not item.name.startswith("_")
and (item / "SKILL.md").is_file()
}
def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]:
return list(discover_source_skills(name, entry))
def fetch_source(name: str, entry: dict[str, Any]) -> Path:
if entry.get("local_path"):
checkout = source_checkout(name, entry)
if not checkout.is_dir():
raise SystemExit(f"source {name!r} 的本地仓库不存在: {checkout}")
return checkout
repo = entry.get("repo")
if not repo:
raise SystemExit(f"source {name!r} 缺少 repo 或 local_path")
checkout = source_checkout(name, entry)
ref = str(entry.get("ref", "main") or "main")
if checkout.exists():
if not (checkout / ".git").exists():
raise SystemExit(f"source checkout 已存在但不是 Git 仓库: {checkout}")
subprocess.run(["git", "-C", str(checkout), "fetch", "--all", "--tags"], check=True)
subprocess.run(["git", "-C", str(checkout), "checkout", ref], check=True)
subprocess.run(["git", "-C", str(checkout), "pull", "--ff-only"], check=True)
else:
checkout.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"git",
"clone",
"--depth",
"1",
"--branch",
ref,
"--",
str(repo),
str(checkout),
],
check=True,
)
return checkout
+5 -2
View File
@@ -66,8 +66,11 @@ def copy_template(src: Path, dst: Path) -> None:
def find_repo_root(start: Path | None = None) -> Path | None: def find_repo_root(start: Path | None = None) -> Path | None:
start = (start or Path.cwd()).resolve() start = (start or Path.cwd()).resolve()
for directory in [start, *start.parents]: for directory in [start, *start.parents]:
if (directory / ".skills.yaml").is_file(): if (directory / ".pouch.yaml").is_file() or (directory / ".skills.yaml").is_file():
return directory return directory
if (directory / "skills").is_dir() and (directory / "registry.yaml").is_file(): if (directory / "skills").is_dir() and (
(directory / "catalog.yaml").is_file()
or (directory / "registry.yaml").is_file()
):
return directory return directory
return None return None
+13 -4
View File
@@ -1,4 +1,4 @@
"""轻量 YAML 读写(覆盖 skiff 使用的子集,无第三方依赖)。""" """轻量 YAML 读写(覆盖 pouch 使用的子集,无第三方依赖)。"""
from __future__ import annotations from __future__ import annotations
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
return True return True
if lower == "false": if lower == "false":
return False return False
if raw == "[]":
return []
if raw == "{}":
return {}
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()): if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
return int(raw) return int(raw)
return raw return raw
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
return f"{pad}{{}}\n" return f"{pad}{{}}\n"
lines: list[str] = [] lines: list[str] = []
for key, value in data.items(): for key, value in data.items():
if isinstance(value, (dict, list)): if isinstance(value, list) and not value:
lines.append(f"{pad}{key}: []")
elif isinstance(value, dict) and not value:
lines.append(f"{pad}{key}: {{}}")
elif isinstance(value, (dict, list)):
if isinstance(value, list) and value and all(isinstance(x, str) for x in value): if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
lines.append(f"{pad}{key}:") lines.append(f"{pad}{key}:")
for item in value: for item in value:
@@ -236,11 +244,12 @@ def _dump_list_dict_item(item: dict[str, Any], indent: int) -> list[str]:
lines: list[str] = [] lines: list[str] = []
first = True first = True
for key, value in item.items(): for key, value in item.items():
prefix = f"{pad}- " if first else f"{pad} " is_first = first
prefix = f"{pad}- " if is_first else f"{pad} "
first = False first = False
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
lines.append(f"{prefix}{key}:") lines.append(f"{prefix}{key}:")
nested = _dump(value, indent + 4 if first else indent + 2) nested = _dump(value, indent + 2 if is_first else indent + 4)
lines.append(nested.rstrip()) lines.append(nested.rstrip())
else: else:
lines.append(f"{prefix}{key}: {_scalar(value)}") lines.append(f"{prefix}{key}: {_scalar(value)}")
-17
View File
@@ -1,17 +0,0 @@
# External skills registry
# Format:
# <name>:
# repo: <git-url>
# ref: <branch|tag> (default: main)
# path: <subpath> (default: .)
#
# Example:
# superpowers:
# repo: https://github.com/obra/superpowers
# ref: main
# path: .
superpowers:
repo: https://github.com/obra/superpowers
ref: main
path: .
+1 -1
View File
@@ -1 +1 @@
# skiff 仅使用 Python 标准库,无第三方依赖 # pouch 仅使用 Python 标准库,无第三方依赖
-149
View File
@@ -1,149 +0,0 @@
# skiff
Agent Skills 安装与管理 CLI。纯 Python 3 实现,无第三方依赖,无需编译。
## 安装
```bash
cd /path/to/skills # 本仓库根目录
./install.sh # 软链到 ~/.local/bin/skiff
```
确保 `~/.local/bin` 在 `PATH` 中。开发时也可直接运行:
```bash
PYTHONPATH=/path/to/skills python3 -m skiff <command>
```
## 首次配置
```bash
skiff setup ~/code/gitea/skills # 将 ~/.skills 软链到仓库
```
若仓库本身就在 `~/.skills`(开发场景),`setup` 会自动识别,无需额外操作。
## 命令参考
### 仓库关联
| 命令 | 说明 |
|------|------|
| `skiff setup <path>` | 关联 `~/.skills` 到 skills 仓库 |
### 查看
| 命令 | 说明 |
|------|------|
| `skiff list` | 列出自研 skill 与 registry 中的外部 skill |
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
### 全局安装(自研 skill)
| 命令 | 说明 |
|------|------|
| `skiff install <name> [--target all]` | 软链到 Agent 全局目录 |
| `skiff uninstall <name> [--target all]` | 移除软链 |
全局目标路径:
| Agent | 路径 |
|-------|------|
| cursor | `~/.cursor/skills/` |
| claude | `~/.claude/skills/` |
| codex | `~/.codex/skills/` |
### 外部 Git skill
| 命令 | 说明 |
|------|------|
| `skiff add <name> <repo-url> [--ref main] [--path .]` | 写入 `registry.yaml` |
| `skiff fetch <name>` | 克隆/更新到 `~/.local/share/skills/externals/<name>/` |
| `skiff install-external <name> [--target all]` | 安装外部 skill 到 Agent 目录 |
### 项目级
| 命令 | 说明 |
|------|------|
| `skiff enable <name> [--target all] [--project <dir>]` | 写入 `.skills.yaml` 并创建项目软链 |
| `skiff disable <name> [--target all] [--project <dir>]` | 从 manifest 移除并删除软链 |
| `skiff sync [--target all] [--project <dir>]` | 按 `.skills.yaml` 重建软链 |
项目目标路径:
| Agent | 路径 |
|-------|------|
| cursor | `<project>/.agents/skills/` |
| claude | `<project>/.claude/skills/` |
| codex | `<project>/.agents/skills/` |
### 脚手架与健康检查
| 命令 | 说明 |
|------|------|
| `skiff create <name>` | 从 `skills/_template/` 创建自研 skill |
| `skiff doctor [--target all] [--fix]` | 检查软链健康状态,`--fix` 自动修复 |
## 常用工作流
### 新建并全局启用自研 skill
```bash
skiff create my-skill
# 编辑 skills/my-skill/SKILL.md
skiff install my-skill --target cursor
skiff doctor --target cursor
```
### 在项目中启用 skill
```bash
cd ~/code/my-app
skiff enable declarative-openspec-loop --target cursor
skiff sync
```
### 安装外部 Git skill
```bash
skiff add my-ext https://github.com/org/repo --ref main
skiff fetch my-ext
skiff install-external my-ext
```
## 源码结构
```
skiff/
├── __init__.py # 版本号
├── __main__.py # python3 -m skiff 入口
├── cli.py # 命令定义与调度
├── paths.py # 路径常量与 Agent 目标
├── skills.py # 自研 skill 发现
├── registry.py # registry.yaml 读写
├── project.py # .skills.yaml 管理
├── symlinks.py # 软链创建/检查/修复
└── yaml_io.py # 轻量 YAML 解析(无第三方依赖)
```
入口脚本:[../bin/skiff](../bin/skiff)
## 路径约定
| 变量 | 路径 | 说明 |
|------|------|------|
| `SKILLS_HOME` | `~/.skills` | skills 仓库(软链) |
| `SKILLS_DIR` | `~/.skills/skills/` | 自研 skill 目录 |
| `REGISTRY_FILE` | `~/.skills/registry.yaml` | 外部 skill 注册表 |
| `EXTERNALS_DIR` | `~/.local/share/skills/externals/` | 已 fetch 的外部仓库 |
## 注意事项
- **禁止**在 `~/.cursor/skills/` 等 Agent 目录直接创建非软链的 skill
- Claude Code 对 symlink 支持不稳定;建议对单个 skill 目录软链,不要软链整个 `~/.claude/skills/`
- 若 Agent 将软链替换为普通目录,运行 `skiff doctor --fix` 重建
## 相关文档
- [项目 README](../README.md)
- [AGENTS.md](../AGENTS.md)
-3
View File
@@ -1,3 +0,0 @@
"""skiff — Agent Skills 安装与管理 CLI。"""
__version__ = "0.1.0"
-429
View File
@@ -1,429 +0,0 @@
"""skiff CLI 入口。"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
from skiff import __version__
from skiff.paths import (
ALL_TARGETS,
EXTERNALS_DIR,
REGISTRY_FILE,
SKILLS_DIR,
SKILLS_HOME,
TEMPLATE_DIR,
agent_skill_dir,
ensure_skills_home,
resolve_targets,
)
from skiff.project import (
add_skill_to_manifest,
iter_manifest_skills,
load_manifest,
remove_skill_from_manifest,
resolve_manifest_skill,
save_manifest,
)
from skiff.registry import external_skill_path, load_registry, save_registry
from skiff.skills import list_owned_skills, owned_skill_path, resolve_skill_source, validate_skill_name
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
def _print(msg: str = "") -> None:
print(msg, file=sys.stdout)
def _err(msg: str) -> None:
print(msg, file=sys.stderr)
def cmd_setup(args: argparse.Namespace) -> None:
repo = Path(args.path).resolve()
if not (repo / "skills").is_dir():
raise SystemExit(f"不是有效的 skills 仓库(缺少 skills/): {repo}")
# 开发场景:仓库本身就在 ~/.skills
if repo == SKILLS_HOME.resolve() and SKILLS_HOME.is_dir() and not SKILLS_HOME.is_symlink():
_print(f"skills 仓库已在 ~/.skills: {repo}")
return
if SKILLS_HOME.is_symlink():
current = SKILLS_HOME.resolve()
if current == repo:
_print(f"已关联: ~/.skills -> {repo}")
return
SKILLS_HOME.unlink()
elif SKILLS_HOME.exists():
raise SystemExit(f"~/.skills 已存在且不是软链: {SKILLS_HOME}")
SKILLS_HOME.parent.mkdir(parents=True, exist_ok=True)
SKILLS_HOME.symlink_to(repo)
_print(f"已关联: ~/.skills -> {repo}")
def cmd_list(args: argparse.Namespace) -> None:
ensure_skills_home()
owned = list_owned_skills()
registry = load_registry()
_print("自研 (owned):")
for name in owned:
_print(f" {name}")
_print("\n外部 (registry):")
if not registry:
_print(" (无)")
else:
for name, entry in registry.items():
repo = entry.get("repo", "?")
_print(f" {name} ({repo})")
def _installed_links(name: str, targets: list[str], project_root: Path | None = None) -> list[tuple[str, Path, Path]]:
skill_path, _ = resolve_skill_source(name)
rows: list[tuple[str, Path, Path]] = []
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name
rows.append((target, link, skill_path))
return rows
def cmd_status(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_targets(args.target)
owned = list_owned_skills()
registry = load_registry()
all_names = owned + [n for n in registry if n not in owned]
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
_print(f"targets: {', '.join(targets)}\n")
for name in all_names:
kind = "owned" if name in owned else "external"
_print(f"[{kind}] {name}")
try:
rows = _installed_links(name, targets)
except SystemExit:
_print(" (未 fetch)")
continue
for target, link, expected in rows:
status = check_link(link, expected)
mark = "✓" if status.ok else "✗"
detail = "" if status.ok else f" — {status.issue}"
_print(f" {mark} {target}: {link}{detail}")
_print("")
def _install_skill(name: str, targets: list[str], project_root: Path | None = None) -> None:
skill_path, kind = resolve_skill_source(name)
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name
create_link(link, skill_path)
scope = "项目" if project_root else "全局"
_print(f"已安装 ({scope}/{target}): {name} -> {skill_path}")
def cmd_install(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
targets = resolve_targets(args.target)
_install_skill(args.name, targets)
def cmd_uninstall(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_targets(args.target)
for target in targets:
link = agent_skill_dir(target) / args.name
if remove_link(link):
_print(f"已移除 ({target}): {link}")
def cmd_add(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
registry = load_registry()
if args.name in registry:
raise SystemExit(f"registry 中已存在: {args.name}")
registry[args.name] = {
"repo": args.repo,
"ref": args.ref,
"path": args.path,
}
save_registry(registry)
_print(f"已添加 registry 条目: {args.name}")
def cmd_fetch(args: argparse.Namespace) -> None:
ensure_skills_home()
registry = load_registry()
if args.name not in registry:
raise SystemExit(f"registry 中不存在: {args.name}")
entry = registry[args.name]
repo = entry["repo"]
ref = entry.get("ref", "main")
dest = EXTERNALS_DIR / args.name
EXTERNALS_DIR.mkdir(parents=True, exist_ok=True)
if dest.exists():
_print(f"更新: {dest}")
subprocess.run(["git", "-C", str(dest), "fetch", "--all", "--tags"], check=True)
subprocess.run(["git", "-C", str(dest), "checkout", ref], check=True)
subprocess.run(["git", "-C", str(dest), "pull", "--ff-only"], check=True)
else:
_print(f"克隆: {repo} -> {dest}")
subprocess.run(
["git", "clone", "--branch", ref, "--", repo, str(dest)],
check=True,
)
def cmd_install_external(args: argparse.Namespace) -> None:
ensure_skills_home()
registry = load_registry()
if args.name not in registry:
raise SystemExit(f"registry 中不存在: {args.name}")
path = external_skill_path(args.name, registry[args.name])
if not path.exists():
raise SystemExit(f"请先 fetch: skiff fetch {args.name}")
targets = resolve_targets(args.target)
for target in targets:
link = agent_skill_dir(target) / args.name
create_link(link, path)
_print(f"已安装外部 ({target}): {args.name} -> {path}")
def _project_root(args: argparse.Namespace) -> Path:
if args.project:
root = Path(args.project).resolve()
else:
root = find_repo_root() or Path.cwd()
return root
def cmd_enable(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
root = _project_root(args)
manifest_path = root / ".skills.yaml"
# 判断来源
registry = load_registry()
if args.name in registry:
source = "registry"
extra = {"source": "registry", "ref": registry[args.name].get("ref", "main")}
add_skill_to_manifest(manifest_path, args.name, source="registry", extra=extra)
else:
owned_skill_path(args.name) # 验证存在
add_skill_to_manifest(manifest_path, args.name, source="owned")
targets = resolve_targets(args.target)
_, data = load_manifest(manifest_path)
manifest_targets = data.get("targets")
if manifest_targets:
targets = [t for t in targets if t in manifest_targets]
_install_skill(args.name, targets, project_root=root)
_print(f"已启用项目 skill: {args.name} @ {root}")
def cmd_disable(args: argparse.Namespace) -> None:
root = _project_root(args)
manifest_path = root / ".skills.yaml"
if not remove_skill_from_manifest(manifest_path, args.name):
_print(f"manifest 中不存在: {args.name}")
return
targets = resolve_targets(args.target)
for target in targets:
link = agent_skill_dir(target, project_root=root) / args.name
if remove_link(link):
_print(f"已禁用 ({target}): {link}")
def cmd_sync(args: argparse.Namespace) -> None:
ensure_skills_home()
root = _project_root(args)
manifest_path = root / ".skills.yaml"
if not manifest_path.is_file():
raise SystemExit(f"未找到 {manifest_path}")
_, data = load_manifest(manifest_path)
targets = resolve_targets(args.target)
manifest_targets = data.get("targets")
if manifest_targets:
targets = [t for t in targets if t in manifest_targets]
for entry in iter_manifest_skills(data):
name = entry["name"]
skill_path, _ = resolve_manifest_skill(entry)
for target in targets:
link = agent_skill_dir(target, project_root=root) / name
create_link(link, skill_path)
_print(f"已同步: {link} -> {skill_path}")
def cmd_create(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
if not TEMPLATE_DIR.is_dir():
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
dst = SKILLS_DIR / args.name
copy_template(TEMPLATE_DIR, dst)
skill_md = dst / "SKILL.md"
content = skill_md.read_text(encoding="utf-8")
content = re.sub(
r"(^name:\s*)skill-name\s*$",
rf"\g<1>{args.name}",
content,
count=1,
flags=re.MULTILINE,
)
skill_md.write_text(content, encoding="utf-8")
_print(f"已创建 skill: {dst}")
_print(f"下一步: 编辑 {skill_md},然后 skiff install {args.name}")
def cmd_doctor(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_targets(args.target)
issues = 0
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
if SKILLS_HOME.is_symlink():
if not SKILLS_HOME.resolve().is_dir():
_err(f"✗ ~/.skills 指向无效路径: {SKILLS_HOME.resolve()}")
issues += 1
else:
_print("✓ ~/.skills 软链正常")
elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir():
_print("✓ ~/.skills 为本地仓库目录")
else:
_err("✗ ~/.skills 未正确配置")
issues += 1
for name in list_owned_skills():
for target, link, expected in _installed_links(name, targets):
status = check_link(link, expected)
if status.ok:
continue
_err(f"✗ [{name}/{target}] {status.issue}: {link}")
issues += 1
if args.fix:
try:
create_link(link, expected)
_print(f" 已修复: {link}")
except Exception as exc: # noqa: BLE001
_err(f" 修复失败: {exc}")
registry = load_registry()
for name in registry:
ext = external_skill_path(name, registry[name])
if not ext.exists():
_err(f"✗ 外部 skill 未 fetch: {name}")
issues += 1
if issues == 0:
_print("\n全部正常")
else:
_print(f"\n发现 {issues} 个问题")
if not args.fix:
_print("提示: 使用 skiff doctor --fix 尝试自动修复软链")
sys.exit(1)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="skiff",
description="Agent Skills 安装与管理 CLI",
)
parser.add_argument("--version", action="version", version=f"skiff {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
p_setup = sub.add_parser("setup", help="关联 ~/.skills 到本仓库")
p_setup.add_argument("path", help="skills 仓库路径")
p_setup.set_defaults(func=cmd_setup)
p_list = sub.add_parser("list", help="列出所有 skill")
p_list.set_defaults(func=cmd_list)
p_status = sub.add_parser("status", help="安装状态总览")
p_status.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_status.set_defaults(func=cmd_status)
p_install = sub.add_parser("install", help="全局安装 skill(软链)")
p_install.add_argument("name", help="skill 名称")
p_install.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_install.set_defaults(func=cmd_install)
p_uninstall = sub.add_parser("uninstall", help="移除全局软链")
p_uninstall.add_argument("name", help="skill 名称")
p_uninstall.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_uninstall.set_defaults(func=cmd_uninstall)
p_add = sub.add_parser("add", help="添加外部 skill 到 registry")
p_add.add_argument("name", help="registry 名称")
p_add.add_argument("repo", help="Git 仓库 URL")
p_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)")
p_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)")
p_add.set_defaults(func=cmd_add)
p_fetch = sub.add_parser("fetch", help="拉取外部 Git skill")
p_fetch.add_argument("name", help="registry 名称")
p_fetch.set_defaults(func=cmd_fetch)
p_inst_ext = sub.add_parser("install-external", help="安装外部 skill 到 Agent 目录")
p_inst_ext.add_argument("name", help="registry 名称")
p_inst_ext.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_inst_ext.set_defaults(func=cmd_install_external)
p_enable = sub.add_parser("enable", help="项目级启用 skill")
p_enable.add_argument("name", help="skill 名称")
p_enable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_enable.add_argument("--project", help="项目根目录(默认自动检测)")
p_enable.set_defaults(func=cmd_enable)
p_disable = sub.add_parser("disable", help="项目级禁用 skill")
p_disable.add_argument("name", help="skill 名称")
p_disable.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_disable.add_argument("--project", help="项目根目录(默认自动检测)")
p_disable.set_defaults(func=cmd_disable)
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
p_sync.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_sync.add_argument("--project", help="项目根目录(默认自动检测)")
p_sync.set_defaults(func=cmd_sync)
p_create = sub.add_parser("create", help="从 _template 创建自研 skill")
p_create.add_argument("name", help="skill 名称")
p_create.set_defaults(func=cmd_create)
p_doctor = sub.add_parser("doctor", help="软链健康检查")
p_doctor.add_argument("--target", choices=[*ALL_TARGETS, "all"], default="all")
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
p_doctor.set_defaults(func=cmd_doctor)
return parser
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()
-48
View File
@@ -1,48 +0,0 @@
"""路径与 Agent 目标定义。"""
from __future__ import annotations
from pathlib import Path
HOME = Path.home()
SKILLS_HOME = HOME / ".skills"
SKILLS_DIR = SKILLS_HOME / "skills"
TEMPLATE_DIR = SKILLS_DIR / "_template"
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
EXTERNALS_DIR = HOME / ".local" / "share" / "skills" / "externals"
PROJECT_MANIFEST = ".skills.yaml"
AGENT_GLOBAL: dict[str, Path] = {
"cursor": HOME / ".cursor" / "skills",
"claude": HOME / ".claude" / "skills",
"codex": HOME / ".codex" / "skills",
}
AGENT_PROJECT: dict[str, str] = {
"cursor": ".agents/skills",
"claude": ".claude/skills",
"codex": ".agents/skills",
}
ALL_TARGETS = ("cursor", "claude", "codex")
def resolve_targets(target: str | None) -> list[str]:
if target is None or target == "all":
return list(ALL_TARGETS)
if target not in ALL_TARGETS:
raise ValueError(f"未知 target: {target!r},可选: {', '.join(ALL_TARGETS)}, all")
return [target]
def agent_skill_dir(target: str, *, project_root: Path | None = None) -> Path:
if project_root is None:
return AGENT_GLOBAL[target]
return project_root / AGENT_PROJECT[target]
def ensure_skills_home() -> None:
if not SKILLS_HOME.is_dir():
raise SystemExit(
f"~/.skills 未配置。请先运行: skiff setup <skills-repo-path>"
)
-33
View File
@@ -1,33 +0,0 @@
"""registry.yaml 读写。"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from skiff import yaml_io
from skiff.paths import REGISTRY_FILE
def load_registry(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or REGISTRY_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"registry 格式错误: {path}")
return {k: v for k, v in data.items() if isinstance(v, dict) and not k.startswith("#")}
def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or REGISTRY_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def external_skill_path(name: str, entry: dict[str, Any] | None = None) -> Path:
from skiff.paths import EXTERNALS_DIR
entry = entry or load_registry().get(name, {})
subpath = entry.get("path", ".") or "."
return (EXTERNALS_DIR / name / subpath).resolve()
-64
View File
@@ -1,64 +0,0 @@
"""自研 skill 发现与解析。"""
from __future__ import annotations
from pathlib import Path
from skiff.paths import SKILLS_DIR, TEMPLATE_DIR, ensure_skills_home
from skiff.registry import external_skill_path, load_registry
def list_owned_skills() -> list[str]:
ensure_skills_home()
if not SKILLS_DIR.is_dir():
return []
names: list[str] = []
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
if entry.name.startswith("_"):
continue
if (entry / "SKILL.md").is_file():
names.append(entry.name)
return names
def owned_skill_path(name: str) -> Path:
path = SKILLS_DIR / name
if not (path / "SKILL.md").is_file():
raise SystemExit(f"自研 skill 不存在: {name}")
return path
def resolve_skill_source(name: str, *, source: str | None = None) -> tuple[Path, str]:
"""返回 (skill_path, kind),kind 为 owned 或 external。"""
ensure_skills_home()
owned = SKILLS_DIR / name
if source in (None, "owned") and (owned / "SKILL.md").is_file():
return owned, "owned"
registry = load_registry()
if source in (None, "registry") and name in registry:
path = external_skill_path(name, registry[name])
if not path.exists():
raise SystemExit(
f"外部 skill {name!r} 尚未 fetch。请先运行: skiff fetch {name}"
)
return path, "external"
if (owned / "SKILL.md").is_file():
return owned, "owned"
raise SystemExit(f"找不到 skill: {name}")
def validate_skill_name(name: str) -> None:
import re
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
raise SystemExit(
f"skill 名称无效: {name!r}(小写 + 连字符,如 security-review)"
)
if name == "_template":
raise SystemExit("不能使用保留名 _template")
+28
View File
@@ -0,0 +1,28 @@
# skill-name
用一句话告诉使用者这个 skill 能解决什么问题。
## 什么时候使用
- 给出用户可以直接说出的典型请求。
- 说明适用范围。
## 使用前准备
- 列出需要用户提前提供或确认的信息、权限和依赖。
## 使用示例
```text
给出一条可以直接交给 Agent 的示例请求。
```
## Agent 会做什么
1. 用面向使用者的语言概括主要步骤。
2. 说明可能发生的外部写操作或重要边界。
## 如何判断完成
- 说明使用者最终会得到什么,以及如何确认结果正确。
+36 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: skill-name name: skill-name
description: >- description: >-
简要描述 skill 做什么、何时触发。Include trigger keywords so agents can match automatically. 简要描述 skill 做什么、何时触发。写入用户可能使用的触发表达,让 Agent 能自动匹配。
--- ---
# Skill 名称 # Skill 名称
@@ -15,6 +15,10 @@ description: >-
- 触发场景 1 - 触发场景 1
- 触发场景 2 - 触发场景 2
## 不适用
- 不应触发的相近场景
--- ---
## 步骤 ## 步骤
@@ -23,8 +27,39 @@ description: >-
2. 第二步 2. 第二步
3. 第三步 3. 第三步
## 运营类 skill 可选:初始化 / 检查 / 工作
需要项目配置才能工作的 skill(例如构建、部署、协作闭环)增加这三个模式:
- **初始化**(用户点名才跑):探项目 → 只建本 skill 拥有的安全空结构 → 用仓库
证据填能填的 → 列出待配置项。不覆盖已有文件,不猜密钥、主机、仓库地址。
- **检查**(只读):同一套诊断,不写文件。
- **工作**:配置不齐就停,告诉用户先初始化。禁止静默初始化。
报告格式:
```text
## <skill> 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: …
下一步: 一句话
```
项目状态放在 skill 真正消费的位置(覆盖层、`makefile.builder`、compose),不要为了对齐
而新建一层没人读的 `.pouch/<skill>/`。`pouch init` 只用于确实有 templates/
覆盖层的 skill。需要 make 目标或发布凭据的 skill 用自己的文件名(如 `makefile.builder`、
`.env.builder`),不要占用用户的 `Makefile` 或 `.env`。
--- ---
## 注意事项 ## 注意事项
- 约束或边界条件 - 约束或边界条件
- `SKILL.md` 只留模式路由、全模式安全边界,以及「若 X 则读 `references/Y.md`」。
单模式步骤不要写进正文。
## 验证
- 说明如何确认 skill 的执行结果正确
+141
View File
@@ -0,0 +1,141 @@
# ACK
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
- Coordinator 拆解需求、派发任务并终检
- Developer 实现并做白盒验证
- Test 独立做黑盒复测
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
只在你显式说 `/ack` 或 `$ack` 时运行。
## 什么时候使用
| 场景 | 怎么说 | 结果 |
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 先建 ship 记录;复杂需求走 specifier / long-ship,小改动走 short-ship |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 short-ship |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
| 回归 | `/ack 回归` | 先布测试环境,再按 `regression.yaml` 用浏览器或 API 跑 |
做需求和修 bug 在 `verified` 之后,都要更新回归目录。
## 安装
```bash
pouch add ack # 当前项目
pouch add ack -g # 全局
```
## 使用前准备
- 项目是 Git 仓库。
- 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok、OMP 或 Pi。
- 使用 Pi 时需安装 `pi`;默认模型用 `agents.yaml` 中 `cli: pi`、`model: null` 表示。
- 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
## 初始化项目
```bash
pouch init ack
pouch init ack --project ~/code/my-app
```
也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
```text
.pouch/ack/
├── project.md
├── tasks.yaml # 项目配置与 taskStore 指针
├── task-records/ # 追加式任务/执行记录
├── knowledge.yaml
├── delivery.yaml # 默认 enabled: false
├── regression.yaml # 默认 cases: []
└── agents.yaml # 可选:临时覆盖 Developer/Test 的 CLI、模型和权限
```
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件;需要消除单文件冲突时,先运行 `scripts/migrate_tasks.py` 的 dry-run。
任务状态追加示例:
```bash
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id T-001 --task-json /tmp/T-001.json --parent EV-T-001-initial
```
## 使用示例
```text
/ack 初始化
/ack 检查配置
/ack 处理这个需求:给后台加导出 CSV
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
/ack 处理飞书里待整理的 bug
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
/ack 重新布一下测试环境,我要测试
/ack 发布一个版本
/ack 回归
/ack 跑 full 回归
/ack 回归 REG-login-001
/ack 修这些回归失败
```
首次配发版也可以一次说清:
```text
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
```
之后做需求时只要在确认计划里选 profile:
```text
/ack 处理这个需求:…。任务验证通过后执行 review profile。
```
临时调整角色启动方式时直接说明意图,例如:
```text
/ack 让这个项目的 dev agent 用 pi 默认模型,test agent 用 cursor-agent auto 模型
```
ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时优先于
`tasks.yaml.project.orchestration`;删除后自动回退。配置 hash 会绑定启动计划和 receipt,
修改后必须重新审阅并生成 plan。
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:规格和任务写到 `.pouch/ship/<id>/`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
6. 回归失败只报告,不会自动开修。
7. 未获授权不提交、不推送、不创建终端、不部署,也不改项目的 `AGENTS.md` / `CLAUDE.md`。
飞书收件时,你维护标题、详细描述和附件。Coordinator 整理问题说明、期望和验收并写回飞书。你针对当前 revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
## 如何判断完成
- 初始化:五个状态文件齐,报告为完成,或列出待配置项和下一步。
- 需求 / bug:任务变为 `verified`,或三轮后记为 `leftover`;回归目录有本轮用例,或明确「无回归」。
- 测试环境:目标环境容器 Up,健康检查通过。
- 发版:停在约定档位;stable / 生产有单独批准记录。
- 回归:列出通过和失败的用例;失败不会被自动拿去修。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
+154
View File
@@ -0,0 +1,154 @@
---
name: ack
description: >-
初始化、检查并运行 ACK 三角色闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 .pouch/ack、按 ACK 做需求或修 bug、指挥
Coordinator/Developer/Test、配置或运行测试环境与发版、或跑回归时使用。
---
# ACK 项目协作入口
项目只在 `.pouch/ack/` 保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
`delivery.yaml`、`regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖配置。
不要复制 Skill 内容,不要修改项目的 `AGENTS.md`、
`CLAUDE.md`。
执行位置和 profile 选择可以统一写在项目根 `.pouch/agent.yaml` 的 `ack.<name>` binding:
`location` 提供 Orca environment/host/project/path,`profile` 提供 cli/model/
`reasoningEffort`/permission。ACK 的 `agents.yaml` 或 tasks orchestration 仍保留其
`profileVersion`、`modelAllowlist`、fingerprint/receipt 字段,并由 ACK adapter 做兼容
映射;不要为了接入 common runtime 删除旧 receipt 或绕过 launcher。远程 ACK worker
必须先按 binding 精确定位 worktree,不能把本机目录当远程目录。
固定配置文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`;任务执行记录从
`.pouch/ack/task-records/` 追加读取。旧项目没有 `taskStore` 时仍兼容直接读取
`tasks.yaml`。固定状态文件为 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml` 和
`.pouch/ack/regression.yaml`。
开始时解析当前 `SKILL.md` 所在目录为 `<ack-skill-dir>`。优先
`git rev-parse --show-toplevel` 作为项目根。
## 选择模式
- 初始化、接入 ACK:执行「初始化」。
- 检查配置是否完整:执行「检查」。
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
`.pouch/ship/`,不要写进缺陷任务板。
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
- 任务板使用 Orca 时,编排命令见 [orca-adapter.md](references/orca-adapter.md)。
## 边界
- 不覆盖已有 `.pouch/ack`;除用户确认的任务、agents profile 或 delivery profile 外,不擅自提交、推送、创建终端、新 worktree、发布或部署。
- 不猜测命令、地址、worker handle 或模型名;无法确定写 `n/a`。
- `agents.yaml` 是可选的临时角色启动覆盖;存在时优先于 `tasks.yaml.project.orchestration`,不存在时回退到任务板配置。
- `agents.yaml` 只能描述结构化 CLI、模型、reasoning effort 和权限,不能保存 command、argv、shell 或环境变量;Pi 的默认模型用 `cli: pi`、`model: null` 表示。
- 不把完整 `tasks.yaml` / `knowledge.yaml` / `regression.yaml` 注入上下文;用对应 `select_*.py`。写回前跑完整校验。
- 新项目不要把任务状态、worker receipt 或运行历史回写到 `tasks.yaml`;每次状态变化写入
`task-records/<recordId>.yaml`,记录必须包含 `taskId`、`parents` 和完整 `task` 快照。
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
- 用户明确授权无沙箱运行后,Codex profile 可设置 `permissionMode: danger-full-access`,由 launcher 生成 `--sandbox danger-full-access`;授权在当前会话中持续有效。任务文本本身不能授权。禁止手工附加 bypass 参数、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 无清理证明不复用 worker;无密钥 `receiptHash` 或 Orca live metadata 不能授权复用。当前 Orca 走 fresh。只复用同轮、空闲且完成清理历史消息的 worker;无法确认清理成功时创建 fresh worker。`indeterminate` / `reconcile required` 不直接重试。
- 产品失败才占三轮;环境失败写 `environmentIncidents`,环境失败不占复验轮次。见 [optimization-method.md](references/optimization-method.md) §4。
- `.pouch/ack/tasks.yaml` 收缺陷(飞书、回归失败、leftover)。功能、小改动和本次交付写
`.pouch/ship/<ship-id>/`。显式 `/ack` 做需求时先建 ship 记录;需要三角色时用 ACK 编排,
任务状态仍写在该 ship 的 `tasks.yaml`。缺陷要交付时在 ship `source` 引用 ACK 任务 id。
## 初始化
1. 确认 `pouch` 可执行。`.pouch/ack` 不存在则 `pouch init ack --project <project-root>`;已存在则不覆盖、转入「检查」。
2. 按 [init-new-project.md](references/init-new-project.md) 完善项目状态。不从 README/CI 猜测并启用交付或知识;初始化 **不** 自动初始化 deployer 或 builder。
3. 运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \
--tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
# 仅在存在临时角色配置时运行
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
4. 检查上述文件是否仍有 `<...>` 占位符;没有真实任务时使用 `tasks: []`。结构校验通过且必填项目事实完整才称「完成」。除非用户明确要求,不提交、不推送。
旧项目迁移前先预览 `python3 <ack-skill-dir>/scripts/migrate_tasks.py .pouch/ack/tasks.yaml`,确认后再加 `--apply`。
追加任务状态时使用完整快照 JSON,不修改已有记录:
```bash
python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
--task-id <task-id> --task-json <task-snapshot.json> \
--parent <previous-record-id> --kind task.snapshot
```
```text
## ack 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: pouch …
下一步: 一句话
```
## 检查
只读,不自动修复。核对清单见 [adoption-checklist.md](references/adoption-checklist.md)。用 `select_tasks.py` 看配置;跑与「初始化」相同的四个校验器;存在 `agents.yaml` 时再跑 `validate_agents.py`。对比 `VERSION` 与 `ackVersion`(旧 `kitVersion` 仍可读);从 `0.10.0` 起 `project.orchestration` 与 `workerReceipts` 必须同时存在。旧项目可无 delivery/regression,存在或被引用时必须校验。若有 `bugIntake` 再跑 `feishu_bug_intake.py check`。若测试环境走 deployer,只读跑其 `check.py`;未通过列入待配置,不要复制 compose 或静默初始化 deployer。用同一报告格式,标题改为 `## ack 检查:…`。
## 工作
1. `.pouch/ack` 不存在:停止并建议先 `/ack` 初始化;不要静默初始化。
2. 读 `project.md`;若存在 `agents.yaml`,先用 `validate_agents.py` 校验并读取其 `defaults`;用 `select_tasks.py`(已知任务加 `--task-id`)和 `select_knowledge.py` 取当前任务相关 `active` 条目。超预算时缩小选择,不回退为完整 yaml。
3. 新需求或尚未确认的计划:读 [kickoff.md](references/kickoff.md)。范围清楚的小改动转
`short-ship`;复杂需求转 `specifier` / `long-ship`。修 bug 写短问题说明、复现和可观测
验收,不写大 PRD;要交付时链接 `short-ship`。
4. 用户已确认后,先读取并按 [closed-loop.md](references/closed-loop.md) 执行;使用 Orca 时同时读取 [orca-adapter.md](references/orca-adapter.md)。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。
这不是 fire-and-forget:Coordinator 不得在 `launch` / `dispatch` 后只回复“已派发”并结束当前会话。每个任务都必须在同一轮持续执行默认 60 秒窗口的 `check --wait`,并按 60–120 秒节奏运行 `worker_probe.py`,直到 Developer 回报 `worker_done` 或 `escalation`;随后写回 `fixed_by_dev`,主动派发 Test,并继续等待 `retest_result`。只有完成 Test 终检、重试/`leftover` 决策、回归决定和 worker 回收后,才可以结束本轮 ACK。
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
6. 知识 `verification.ref` 只经 `run_verification.py`。不把 `worker_done` 或 Test 自报成功当作完成。环境失败先有界恢复并报告 `userAction`;三轮产品失败记 `leftover`。
7. 任务 `verified` 且改了可见行为或 API 后,转入「运行回归」收获用例。若本次计划含交付,再走对应交付模式;默认最多到 `validation_ready` 或 `review_ready`。
8. 整轮任务完成后 `reclaim_workers.py` 先 dry-run 再 `--apply`,回收所有只属于 `verified` 任务的 worker 终端,并核对关闭回执;仍关联未完成任务的 worker 不设置 TTL。关闭结果不确定则记录,不伪报。
不能停在“任务已 `verified` 却声称整轮 ACK 已结束”:必须完成回归决定和 worker 回收。
## 角色启动配置维护
用户明确说「让这个项目的 dev agent 用 pi 默认模型、test agent 用 cursor-agent auto」等临时调整时,创建或更新 `.pouch/ack/agents.yaml`。配置文件形状见 [agents.template.yaml](templates/agents.template.yaml) 和 [agents.example.yaml](examples/agents.example.yaml),只写结构化 profile,不写自由命令。
存在 `agents.yaml` 时,ACK 的 `profile-hash`、`plan`、`launch` 和 receipt 全部绑定该文件的 hash;修改配置后必须重新 plan,旧 fingerprint 不能继续启动。删除该文件即可回退到 `tasks.yaml.project.orchestration`。配置本身不代表提交、推送、发布或部署授权。
## 飞书收件
若存在 `project.bugIntake`,读 [feishu-bug-intake.md](references/feishu-bug-intake.md)。用户针对当前 `draftRevision` 明确审核通过并亲自把飞书状态改为 `已确认` 前:不创建或刷新任务、不启动 worker、不派发、不改应用代码。Coordinator 不得自行写入 `已确认`。未声明 workflow 的旧配置只按 `read-only-v1`,不得写回。来源消失或读取失败时不删除已有任务。
## 启动 worker
只使用 `scripts/launch_worker.py plan|launch`。不得直接 `orca terminal create --command`,不得拼接自由 command、argv、executable、env 或 cwd。读 [model-routing.md](references/model-routing.md)。先审阅 `plan.launchFingerprint`,再作为 `launch --expected-launch-fingerprint` 传入。派发文案用 [prompt-templates.md](references/prompt-templates.md)。派发后 terminal read 确认已开始;卡在审批、未回车或额度限制按环境失败处理。等待期间用 `scripts/worker_probe.py`,不盲等 `worker_done`。收到无错误的 `worker_done` / Test 复测结果后,按 [orca-adapter.md](references/orca-adapter.md) 用 `reclaim_workers.py --completed-task-id ... --completed-role ... --apply` 关闭对应 Orca terminal;失败、escalation 或环境异常不关闭。
## 交付配置维护
读 [delivery.md](references/delivery.md)。测试环境写成 `{via: deployer, env: <env>}`;`.pouch/deployer/<env>` 未就绪则停止并加载 deployer「初始化」,不在 ACK 里复制 compose。发版写在同一份 `delivery.yaml`,不保存 shell。本地 `npm run dev` / `go run` 不算测试环境。旧项目首次启用只补文件指针与 `deliveryRuns: []`,保持 `enabled: false`,用户确认后才启用。进行中的 run 使用开始时的 commit/config 快照。
## 运行测试环境
读 `delivery.yaml` 与 [delivery.md](references/delivery.md),并加载 deployer。`enabled` 非 true 或 intent 为 null:停止,转入交付配置维护。intent 必须是 `{via: deployer, env: <env>}`;旧 profile ID 字符串要先迁移。不要用 ACK delivery profile 布环境,不猜测启动命令。不要求任务已 `verified`。对 `.pouch/deployer/<env>` 按 deployer:list → 按服务 sync+up → 健康检查。证据写入 `deliveryRuns`(`intent: testEnvironment`,`profile: deployer-<env>`)。未安装、缺目录、`check.py` 失败或健康检查失败:fail closed,报告 `userAction`,不记产品失败。
## 运行回归
读 [regression.md](references/regression.md)。缺目录则停止;用户授权后从模板生成空文件并只补指针与 `regressionRuns: []`。用 `select_regression.py` 读 active 用例(默认 `--suite smoke`);无命中则停止。先执行「运行测试环境」,再派独立 Test worker。Coordinator 不亲自点浏览器或打 API;`browser` 不得改成只打 API。终检写入 `regressionRuns`。失败不自动派 Developer,不占三轮预算。任务 `verified` 后给出新增/更新/退役/无回归四选一,用户确认后写入;Test 只提名。
## 运行版本发布
1. 读同一份 `delivery.yaml` 与 [delivery.md](references/delivery.md)。
2. `enabled` 不为 true,或 `intents.release` 为 null:停止,先做交付配置维护。
3. 按该 profile 顺序执行。stable 发布和生产部署的 `approval` 不能用口头「发版」代替。
4. 证据写入 `deliveryRuns`,`intent: release`;绑定了任务时 `taskIds` 仍只能引用 `verified` 任务。
+1
View File
@@ -0,0 +1 @@
0.20.0
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "ACK"
short_description: "初始化、检查并运行 ACK 开发、验证、测试环境、发版与回归闭环"
default_prompt: "Use $ack to initialize or check ACK, coordinate feature or bug work, bind the test environment to deployer, redeploy it, publish a release, or run regression."
policy:
allow_implicit_invocation: false
+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"
+155
View File
@@ -0,0 +1,155 @@
version: 1
updatedAt: "2026-08-01T10:00:00+08:00"
project:
name: "notes-api"
enabled: true
defaultProfile: "review"
intents:
testEnvironment:
via: deployer
env: test
release: null
entrypoints:
verify:
kind: make
target: verify
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 1800
build-deb:
kind: make
target: build-deb
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 3600
build-image:
kind: make
target: build-image
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 3600
upload-deb:
kind: script
path: "scripts/upload-preview-deb.sh"
args: []
requiredSecrets: ["DEB_TOKEN"]
workingDirectory: "."
timeoutSeconds: 900
upload-image:
kind: script
path: "scripts/publish-preview-image.sh"
args: []
requiredSecrets: ["REGISTRY_TOKEN"]
workingDirectory: "."
timeoutSeconds: 1800
deploy-test:
kind: script
path: "scripts/deploy-test.sh"
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 900
health-test:
kind: script
path: "scripts/check-test.sh"
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 300
rollback-test:
kind: script
path: "scripts/rollback-test.sh"
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 900
artifacts:
service-deb:
type: deb
build: build-deb
outputs: ["dist/*.deb"]
service-image:
type: oci-image
build: build-image
image: "registry.example.com/notes/service"
platforms: ["linux/amd64", "linux/arm64"]
destinations:
preview-apt:
type: apt-repository
channel: preview
endpoint: "https://packages.example.com"
repository: "testing"
upload: upload-deb
preview-registry:
type: oci-registry
channel: preview
registry: "registry.example.com"
repository: "notes/service"
upload: upload-image
environments:
test-server:
type: ssh-host
classification: development
target: "notes-test"
deploy: deploy-test
healthCheck: health-test
rollback: rollback-test
mutex: "notes-test-deploy"
profiles:
review:
stopAt: review_ready
steps:
- id: verify
action: verify
entrypoint: verify
- id: open-pr
action: pull-request
draft: true
remote: origin
baseBranch: main
- id: build-deb
action: build
artifact: service-deb
- id: publish-deb
action: publish
artifact: service-deb
destination: preview-apt
- id: deploy-test
action: deploy
artifact: service-deb
environment: test-server
- id: smoke-test
action: health-check
environment: test-server
- id: ready
action: mark-ready
review-image:
stopAt: review_ready
steps:
- id: verify
action: verify
entrypoint: verify
- id: open-pr
action: pull-request
draft: true
remote: origin
baseBranch: main
- id: build-image
action: build
artifact: service-image
- id: publish-image
action: publish
artifact: service-image
destination: preview-registry
- id: ready
action: mark-ready
@@ -0,0 +1,47 @@
version: 1
updatedAt: "2026-07-31T12:00:00+08:00"
project:
name: "notes-web"
verificationRegistry:
service-worktree-alignment:
path: "tests/ack/check_service_worktree.py"
args: ["--require-current-commit"]
entries:
- id: "K-001"
revision: 1
kind: "verification"
status: "active"
title: "复测前确认服务与 worktree/commit 对齐"
subject: "service-worktree-alignment"
scope:
all: false
components: ["web"]
paths: ["web/**"]
dependencies: []
versions: []
tags: ["long-running-service"]
symbols: []
errorSignatures: []
appliesWhen: "修改常驻 Web 服务或前端构建产物"
directive: "复测前重启服务,并核对服务实例对应的 worktree 与 commit"
rationale: "历史任务曾因 Test 连接旧进程而产生假通过"
verification:
ref: "service-worktree-alignment"
expected: "服务实例、worktree 和 commit 一致"
provenance:
taskId: "BUG-002"
attemptId: "BUG-002-A2"
codeRef: "abc1234"
evidenceRef: "tasks.yaml#BUG-002"
owner: "web-team"
author: "developer"
reviewer: "test"
approval: null
createdAt: "2026-07-30T10:00:00+08:00"
lastValidatedAt: "2026-07-31T11:00:00+08:00"
reviewAfter: "2099-10-31T11:00:00+08:00"
temporary: false
removalCondition: null
statusReason: null
supersedes: []
conflictsWith: []
+114
View File
@@ -0,0 +1,114 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.20.0。
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、`delivery.yaml`
> 与 `regression.yaml`。
## 项目概览
- 项目:`notes-web`
- 技术栈:`TypeScript + React (Vite) + Go`
- 运行命令:`npm run dev`(前端)、`go run ./server`(后端)
- Base URL:`http://localhost:5173`
- 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`
- 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`
- 回归目录:`.pouch/ack/regression.yaml`
- 覆盖层文件:`.pouch/ack/project.md`
## 稳定规范(引用,不重复)
- 开始一个需求(启动手册):`references/kickoff.md`
- 角色 / 权限 / 状态机 / 完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程(含手动模式、worktree 对齐):`references/closed-loop.md`
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/orca-adapter.md`
- 验证后交付:`references/delivery.md`
- 回归目录与运行:`references/regression.md`
## Worker 路由
结构化配置默认位于 `.pouch/ack/tasks.yaml` 的 `project.orchestration`;存在
`.pouch/ack/agents.yaml` 时以后者为准。启动记录位于顶层 `workerReceipts`。
本项目默认使用:
| 角色 | profile ID | 档位 |
|------|------------|------|
| Developer | `codex-dev-standard` | standard |
| Test | `codex-test-standard` | standard |
| Developer 升级 | `codex-dev-strong` | strong |
项目如临时改用 Pi、Cursor 或 Grok,应修改 `agents.yaml` 的结构化 profile、allowlist
和 defaults,再运行对应校验。本文件不保存或拼接 worker shell 命令。
## 路径权限
| 路径 | Coordinator | Test | Developer | 说明 |
|------|:-----------:|:----:|:---------:|------|
| `docs/spec/**` | R/W | Read-only | Read-only | 产品规格(PM 拥有) |
| `tests/browser/**` | Read-only | R/W | Read-only | 浏览器回归用例(Test 拥有) |
| `.qa-records/**` | Read-only | R/W | Read-only | 复测记录(gitignore) |
| `web/**`、`server/**` | Read-only | Read-only | R/W | 应用源码 |
| `**/*_test.go`、`web/**/*.test.tsx` | Read-only | Read-only | R/W | 单元测试 |
| `config/*.example.*` | Read-only | Read-only | R/W | 可提交配置模板 |
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
## 命令
Developer 白盒验证:
```bash
npm run build
go test ./...
npm run dev # 本地起前端
```
Test 黑盒复测:
```bash
curl -s http://localhost:5173/api/health
curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
# 浏览器回归:tests/browser/cases/*.md
```
项目已审查的知识检查入口保存在 `knowledge.yaml.verificationRegistry`,格式为检查
ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
path/args。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
`scripts/validate_regression.py` 执行。
测试环境由 ACK 内部调用 deployer;发版机器入口以 `delivery.yaml` 为准,本覆盖层
不维护第二套发布或部署命令。
## 硬规则(其余见 references/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现。
- 模型分层:Coordinator 强模型不跑测试,Test/Developer 中低模型(见 references/model-routing.md)。
- 自动 worker 只通过 ACK 的 `scripts/launch_worker.py` 启动;不直接执行自由
`orca --command`。v0.10 的自动 profile 只允许 `read-only` 或
`workspace-write`,不允许 full-access、bypass、YOLO/force 或关闭 sandbox。
- 每次自动派发都先审阅 plan,再用 `--expected-launch-fingerprint` 启动 fresh
worker;持久化 receipt 只作审计。仅同轮空闲、身份匹配并可信清理历史消息后复用;
整轮结束回收 verified 任务终端,blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成,必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `task-records/` 和 `knowledge.yaml`;Test 与 Developer 只读。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
- 交付只在任务 `verified` 后运行;默认 profile 停在 `validation_ready` 或 `review_ready`。stable 发布与
production 部署保留显式 approval,配置变更只影响下一次 run。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
@@ -0,0 +1,39 @@
version: 1
updatedAt: "2026-08-25T10:00:00+08:00"
project:
name: "notes-web"
cases:
- id: REG-login-001
title: 登录后进入工作台
status: active
source:
taskId: BUG-001
kind: bug
suite: smoke
surface: browser
setup: 使用测试账号 A,未登录
steps:
- 打开 /login
- 输入账号 A 并提交
expected:
- kind: visible-text
value: 工作台
- kind: url
value: /dashboard
- id: REG-notes-create-001
title: 创建笔记后列表出现标题
status: active
source:
taskId: FEAT-012
kind: feature
suite: full
surface: api
setup: 已登录账号 A,笔记列表为空
steps:
- POST /api/notes 创建标题为回归笔记的条目
- GET /api/notes
expected:
- kind: api-status
value: "200"
- kind: api-field
value: "items[].title=回归笔记"
+284
View File
@@ -0,0 +1,284 @@
# 填好的手动闭环示例(虚构的 notes-web 项目)。历史轮次没有自动 launcher receipt;
# 因此使用 manual 模式。可用 scripts/validate_tasks.py 校验通过。
version: 1
updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.20.0"
project:
name: "notes-web"
baseUrl: "http://localhost:5173"
overlayFile: ".pouch/ack/project.md"
knowledgeFile: ".pouch/ack/knowledge.yaml"
deliveryFile: ".pouch/ack/delivery.yaml"
regressionFile: ".pouch/ack/regression.yaml"
orchestration:
profileVersion: 1
mode: "manual"
# allowedWorktrees 已废弃(v0.19 起);worker 默认在 --project-root 工作。
modelAllowlist:
codex:
developer:
standard: ["gpt-5.6-terra"]
strong: ["gpt-5.6-sol"]
test:
standard: ["gpt-5.6-luna"]
cursor-agent:
developer:
standard: ["auto"]
test:
standard: ["auto"]
grok:
developer:
standard: ["grok-4.5"]
strong: ["grok-4.6"]
test:
standard: ["grok-4.5"]
profiles:
codex-dev-standard:
role: "developer"
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
codex-test-standard:
role: "test"
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
codex-dev-strong:
role: "developer"
cli: "codex"
tier: "strong"
model: "gpt-5.6-sol"
reasoningEffort: "high"
permissionMode: "workspace-write"
cursor-dev-standard:
role: "developer"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
cursor-test-standard:
role: "test"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
grok-dev-standard:
role: "developer"
cli: "grok"
tier: "standard"
model: "grok-4.5"
reasoningEffort: "medium"
permissionMode: "workspace-write"
grok-test-standard:
role: "test"
cli: "grok"
tier: "standard"
model: "grok-4.5"
reasoningEffort: "low"
permissionMode: "workspace-write"
grok-dev-strong:
role: "developer"
cli: "grok"
tier: "strong"
model: "grok-4.6"
reasoningEffort: "high"
permissionMode: "workspace-write"
defaults:
developer: "codex-dev-standard"
test: "codex-test-standard"
developerUpgraded: "codex-dev-strong"
workerReceipts: []
deliveryRuns: []
regressionRuns: []
summary:
verified: ["BUG-002"]
open: []
failedRetest: []
leftovers: ["BUG-003"]
tasks:
- id: "BUG-002"
type: "bug"
title: "预览变更点击后不显示 diff 行"
priority: "P0"
status: "verified"
assignee: "developer"
component: "web/app/fix/page.tsx"
specRefs:
- "docs/spec/fix-preview.md#preview"
testRefs:
- "tests/browser/cases/01-preview.md"
knowledgeRefs:
- "K-001@1"
knowledgeApplied:
- ref: "K-001@1"
result: "applied"
evidence: "重启服务并核对 commit 后再交给 Test"
knowledgeCandidates: []
knowledgeChecks:
- ref: "K-001@1"
result: "passed"
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
checkedBy: "test-worker-1"
checkedAt: "2026-07-06T09:36:00+08:00"
# 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。
source:
kind: "feishu-base"
ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
recordId: "recExample"
updatedAt: "2026-07-06T09:00:00Z"
description: >
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
fixLogic: >
修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。
acceptanceCriteria:
- "预览后确认区显示 title、修改前值、修改后值和 coverChanged"
- "取消预览不会产生保存请求"
- "原先确认区为空的问题不再出现"
stepsToReproduce:
- "打开 /fix?fileId=1"
- "搜索并选择候选项"
- "点击“预览变更”"
expected: >
确认区出现 title、修改前值、修改后值、coverChanged 提示四行。
actual: >
API 返回 code=0,但页面确认区为空。
evidence:
browser: "确认区 DOM 为空,无 diff 行"
api: "POST /api/fix/preview -> code=0, 含 diff 字段"
logs: "无报错"
verification:
commands:
- "npm run build"
- "curl -s -X POST localhost:5173/api/fix/preview -d @fixtures/preview.json"
browser:
page: "/fix?fileId=1"
checks:
- "预览后出现 4 行 diff:title/before/after/coverChanged"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
test:
profileId: "codex-test-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
rounds:
- round: 1
attemptId: "BUG-002-A1"
result: failed
evidence: "只渲染了 title,缺少 before/after/coverChanged"
- round: 2
attemptId: "BUG-002-A2"
result: passed
evidence: "复测 4 行 diff 全部出现,取消不触发写入"
environmentIncidents:
- id: "BUG-002-ENV-1"
attemptId: "BUG-002-A2"
role: test
phase: service
status: resolved
summary: "第一次复测连接到旧构建"
evidence: "服务进程的 worktree 与当前任务不一致"
impact: "该次执行没有形成产品通过或失败结论,不计复验轮次"
recoveryAction: "重启当前 worktree 的最新服务后重新复验"
userAction: "无需操作;Coordinator 已完成服务对齐"
reportedAt: "2026-07-06T09:30:00+08:00"
resolvedAt: "2026-07-06T09:32:00+08:00"
resolution:
fixedBy: "dev-worker-1"
verifiedBy: "test-worker-1"
verifiedAt: "2026-07-06T09:38:00+08:00"
leftoverReason: null
- id: "BUG-003"
type: "bug"
title: "并发保存偶发覆盖他人修改"
priority: "P1"
status: "leftover"
assignee: "developer"
component: "server/store/notes.go"
specRefs:
- "docs/spec/concurrency.md"
testRefs: []
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates:
- kind: "pitfall"
title: "乐观锁检查必须覆盖批量导入路径"
claim: "只在 API 写入路径检查版本号会漏掉批量导入"
scope:
components: ["server-store"]
paths: ["server/store/**"]
tags: ["concurrency"]
appliesWhen: "修改 note 的并发写入或批量导入逻辑"
directive: "同时验证 API 与批量导入的版本冲突处理"
rationale: "第二轮修复只覆盖 API,批量导入仍可静默覆盖"
evidenceRefs:
- "tasks.yaml#BUG-003-round-2"
proposedBy: "test-worker-1"
proposedAt: "2026-07-06T09:32:00+08:00"
knowledgeChecks: []
description: >
两个会话同时保存同一条 note 时,后写覆盖先写,无冲突提示。
stepsToReproduce:
- "会话 A、B 同时打开 note 42"
- "A 保存,B 保存"
expected: >
B 保存时应检测到版本变化并提示冲突,而非静默覆盖。
actual: >
B 直接覆盖 A 的修改,无提示。
evidence:
api: "两次 PUT /api/notes/42 均返回 200,无版本校验"
verification:
commands:
- "go test ./server/store/..."
browser:
page: "/notes/42"
checks:
- "并发保存时后写方收到冲突提示"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
test:
profileId: "codex-test-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
rounds:
- round: 1
attemptId: "BUG-003-A1"
result: failed
evidence: "加了版本号但未在写入路径校验"
- round: 2
attemptId: "BUG-003-A2"
result: failed
evidence: "校验只覆盖 API,未覆盖批量导入路径"
- round: 3
attemptId: "BUG-003-A3"
result: failed
evidence: "乐观锁与前端重试逻辑冲突,需重新设计"
resolution:
fixedBy: null
verifiedBy: null
verifiedAt: null
leftoverReason: "failed after 3 supervised developer rounds; 需重新设计并发模型"
+130
View File
@@ -0,0 +1,130 @@
# ACK 接入清单
## 安装与初始化
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `pouch init ack --project <project-root>`。
- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
- [ ] `tasks.yaml` 使用 `ackVersion` 记录 ACK Skill 版本。
- [ ] 若存在 `.pouch/ack/agents.yaml`,已运行 `validate_agents.py`;它优先于任务板
`project.orchestration`,且只包含结构化 profile,不包含自由 command/argv/env。
## 项目覆盖层
- [ ] `project.md` 只保存项目差异,不复制 Skill 的通用规范。
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
`.pouch/ack/knowledge.yaml`。
- [ ] 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,顶层有
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
- [ ] 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,顶层有
`regressionRuns: []`;旧项目未采用回归能力时可无这两项。
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
allowlist,默认 profile 与角色/档位一致。
- [ ] `agents.yaml`(若存在)使用受支持的 profileVersion,Pi 默认模型使用
`cli: pi` + `model: null`,配置 hash 会绑定启动 plan/fingerprint/receipt。
- [ ] `allowedWorktrees` 已废弃(v0.19 起),新任务板不配置;worker 默认在
`--project-root` 工作,其它 worktree 由 launcher 按同 git 仓库且已注册约束放行。
- [ ] `tasks.yaml` 不需要保存 `repoPath` 或 `devWorktree`;项目状态从当前
`--project-root/.pouch/ack/` 推导,worker 路径由 `--worktree` 显式指定。
## 路径权限
- [ ] Coordinator 可写规格和任务板。
- [ ] Test 可写集成测试与复测记录,但不能改源码。
- [ ] Developer 可写源码与单元测试,但不能改规格或黑盒验收。
- [ ] 私有配置只读且不提交。
- [ ] `tasks.yaml` 只有 Coordinator 写。
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
- [ ] `regression.yaml` 只有 Coordinator 写;Test 只通过 `regressionCandidates` 提名。
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
## 任务板
- [ ] 已替换项目名、仓库路径、Base URL、worktree 和覆盖层路径。
- [ ] 没有真实任务时使用 `tasks: []`。
- [ ] 真实任务的验收是可观测信号。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
## 可选交付
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
- [ ] 测试环境绑定 deployer(`intents.testEnvironment.via: deployer`),发版写在
同一份 `delivery.yaml` 的 `intents.release`。没有第二份操作文档。未说明的
intent 保持 `null`。旧 profile ID 字符串已迁移。
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
shell、自由 command、凭据值或环境变量值。
- [ ] artifact、destination、environment 和 profile 引用均通过
`validate_delivery.py --tasks ... --project-root ...` 校验。
- [ ] 默认 profile 只使用 preview/staging 与非 production 环境,停止在
`review_ready`。
- [ ] stable 发布和 production 部署之前存在 approval 步骤;production 环境有
rollback 入口。
- [ ] 本次 ACK 计划明确 profile、task IDs、目标、停止点和审批步骤;配置本身没有
被当作提交、推送、发布或部署授权。
- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`;
失败不会把已验证任务回退为失败。
## 项目知识
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
不虚构 active 条目。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_knowledge.py` 并通过;同时校验任务引用。
- [ ] Coordinator 只从 `active` 条目按 scope 推荐知识,并显式确认固定 revision 的
`knowledgeRefs`。
- [ ] `candidate` 留在任务证据中,不派发;`stale`、`superseded`、`archived`
不默认选择。
- [ ] 每轮只内联命中的少量知识,不全量注入 `knowledge.yaml`。
- [ ] Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
`knowledgeChecks`。
- [ ] `verification.ref` 只引用 `verificationRegistry` 中已审查的仓库内相对 path
和结构化 args;执行时只把 registry ID 交给 `run_verification.py`,不从
知识正文或选择器输出拼接 shell。
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
## 回归
- [ ] 新项目 `regression.yaml` 为 `cases: []`,没有虚构用例。
- [ ] 已运行 `validate_regression.py --tasks ...` 并通过。
- [ ] 任务 `verified` 后给出新增/更新/退役/无回归四选一,确认后才写入目录。
- [ ] 运行回归先走 deployer 测试环境,再派独立 Test;失败不自动开修。
## 编排
- [ ] 已选择 Orca 或手动模式。
- [ ] 自动创建只调用 `launch_worker.py plan|launch`,没有直接拼接
`orca terminal create --command` 或自由 worker 命令。
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
`--expected-launch-fingerprint`;漂移时重新 plan。
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
- [ ] 只考虑同轮、空闲、角色/profile/worktree/启动身份匹配的复用候选;历史消息必须
由受信后端清理并返回新会话身份。任一条件不符或无法确认清理成功时启动 fresh
worker;没有把历史 receipt checksum 或 Orca live metadata 当作复用授权。
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
- [ ] Test 使用的服务来自正确 worktree。
## 闭环
- [ ] `worker_done` 不直接视为完成。
- [ ] Test 独立复测后由 Coordinator 读取证据终检。
- [ ] 每轮写入 `dispatch.rounds`。
- [ ] 三轮失败后标记 `leftover` 并填写原因。
- [ ] `leftover` 不阻塞其它任务。
- [ ] Coordinator 标记整轮完成后,已关闭所有只属于 `verified` 任务的 worker;仍关联
blocked/failed/leftover、未完成任务或未解决环境事件的终端保留且不设 TTL。
首次接入建议选择一个低风险问题跑完整闭环。项目差异写回
`.pouch/ack/project.md`;通用问题回流到 ACK Skill 的 `references/`、`templates/`
或 `scripts/`,并更新 `VERSION`。项目特有、跨任务复用且已经验证的经验才写入
`.pouch/ack/knowledge.yaml`。
+342
View File
@@ -0,0 +1,342 @@
# 闭环流程(稳定核心,编排无关)
本文件定义**与具体编排工具无关**的三角色协作闭环。运行时调度可以用 Orca(见 `orca-adapter.md`),也可以手动跑(见下方「手动模式」)。
原则:调度消息只是运行时载体,任务结论必须追加到 `task-records/`(由读取层
materialize 成任务视图);已经验证且可跨
任务使用的项目经验必须由 Coordinator 回写到 `knowledge.yaml`。不要把消息当最终记录。
角色定义见 `roles-and-permissions.md`:Coordinator 只编排、Test 只验证、
Developer 只实现。
---
## 编排抽象
无论用什么工具,闭环都由这几个能力组成。注意 `dispatch` 有两个目标(Developer 与 Test):
| 抽象动作 | 含义 | Orca 实现 | 手动实现 |
|----------|------|-----------|----------|
| `prepare(task)` | Coordinator 把任务和验收信号写进 `task-records/`,按 scope 推荐知识并确认显式 `knowledgeRefs` | 同左 | 同左 |
| `dispatch(task, developer)` | 把修复任务连同上下文交给 Developer | `orca orchestration dispatch` | 复制 prompt 到 Developer 终端/会话 |
| `dispatch(task, test)` | 把复测任务连同验收信号交给 Test | `orca orchestration dispatch` | 复制 prompt 到 Test 终端/会话 |
| `wait()` | 等待 `worker_done` / `retest_result` / `escalation` / `decision_gate` | `orca orchestration check --wait` | 人工等待回报 |
| `gate(task)` | Coordinator 读 Test 证据、`knowledgeChecks` 并对齐原始意图(不重测) | 同左 | 同左 |
| `writeback(task, result)` | Coordinator 把任务结果追加到 `task-records/`,把验证通过的跨任务经验按权限写入 `knowledge.yaml` | 同左 | 同左 |
派发用的 prompt 见 `prompt-templates.md`。状态流转见 `roles-and-permissions.md` §「任务状态机」。**独立复测由 Test 执行,不是 Coordinator**;Coordinator 只做读证据的终检。
---
## 标准闭环
```text
Coordinator 发现或读取 open 任务
-> prepare:写/补全 task-records/ 中的任务验收信号
-> 从 knowledge.yaml 按 scope 推荐 active 知识,Coordinator 确认固定 revision 的 knowledgeRefs
-> 为新轮次生成稳定 attemptId(<task-id>-A<round>),Developer 与 Test 共用
-> 决定 worktree:当前 worktree 起子 agent,还是新建隔离 worktree(见下节「子任务放哪」)
-> 解析安全 profile:机器配置只读 agents.yaml(存在时),否则读取 tasks.yaml.project.orchestration
-> 运行 launcher plan 并审阅 launchFingerprint
-> 检查同轮空闲 worker;可信清理历史消息成功才复用,否则带 expected fingerprint 启动 fresh worker
-> 把本次 task/attempt receipt 追加到 task-records/(见 orca-adapter.md)
-> dispatch 给 Developer(--to <worker handle>)
-> 确认 Developer 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
-> writeback fixed_by_dev
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先按 deployer
绑定拉起 `.pouch/deployer/<env>`,再派 Test;Test 不发明编译或启动命令
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
-> dispatch 给 Test(retesting)
-> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
直到 Test 的 retest_result(含 knowledgeChecks 和 candidate 独立证据)
-> Test 通过:gate(Coordinator 读证据对齐意图)
-> 通过 gate:writeback verified
-> gate 不满足意图:writeback failed_retest,带意图差异再派发 Developer
-> Test 因环境无法完成:记录 environmentIncidents,不计轮次;有界恢复并明确告知用户下一步
-> Test 观察到产品失败:writeback failed_retest,追加证据,再派发 Developer(最多累计三轮有效产品复验)
-> 累计三轮有效产品失败:writeback leftover,继续下一个任务
```
一次派发只修一个明确问题(细则见 `optimization-method.md` §「每轮派发只修一个明确问题」)。
「决定 worktree」「解析 profile/receipt」几步的决策见下节与 `model-routing.md` /
`orca-adapter.md`。历史 receipt、标题、preview、分支名、worker 自报和无密钥 receipt
checksum 都不能代替可信启动 attestation 或历史消息清理证明。只复用同一轮内明确空闲、
身份完全匹配且得到新会话身份的 worker;清理能力缺失或结果不确定时必须 fresh launch。
Coordinator 默认给新逻辑轮次生成 `<task-id>-A<round>`,并在记录轮次结果时写入
`tasks[].dispatch.rounds[].attemptId`。它独立于编排工具产生的 `taskId` 和
`dispatchId`:前者用于知识来源追溯,后两者只用于查询运行时。0.9.0 之前的历史
轮次允许暂时缺省;但要把该轮证据晋升为知识前,必须按确定性格式补齐。晋升时,
`provenance.taskId` 和 `provenance.attemptId` 必须能精确回到该任务及轮次。
### 知识选择与晋升
`prepare(task)` 时可以使用 Skill 自带的 `scripts/select_knowledge.py` 按
component、path、dependency、version 和 tag 确定性推荐条目。选择器只返回
`active`,并受条目数限制;`stale`、`superseded` 和 `archived` 不默认派发。自动
匹配只是推荐。条目中每个非空 scope 维度都必须被任务上下文命中,因此调用时应
提供当前任务已知的全部 component、path、dependency、version、tag、symbol 和错误
特征。Coordinator 确认并写入当前任务的显式 `knowledgeRefs` 后才成为本轮权威
上下文。
`scope.paths` 使用 POSIX 路径 glob:`*` 不跨目录分隔符,`**` 才能跨目录。多个
pattern 是同一维度内的 OR,候选越多表示范围越宽;超过选择预算时,选择器优先保留
命中维度更多、OR 候选更少、字面约束更多且通配符更少的条目。
派发时只内联本轮引用的少量知识,不要求 worker 全量读取 `knowledge.yaml`。这也
避免隔离 worktree 读不到 Coordinator 工作树中尚未提交的项目状态。
进行中的任务只能引用 `active` 条目。已经进入 `verified` 或 `leftover` 的终态任务
可以继续保留固定 revision 的历史引用,即使对应知识后来变成 `stale`、
`superseded` 或 `archived`;这些旧条目不会再次被选择器派发。
Developer 或 Test 发现经验时只能通过 `knowledgeCandidates` 提名,并带上当前任务
的独立观测、scope、触发条件和证据。candidate 留在任务证据中,不进入知识选择器,
也不能被下一轮当作 active 指令。只有 Test 独立验证、Coordinator gate 通过后,
Coordinator 才能写入或更新 `knowledge.yaml`;全项目强制或权限类规则还需
User / Decision Owner 确认。
Test 对显式引用回报 `not_applicable` 时,说明 Coordinator 的选择需要纠正。该引用
仍在 `knowledgeRefs` 中就不能把任务标为 `verified`;Coordinator 应记录原因、移除
误选引用并重新校验,或继续补充验证。
知识中的 `verification.ref` 只能引用
`knowledge.yaml.verificationRegistry` 里已审查的仓库内相对 path 和结构化 args,
不能从知识正文拼接或执行自由 shell。Developer/Test 只把 `verification.ref` 交给
`<ack-skill-dir>/scripts/run_verification.py`;该入口从项目根目录逐段安全打开检查
文件,拒绝 symlink 路径,并从匿名稳定快照执行。关键约束应下沉为测试、lint、CI
或正式规范。检查进程从固定的项目根 fd cwd 运行;检查脚本应使用 cwd 或
`ACK_PROJECT_ROOT` 定位资源。后者是 fd 路径,原始展示路径只在
`ACK_PROJECT_ROOT_DISPLAY` 中用于日志;不能依赖 `$0` / `__file__` 的目录。
---
## 子任务放哪:新 worktree 还是当前 worktree
Coordinator 派发前先决定 Developer/Test 在哪工作。两种方式:
**方式 1:当前 worktree 复用安全重置的空闲子 agent,或起 fresh 子 agent(终端)**
```bash
# 当前 Orca 不能可信清理历史消息,因此先审阅 fresh launch plan:
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-current-worktree> --slot <1..99>
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-current-worktree> --slot <1..99> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
- 适合:串行闭环、一次一个 Developer 任务、小改动、Test 要复测的正是 Developer 改的那棵树。
- 优点:单一服务实例、无跨 worktree 对齐困扰、开销小。
- 风险:多个 agent 同时改同一棵树会互相踩(半构建状态、git 冲突)。
- 记录:`launch` 成功后把 receipt 追加到当前任务记录的 `workerReceipts`,并把
`receipt.id` 写入 `dispatch.<role>.receiptId`、把 `profileId` 写入
`dispatch.<role>.profileId`、把本轮稳定 ID 写入 `dispatch.<role>.attemptId`;
receipt 的 task/role/profile/attempt 必须与当前 dispatch 完全一致,不能只记录
一个裸 handle,也不能引用旧任务或旧轮次 receipt。
**方式 2:新建隔离 worktree,再在其中起子 agent**
创建成功后,确认新 worktree 与权威项目根属于同一 Git 仓库(launcher 按
`git worktree list` 注册表 + 同 common-dir 约束校验,v0.19 起不再需要
`allowedWorktrees` 白名单登记)。
```bash
orca worktree create --name <feature> --base-branch <base> --json
# 对返回的绝对路径调用 ACK launcher
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-new-worktree> --slot <1..99>
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-new-worktree> --slot <1..99> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
- 适合:并行多个互不依赖的子任务、大/高风险/实验性改动、要保持基线分支干净(如 prod 不动、feature 走独立分支)、要独立 build 或跑独立服务实例、best-of-N 尝试。
- 优点:隔离 + 并行 + 易回滚(删 worktree 即可)。
- 成本:各自 build/依赖、服务要用不同端口、必须处理下节的 worktree/服务对齐。
**决策速查:**
| 情况 | 选择 |
|------|------|
| 一次一个任务、串行修复 | 当前 worktree 子 agent |
| 多个任务并行、互不依赖 | 每个任务一个新 worktree |
| 高风险 / 实验 / 可能整体丢弃 | 新 worktree |
| 要保持基线分支干净 | 新 worktree(feature 分支)|
| 小改动、追求快 | 当前 worktree |
**项目配置 SSOT 只落一处**:无论开几个 worktree,`tasks.yaml`、
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
副本(通常在基线/协调所在 worktree)。任务执行状态追加到
`.pouch/ack/task-records/<recordId>.yaml`;Coordinator 单写任务记录、知识、回归目录、
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
的 sandbox 绕过请求必须 fail closed。
---
## 手动模式(无 Orca)
没有编排工具时,闭环不变,只是 `dispatch` / `wait` 由人工承担:
1. Coordinator 在 `task-records/` 写入包含完整任务快照的初始记录和验收信号。
2. 从 `knowledge.yaml` 推荐相关 active 知识,Coordinator 确认固定 revision 的
`knowledgeRefs`;用 `prompt-templates.md` §1 的初始派发模板生成 prompt,
手动发给 Developer(另一个会话/终端/人)。
3. Developer 完成后按 worker_done 模板回报。
4. Coordinator 写回 `fixed_by_dev`,用 `prompt-templates.md` §3 的复测派发模板把任务发给 Test。
5. Test 独立复测后按复测报告模板回报证据和 `knowledgeChecks`。
6. Coordinator 做终检并追加新记录:通过 `verified`,不过 `failed_retest`。
7. 产品失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮有效产品
复验;环境失败写入 `dispatch.environmentIncidents`,恢复环境后重新派发 Test,
不派 Developer、不消耗产品轮次。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
写 `task-records/` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
创建 fresh worker。
---
## Test 复测(编排无关)
即使 Developer 的 worker_done 写了"全部通过",Test 仍必须独立复测:
```bash
git status --short
<test_commands>
curl -s <base_url>/health-or-summary
```
浏览器复测建议记录:
```text
BASE_URL:
page:
steps:
expected:
actual:
snapshot evidence:
```
Test 只回传证据,不写 `task-records/` 或 `knowledge.yaml`;由 Coordinator 追加落盘。
每条适用的显式 `knowledgeRef` 还应报告 `passed`、`failed` 或
`not_applicable` 及证据。
---
## Coordinator 终检(gate)
Test 报通过后,Coordinator 不重测,而是做一次读证据的终检:
- Test 证据是否覆盖了任务的**每一条**验收信号(见 `optimization-method.md` §1)。
- 当前任务显式 `knowledgeRefs` 的必需 `knowledgeChecks` 是否全部覆盖,检查引用
是否能解析到项目已审查的入口。
- 结果是否符合任务的**原始意图**,而不只是通过了字面文案。
- 运行环境是否对齐(见下方「服务与 worktree 对齐」)。
终检不通过(例如意图漂移、验收信号写漏)时,回写 `failed_retest` 并把意图差异带给下一轮 Developer,必要时先补全验收信号。
---
## 服务与 worktree 对齐(防假通过/假失败)
Test 复测前记录运行环境:
```bash
pwd
git rev-parse --abbrev-ref HEAD
git rev-parse --short HEAD
```
```text
serverPid:
serverCommand:
BASE_URL:
frontendDir:
worktreePath:
```
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以 deployer 绑定为准,不另写一套启动命令。
---
## 结果回写
Coordinator 追加任务记录时按状态填写(记录内的 `task` 字段结构见
`templates/tasks.schema.json`):
```yaml
# 通过(Test 复测 + Coordinator 终检)
status: verified
resolution:
verifiedAt: "<timestamp>"
verifiedBy: "test"
evidence:
verification: "<commands passed>"
browser: "<snapshot or API evidence>"
```
```yaml
# 失败但未满三轮
status: failed_retest
dispatch:
rounds:
- round: 1
result: failed
evidence: "<latest evidence from Test or gate>"
```
```yaml
# 累计三轮失败
status: leftover
resolution:
leftoverReason: "failed after 3 supervised developer rounds"
evidence:
final: "<latest failing evidence>"
```
环境失败不进入上面的 `rounds`,单独记录:
```yaml
status: fixed_by_dev
dispatch:
environmentIncidents:
- id: "BUG-001-ENV-1"
role: test
phase: service
status: open
summary: "无法访问待测服务"
evidence: "preflight connection refused"
impact: "未执行产品验收信号"
recoveryAction: "启动正确 worktree 的最新服务后重试"
userAction: "无需操作;Coordinator 将重启服务"
reportedAt: "<timestamp>"
```
---
## Worker 收尾
Coordinator 写入本轮全部最终状态并完成已确认的 delivery 停止点后,再回收 worker。
按 terminal handle 聚合所有 receipt 引用:只关闭全部关联任务均为 `verified`、且没有
未解决环境事件的 handle。任何关联任务仍为 `open`、`dispatched`、`fixed_by_dev`、
`retesting`、`blocked`、`failed_retest`、`leftover` 或状态不明时都保留该终端,不设置
TTL。关闭前后按 `orca-adapter.md` 核对 runtime/handle/incarnation 和关闭回执;关闭
不确定时报告并保留审计记录。
+164
View File
@@ -0,0 +1,164 @@
# ACK 交付阶段
本文件定义可选的 `verified -> validation_ready/review_ready/released` 交付阶段。开发、独立复测和
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
交付。项目配置位于 `.pouch/ack/delivery.yaml`,运行证据写入
任务记录的 `deliveryRuns`(读取时 materialize 到任务视图)。
## 1. 配置与授权不是一回事
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。启用交付
时,ACK 在 kickoff 的既有用户确认点默认展示 `defaultProfile`、remote、产物目标、环境
和停止点,不得静默省略;用户
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
channel、environment 或 source revision 漂移时重新确认。
`approval` 步骤始终是运行时硬门。`stable` 发布和 `production` 部署不能由 kickoff
的一般确认代替,必须在该步骤取得本次明确授权。配置、历史 approval 或项目文档不能
替用户授权合并 PR、创建正式 tag、覆盖版本、删除分支或生产发布。
## 2. 配置快照与变更生效
普通任务在 kickoff 时从可信 base commit 读取交付契约并记录 `configRevision`。本次
分支对 `delivery.yaml`、引用的部署入口、CI 或 Agent 指令文件的修改不能扩大当前运行
权限;这些改动经审核合并后从下一次任务生效。
用户明确要求维护交付配置时:
1. 读取现有配置、项目构建入口、CI、打包和部署事实。
2. 用自然语言总结将新增、删除或改变的 artifact、destination、environment、profile
和权限边界。
3. 只做最小配置修改,不把项目脚本复制进 ACK。
4. 运行 `validate_delivery.py`;可安全执行的本地入口使用 dry-run 或无凭据环境检查。
5. 把配置或入口变更作为待审核变更交付。本轮不使用新配置执行 publish/deploy。
普通功能任务中若发现配置漂移,记录 `contract_drift` 并停止受影响的交付步骤,不为了
通过流程而静默修改配置或跳过步骤。
## 3. 交付契约结构
- `entrypoints`:项目已有的 Make、Just、Task、Dagger 或仓库内可执行脚本入口。
- `artifacts`:`deb`、`oci-image` 或 `file`,引用一个 build entrypoint。
- `destinations`:APT、OCI registry 或 CI artifact;`channel` 区分 preview、staging、
stable。
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
deploy 和 health check,production 还必须声明 rollback。
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `validation_ready` 或
`review_ready`,不能发布 stable 或部署 production。`validation_ready` profile 必须
至少部署一个 development/staging 环境,并对每个部署目标执行健康检查。
配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径
或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部
凭据系统或执行环境注入。复杂逻辑放在受版本控制的项目入口中。entrypoint 使用 argv
语义执行,不能拼成 `sh -c` 字符串。
## 3.1 测试环境与发版写在同一份契约
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一文档。不要另写操作手册,
也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或
「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`。
```yaml
intents:
testEnvironment:
via: deployer
env: test # 项目 .pouch/deployer/test;尚未说明时为 null
release: null # profile ID,或 null
```
- `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」
「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.pouch/deployer/<env>`
按服务执行 sync + up 和健康检查。环境目录不存在或 deployer `check.py` 未通过
时,加载 deployer skill 的「初始化」,不要在 ACK 里复制 compose 命令。派发
Test 复测或跑回归前,若该 intent 已配置且 `enabled: true`,Coordinator 也先
执行它。不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动
命令。旧的 profile ID 字符串不再执行,必须迁到 `{via: deployer, env: <env>}`。
本地进程启动写在 `project.md`,不算这个 intent。
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
口头「发版」不能代替 stable/production 的 `approval` 步骤。
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
- 用户触发的 intent 运行追加到任务记录的 `deliveryRuns`,`intent` 填
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
`verified` 任务。
## 4. 运行前检查
1. 从 `tasks.yaml.project.deliveryFile` 解析文件;未引用或 `enabled=false` 时保持旧 ACK
行为,收尾停在 `verified`。
2. 运行:
```bash
python3 <ack-skill-dir>/scripts/validate_delivery.py \
.pouch/ack/delivery.yaml --tasks .pouch/ack/tasks.yaml \
--project-root <project-root>
```
3. 确认选中 profile 是 kickoff 已确认的 profile,所有 task 已是 `verified`,工作区与
服务对应正确 source revision。
4. 检查 referenced entrypoint、delivery config、CI 和凭据边界是否在本次变更中被
修改;被修改时禁止用它们执行带远端写权限或 secret 的步骤。
5. 检查本次步骤引用的 `requiredSecrets` 是否由外部环境提供,只报告名称和是否存在,
不读取、打印或持久化值。缺失时在第一次相关写操作前标记 blocked。
6. 将已确认工作树固化为本地 source revision,再创建 `deliveryRuns` 的 `planned`
记录,绑定 task IDs、profile、source revision 和 config revision;推送仍等到对应
`pull-request` 步骤。
## 5. 步骤语义
按 profile 中的顺序执行,不自行插入或省略步骤:
- `verify`:运行指定 entrypoint,失败即停止。
- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
明确要求时才调用独立的 `releaser`。没有对应能力或认证时标记 blocked,不用
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
- `build`:对当前 source revision 调用已安装的 `builder` `obtain.sh --sha <revision>`,
不要假定本机 `make`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线;没有流水线或 job 失败
则本步 `failed`。DEB 必须记录包名、版本、架构和 SHA-256;OCI image 必须记录完整
引用、platform 和 digest。产物必须绑定当前 source revision,不能在目标机器重新拉
源码构建。
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
时才加载,否则必须走契约中已审查的 upload entrypoint。`BUILD_VENUE=ci` 且 obtain
已带回 registry digest 时,本步只复核,不再本机 push。项目入口只接受
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
mutex 后执行,不能并发部署同一目标。
- `health-check`:在对应 deploy 成功后运行环境 health check,记录可观测证据。失败时
按项目入口执行 rollback;rollback 未证明成功时不得声称恢复。
- `approval`:停止并展示准确 artifact、destination/environment、source revision 和
回滚计划,等待用户本次确认。
- `mark-ready`:所有前序步骤成功后将 Draft PR/MR 标为 ready,并写入最终证据。
## 6. 状态与恢复
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
`blocked`、`failed`、`validation_ready`、`review_ready`、`released` 或 `skipped`。
`validation_ready` 表示已把不可变产物部署到开发/测试环境并通过健康检查,可以交给
用户手工验证,不要求存在 PR/MR。部署或 Forge 暂时失败不把
任务改回 `failed_retest`。
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成同一运行的资源。
若恢复过程中修改了任何 tracked file,原 source revision 和交付证据失效:回到 ACK
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
时,才可从失败步骤继续。
`validation_ready` 至少记录:source/config revision、所有产物引用与 digest、部署环境、
访问地址和用户下一步,以及健康检查证据。`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
“完成”掩盖其中某一阶段失败或待审批。
## 7. 与低层 Skill 的边界
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`releaser` 和
`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载
deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失
builder 或 releaser 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用
时把对应步骤标为 `blocked`。低层 skill 自身要求显式调用时,ACK 不能绕过它的
触发与授权边界;ACK 内部调用 deployer 布测试环境是该 skill 的合法调用路径。
+148
View File
@@ -0,0 +1,148 @@
# 飞书 Base Bug 整理与审核
飞书 Base 是 Bug 在审核通过前的唯一协作区。用户只需填写 `标题`,可选填写
`详细描述`,并把截图、录屏或日志放在 `附件`。Coordinator 结合来源事实、附件和项目
现状整理 `问题说明`、`期望效果`、`验收标准`,但不在收件箱写修复逻辑。
审核通过前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker、派发 Developer/Test
或修改应用代码。字段已填满、记录进入某个 view、用户暂时没有回复,都不等于审核通过。
## 项目配置与 Base 结构
`.pouch/ack/tasks.yaml` 的 `project.bugIntake` 必须声明 `provider: feishu-base`、
`workflow: clarified-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、`viewId` 和字段映射:
```yaml
fields:
title: 标题
details: 详细描述
problemStatement: 问题说明
expectedOutcome: 期望效果
acceptance: 验收标准
intakeStatus: 处理状态
ackTaskId: ACK任务ID
attachments: 附件
updatedAt: 更新时间
```
人维护 `标题`、`详细描述`、`附件`;ACK 维护 `问题说明`、`期望效果`、`验收标准`;系统字段
是 `处理状态`、`ACK任务ID`、`更新时间`。状态按
`待整理 → 需补充/待审核 → 已确认 → 已导入` 流转。优先级在批准后做任务规划时确定,
不属于收件箱审核内容。原 `read-only-v1` 和 `reviewed-writeback-v1` 继续兼容旧项目。
结构变更必须先执行只读计划,再携带计划指纹应用;适配器只新增目标字段、迁移来源信息并
调整当前 view 的可见字段,不删除旧列:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-plan .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py schema-apply .pouch/ack/tasks.yaml \
--expected-schema-fingerprint <schemaFingerprint>
```
迁移时,旧 `期望结果` 会以 `用户原始期望:…` 合并进 `详细描述`,旧字段继续保留但从
当前 view 隐藏;空状态初始化为 `待整理`。不要保存 App Secret、access token 或 profile
凭据。
## Profile 和安全边界
在账号级可信目录安装官方 CLI:
```bash
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
--name project-feishu --app-id "$FEISHU_APP_ID" \
--app-secret-stdin --brand feishu
```
使用官方 `lark-cli`,项目只保存 profile 名。不要依赖 active profile;所有读取、附件下载、
写回和结构迁移都显式传配置 profile。profile 需要 Base 读写和附件下载权限。
所需 scope 至少包括 `base:record:read`、`base:record:write` 和
`docs:document.media:download`。不要把 `lark-cli auth check` 当作 app/bot scope 的证明,
它只检查当前用户的 stored user token。
适配器只搜索账号和系统的可信工具目录,子进程只收到实际账号 HOME、可信 PATH 和基础
locale;调用者环境中的凭据和运行时注入变量不会传入。不得绕过适配器直接操作 Base。
每条记录最多 10 个附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,
整批下载最多 5 分钟。
## 读取与整理
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check .pouch/ack/tasks.yaml
tmpdir=$(mktemp -d)
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch .pouch/ack/tasks.yaml \
--output-dir "$tmpdir"
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan .pouch/ack/tasks.yaml \
--output-dir "$tmpdir"
```
`fetch` 输出标准化 JSON,并为每条记录计算覆盖来源事实、附件身份、整理字段和状态的
`draftRevision`。标题和更新时间是必需来源事实;详细描述和附件可为空。问题说明、期望
效果、验收标准缺失时返回 `enrichmentRequired`。`plan` 只给出查重和漂移预览,审核前
不得据此创建任务。
Coordinator 对每条 Bug:
1. 读取标题、详细描述、附件及相关产品/代码上下文;证据不足时明确假设,不伪装成用户原文。
2. 写 `problemStatement`:说清现象、影响范围和边界,不包含修复方案。
3. 写 `expectedOutcome`:说明正确情况下用户能观察到的行为,不包含实现方式。
4. 写 `acceptance`:形成可独立复测的、可观察的标准,不扩张用户未表达的产品范围。
5. 将草案保存为不超过 64 KiB 的 JSON,且只含上述三个键:
```json
{
"problemStatement": "非空字符串",
"expectedOutcome": "非空字符串",
"acceptance": ["非空验收项"]
}
```
使用当前 `sourceRef` 和 `draftRevision` 写回:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
.pouch/ack/tasks.yaml --record-id <record-id> \
--expected-source-ref <sourceRef> \
--expected-draft-revision <draftRevision> --input <draft.json>
```
适配器在写前检查记录仍位于配置 view 且 revision 未漂移,只覆盖问题说明、期望效果、
验收标准和处理状态(设为 `待审核`),随后回读并返回新 revision。把新 revision 连同整理
结果交给用户审核。用户反馈后重新读取、修订和写回;不要在聊天或本地维护分叉版本。
## 审核门禁与导入
只有用户针对当前 revision 明确表示审核通过,并由用户本人在飞书把处理状态改为
`已确认`,才可继续导入。Coordinator 使用的适配器不提供把草案自行标成已确认的命令;
状态和任务 ID 不参与内容 revision,因此用户确认状态不会改变已批准内容的 revision。
重新读取并核对状态和 revision 后执行:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
.pouch/ack/tasks.yaml --record-id <record-id> \
--expected-source-ref <approved-sourceRef> \
--expected-draft-revision <approvedDraftRevision>
```
任一来源事实、附件或整理字段变化都会使旧批准失效。导出的 `taskDraft` 把问题说明映射为
`description`、期望效果映射为 `expected`、验收标准映射为 `acceptanceCriteria`;不包含
修复逻辑、复现步骤或优先级。Coordinator 在后续任务规划中补充优先级,但不得改写已审核
字段;校验器会重算 `approvedPayloadHash`。
把 `taskDraft` 写入 `tasks.yaml`、补齐任务 ID 和规划字段并通过任务板校验后,再把最终
任务 ID 与同一批准 revision 写回飞书:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py mark-imported \
.pouch/ack/tasks.yaml --record-id <record-id> --task-id <ack-task-id> \
--expected-source-ref <approved-sourceRef> \
--expected-draft-revision <approvedDraftRevision>
```
适配器只在任务板恰有一条 ID 匹配、来源引用、记录 ID、批准 revision 和 payload hash
全部一致的任务时,写入 `ACK任务ID` 并把状态推进到 `已导入`;回读不一致则失败。重复执行
同一绑定是幂等的,不能把一条记录改挂到另一个任务。
按 `source.ref` 查重:仅未派发的 `open` 任务可刷新;其它状态只报告来源漂移,不覆盖。
来源消失、不可访问或同步失败时,不删除已有 ACK 任务。
+186
View File
@@ -0,0 +1,186 @@
# 在新项目初始化 ACK
本文件说明如何把 ACK 的项目状态初始化到目标项目。ACK Skill 自身已经通过 Agent
的 Skill 机制安装;项目不复制、不链接 Skill 内容。
## 前提与边界
开始前确认:
1. 目标项目根目录。
2. ACK Skill 已全局安装或安装到当前项目。
3. `pouch` 命令可用。
不要覆盖已有的 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、
`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml`、`.pouch/ack/regression.yaml`、
`AGENTS.md` 或其它 Agent
指令文件。ACK 不会自动
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
内容或其它私有配置写入 ACK 项目状态。
## 初始化
在目标项目执行:
```bash
pouch init ack
```
或显式指定项目:
```bash
pouch init ack --project <project-root>
```
命令从 ACK Skill 自带的 `templates/` 生成:
```text
.pouch/ack/
├── project.md
├── tasks.yaml
├── knowledge.yaml
├── delivery.yaml # 默认 enabled: false
└── regression.yaml # 默认 cases: []
```
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
`framework` 或其它指向 Skill 的软链接。
### 旧项目补充知识库
旧项目已经有 `project.md` 和 `tasks.yaml`、但没有 `knowledge.yaml` 时,不要重跑
`pouch init ack`。先检查现有文件并向用户报告缺失项;用户授权后,只从
`templates/knowledge.template.yaml` 生成 `.pouch/ack/knowledge.yaml`,替换项目名和
当前时间,保留 `entries: []`。如果现有任务板缺少
`project.knowledgeFile`,同一次授权只补
`knowledgeFile: .pouch/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
知识库和跨文件引用校验。
### 旧项目补充交付配置
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
任务板补 `project.deliveryFile: .pouch/ack/delivery.yaml` 与顶层
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
解析结果后才启用。不要重跑 `pouch init ack`,也不要改写已有任务或知识。
### 旧项目补充回归目录
`regression.yaml` 对旧项目是可选能力。用户明确要求回归或授权补齐时,从
`templates/regression.template.yaml` 生成 `.pouch/ack/regression.yaml`,并只补
`project.regressionFile: .pouch/ack/regression.yaml` 与顶层 `regressionRuns: []`。
保持 `cases: []`,不要从聊天虚构用例。
## 完善项目覆盖层
编辑 `.pouch/ack/project.md`,填入:
- 项目名、技术栈、运行命令和 Base URL。
- Coordinator、Developer、Test 的实际模型档位。
- 规格、集成测试、源码、单元测试和私有配置的路径权限。
- Developer 白盒验证命令。
- Test 黑盒复测命令。
无法从项目证据确定的值写为 `n/a`,不要猜测。
## 完善任务板
编辑 `.pouch/ack/tasks.yaml`:
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
自动补交付配置。
- `updatedAt` 使用当前带时区时间。
- `project.name` 使用真实值;`overlayFile` 和 `knowledgeFile` 使用项目内相对路径。
ACK 从命令行 `--project-root` 下固定的 `.pouch/ack/` 布局解析项目状态,不把
`repoPath` 或 `devWorktree` 绝对路径写入任务板。旧任务板中的这两个字段仅兼容读取,
不再参与路径绑定。
- 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,并保留顶层
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
- 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,并保留顶层
`regressionRuns: []`。旧项目只有在采用回归能力时才补这两个字段。
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
- `.pouch/ack/agents.yaml` 是可选的临时 worker 路由覆盖;存在时优先于任务板中的
`project.orchestration`。它必须使用结构化 profile;Pi 默认模型写成 `cli: pi`、
`model: null`,不能写自由命令。
- 非服务项目的 `baseUrl` 写为 `n/a`。
- 没有真实任务时使用 `tasks: []`,不要保留或虚构示例任务。
每个真实任务的验收必须是可观测信号,例如可见文本、API 状态和字段,或明确的交互
结果;不要只写“功能正常”。
## 初始化项目知识
新项目的 `.pouch/ack/knowledge.yaml` 保持 `verificationRegistry: {}` 与
`entries: []`。不要从聊天、README、issue 或单次失败中猜测并激活知识。
项目运行 ACK 后,Developer 和 Test 可以通过回报提名 `knowledgeCandidates`;
candidate 留在任务证据中,不会被派发。只有 Test 独立验证且 Coordinator gate
通过后,Coordinator 才能把它写成 `active` 条目。全项目范围的强制或权限类规则
还需要 User / Decision Owner 确认。
知识条目只引用项目已审查的 `verification.ref`。对应入口保存在
`knowledge.yaml.verificationRegistry`,只允许仓库内相对 path 和结构化 args,
不保存或自动执行自由 shell 命令。需要执行时只把 registry ID 交给
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
最终下沉为测试、lint、CI 或正式规范。
## 初始化项目交付
新项目的 `.pouch/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
`intents.testEnvironment: null` 与 `intents.release: null`。
不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator
把 `intents.testEnvironment` 写成 `{via: deployer, env: <env>}`,若
`.pouch/deployer/<env>` 尚未就绪则加载 deployer skill 的「初始化」;发版仍指向
profile。ACK 初始化不自动跑 deployer 初始化。
配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式
approval 步骤。
## 初始化回归目录
新项目的 `.pouch/ack/regression.yaml` 保持 `cases: []`。不要从 README 或聊天猜测
用例。任务 `verified` 后按 `regression.md` 收获。
## 校验
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
# 存在临时角色配置时
python3 <ack-skill-dir>/scripts/validate_agents.py .pouch/ack/agents.yaml
```
同时确认:
- `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 和
`regression.yaml` 没有未替换的 `<...>` 占位符。
- `project.overlayFile` 指向真实文件。
- `project.knowledgeFile` 指向 `.pouch/ack/knowledge.yaml`。
- 新项目的 `project.deliveryFile` 指向 `.pouch/ack/delivery.yaml`;交付默认关闭。
- Developer 与 Test 的验证命令可执行。
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
- 若存在 `agents.yaml`,其 profile/allowlist/defaults 也通过校验,且启动配置 hash
会绑定 plan、fingerprint 和 receipt。
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
不能单独授权复用旧终端;复用还需要空闲状态、身份匹配和可信历史清理证明。
- 网站或 API 项目写清服务启动、重启和 Base URL。
- 任务中的固定 revision `knowledgeRefs` 都能解析,非 `active` 条目没有被派发。
## 初始化报告
按 `SKILL.md`「初始化」最后一步的格式报告(`## ack 初始化:完成 | 部分完成 | 阻塞`),
列出已具备项、待配置项(路径 + 字段 + 示例)、工具链和下一步。
只有结构校验通过且必填项目事实完整时才称「完成」;否则称「部分完成」或「阻塞」。
除非用户明确要求,不提交、不推送。
+207
View File
@@ -0,0 +1,207 @@
# 如何开始一个需求(Kickoff)
从零开一个需求的启动手册。本文件只覆盖确认前的产品文档、任务拆分与验收信号。
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
不要在本文件开头预读其它规范。
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + long-ship)。ACK 任务板
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
---
## 前提:谁是 Coordinator
**你(发起编排的强模型会话)就是 Coordinator (PM) / 产品。** 你负责写文档、拆任务、
编排、终检,**不亲自写代码、不亲自跑测试**。开发和测试是另起的 worker agent;
具体 CLI、模型、reasoning effort 和执行模式的机器事实源优先是
`.pouch/ack/agents.yaml`,不存在时回退到 `.pouch/ack/tasks.yaml` 的
`project.orchestration`。`.pouch/ack/project.md` 只解释项目
差异,不能提供另一套启动命令。
---
## 第 0 步:给 Coordinator 的开场指令(复制改需求)
```text
我要做一个新需求:<一句话需求>。
你作为 ack 的 Coordinator(PM),按 ACK Skill 执行:
1. 先读 .pouch/ack/project.md,并用 `scripts/select_tasks.py .pouch/ack/tasks.yaml`
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
完整 tasks.yaml 注入上下文。校验 .pouch/ack/knowledge.yaml 并用
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目。
本 kickoff 只做确认前的产品文档与任务拆分;用户确认后的闭环、飞书、启动
worker、交付和回归按 ACK Skill 的模式路由按条件加载,不要在本步预读其它
references。配置本身不是执行授权。
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
4. 把任务作为完整快照追加到 `.pouch/ack/task-records/`(只有你写),校验任务视图和 knowledge.yaml。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
fingerprint 创建 fresh worker;
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
dispatch 测试独立复测 → 你读证据终检 → 追加 task-records;
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
记录、恢复并告诉我下一步,不占产品复验轮次。
7. 所选任务都 verified 后,按 SKILL.md「运行回归」给出新增/更新/退役/无回归,确认后写入
.pouch/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
或 review_ready,stable/production 步骤再次向我确认。
```
---
## 第 1 步:Coordinator 产出(确认前)
1. 产品文档 → `docs/PRD-<feature>.md` 等(Coordinator R/W)。
2. 任务板 → `.pouch/ack/tasks.yaml`,每条任务带 `expected` + `verification`,验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 项目知识 → 从 `.pouch/ack/knowledge.yaml` 按 component、path、dependency、version
和 tag 推荐 `active` 条目,Coordinator 确认后写入固定 revision 的显式
`knowledgeRefs`。candidate 不参与选择。
4. 校验结构与引用:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml
```
有效 worker routing(`agents.yaml` 优先,否则 `project.orchestration`)是 profile 的机器
SSOT;未知字段、非 allowlist 模型、不匹配的角色/档位和非安全权限必须在派发前 fail closed。
读取任务上下文使用:
```bash
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml \
--task-id <ack-task-id>
```
选择器解析并校验完整任务板,但只输出命中任务及其 receipt/delivery run;超过预算时
显式失败,不得改用 `cat` 或整文件 `sed`。
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
发布 channel、部署环境、PR 是否创建/标 ready、停止点,以及哪些步骤会再次审批。
---
## 第 2 步:决定 worktree
- 需求大 / 要并行 / 要保基线分支干净 → 新建隔离 worktree。
- 小改动 / 串行修复 → 当前 worktree 起子 agent。
用户确认后的闭环步骤由 SKILL.md「工作」加载,不要在本步预读其它规范。
---
## 第 3 步:审阅并启动 worker
先读有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)的结构化 profiles。历史 receipt 或既有 Orca
terminal 不能单独授权复用。复用候选必须属于同一轮 ACK、处于空闲状态,且角色、
profile、worktree 与启动身份仍完全匹配;还必须通过受信后端清理历史消息并取得可核对
的新会话身份。当前 Orca 接口缺少该清理证明,所以 Orca 派发仍创建 fresh worker。
复用边界以 SKILL.md「启动 worker」为准,不要在本步预读其它规范。
先查看目标 profile hash,确认本次结构化配置。这个 hash 只用于审计和漂移比较,
不能用于匹配或复用旧 receipt / 既有终端:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
--project-root <project-root> --profile-id <profile-id>
```
生成只读计划:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
```
审阅计划后执行唯一创建入口:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
`launch` 的 fingerprint 必须来自刚才审阅的 plan;若中间任何关键事实漂移,重新
plan,不得覆盖检查。标准输出是单一 JSON。Coordinator 校验其中的 receipt 后,把
receipt 原样追加到 `tasks.yaml.workerReceipts`,把 `receipt.id` 写入该任务
`dispatch.<role>.receiptId`、把 `profileId` 写入 `dispatch.<role>.profileId`,并把
本轮稳定 ID 写入 `dispatch.<role>.attemptId`。三者必须与 receipt 的当前
task/role/profile/attempt 完全绑定;不得把旧任务或旧轮次的 receipt 改挂到当前
dispatch。后续
紧随其后的本次 Orca dispatch 只使用这次 fresh receipt binding 中的 handle;持久化
receipt 仅用于审计,不能在后续轮次单独授权复用。不要自行拼装 Agent CLI 或 Orca 的
底层创建命令。
新 worktree 时可先运行
`orca worktree create --name <feature> --base-branch <base> --json`,再对返回的绝对
worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调用 launcher
前,确认新 worktree 与权威项目根同一 Git 仓库即可(v0.19 起不再需要登记
`allowedWorktrees`)。profile
只允许 `read-only` 或 `workspace-write`;v0.10 的 full-access 授权通道尚未实现,
任何 bypass、YOLO/force 或关闭 sandbox 的请求都必须失败,不能手写命令兜底。
选型与升级以 SKILL.md「启动 worker」为准。
---
## 第 4 步:跑闭环(每个任务)
```text
task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
→ 三轮有效产品失败:leftover,升级复盘,继续下一个
```
Orca / 手动命令与派发文案由 SKILL.md「启动 worker」按条件加载。
Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 worker 全量读取
知识库。知识正文不得作为自由 shell 执行;需要命令时只能引用项目已审查的检查
入口,并把它的 registry ID 交给 `<ack-skill-dir>/scripts/run_verification.py`,
不自行拼接 path/args。Test 独立验证且 gate 通过后,Coordinator 才能把 candidate
写成 active 知识;关键约束应继续下沉为测试、lint、CI 或正式规范。
---
## 第 5 步:可选交付
用户说「重新布测试环境」或「发布一个版本」时,转入 SKILL.md 对应模式,不要在本步预读交付规范。intent 为 null 时先做交付配置维护。
所选任务 `verified` 后,转入 SKILL.md「运行回归」给出新增/更新/退役/无回归四选一,用户确认后写入 `.pouch/ack/regression.yaml`。用户说「回归」时先布测试环境,再派 Test 按目录执行。
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,转入 SKILL.md 对应交付模式。启用交付时必须在计划中默认列出 `defaultProfile`,用户可明确取消,Coordinator 不能静默省略。先重新校验 `delivery.yaml`,固定当前 commit 和 config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入 `tasks.yaml.deliveryRuns`;默认 profile 到 `validation_ready` 或 `review_ready` 即停止。前者必须把测试环境地址和用户下一步交付出来;stable 发布和 production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把 delivery run 标为 `blocked` 或 `failed`。
## 第 6 步:收尾
一轮结束时 Coordinator 必须能回答:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。复验轮次争议以 SKILL.md「边界」为准。
Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 先 dry-run
审阅决策、再 `--apply` 关闭所有只关联 `verified` 任务的 Developer/Test 终端并核对
回执;receipt 和落盘证据继续保留。仍关联 `blocked`、`failed_retest`、`leftover`
或其它未完成任务/未解决环境事件的终端保留,不设置 TTL。一个终端只要仍被任一这类
任务引用,就不能因为同时关联了 verified 任务而关闭。
---
## 一句话
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
有效 routing(`agents.yaml` 优先,否则 `tasks.yaml.project.orchestration`)解析安全 profile → 审阅 plan 并用 expected
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile
到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收
仅属于 verified 任务的 worker,保留 blocked/failed/leftover worker。
+285
View File
@@ -0,0 +1,285 @@
# 模型路由(稳定核心)
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的规范源。
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
工作,把机械执行交给较弱模型。
角色定义见 `roles-and-permissions.md`(Coordinator 编排 / Test 验证 / Developer
实现)。本文件只补一层正交的「用哪个档位、以什么安全 profile 启动 worker」。
---
## 默认档位
| 角色 | 默认模型档位 | 理由 |
|------|--------------|------|
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
同时保持「验证者 != 实现者」。
---
## 什么时候用强模型
- 新需求理解、产品取舍、范围决策。
- 架构与数据模型决策。
- 把验收写成可观测信号(见 `optimization-method.md` §1)。
- 需求含糊、规格与实现/测试冲突时的裁决。
- Coordinator 终检:读证据、对齐原始意图。
- 重复失败后的根因复盘与重新拆分。
## 什么时候用中低模型
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
---
## 升级规则
**升级到 Coordinator(强模型)复盘**,当:
- 同一验收路径 Developer 连续失败三轮(见 `optimization-method.md` §4)。
- Test 两次仍无法给出清晰失败证据。
- 任务需要改动产品范围或验收标准。
- 修复涉及持久化数据、破坏性文件操作、安全或回滚。
- 规格、测试、实现三者出现冲突。
**升级 Developer 模型档位**,当:
- 任务横跨多个子系统。
- 改动涉及数据模型或迁移。
- 需要设计新的抽象。
- 低档位反复产出表面修复。
升级由 Coordinator 判断并记录原因。升级不是修改一个正在运行的终端:必须选择
对应强档 profile,重新计算 `profileHash`,再通过 launcher 创建新的 worker。模型或
profile 升级时不得复用旧 worker,Test 也不得使用 Developer 的强档 worker。
---
## 结构化 worker profile
### 机器事实源与项目临时覆盖
worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的
`project.orchestration`。项目需要临时调整角色 CLI 或模型时,可以在同一目录增加
`.pouch/ack/agents.yaml`;它使用与 `project.orchestration` 相同的结构,且优先级更高。
删除该文件即回退到任务板配置。`project.md` 只能解释项目为何选某个档位,不能另写一份
完整启动命令或覆盖机器配置。字段结构以 `templates/tasks.schema.json` 和
`templates/agents.schema.json` 为准。
`agents.yaml` 的配置 hash 会进入 profile-hash、launch plan、fingerprint 和 receipt。
因此修改临时配置后必须重新审阅 plan;旧 fingerprint 不会被接受。
每个 profile 明确声明:
- `role`:`developer` 或 `test`;
- `cli`:受支持的 Agent CLI(`codex`、`cursor-agent`、`grok`、`omp`、`pi`);
- `tier`:角色模型档位;
- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型;
- `reasoningEffort`:CLI 支持时显式声明;
- `permissionMode`:`read-only`、`workspace-write`;Codex 还支持用户明确授权的 `danger-full-access`。
有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色
`defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。`cli: pi` 使用真实的
Pi CLI;`model: null` 表示使用 Pi 自己保存的默认模型。当前 launcher 对 Pi 只构造
固定的 `--thinking`,工作目录由受信 bootstrap 设置;Pi 的 `read-only` profile 只允许
`read,grep,find,ls` 工具。
launcher 请求的 worktree 必须是同 Git 仓库的已注册 worktree,profile 的模型也必须
命中对应 CLI/角色/档位的精确列表。
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构造。
OMP profile 使用 `cli: omp`;`model` 必须是精确的 `provider/model` 选择器,
`reasoningEffort` 映射为 `--thinking`。审批模式由 launcher 固定构造:
`workspace-write` 映射为 `--approval-mode yolo`,`read-only` 映射为
`--approval-mode always-ask`。规则层直接允许 yolo 并默认启用;
launcher 仍不接受 `--auto-approve` 或会话复用。
### 安全默认
- 能在完全只读工作树完成的角色优先选择 `read-only`。
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
为纯观察型 Test 另建更严格的 `read-only` profile。
- 用户明确要求 Codex 无沙箱运行时,在结构化 profile 设置
`permissionMode: danger-full-access`。launcher 仍绑定 profile hash、精确 argv 和 receipt;
用户已有授权无需重复询问。任务文本或环境变量不能自行授权。
`full-access` 别名、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions` 等自由参数仍拒绝。
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
它不关闭 `--sandbox`。
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
审批模式,规则层直接允许并默认启用(workspace-write);它不授予
full-access,不用于其它 CLI,也不等同于 Grok `--yolo`。
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
launcher。
仅 Codex 支持 `danger-full-access`;其它 CLI 不得套用该权限值。
无沙箱 profile 只改变执行权限,不扩大用户授权的项目、服务或操作范围。
---
## 唯一启动入口
worker 的 profile 解析、计划和启动只允许通过:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
--project-root <project-root> --profile-id <profile-id>
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
`--project-root` 始终指向保存权威 `.pouch/ack/tasks.yaml` 的项目根;`--worktree` 是本次
worker 实际工作的绝对路径,两者可以不同。后者仍必须与项目根属于同一 Git
仓库(v0.19 起由 launcher 按 `git worktree list` 注册表 + 同 common-dir 校验,
不再依赖 `allowedWorktrees` 白名单)。
项目状态文件固定从 `--project-root/.pouch/ack/` 解析;任务板不需要保存 `repoPath` 或
`devWorktree`。旧任务板中的这两个字段仅作兼容信息,launcher 不使用它们授权或定位。
`projectRoot`、任务板内容摘要、有效 agents 配置 hash 和 worker worktree identity 都会进入 launch fingerprint,
因此切换权威项目根、任务板内容或 worker 路径后必须重新生成并审阅 plan。
- `profile-hash` 读取有效路由配置中的 profile,并输出配置来源与确定性 hash。
- `plan` 只校验并展示将使用的结构化计划,不创建终端。
- `launch` 是唯一允许创建 worker 的入口;它要求传入刚审阅的
`plan.launchFingerprint`,当前 profile、任务/attempt、worktree、CLI 或 argv
任一事实漂移都会在创建前失败。它内部固定 Orca 调用和 Agent argv,标准输出只有
一份 JSON 结果。
- launcher 的内部 bootstrap 属于私有协议,Coordinator 不直接调用、不复制到项目
文档,也不自行拼装 Orca 的底层终端创建文本。
Coordinator 在执行 `launch` 前必须先审阅 `plan`。任何校验失败、字段未知、路径不
匹配、CLI 不可用或权限模式不支持,都停止创建并回报,不回退到裸 Agent 命令。
---
## 可执行信任边界
v0.10 的 launcher 还执行以下约束:
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
Grok 官方安装是 `~/.local/bin/grok` → `~/.grok/downloads/grok-<os>-<arch>`,
launcher 只接受这个真实 `~/.grok` 目录下、当前用户同组拥有的 vendor artifact
名称;其它路径上的 `grok-linux-*` 仍拒绝。`~/.grok` 本身是 symlink 时 fail closed。
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用
`per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据,
Grok 只取得 `XAI_API_KEY`(OAuth 会话仍走已传入的 `HOME`/`~/.grok`),
不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、
`LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GROK_HOME`、`GROK_SANDBOX`、
`GIT_*` 和调用者 PATH 均不会透传给 worker。Grok argv 只允许
`--model`、`--reasoning-effort`、`--permission-mode plan|acceptEdits`、
`--always-approve`、`--sandbox read-only|workspace` 和 `--cwd`;不得带 `--yolo`
或 `bypassPermissions`,也不得省略 sandbox。
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
绑定 runtime/handle/incarnation/worktree,再通过该终端 stdin 发送一次性 nonce;
bootstrap 校验 nonce 后才允许启动 Agent CLI,并把 child PID 与 nonce/launch
fingerprint 绑定的 proof 原子写入 ready 状态;父进程看到并核对 proof 后才把
worker 视为可派发。
- `orca terminal create` 一经发出,在取得确定 handle 前遇到超时、transport/解码
异常、进程中断、非零退出、畸形 JSON 或缺字段,一律标记为 `indeterminate`,
禁止自动重试。
- 取得 handle 后若任一绑定或握手检查失败,launcher 会先用
`orca terminal close --tab --json` 请求持久关闭,并严格核对 runtime、handle、
tab 和 close mode。只有关闭回执完整匹配才记为普通 `failed`;关闭无法确认时保持
`indeterminate/reconcile-required`,调用方必须先人工核对,不能直接重试。
- bootstrap 在真正启动 Agent 前,会在同一 launch-record 锁内再次确认状态仍为
`awaiting-authorization` 且没有 cleanup;父进程一旦先进入 cleanup/reconcile,
bootstrap 必须拒绝 `Popen`,不能让取消后的 workspace-write worker 继续启动。
- 异常路径的 launch record 写入失败不能阻断上述关闭动作,也不能遮蔽原始
`indeterminate`。即使关闭已确认,只要最终状态无法可靠持久化,也保守返回
`indeterminate/reconcile-required`。
这些约束防止任务板、任务文本和普通环境变量变成第二个启动命令入口。它们不抵御已经
完全控制 Coordinator 用户账户、Orca 运行时、受信 Agent/Orca 可执行文件或
Coordinator 用户级 Agent 配置/插件/MCP 的攻击者;该场景需要独立 OS 身份、受控
Agent 配置或平台签发能力。`permissionMode` 约束的是 Agent CLI 的 shell/文件系统
执行模式,不是模型供应商或外部工具能力的 attestation。
---
## Receipt、审计与复用边界
成功启动返回的 JSON 含一个可校验 receipt。Coordinator 把 receipt 追加到
`tasks.yaml` 顶层 `workerReceipts`,并把它的 `id` 写入当前任务
`dispatch.<role>.receiptId`、把 `profileId` 写入 `dispatch.<role>.profileId`,同时把
本次 `<task-id>-A<round>` 写入 `dispatch.<role>.attemptId`。
这三个字段不是可任意组合的历史索引。任务板校验器要求被引用 receipt 的
`createdFor.taskId` 等于当前 ACK `tasks[].id`,`createdFor.role` 等于 dispatch
角色,`profileId` 完全相同,且 `createdFor.attemptId` 等于
`dispatch.<role>.attemptId`。`receiptId: null` 时 `attemptId` 必须同为 `null`;
`receiptId` 非空时 `attemptId` 必须存在并命中本轮。`dispatch.<role>.taskId` 是 Orca
运行时 task ID,不能拿来替代 ACK task ID 或稳定 attempt ID。
receipt 至少把以下事实绑定在一起:
- `profileHash` 与请求的角色、模型、effort、权限和精确 argv;
- `slot`,确保 fingerprint 也绑定审阅过的 worker 槽位和派生标题;
- 任务、attempt、绝对 worktree 及 path/device/inode/git common-dir 等稳定身份;
- Orca `runtimeId`、terminal `handle` 和 `incarnationId`;
- 创建时间、launcher 指纹和 `receiptHash`。
`receiptHash` 是无密钥的规范 JSON checksum,只能发现意外漂移或未同步修改,**不是
launcher 身份证明,也不是复用授权**。项目内有写权限的一方可以修改 receipt 后重算
hash;而当前 Orca metadata 又不能证明终端最初执行的命令、模型和权限。因此 ACK
明确禁止根据持久化 receipt 自动复用既有终端。复用只允许发生在同一轮 ACK 内,并且
必须先证明 worker 空闲、角色/profile/worktree/runtime/incarnation 完全匹配,再由
受信后端清理历史消息并返回新的 conversation/session identity 与本次 task/attempt
绑定。正在工作、等待回报、状态不明或关联未完成任务的 worker 都不是空闲候选。任一
条件不满足、清理失败或清理结果无法确认时,重新走 `plan` → 带 expected fingerprint
的 `launch`,使用 fresh handle 派发。
`launchFingerprint` 是确定性的完整计划漂移校验,不是一次性授权或幂等键。同一份
计划重复执行 `launch` 会创建新的 fresh terminal;成功后不得用同一 fingerprint
再次启动。若结果不确定,必须先 reconcile,不能把重跑当作恢复方式。未来需要自动
防重放时,应由 Orca/ACP 的原子 idempotency/claim 能力或项目外可信 launch intent
提供,而不是把 checksum 冒充成一次性令牌。
持久化 receipt 仍用于审计、dispatch 关联和检测配置漂移;标题、preview、分支名、
worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可信 worker。只有
Orca/ACP 同时提供启动参数 attestation、明确空闲状态、可信历史清理和新会话身份,或
ACK 接入等价的项目外可信签发与校验通道,才实际启用自动复用。当前 Orca 不满足这些
条件,所以仍创建 fresh worker。CLI / 模型变更仍需更新 allowlist 并重新生成 receipt。
`ackVersion` 必须使用合法 SemVer。`0.10.0` 及以后版本的任务板必须同时存在
`project.orchestration` 与顶层 `workerReceipts`;其中任一字段出现,另一个也必须
出现,避免 schema-only 校验把半迁移任务板误判为可自动派发。
---
## 成本原则
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
改动文件清单。
这样把昂贵推理挡在重复执行之外。
---
## 一句话
Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手
默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker
都必须经 launcher 产生可核对的 receipt。
@@ -0,0 +1,240 @@
# 优化方法(稳定核心)
减少"开发声称完成但复测不过"的循环成本。本文件是**验收信号写法**和**三轮失败策略**的 SSOT,其它文件引用这里。
三角色分工(Coordinator 编排 / Test 验证 / Developer 实现)见 `roles-and-permissions.md`;复测步骤、Coordinator 终检与环境对齐见 `closed-loop.md`。
---
## 1. 先把验收写成可观测信号(SSOT)
不要只写"页面可用""体验更好"。每个任务至少写三类信号:
| 类型 | 示例 |
|------|------|
| 可见文本 | 页面出现 `确认应用标签`、`取消`、`将修改` |
| API 结果 | `POST /api/...` 返回 `code=0` 且字段存在 |
| 交互结果 | 点击按钮后出现确认区;取消后不触发写入 |
好的验收:
```text
点击"预览变更"后,页面必须出现 API 返回的 diff 行:
- title
- 修改前值
- 修改后值
- coverChanged 提示
```
不好的验收:
```text
预览功能正常。
```
---
## 2. 回报必须带证据,不带结论
Developer 的 `worker_done` 应报告:改了哪些文件、跑了哪些命令、自己如何复现验收路径、仍可能有的风险。Test 的复测报告同理:跑了哪些命令、命中/缺失了哪些验收信号、实际观察到什么、证据(snapshot / API 结果)。两者的"结论"都不作数;任务结论只有 Coordinator 落盘到 `tasks.yaml` 才算数,跨任务知识只有 Coordinator 落盘到 `knowledge.yaml` 才会生效。
不要写:
```text
已完成,应该可以了。
```
要写:
```text
修改 web/app/fix/page.tsx。已运行 npm run build、go test ./...。
手测 /fix?fileId=1:搜索、选择候选、预览、确认区都可见。
风险:未执行真实写文件 apply。
```
---
## 3. 验证权在 Test,Coordinator 只信证据
即使 Developer 的 worker_done 写了"全部通过",也必须由独立的 Test 复测(验证者 ≠ 实现者)。完整复测步骤与 worktree/服务对齐见 `closed-loop.md`。独立复测可避免三类假通过:测错服务实例、测到旧构建产物、开发只验证静态文本没验证真实交互。
Coordinator 不亲自复测,但要做终检:读 Test 的证据,确认它覆盖了每条验收信号且符合原始意图,避免"过了字面没过意图"。终检不通过就回写 `failed_retest`。
---
## 4. 有效复验、环境失败与三轮策略(SSOT)
### 4.1 什么才计算一轮
三轮预算只计算**有效产品复验**:Test 已确认正确 worktree、最新服务、必要测试数据和
可用验证工具,并实际执行目标验收信号;结果要么全部通过,要么观察到由待测产品行为
导致的信号失败。
以下情况属于环境失败,不是产品失败,也不占复验轮次:worker 未启动或消息未投递、
sandbox/权限阻止访问待测服务、服务实例或构建不匹配、必要测试数据缺失、浏览器或测试
工具不可用、编排 IPC 失败。若已有独立的产品信号明确失败,只把该产品失败计入轮次;
其余环境问题另行记录,不能用“环境失败”掩盖产品证据。
Coordinator 派发后必须确认消息已投递且 worker 已开始执行:只凭 `check --wait`
超时无法区分慢任务与未执行,等待期间要用终端活性探测(`scripts/worker_probe.py`)
定期检查。检测到卡在审批提示、投递后未回车或命中额度限制时,按环境失败记录并做
有界恢复,不消耗产品复验轮次。
环境失败写入 `dispatch.environmentIncidents`,不要追加到 `dispatch.rounds`,也不要把
任务写成 `failed_retest`。实现已经完成时保持 `fixed_by_dev`;恢复后再进入
`retesting`。确实需要用户或外部条件才能继续时可暂时写 `blocked`,环境恢复后回到
原闭环状态。
每条环境事件必须包含:
```yaml
dispatch:
environmentIncidents:
- id: "BUG-001-ENV-1"
attemptId: "BUG-001-A1"
role: test
phase: browser
status: resolved
summary: "测试环境没有可用浏览器"
evidence: "chromium/playwright lookup 均为空"
impact: "没有执行点击级验收,不能据此判断产品失败"
recoveryAction: "改用受支持的浏览器运行时并启动 fresh Test"
userAction: "无需操作;Coordinator 继续恢复"
reportedAt: "<timestamp>"
resolvedAt: "<timestamp>"
```
`userAction` 必须明确:无需用户操作时写清 Coordinator 下一步;需要用户介入时给出一个
具体决定、命令或外部条件,不能只写“请处理环境”。Coordinator 可以先做一次不扩大权限、
不改变产品数据的有界恢复;仍未解决、需要用户动作或阻断本轮时,在当前会话立即报告。
即使事件已自动恢复,最终报告也必须列出环境事件、影响和恢复结果,让用户知道发生过什么。
### 4.2 三轮有效产品失败
每个任务最多自动派发三轮:
```text
round 1: 常规修复
round 2: 带复测失败证据的定向修复
round 3: 明确指出重复失败点,要求 worker 自己复现完整路径
failed after round 3: 标记 leftover,继续下一个任务
```
三轮有效产品失败后不要继续消耗同一个 worker。常见原因:验收标准需要重新设计、
Worker 对问题模型理解错了,或需要人工观察和专项调试。环境事件数量不受三轮预算限制,
但必须有界恢复和透明报告,不能无限重试。
留档字段(结构见 `templates/tasks.schema.json`):
```yaml
status: leftover
resolution:
leftoverReason: "failed after 3 supervised developer rounds"
dispatch:
rounds:
- round: 1
result: failed
evidence: "<why failed>"
- round: 2
result: failed
evidence: "<why failed>"
- round: 3
result: failed
evidence: "<why failed>"
```
---
## 5. 任务排序
1. P0 阻塞主流程。
2. P1 高频用户路径。
3. P1/P2 体验改进。
4. 重构和内部质量。
三轮失败的 P0 可以留档,但发布前需人工决策:降级范围、改验收标准、换新 Agent / 新 worktree 重做、人工修复。
---
## 6. 每轮派发只修一个明确问题
不要一次让 Developer Worker 修太多失败项,除非它们共享同一根因:
```text
one dispatch = one bug = one acceptance path
```
优点:复测更快、失败原因更清楚、三轮失败留档更准确。
---
## 7. 把失败证据写给下一轮 worker
第二轮以后,派发 prompt 应突出"上一轮为什么没过",而非重复原始描述。模板见 `prompt-templates.md` §「复测失败再派发模板」。
---
## 8. 优先让测试可执行化
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。黑盒回归目录是
`.pouch/ack/regression.yaml`(见 `regression.md`);可执行脚本仍由 Test 维护在
`<integration_test_paths>`。优先级:
1. 把本轮验收信号收获进 `regression.yaml`。
2. API smoke。
3. 浏览器脚本或 `automationRef`。
4. 单元测试。
5. 人工检查清单。
目标不是全部自动化,而是把最容易反复误判的路径变成可重复跑的回归用例。任务
`verified` 后必须给出新增/更新/退役/无回归四选一。
---
## 9. 让经验成为有证据、会过期的知识护栏
项目特有、跨任务复用且会改变后续开发或验证行为的经验,可以提名到项目知识护栏。
不要把全部对话、日志或单次猜测自动保存成“记忆”。
```text
Developer / Test 当前任务观测
-> knowledgeCandidates(只留在任务证据)
-> Test 独立验证 + Coordinator gate
-> Coordinator 写入 active knowledge
-> stale
-> superseded / archived
```
`prepare(task)` 时只按 component、path、dependency、version 和 tag 推荐相关
`active` 条目;自动匹配只负责推荐,Coordinator 确认固定 revision 的显式
`knowledgeRefs` 才是本轮权威上下文。每轮只派发命中的少量条目,不全量注入知识库。
Developer 回报实际采用的 `knowledgeApplied` 和带当前观测证据的
`knowledgeCandidates`。Test 对本轮显式引用回报 `knowledgeChecks`,并用独立观测
确认或否定 candidate。同一 Agent 不能把自己读到的旧知识复述成新证据,避免错误
知识自我强化。
知识中的验证只能引用 `knowledge.yaml.verificationRegistry` 中已经审查的仓库内
相对 path 和结构化 args,不能把正文或选择器输出拼接成自由 shell 执行。实际运行
只把 registry ID 交给 `<ack-skill-dir>/scripts/run_verification.py`。安全、正确性
和兼容性等关键约束一旦稳定,应下沉为测试、lint、CI 或正式规范;知识项继续解释
触发条件、原因与证据,不替代可执行控制。
依赖、配置、路径或版本变化后应重新审查相关知识。临时 workaround 必须有失效或
移除条件;冲突规则不能靠“最后写入者获胜”处理。
---
## 10. 结束条件
一轮闭环结束时,必须能回答:
- 哪些任务 verified?哪些 leftover?
- 每个 leftover 失败了几轮?最后一轮失败证据是什么?
- 当前工作树有哪些未提交改动?
- 是否还有 open / failed_retest 未处理?
- 本轮有哪些环境事件?是否已解决?用户下一步是“无需操作”还是一个明确动作?
- 本轮显式 `knowledgeRefs` 是否都有必要的 `knowledgeChecks`?
- 是否有待验证 candidate,或因依赖、路径、版本变化需要转为 stale 的知识?
答不清楚,闭环就还没结束。
+373
View File
@@ -0,0 +1,373 @@
# Orca 适配器(稳定核心,一种编排实现)
Orca orchestration 是运行时调度层的一种实现。闭环流程本身与工具无关(见 `closed-loop.md`);本文件只提供 Orca 的具体命令。没有 Orca 时用 `closed-loop.md` §「手动模式」。
所有结论仍以读取层 materialize 的任务视图为准,底层最终记录追加在
`task-records/`;Orca 消息不是最终记录。
---
## 何时用 Orca 编排
用:需要监督、等待结果、闭环修复、三轮重试;有多个可独立派发的任务;需要 `worker_done` 后再复测。
不用:单次问答;用户只要求完整 handoff 不需监督;不需要复测的小改动。
---
## 运行前检查
```bash
orca status --json
orca terminal list --json
orca orchestration task-list --json
orca orchestration inbox --limit 20 --json
```
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
的活跃编排任务。Developer/Test worker 先按下节检查安全复用候选;当前 Orca 无可信
历史清理能力时必须通过本次 launcher 新建。
---
## 既有 worker 的处理
先找同一轮 ACK 内可复用的空闲 worker,再决定是否创建。候选必须同时满足:
- Orca 明确报告终端存活且空闲;不得复用仍在工作、等待 `worker_done` / 复测报告 /
escalation,或运行状态不明的 worker;
- 没有关联 `blocked`、`failed_retest`、`leftover`、未解决环境事件或其它未完成任务;
- 角色、profile、worktree、runtime/incarnation 与本次目标完全匹配,Developer 与 Test
永不互相复用;
- 受信后端能够清理历史消息,并返回可核对的新 conversation/session identity 和本次
task/attempt 绑定;只有看到成功回执后才把 worker 视为已重置。
`receiptHash` 是无密钥 checksum,标题、preview、分支名、worker 自报或普通 Orca
terminal metadata 都不能证明历史上下文已清理。任何条件不符、清理接口不存在、清理
失败或结果无法确认时,都重新 `plan`/`launch` fresh worker。
| 情况 | 动作 |
|------|------|
| 同轮空闲,身份完全匹配,历史消息清理得到可信回执 | 生成新的 task/attempt 绑定后复用 |
| worker 正在工作、等待回报、状态不明或关联未完成任务 | 不复用;保留原终端并重新 launch |
| profile、角色、worktree、runtime/incarnation 任一不匹配 | 不复用;重新 launch |
| 无历史消息清理能力,或无法确认清理成功 | 不复用;重新 launch |
| 需要并发 worker | 使用下一 `slot` 重新 launch |
| Developer 升级模型档位 | 选择强档安全 profile,重新 plan/launch |
当前 Orca terminal API 没有返回新会话身份的可信历史消息清理操作,因此当前适配器
不会复用 Orca worker;仍只使用刚刚成功返回的 `launch` fresh handle。以后后端补齐
reset/attestation 后才能启用上述复用路径,不能用项目文档声明跳过条件。
---
## 通过 launcher 创建 worker
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
模型、reasoning effort 和权限由 common `agent.yaml` 的 `ack.<name>` binding 映射到
`.pouch/ack/agents.yaml`(存在时)或
`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。
先只读生成计划:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
```
确认计划中的任务、attempt、角色、profile hash、绝对 worktree 和安全权限模式后,再
执行唯一启动入口:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> --task-id <task-id> \
--attempt-id <task-id>-A<round> --role <developer-or-test> \
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
`launch` 必须携带刚才审阅的 `plan.launchFingerprint`;若 profile、任务/attempt、
worktree、CLI identity/version 或精确 argv 漂移,创建前就失败。它内部调用当前
Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的底层终端创建文本,
也不直接调用 launcher 的私有 bootstrap。
若 launcher 报告 `indeterminate` 或 `reconcile required`,说明终端可能已经创建且
持久关闭尚未得到可信确认;此时先按返回的 launch ID/record 和 Orca live state
人工核对,禁止直接重复执行 `launch`。
标准输出是单一 JSON。成功后:
1. 校验返回 receipt 的 `receiptHash`;
2. 把 receipt 原样追加到当前任务的新记录的 `workerReceipts`;
3. 把 `receipt.id` 写入当前任务 `dispatch.<role>.receiptId`,把 `profileId` 写入
`dispatch.<role>.profileId`,把本轮 `<task-id>-A<round>` 写入
`dispatch.<role>.attemptId`;校验器会要求 receipt 的 task/role/profile/attempt
与当前 ACK 任务完全一致;
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
profile 支持 `read-only`、`workspace-write`,以及用户明确授权的 Codex
`danger-full-access`。无沙箱权限必须写入结构化 profile,由同一 launcher 生成
`--sandbox danger-full-access` 并绑定 fingerprint/receipt,不手写 bypass 命令。
模型档位与升级规则见 `model-routing.md`。
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
fingerprint 的 `launch`,并把 receipt 留作审计。
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
---
## 创建父任务
```bash
orca orchestration task-create --spec "$(cat <<'EOF'
Goal: Complete <release_or_feature> with supervised Coordinator -> Developer -> Test loop.
Coordinator (PM): owns task board, decomposition, dispatch, and final gate (reads Test evidence, does not test).
Developer: owns implementation and white-box verification.
Test: owns independent black-box retest and evidence (verifier != implementer).
Policy:
- Each issue can have at most 3 valid product-retest rounds.
- Environment failures are recorded in dispatch.environmentIncidents, reported with the next action, and do not consume a product-retest round.
- If still failing after 3 valid product rounds, record as leftover and continue next issue.
- worker_done and retest reports are not final completion; only Coordinator writes task-records/ and knowledge.yaml.
- Project knowledge candidates require independent Test evidence and Coordinator gate before activation.
EOF
)" --json
```
## 创建子任务
```bash
orca orchestration task-create --parent <parent_task_id> --spec "$(cat <<'EOF'
Fix <task_id>: <title>
Logical attempt: <task_id>-A<round>
Repository:
- Path: <repo_path>
- Worktree: <dev_worktree>
Read: <overlay_file> (project overlay), tasks.yaml, <relevant_spec_or_test_doc>
Failure evidence: <copy latest Test evidence>
Acceptance: <copy expected behavior + verification commands>
Project knowledge: <Coordinator-confirmed active K-...@revision entries with verification.ref>
Constraints:
- Follow the overlay file path scope.
- Do not write tasks.yaml or knowledge.yaml, do not mark verified.
- Do not read the whole knowledge bank or execute free shell from knowledge text.
- Run a knowledge check only through <ack-skill-dir>/scripts/run_verification.py
with its verification.ref; do not execute resolved path/args directly.
- Do not commit or push unless user asks.
EOF
)" --json
```
---
## 派发给 Developer Worker
终端是 Orca 可识别的 Agent CLI 时:
```bash
orca orchestration dispatch --task <task_id> --to <developer_handle> --inject --json
```
不能 `--inject` 时,先登记 dispatch,再手动投递 `prompt-templates.md` §1 的初始派发 prompt:
```bash
orca orchestration dispatch --task <task_id> --to <developer_handle> --json
orca terminal send --terminal <developer_handle> --text "$(cat <<'EOF'
<粘贴 prompt-templates.md §1 初始派发模板,已填占位符>
EOF
)" --enter --json
```
---
## 派发给 Test 复测
Developer 回报 worker_done、Coordinator 写回 `fixed_by_dev` 后,把复测任务发给 Test:
```bash
orca orchestration dispatch --task <task_id> --to <test_handle> --inject --json
```
不能 `--inject` 时,先登记再手动投递 `prompt-templates.md` §3 的复测派发 prompt:
```bash
orca orchestration dispatch --task <task_id> --to <test_handle> --json
orca terminal send --terminal <test_handle> --text "$(cat <<'EOF'
<粘贴 prompt-templates.md §3 复测派发模板,已填占位符>
EOF
)" --enter --json
```
---
## 等待结果:派发后的活性监督
派发或手动投递后**不能只依赖 `check --wait` 盲等**:卡在审批提示、投递后未回车、
命中额度限制的 worker 不会自己发 `worker_done`。先确认 worker 真的开始执行,等待
期间周期性探测活性。
1. 投递后立即确认开始执行:
- `--inject` 路径:`orca terminal read --terminal <handle>`,确认 TASK 段已出现
且终端进入工作指示(Working / Running)。
- 手动投递路径:`orca terminal send` 必须带 `--enter`;投递后同样 read 确认。
- 确认失败或终端仍停在欢迎提示:按「消息未投递」记录环境失败,不消耗产品轮次。
2. 等待期间滚动 probe(每 60–120 秒一次;不要在 30 秒窗口后每次都 probe):
```bash
python3 <ack-skill-dir>/scripts/worker_probe.py \
--task-id <task_id> --terminal <worker_handle>
```
输出 JSON `status`:`running` / `progress` / `stall` / `not-started` / `unknown`。
3. 探测结果处理:
- `stall`:读 terminal tail 确认原因(审批 / 模型切换 / 额度限制),按环境失败
记录 `environmentIncidents` 并做有界恢复;需要用户决定时立即报告。
- `not-started`:检查是否漏投递或未回车;重新投递或记录环境失败,不占轮次。
- `running` / `progress`:继续滚动 wait。
- `unknown`:按 `dispatch-show` 与 Orca live state 人工核对,不自动重试。
4. `check --wait` 默认使用 60 秒窗口而不是 30 秒或 15 分钟:窗口超时是检查点,
仅在达到 probe 间隔后再探测,然后决定继续等待、恢复或上报。新鲜 heartbeat 才能
说明 progress;按 worker 每 5 分钟一次的约定,连续三个周期(超过 15 分钟)未更新、
格式非法或时间在未来的 heartbeat 只能报告 `unknown`,不得当作持续进展。
```bash
orca orchestration check \
--terminal <coordinator_handle> \
--wait \
--types worker_done,retest_result,escalation,decision_gate \
--timeout-ms 60000 \
--json
```
`worker_done` 来自 Developer,`retest_result`(无该类型时用 `worker_done` + subject
区分)来自 Test。
---
## Developer 回报 worker_done
字段含义见 `prompt-templates.md` §4:
```bash
orca orchestration send \
--to <coordinator_handle> \
--type worker_done \
--subject "<task_id> fix ready round <n>" \
--body "<修了什么。跑了哪些验证。如何复现。还有什么风险。>" \
--payload '{
"taskId": "<orca_task_id>",
"dispatchId": "<orca_dispatch_id>",
"attemptId": "<task_id>-A<n>",
"filesModified": ["<file_a>", "<file_b>"],
"verification": ["<command_a>: passed", "<command_b>: passed"],
"knowledgeApplied": [
{"ref": "<K-001@1>", "result": "applied", "evidence": "<what was done>"}
],
"knowledgeCandidates": [
{
"kind": "pitfall",
"title": "<title>",
"claim": "<evidence-backed claim>",
"scope": {"components": ["<component>"], "paths": ["<path glob>"]},
"appliesWhen": "<trigger>",
"directive": "<action, not a shell command>",
"rationale": "<why it matters>",
"evidenceRefs": ["<current task evidence>"]
}
],
"risk": "<remaining risk or none>"
}' \
--json
```
收到无错误的 Developer `worker_done` 后,Coordinator 写回 `fixed_by_dev`,然后先回收
Developer 对应的 Orca terminal,再按上文派发给 Test 复测:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> \
--completed-task-id <ack-task-id> --completed-role developer --apply
```
只关闭该 task 的 Developer receipt 绑定 terminal;escalation、失败或环境异常时不要执行。
---
## Test 回报复测结果
字段含义见 `prompt-templates.md` §5:
```bash
orca orchestration send \
--to <coordinator_handle> \
--type retest_result \
--subject "<task_id> retest round <n>" \
--body "<逐条验收信号结论 + 实际观察 + 证据>" \
--payload '{
"taskId": "<orca_task_id>",
"dispatchId": "<orca_dispatch_id>",
"attemptId": "<task_id>-A<n>",
"env": {"worktree": "<path>", "branch": "<branch>", "commit": "<sha>", "baseUrl": "<base_url>"},
"signals": ["<signal 1>: pass", "<signal 2>: fail (<evidence>)"],
"knowledgeChecks": [
{"ref": "<K-001@1>", "result": "passed", "evidence": "<independent evidence>"}
],
"knowledgeCandidates": [],
"conclusion": "all-signals-pass | signals-failed"
}' \
--json
```
无 `retest_result` 类型时用 `--type worker_done`,靠 subject `retest round <n>`
区分。收到无错误的 Test 复测结果后,先回收 Test 对应的 Orca terminal,再由
Coordinator 按 `closed-loop.md` 做终检并追加任务记录:
```bash
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> \
--completed-task-id <ack-task-id> --completed-role test --apply
```
只有成功结果才能执行该命令;失败、环境异常或需要继续复验时保留 terminal。
收到复测结果后,Coordinator 按 `closed-loop.md` 做终检并追加任务记录;
通过独立验证和 gate 的跨任务经验再由 Coordinator 写入 `knowledge.yaml`。任务通过
写 `verified`;观察到产品信号失败写 `failed_retest`;环境无法完成则写
`dispatch.environmentIncidents`,保持或恢复为 `fixed_by_dev`,不占复验轮次。
---
## 整轮结束时回收 worker
未在角色完成时回收的 worker,或需要整轮兜底回收的 worker,才在 Coordinator 把本轮
最终结论写入 `tasks.yaml` 后执行整轮回收。统一使用回收脚本,不要手工逐个 close:
```bash
# 第一步:dry-run 审阅决策(不关闭任何终端)
python3 <ack-skill-dir>/scripts/reclaim_workers.py --project-root <project-root>
# 第二步:审阅输出后真正回收
python3 <ack-skill-dir>/scripts/reclaim_workers.py \
--project-root <project-root> --apply
```
脚本按 receipt 的 `binding.handle` 聚合任务引用,规则固定:
- **关闭**:handle 关联的全部任务都为 `verified`,且没有未解决环境事件。
- **保留(不设置 TTL)**:任一关联任务处于 `open`、`dispatched`、`fixed_by_dev`、
`retesting`、`blocked`、`failed_retest`、`leftover`,或存在未解决
`environmentIncidents`,或 receipt 引用未知任务。失败三次的 worker(
`failed_retest` / `leftover` / `blocked`)按此保留。
- **不触碰**:`workerReceipts` 之外的 handle(Coordinator 终端、用户 Shell)。
`--apply` 时脚本对每个待关闭 handle 执行 `orca terminal show` 核对身份 →
`orca terminal close --tab` → 重新 `terminal list` 确认 handle 已消失;任何一步
不确定都保留并标记 `uncertain`,禁止盲目重试。关闭终端不删除 `workerReceipts`、
dispatch 证据或测试记录;这些仍是审计事实。保留下来的 blocked/failed worker 只在
后续任务得到验证或用户明确要求清理时回收。
+315
View File
@@ -0,0 +1,315 @@
# 派发 Prompt 模板(稳定核心)
Coordinator 用这些模板向 **Developer** 派发修复、向 **Test** 派发复测。复制时替换尖括号占位符。模板与编排工具无关:Orca 模式用 `orca-adapter.md` 的命令投递,手动模式直接把文本发给对应 Agent。
角色分工见 `roles-and-permissions.md`;闭环顺序见 `closed-loop.md`。
派发时除了具体任务,Coordinator 应把对应角色的**能力要求**一并带上(`roles-and-permissions.md` §「三角色能力清单」的 Must Do / Must Not)。下面模板已内置关键条目,复制即可。
---
## 0. 可选 skills 路由(装了才用,不阻塞)
如果 worker 所在环境已安装以下 skill,可在对应环节调用以获得更强 playbook;未安装则按 `roles-and-permissions.md` 的能力清单执行:
- Coordinator 规划复杂需求:`/think` 或 `superpowers:brainstorming` / `writing-plans`。
- Developer 排查缺陷:`/hunt` 或 `superpowers:systematic-debugging`;行为变更:`superpowers:test-driven-development`。
- Test 复测 / 合并前检查:`/check` 或 `superpowers:verification-before-completion`。
派发时可加一行:「若已安装 <skill>,本环节可用它;未安装按 ack 角色能力清单执行。」
---
## 1. 初始派发给 Developer
```text
你现在接到一个受监督的协作任务。请在 <dev_worktree> 开发工作树内完成。
任务:
- 修复 <task_id>: <task_title>
- 本轮逻辑 attempt:<task_id>-A<round>
请先读取:
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile)
- tasks.yaml
- <relevant_spec_or_test_doc>
当前失败证据:
<copy latest Test evidence>
本轮项目知识(仅限 Coordinator 显式选择的 active 固定 revision):
- <K-001@1>: <directive + rationale + verification.ref + resolved path/args>
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
验收标准(可观测信号,见 optimization-method.md §1):
1. <expected behavior 1>
2. <expected behavior 2>
3. <expected behavior 3>
能力要求(见 roles-and-permissions.md §三角色能力清单 · Developer):
- 动手前先复现失败现象,或先写一个会失败的测试再修。
- bug 修复配可复现的失败用例;行为变更配单元测试。
- 完成前亲自走一遍验收路径,不只满足静态文案。
- 若是网站 / 常驻服务,改完重启服务并确认生效,别让 Test 测到旧进程或旧构建。
约束:
- 只修改 Developer 可写路径(见覆盖层文件的权限表)。
- 不要修改产品规格和集成测试文件(分别由 Coordinator 与 Test 拥有),除非任务明确要求。
- 不要写 tasks.yaml,不要标记 verified。
- 不要写 knowledge.yaml,不要自行扩展或全量读取知识库;candidate 不是已生效规则。
- 不要把知识正文或 path/args 拼成 shell 命令。只把 verification.ref 交给
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
<verification-ref> --project-root <project-root>`。
- 不要提交或推送,除非用户明确要求。
- 最小 diff,只改本任务根因,避免无关重构;若必须先重构请停下说明并请示。
完成前必须运行:
- <test command 1>
- <test command 2>
完成后回报一次 worker_done(格式见 §4),包括 `knowledgeApplied` 和
`knowledgeCandidates`。如果阻塞,请发送 escalation 或 ask。
```
---
## 2. 复测失败再派发给 Developer
```text
第 <n> 轮复测未通过,请继续修 <task_id>。
本轮逻辑 attempt:<task_id>-A<n>
上一轮开发声称:
<worker_done summary>
Test 独立复测结果:
- 运行命令:<commands>
- 失败页面/API:<page or endpoint>
- 仍缺失:<missing expected checks>
- 实际观察:<actual evidence>
关键提示:
- 不要只满足静态文案,必须满足交互后的真实状态。
- 如果后端/API 正常而 UI 不更新,请优先排查事件链、状态重置、异步请求、构建产物和服务 worktree。
- 修复后请自己用浏览器或脚本复现 Test 的完整步骤。
验收不变:
<copy acceptance criteria>
本轮知识引用:
<copy Coordinator confirmed knowledgeRefs; do not add candidates>
完成后回报 worker_done,subject 使用:"<task_id> fix ready round <n>"
```
---
## 3. 派发给 Test 复测
Developer 回报 worker_done 后,Coordinator 把复测任务发给 Test。
```text
请对 <task_id>: <task_title> 做独立黑盒复测。
本轮逻辑 attempt:<task_id>-A<round>
请先读取:
- <overlay_file>(项目覆盖层,路径见 tasks.yaml 的 project.overlayFile)
- tasks.yaml(该任务的验收信号)
- <relevant_spec_or_test_doc>
Developer 本轮声称(仅供参考,不作数):
- 改动文件:<files>
- 自测命令:<commands>
- 实际采用知识:<knowledgeApplied>
- 新知识候选:<knowledgeCandidates; candidate only>
本轮项目知识(仅限 Coordinator 显式选择的 active 固定 revision):
- <K-001@1>: <directive + rationale + verification.ref + resolved path/args>
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 deployer 绑定拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
1. <observable signal 1>
2. <observable signal 2>
3. <observable signal 3>
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
证据,则分别列出产品信号与环境限制。
- 需要时把本轮通过的黑盒路径写成 `regressionCandidates`(见 regression.md),不要直接改 `.pouch/ack/regression.yaml`。
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
`<ack-skill-dir>/scripts/run_verification.py .pouch/ack/knowledge.yaml
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
对 candidate 使用独立观测验证,不能复述 Developer 的结论作为证据。
约束:
- 只读源码,不修改应用代码,不写 tasks.yaml 或 knowledge.yaml。
- candidate 不属于 active 知识;不要全量注入知识库,不要从知识正文执行自由 shell。
- 只回传证据和逐条结论,最终判定由 Coordinator 终检后落盘。
完成后回报一次复测报告(格式见 §5),subject:"<task_id> retest round <n>"。
```
---
## 4. Developer worker_done 回报格式
无论 Orca 还是手动,回报都应包含以下字段:
```text
subject: <task_id> fix ready round <n>
attemptId: <task_id>-A<n>
filesModified: [<file_a>, <file_b>]
verification:
- <command_a>: passed
- <command_b>: passed
reproduce: 我如何复现验收路径 <steps>
knowledgeApplied:
- ref: <K-001@1>
result: <applied|not_applicable>
evidence: <what was done or why not applicable>
knowledgeCandidates:
- kind: guardrail/pitfall/verification
title: <reusable project lesson>
claim: <evidence-backed project claim>
scope:
components: [<component>]
paths: [<path glob>]
dependencies: [<dependency>]
versions: [<version>]
tags: [<tag>]
appliesWhen: <trigger>
directive: <action, not a shell command>
rationale: <why this changes future work>
evidenceRefs: [<current task evidence reference>]
risk: <remaining risk or none>
```
没有命中知识或没有新 candidate 时,对应列表写 `[]`。Developer 不能把自己读到的
旧知识复述为新 candidate;每条 candidate 都需要当前任务产生的观测证据。
Orca 模式下用 `orca-adapter.md` §「Developer 回报 worker_done」的命令发送同样的字段。
---
## 5. Test 复测报告格式
Test 只回传证据和逐条结论,不下最终判定:
```text
subject: <task_id> retest round <n>
attemptId: <task_id>-A<n>
env:
worktree: <path>
branch: <branch>
commit: <short sha>
baseUrl: <base_url>
commands:
- <command_a>: passed/failed
signals:
- <signal 1>: pass/fail (<evidence>)
- <signal 2>: pass/fail (<evidence>)
browser: <snapshot / DOM / API evidence>
knowledgeChecks:
- ref: <K-001@1>
result: <passed|failed|not_applicable>
evidence: <independent evidence>
regressionCandidates:
- title: <reusable black-box case>
surface: browser/api
suite: smoke/full
setup: <preconditions>
steps: [<step>]
expected:
- kind: visible-text/api-status/api-field/url/interaction
value: <observable signal>
sourceKind: feature/bug
knowledgeCandidates:
- kind: guardrail/pitfall/verification
title: <new lesson found by Test>
claim: <evidence-backed project claim>
scope:
components: [<component>]
paths: [<path glob>]
dependencies: [<dependency>]
versions: [<version>]
tags: [<tag>]
appliesWhen: <trigger>
directive: <action, not a shell command>
rationale: <why this changes future work>
evidenceRefs: [<independent evidence reference>]
conclusion: all-signals-pass / signals-failed
environmentFailure: null / <phase, evidence, impact, recovery suggestion>
notes: <observations, suspected cause, and independent candidate validation evidence>
```
没有适用项时列表写 `[]`。Test 的知识结论仍只是证据;只有 Coordinator 能写入或
激活 `knowledge.yaml`。
Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发送同样的字段。
---
## 6. Coordinator 最终报告模板
```text
本轮闭环完成。
已验证(Test 复测通过 + 终检):
- <task_id>: <what passed>
环境事件:
- <task_id>: <原因、影响、是否恢复、Coordinator 下一步、userAction>
遗留:
- <task_id>: 三轮仍未通过,已记录为 leftover。最后失败证据:<evidence>
验证命令:
- <command>: passed
项目知识:
- 本轮采用:<knowledgeRefs and checks>
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
- 待验证 candidate:<remaining candidates or none>
回归:
- 收获:新增/更新/退役/无回归 <case ids or none>
- 最近一次回归运行:<RR-id / passed|failed|n/a>
交付(未启用时写 n/a):
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
- PR/MR:<URL and head/base>
- 产物:<reference, digest, destination>
- 部署:<environment, health/rollback evidence>
- 待审批或未完成:<approval/blocked step or none>
工作树状态:
- <repo_path>: <git status summary>
- <dev_worktree>: <git status summary>
```
---
## 7. 派发给 Test 跑回归
```text
请按回归目录对当前测试环境做独立黑盒回归。
suite: <smoke|full|custom>
baseUrl: <deployer 给出的地址>
用例(来自 select_regression.py,不要发明步骤):
- <REG-id>: surface=<browser|api>
setup: <setup>
steps: <steps>
expected: <expected signals>
要求:
- 先确认 Base URL 可访问;不可访问时回报 environmentFailure,不要编造产品失败。
- surface=browser 必须走真实页面交互,不能改成只打 API。
- 逐条对照 expected 回报 pass/fail 与证据。
- 不要修改源码,不要写 tasks.yaml / knowledge.yaml / regression.yaml。
完成后按 §5 的复测报告格式回报,signals 使用 REG-id。
```
+73
View File
@@ -0,0 +1,73 @@
# ACK 回归目录与运行
本文件是回归用例目录、收获时机和运行模式的 SSOT。三角色闭环仍见
`closed-loop.md`;测试环境部署见 `delivery.md` 与 deployer skill。
回归不是新 skill,也不写入 `knowledge.yaml`。知识是护栏;回归是可观测的黑盒
用例清单。
## 1. 目录
项目状态是 `.pouch/ack/regression.yaml`。任务板用
`project.regressionFile: .pouch/ack/regression.yaml` 声明,运行证据写在
任务记录的 `regressionRuns`(读取时 materialize 到任务视图)。新项目初始化会生成空目录;旧项目没有该文件时,
「运行回归」先停下来,用户授权后再从模板补齐。
只有 Coordinator 写 `regression.yaml` 和任务记录。Test 在复测报告里提名
`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
`.pouch/ack/`。
`tests/browser/**` 仍可由 Test 维护可执行脚本。用例可用可选 `automationRef`
指向那些脚本;没有脚本时 Test worker 按目录里的步骤和验收信号执行。
## 2. 用例
每条用例是给独立 Test worker 的说明书,复用
`optimization-method.md` §1 的可观测信号。ACK 不提供 YAML-DSL 解释器,也不强制
Playwright。
读取时用 `scripts/select_regression.py`,不要把完整目录注入上下文:
```bash
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
--suite full
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
--case-id REG-login-001
```
默认 `--suite smoke` 只返回 `status: active` 且 `suite: smoke` 的用例。
`--suite full` 返回全部 active 用例。写回前仍运行
`scripts/validate_regression.py`。
`surface: browser` 的用例必须走真实页面交互,不能改成只打 API。
`surface: api` 以 HTTP 状态和字段为证据。
## 3. 收获
任务进入 `verified` 且改了用户可见行为或 API 后,闭环结束前 Coordinator 必须给出
四选一,不能跳过:
- 新增
- 更新已有用例
- 退役(功能已删除)
- 无回归(纯内部重构、leftover、无用户可见变化)
草案来自任务验收信号和 Test 的独立证据,按 `path + expected` 去重。用户确认后
才写入目录。P0 / 主路径默认 `suite: smoke`,其余 `full`。
## 4. 运行
用户说「回归」「跑回归测试」时执行本模式,不要求当前有 `verified` 任务。
1. 目录存在且有选中的 active 用例;否则停止。
2. 先按「运行测试环境」调用 deployer,拿到 Base URL。
3. Coordinator 派独立 Test worker,只带选中用例、Base URL 和 surface。
4. Test 逐条对照 `expected` 回报证据。Coordinator 终检后写入 `regressionRuns`。
5. 失败只报告,不自动派 Developer,不占任务三轮预算。用户明确要求修复时,按
「修 bug」为每条失败开一个任务。
环境失败(没有浏览器、打不开部署地址、deployer 失败)记环境事件,不记产品失败。
默认跑 smoke。用户点名 full 或具体 case id 时按点名范围执行。
@@ -0,0 +1,215 @@
# 角色与权限(稳定核心)
本文件是**角色模型、路径权限、任务状态机、完成定义**的单一事实源(SSOT)。其它文件只引用本文件,不重复定义。
目标:让每个 Agent 只处理自己能验证的事情,减少上下文污染和越权修改。
---
## 角色模型(三角色)
ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Developer 只实现**。关键属性是**验证者 ≠ 实现者**:Developer 不能给自己盖章,验证权在独立的 Test。
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------|
| Coordinator (PM) | 需求拆解、定验收信号、单写 `task-records/` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
**独立验证权归 Test。** Coordinator 不亲自复测,它读 Test 的证据,并对照任务的原始意图做一次终检(见「完成定义」)。`worker_done` 不等于完成的原则同时适用于 Developer 和 Test:结论只有追加到 `task-records/` 才算数。
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
---
## 三角色能力清单(SSOT)
上面的表定义了**边界**(谁能碰什么),这一节定义**能力**(每个角色到底该怎么做好自己的事)。每个角色用同一骨架描述:`Outcome`(产出什么)/ `Must Do`(必须做)/ `Must Not`(不能做)/ `Evidence`(拿什么证明)/ `Output`(交付格式)。派发 prompt 会引用这里,见 `prompt-templates.md`。
这些是**通用工程习惯**,不含项目命令与路径;项目差异写在覆盖层文件(默认 `.pouch/ack/project.md`)。装了外部 skill 的环境可按每个角色末尾的「可选 skills」加速,未装则照本清单执行,不阻塞。
### Coordinator (PM):拆解与终检
- **Outcome**:把一句话需求变成可执行、验收可观测的任务集,并跑完闭环得到明确结论(verified / leftover)。
- **Must Do**
- 先澄清意图再动手:目标、成功标准、约束、明确「不做什么」。歧义有多解或多来源冲突时,先问清再拆。
- 每个任务写**可观测验收信号**(可见文本 / API 结果 / 交互结果,见 `optimization-method.md` §1),而不是「功能正常」。
- 拆任务时点明最脆弱的假设:「本任务假设 X,若 X 不成立则 Y」;列出被否掉的方案与原因。
- 拆分/验收先给用户确认,再派发(`kickoff.md` 第 1 步的停顿点)。
- `prepare(task)` 时按 component、path、dependency、version 和 tag 从
`knowledge.yaml` 推荐相关 `active` 知识;人工确认后把固定 revision 的
`knowledgeRefs` 写入当前任务上下文。
- 新逻辑轮次默认分配稳定的 `<task-id>-A<round>`,记录到
`dispatch.rounds[].attemptId`;旧轮次作为知识来源前再补齐,不要用编排工具的
`dispatchId` 代替。
- gate 时检查 Developer 的 `knowledgeApplied`、Test 的 `knowledgeChecks` 和
candidate 独立证据;只有证据充分时才由 Coordinator 激活、废弃或替代知识。
- 一次派发只针对一个明确问题(`optimization-method.md` §6);每任务最多三轮有效
产品复验。环境失败单独记录、恢复和报告,不占轮次。
- 终检:读 Test 证据,逐条对齐原始意图后才落 `verified`,不亲自复测。
- **Must Not**:改源码、亲自跑测试、凭 `worker_done` 直接标 `verified`、把多个无关失败塞进一次派发、派发 `candidate` 或全量注入知识库、把知识正文当作 shell 执行。
- **Evidence**:产品文档、`tasks.yaml` 里的 `expected` + `verification`、Test 回传的复测证据。
- **Output**:确认前给「产品文档 + 任务拆分 + 验收信号」;闭环结束给最终报告(`prompt-templates.md` §6)。
- **可选 skills**:复杂需求可先用 `/think` 或 `superpowers:brainstorming` / `writing-plans` 收敛设计与计划。
### Developer:实现与白盒验证
- **Outcome**:在授权路径内做出满足验收信号的最小改动,并用白盒证据证明它可复现。
- **Must Do**
- 动手前先读覆盖层文件、`tasks.yaml` 对应任务、相关规格;复现失败现象或先写会失败的测试。
- 只使用 Coordinator 本轮显式派发的 `active` 知识,按固定 revision 回报
`knowledgeApplied`;发现跨任务可复用的项目经验时提交带当前观测证据的
`knowledgeCandidates`。
- 最小 diff,只改一个明确问题的根因,不顺手重构无关代码。
- 行为变更配单元测试;bug 修复先有一个能复现的失败用例再修。
- 完成前跑覆盖层里规定的命令(构建 / 单测 / 本地运行),亲自走一遍验收路径。
- **网站 / 常驻服务**:改完重启服务(或触发热更并确认生效),保证运行实例跑的是新代码,避免 Test 测到旧进程 / 旧构建。
- **Must Not**:改产品规格与集成测试、写 `tasks.yaml` 或 `knowledge.yaml`、标
`verified`、把 candidate 当作已生效规则、绕过测试声称完成、把 bug 修复扩成大
重构(需要就先停下说明并请示)。
- **Evidence**:改了哪些文件、跑了哪些命令及结果、如何复现验收路径、实际采用的
`knowledgeRefs`、新 candidate 的当前任务证据、残留风险。
- **Output**:一次 `worker_done`,字段见 `prompt-templates.md` §4(只报证据,不下最终结论)。
- **可选 skills**:排查用 `/hunt` 或 `superpowers:systematic-debugging`(先根因后修);实现行为变更用 `superpowers:test-driven-development`。
### Test:独立黑盒复测
- **Outcome**:以独立视角复现验收路径,逐条给出通过/失败的可观测证据,供 Coordinator 终检。
- **Must Do**
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 `closed-loop.md`),避免测错实例或旧构建;网站类先确认服务已按新代码重启。
- 逐条验证验收信号,验证交互后的真实状态,而不是只看静态文案。
- 对 Coordinator 派发的每条 `knowledgeRef` 执行适用的额外检查,回报
`knowledgeChecks`;对本轮 candidate 使用独立观测验证,不能复述 Developer
结论当作证据。
- **网站类任务优先用浏览器复测**真实交互(点击 / 跳转 / 渲染),其次才是 API / 脚本;纯后端 / CLI 则以 API smoke 或脚本为主。
- 把最容易反复误判的路径沉淀成可执行测试(`optimization-method.md` §8)。
- 只回传证据 + 逐条结论,最终判定留给 Coordinator。
- **Must Not**:改应用源码、改产品规格、写 `tasks.yaml` 或 `knowledge.yaml`、凭
Developer 的 `worker_done` 直接下结论、把 candidate 作为 active 知识执行。
- **Evidence**:运行环境快照、命令结果、每条信号 pass/fail + 证据
(snapshot / DOM / API 结果)、每条适用知识的
`passed` / `failed` / `not_applicable` + 证据。
- **Output**:一次复测报告,字段见 `prompt-templates.md` §5。
- **可选 skills**:合并 / 发版前检查可用 `/check` 或 `superpowers:verification-before-completion`(证据先于结论)。
---
## 路径权限模板
目标项目在自己的**覆盖层文件**中填入实际路径(模板见 `templates/project.template.md`;覆盖层默认 `.pouch/ack/project.md`,路径记在 `tasks.yaml` 的 `project.overlayFile`)。
| 路径 | Coordinator | Test | Developer | 说明 |
|------|:-----------:|:----:|:---------:|------|
| `<spec_paths>` | R/W | Read-only | Read-only | PRD、API spec、设计文档,Coordinator(PM)拥有 |
| `<integration_test_paths>` | Read-only | R/W | Read-only | 浏览器用例、API smoke、回归清单,Test 拥有 |
| `<test_records_path>` | Read-only | R/W | Read-only | 复测记录,通常可 gitignore |
| `<source_paths>` | Read-only | Read-only | R/W | 应用源码 |
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只保存稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
---
## 任务状态机(SSOT)
```text
open
-> dispatched (派发给 Developer)
-> fixed_by_dev (Developer 声称已修)
-> retesting (派发给 Test 复测)
-> verified (Test 通过 + Coordinator 终检)
```
失败分支:
```text
dispatched -> blocked
retesting -> failed_retest -> dispatched
failed_retest(累计 3 轮) -> leftover
```
环境分支不进入 `failed_retest`:
```text
launch / service / data / browser / tooling environment failure
-> dispatch.environmentIncidents
-> 有界恢复并告知用户下一步
-> fixed_by_dev(待恢复)或 blocked(需外部动作)
-> 环境恢复后 retesting
```
状态定义(所有状态都只由 Coordinator 追加到 `task-records/`,来源不同):
| 状态 | 依据来源 | 含义 |
|------|----------|------|
| `open` | Coordinator 自己发现/记录 | 已发现,等待处理 |
| `dispatched` | Coordinator 派发动作 | 已派发给 Developer |
| `fixed_by_dev` | Developer 的 worker_done | 开发声称已修复并提供白盒验证 |
| `retesting` | Coordinator 派发动作 | 已派发给 Test,正在黑盒复测 |
| `failed_retest` | Test 的产品复测报告 | 环境对齐后观察到产品验收失败,可继续派发 Developer |
| `verified` | Test 通过 + Coordinator 终检 | 复测通过且符合原始意图 |
| `blocked` | Coordinator 判断 | 需要用户决策或外部条件 |
| `leftover` | Coordinator 判断 | 累计 3 轮仍未通过,留给人工或专项处理 |
有效复验与环境失败的处理细则见 `optimization-method.md` §4。
## 交付状态(与任务状态正交)
任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在
`tasks.yaml.deliveryRuns`:
```text
planned -> running -> validation_ready | review_ready | released
-> blocked | failed
planned -> skipped
```
`validation_ready` 表示开发/测试环境已部署且健康检查通过,等待用户手工验证;
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
审批点和恢复规则见 `delivery.md`。
---
## 项目状态写入约定(并发安全)
`task-records/` 是任务、交付运行与回归运行事实源,`tasks.yaml` 是稳定项目配置,
`knowledge.yaml` 是跨任务项目知识事实源,`regression.yaml` 是黑盒回归用例事实源,
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
- **只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`**。
Test 与 Developer 对它们都是只读的。
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
知识。
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
- `delivery.yaml` 只在用户显式要求维护配置时修改;运行只写
`tasks.yaml.deliveryRuns`,不能反向改写能力定义。
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
证据引用,不替代可执行控制。
---
## 完成定义(Definition of Done)
一个任务只有同时满足以下条件,才能标记 `verified`:
- Developer 已提供修改文件和白盒验证证据(`worker_done`)。
- Test 在正确 worktree 和正确服务实例上独立复测通过(对齐检查见 `closed-loop.md`),并产出可观测证据。
- 相关单元测试、构建、集成或浏览器检查通过。
- 当前任务显式 `knowledgeRefs` 对应的必需 `knowledgeChecks` 已由 Test 覆盖;
未覆盖或检查引用无法解析时不能标记 `verified`。
- **Coordinator 终检**:读 Test 的证据,确认它满足任务的原始意图与验收信号(不是重测,是审证据 + 对齐意图;避免"过了字面没过意图")。
- `tasks.yaml` 中记录了复测证据与 `resolution.verifiedBy`。
- 用户可见行为符合验收标准。
+11
View File
@@ -0,0 +1,11 @@
参考项目1
[https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team](https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team)
参考项目2
[https://github.com/DannyMac180/sol-advisor](https://github.com/DannyMac180/sol-advisor)
参考项目3
[https://cursor.com/cn/blog/agent-swarm-model-economics](https://cursor.com/cn/blog/agent-swarm-model-economics)
+64
View File
@@ -0,0 +1,64 @@
"""Canonical reviewed Bug task payload shared by intake and validation."""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
LEGACY_PAYLOAD_FIELDS = (
"title",
"description",
"priority",
"actual",
"expected",
"stepsToReproduce",
"fixLogic",
"acceptanceCriteria",
)
CLARIFIED_PAYLOAD_FIELDS = (
"title",
"description",
"actual",
"expected",
"acceptanceCriteria",
)
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
def review_items(value: str) -> list[str]:
"""Recover line or numbered-list review text as stable non-empty items."""
lines = [line.strip(" \t-*•") for line in value.splitlines() if line.strip()]
if len(lines) > 1:
return lines
text = value.strip()
matches = list(NUMBERED_ITEM.finditer(text))
if matches:
items: list[str] = []
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
item = text[start:end].strip()
if item:
items.append(item)
if items:
return items
return [text] if text else []
def approval_payload_hash(task: dict[str, Any]) -> str:
"""Hash the exact reviewed fields that Developer and Test consume."""
source = task.get("source")
workflow = source.get("workflow") if isinstance(source, dict) else None
fields = (
CLARIFIED_PAYLOAD_FIELDS
if workflow == "clarified-writeback-v1"
else LEGACY_PAYLOAD_FIELDS
)
payload = {field: task.get(field) for field in fields}
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
File diff suppressed because it is too large Load Diff
+1790
View File
File diff suppressed because it is too large Load Diff
+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())
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Reclaim ACK worker terminals at the end of a coordination round.
Reads the authoritative tasks.yaml and decides, per worker receipt handle,
whether the terminal may be closed:
- close: every task referencing the handle is ``verified`` and no referenced
task has an unresolved ``dispatch.environmentIncidents`` entry;
- retain: any referenced task is still open/dispatched/fixed_by_dev/retesting/
blocked/failed_retest/leftover, has an open environment incident, or the
receipt references an unknown task. Failed-three-times workers stay open by
design (leftover/failed_retest/blocked are all retained);
- skip: handles not present in workerReceipts (coordinator terminal, user
shells) are never touched.
Default mode is dry-run: print decisions only. Pass ``--apply`` to actually
show-verify and close. Closing is conservative: identity must match the
receipt, the close receipt must be ok, and the handle must disappear from the
live terminal list; anything uncertain stays retained and is reported instead
of being retried. A completed role can be reclaimed before the whole task is
verified with ``--completed-task-id`` and ``--completed-role``; this is only
valid after that role has reported success.
Output is a single JSON document:
{"mode": "dry-run|apply", "handles": [{handle, decision, reason, tasks, closed}]}
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from launch_worker import ( # noqa: E402
LaunchError,
load_authoritative_board,
resolve_executable,
run_json,
)
KEEP_STATUSES = frozenset(
{"open", "dispatched", "fixed_by_dev", "retesting", "blocked", "failed_retest", "leftover"}
)
COMPLETED_ROLE_STATUSES = {
"developer": frozenset({"fixed_by_dev", "retesting", "verified"}),
"test": frozenset({"retesting", "verified"}),
}
def collect_handle_tasks(board: dict) -> dict[str, dict[str, object]]:
"""Map each worker receipt handle to its referenced task facts."""
tasks_by_id = {task.get("id"): task for task in board.get("tasks", []) if isinstance(task, dict)}
handles: dict[str, dict[str, object]] = {}
for receipt in board.get("workerReceipts", []):
if not isinstance(receipt, dict):
continue
binding = receipt.get("binding")
created_for = receipt.get("createdFor")
if not isinstance(binding, dict) or not isinstance(created_for, dict):
continue
handle = binding.get("handle")
task_id = created_for.get("taskId")
if not isinstance(handle, str) or not handle:
continue
entry = handles.setdefault(handle, {"taskIds": [], "tasks": []})
if isinstance(task_id, str) and task_id not in entry["taskIds"]:
entry["taskIds"].append(task_id)
entry["tasks"].append(tasks_by_id.get(task_id))
return handles
def unresolved_incidents(task: dict | None) -> list[str]:
if not isinstance(task, dict):
return []
dispatch = task.get("dispatch")
if not isinstance(dispatch, dict):
return []
incidents = dispatch.get("environmentIncidents")
if not isinstance(incidents, list):
return []
return [
str(incident.get("id"))
for incident in incidents
if isinstance(incident, dict) and incident.get("status") == "open"
]
def decide(handle: str, entry: dict[str, object]) -> tuple[str, str, list[str]]:
task_ids = entry["taskIds"]
tasks = entry["tasks"]
statuses: list[str] = []
for task in tasks:
if isinstance(task, dict):
statuses.append(str(task.get("status")))
else:
statuses.append("unknown-task")
incidents: list[str] = []
for task in tasks:
incidents.extend(unresolved_incidents(task))
if incidents:
return (
"retain",
f"unresolved environment incident(s): {', '.join(incidents)}",
statuses,
)
bad = [status for status in statuses if status in KEEP_STATUSES or status == "unknown-task"]
if bad:
return (
"retain",
f"referenced task(s) not verified: {', '.join(bad)} (task ids: {', '.join(task_ids)})",
statuses,
)
if not task_ids:
return "retain", "receipt has no task reference", statuses
return "close", "all referenced tasks verified", statuses
def close_terminal(orca: Path, handle: str) -> None:
"""Show-verify identity, close the tab, then confirm it left the live list."""
try:
show = run_json(
[str(orca), "terminal", "show", "--terminal", handle, "--json"],
"terminal show",
)
except LaunchError:
# A role may already have been reclaimed before the final verified-task
# sweep. Treat a handle absent from the live list as idempotent success;
# retain other show failures for manual reconciliation.
listing = run_json(
[str(orca), "terminal", "list", "--json"],
"terminal list",
)
terminals = listing.get("result", {}).get("terminals")
if isinstance(terminals, list) and not any(
isinstance(item, dict) and item.get("handle") == handle for item in terminals
):
return
raise
terminal = show.get("result", {}).get("terminal")
if not isinstance(terminal, dict) or terminal.get("handle") != handle:
raise LaunchError(f"terminal show 未返回匹配的 handle: {handle}")
close = run_json(
[str(orca), "terminal", "close", "--terminal", handle, "--tab", "--json"],
"terminal close",
)
result = close.get("result")
if isinstance(result, dict):
closed_handle = result.get("handle") or (result.get("terminal") or {}).get("handle")
if closed_handle not in (None, handle):
raise LaunchError(f"terminal close 回执 handle 不匹配: {closed_handle!r}")
listing = run_json(
[str(orca), "terminal", "list", "--json"],
"terminal list",
)
terminals = listing.get("result", {}).get("terminals")
if isinstance(terminals, list) and any(
isinstance(item, dict) and item.get("handle") == handle for item in terminals
):
raise LaunchError(f"terminal close 后 handle 仍在 live list: {handle}")
def completed_role_handle(board: dict, task_id: str, role: str) -> str:
"""Resolve the receipt-bound terminal for a role that just succeeded."""
if role not in COMPLETED_ROLE_STATUSES:
raise LaunchError(f"不支持的完成角色: {role}")
tasks = board.get("tasks")
tasks_by_id = (
{task.get("id"): task for task in tasks if isinstance(task, dict)}
if isinstance(tasks, list)
else {}
)
task = tasks_by_id.get(task_id)
if not isinstance(task, dict):
raise LaunchError(f"找不到 ACK task: {task_id}")
status = task.get("status")
if status not in COMPLETED_ROLE_STATUSES[role]:
raise LaunchError(
f"{role} 完成回收要求任务处于 {sorted(COMPLETED_ROLE_STATUSES[role])},当前是 {status!r}"
)
if unresolved_incidents(task):
raise LaunchError(f"task {task_id} 存在未解决环境事件,不能回收 {role} terminal")
dispatch = task.get("dispatch")
role_dispatch = dispatch.get(role) if isinstance(dispatch, dict) else None
receipt_id = role_dispatch.get("receiptId") if isinstance(role_dispatch, dict) else None
if not isinstance(receipt_id, str) or not receipt_id:
raise LaunchError(f"task {task_id} 缺少 {role} receiptId")
receipts = board.get("workerReceipts")
receipt = next(
(
item
for item in receipts
if isinstance(item, dict) and item.get("id") == receipt_id
),
None,
) if isinstance(receipts, list) else None
if not isinstance(receipt, dict):
raise LaunchError(f"找不到 {role} receipt: {receipt_id}")
created_for = receipt.get("createdFor")
binding = receipt.get("binding")
if (
not isinstance(created_for, dict)
or created_for.get("taskId") != task_id
or created_for.get("role") != role
or not isinstance(binding, dict)
or not isinstance(binding.get("handle"), str)
or not binding.get("handle")
):
raise LaunchError(f"{role} receipt 与 task/role/handle 不匹配: {receipt_id}")
return binding["handle"]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-root", required=True, help="权威 tasks.yaml 所在项目根")
parser.add_argument("--apply", action="store_true", help="真正关闭;默认只输出决策")
parser.add_argument("--handle", help="只处理指定 handle(默认全部)")
parser.add_argument("--completed-task-id", help="刚完成的 ACK task ID;与 --completed-role 一起使用")
parser.add_argument(
"--completed-role",
choices=("developer", "test"),
help="刚成功完成的角色;与 --completed-task-id 一起使用",
)
args = parser.parse_args(argv)
try:
project_root, board = load_authoritative_board(args.project_root)
except LaunchError as exc:
print(json.dumps({"mode": "dry-run" if not args.apply else "apply", "error": str(exc)}, ensure_ascii=False))
return 2
if bool(args.completed_task_id) != bool(args.completed_role):
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"error": "--completed-task-id 与 --completed-role 必须同时提供",
},
ensure_ascii=False,
)
)
return 2
if args.completed_task_id:
if args.handle:
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"error": "完成角色回收不能同时使用 --handle",
},
ensure_ascii=False,
)
)
return 2
try:
handle = completed_role_handle(board, args.completed_task_id, args.completed_role)
closed: bool | None = None
decision = "close-completed-role"
reason = f"{args.completed_role} reported success for task {args.completed_task_id}"
if args.apply:
close_terminal(resolve_executable("orca"), handle)
closed = True
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"handles": [
{
"handle": handle,
"decision": decision,
"reason": reason,
"tasks": [args.completed_task_id],
"role": args.completed_role,
"closed": closed,
}
],
},
ensure_ascii=False,
)
)
return 0
except LaunchError as exc:
print(
json.dumps(
{
"mode": "apply" if args.apply else "dry-run",
"error": str(exc),
},
ensure_ascii=False,
)
)
return 2
handles = collect_handle_tasks(board)
results: list[dict[str, object]] = []
for handle in sorted(handles):
if args.handle and handle != args.handle:
continue
entry = handles[handle]
decision, reason, statuses = decide(handle, entry)
closed: bool | None = None
if decision == "close" and args.apply:
try:
close_terminal(resolve_executable("orca"), handle)
closed = True
except LaunchError as exc:
decision = "uncertain"
reason = f"close failed: {exc}"
closed = None
results.append(
{
"handle": handle,
"decision": decision,
"reason": reason,
"tasks": entry["taskIds"],
"statuses": statuses,
"closed": closed,
}
)
print(
json.dumps(
{"mode": "apply" if args.apply else "dry-run", "handles": results},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""安全执行 knowledge.yaml 中已审查的 verificationRegistry 条目。
本入口只接受 registry ID,不接受额外命令或参数。执行前只打开一次项目根目录
fd,知识库、检查目标和子进程 cwd 都固定到该 fd;检查文件逐段以 O_NOFOLLOW
打开后复制到匿名、尽可能 sealed 的稳定快照,再使用结构化 argv 和 shell=False
启动,避免检查与执行之间被替换。
退出码: 0..125 沿用检查结果 / 1 知识或执行失败 / 2 环境、路径或用法错误。
"""
from __future__ import annotations
import argparse
import fcntl
import os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
from validate_knowledge import ( # type: ignore
infer_project_root,
load_yaml_text,
validate_all,
)
MAX_KNOWLEDGE_BYTES = 16 * 1024 * 1024
MAX_TARGET_BYTES = 16 * 1024 * 1024
def _project_root(
value: str | None,
knowledge_path: Path,
) -> tuple[Path | None, str | None]:
inferred = infer_project_root(knowledge_path)
if value is not None:
candidate = Path(value).expanduser()
if not candidate.is_dir():
return None, f"项目根目录不存在: {candidate}"
resolved = candidate.resolve(strict=True)
if inferred is not None and resolved != inferred:
return (
None,
f"--project-root {resolved} 与 knowledge.yaml 推断的项目根目录 "
f"{inferred} 不一致",
)
return resolved, None
if inferred is None:
return None, "无法从 knowledge.yaml 确定现有项目根目录"
return inferred, None
def _validate_knowledge_location(
knowledge_path: Path,
project_root: Path,
) -> str | None:
expected = project_root / ".pouch" / "ack" / "knowledge.yaml"
lexical = Path(os.path.abspath(knowledge_path.expanduser()))
if lexical != expected:
return (
"只允许执行项目权威知识库 "
f"{expected},当前输入为 {lexical}"
)
return None
def _open_regular_beneath(
project_root: Path | int,
relative_path: str,
*,
require_executable: bool,
) -> tuple[int | None, str | None]:
"""从根目录 fd 逐段打开目标,不允许任一段通过 symlink 跳转。"""
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
return None, "当前平台不支持安全的 O_NOFOLLOW/O_DIRECTORY 路径解析"
parts = PurePosixPath(relative_path).parts
if (
not parts
or PurePosixPath(relative_path).is_absolute()
or any(part in {"", ".", ".."} for part in parts)
):
return None, "检查目标必须是规范的项目内相对路径"
directory_fds: list[int] = []
target_fd: int | None = None
try:
root_fd = (
os.dup(project_root)
if isinstance(project_root, int)
else os.open(
project_root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
)
directory_fds.append(root_fd)
current_fd = root_fd
for segment in parts[:-1]:
current_fd = os.open(
segment,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=current_fd,
)
directory_fds.append(current_fd)
target_fd = os.open(
parts[-1],
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=current_fd,
)
metadata = os.fstat(target_fd)
if not stat.S_ISREG(metadata.st_mode):
os.close(target_fd)
return None, "检查目标不是普通文件"
if require_executable and metadata.st_mode & 0o111 == 0:
os.close(target_fd)
return None, "检查目标不可执行"
return target_fd, None
except OSError as exc:
if target_fd is not None:
os.close(target_fd)
return None, f"检查目标不存在、不可访问或路径包含软链接: {exc}"
finally:
for directory_fd in reversed(directory_fds):
os.close(directory_fd)
def _stable_metadata(before: os.stat_result, after: os.stat_result) -> bool:
fields = (
"st_dev",
"st_ino",
"st_mode",
"st_size",
"st_mtime_ns",
"st_ctime_ns",
)
return all(getattr(before, field) == getattr(after, field) for field in fields)
def _read_stable_bytes(
source_fd: int,
*,
maximum: int,
) -> tuple[bytes | None, str | None]:
before = os.fstat(source_fd)
if before.st_size > maximum:
return None, f"知识库超过大小上限 {maximum} bytes"
chunks: list[bytes] = []
total = 0
try:
os.lseek(source_fd, 0, os.SEEK_SET)
while True:
chunk = os.read(source_fd, min(1024 * 1024, maximum - total + 1))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > maximum:
return None, f"知识库超过大小上限 {maximum} bytes"
except OSError as exc:
return None, f"无法读取权威知识库快照: {exc}"
after = os.fstat(source_fd)
if not _stable_metadata(before, after):
return None, "权威知识库在读取期间发生变化,拒绝执行"
return b"".join(chunks), None
def load_authoritative_knowledge(
project_root: Path | int,
) -> tuple[dict[str, Any] | None, str | None]:
source_fd, open_error = _open_regular_beneath(
project_root,
".pouch/ack/knowledge.yaml",
require_executable=False,
)
if open_error is not None or source_fd is None:
return None, open_error or "无法安全打开权威知识库"
try:
content, read_error = _read_stable_bytes(
source_fd,
maximum=MAX_KNOWLEDGE_BYTES,
)
finally:
os.close(source_fd)
if read_error is not None or content is None:
return None, read_error or "无法读取权威知识库"
try:
text = content.decode("utf-8")
except UnicodeDecodeError as exc:
return None, f"权威知识库不是有效 UTF-8: {exc}"
return load_yaml_text(text, "知识库"), None
def _write_all(file_descriptor: int, chunk: bytes) -> None:
remaining = memoryview(chunk)
while remaining:
written = os.write(file_descriptor, remaining)
if written <= 0:
raise OSError("无法写入检查快照")
remaining = remaining[written:]
def _snapshot_executable(source_fd: int) -> tuple[int | None, str | None]:
"""复制到匿名快照;Linux 上进一步 seal,冻结本次执行内容。"""
before = os.fstat(source_fd)
if before.st_size > MAX_TARGET_BYTES:
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
snapshot_fd: int | None = None
seal_snapshot = all(
hasattr(owner, name)
for owner, name in (
(os, "memfd_create"),
(os, "MFD_ALLOW_SEALING"),
(os, "MFD_CLOEXEC"),
(fcntl, "F_ADD_SEALS"),
(fcntl, "F_SEAL_SEAL"),
(fcntl, "F_SEAL_SHRINK"),
(fcntl, "F_SEAL_GROW"),
(fcntl, "F_SEAL_WRITE"),
)
)
try:
if seal_snapshot:
snapshot_fd = os.memfd_create( # type: ignore[attr-defined]
"ack-verification",
os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING, # type: ignore[attr-defined]
)
else:
snapshot_fd, snapshot_path = tempfile.mkstemp(
prefix="ack-verification-"
)
os.unlink(snapshot_path)
os.lseek(source_fd, 0, os.SEEK_SET)
total = 0
while True:
chunk = os.read(
source_fd,
min(1024 * 1024, MAX_TARGET_BYTES - total + 1),
)
if not chunk:
break
_write_all(snapshot_fd, chunk)
total += len(chunk)
if total > MAX_TARGET_BYTES:
os.close(snapshot_fd)
return None, f"检查目标超过大小上限 {MAX_TARGET_BYTES} bytes"
after = os.fstat(source_fd)
if not _stable_metadata(before, after):
os.close(snapshot_fd)
return None, "检查目标在创建执行快照期间发生变化,拒绝执行"
os.fchmod(snapshot_fd, before.st_mode & 0o777)
os.lseek(snapshot_fd, 0, os.SEEK_SET)
if seal_snapshot:
seals = (
fcntl.F_SEAL_SEAL
| fcntl.F_SEAL_SHRINK
| fcntl.F_SEAL_GROW
| fcntl.F_SEAL_WRITE
)
fcntl.fcntl(snapshot_fd, fcntl.F_ADD_SEALS, seals)
return snapshot_fd, None
except OSError as exc:
if snapshot_fd is not None:
os.close(snapshot_fd)
return None, f"无法创建稳定的检查执行快照: {exc}"
def open_target(
data: dict[str, Any],
verification_ref: str,
project_root: Path | int,
) -> tuple[int | None, list[str] | None, str | None]:
registry = data.get("verificationRegistry")
if not isinstance(registry, dict) or verification_ref not in registry:
return None, None, f"verification.ref {verification_ref!r} 未在 registry 注册"
target = registry.get(verification_ref)
if not isinstance(target, dict):
return None, None, f"verificationRegistry.{verification_ref} 不是对象"
relative_path = target.get("path")
args = target.get("args")
if not isinstance(relative_path, str) or not isinstance(args, list):
return None, None, f"verificationRegistry.{verification_ref} 结构无效"
if any(not isinstance(arg, str) for arg in args):
return (
None,
None,
f"verificationRegistry.{verification_ref}.args 必须是字符串数组",
)
source_fd, error = _open_regular_beneath(
project_root,
relative_path,
require_executable=True,
)
if error is not None or source_fd is None:
return None, None, error or "无法安全打开检查目标"
try:
target_fd, snapshot_error = _snapshot_executable(source_fd)
finally:
os.close(source_fd)
if snapshot_error is not None or target_fd is None:
return None, None, snapshot_error or "无法创建检查执行快照"
return target_fd, args, None
def _fd_executable_path(target_fd: int) -> str | None:
for prefix in ("/proc/self/fd", "/dev/fd"):
candidate = f"{prefix}/{target_fd}"
if Path(candidate).exists():
return candidate
return None
def _fd_directory_path(directory_fd: int) -> str | None:
for prefix in ("/proc/self/fd", "/dev/fd"):
candidate = f"{prefix}/{directory_fd}"
if Path(candidate).is_dir():
return candidate
return None
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="执行 ACK verificationRegistry 中已审查的检查"
)
parser.add_argument("knowledge", help="knowledge.yaml 路径")
parser.add_argument("verification_ref", help="verificationRegistry 中的检查 ID")
parser.add_argument(
"--project-root",
help="项目根目录;默认从 knowledge.yaml 的 .pouch/ack 布局或 Git 推断",
)
args = parser.parse_args(argv)
knowledge_path = Path(args.knowledge)
project_root, root_error = _project_root(args.project_root, knowledge_path)
if project_root is None:
sys.stderr.write(f"{root_error or '无法确定现有项目根目录'}\n")
return 2
location_error = _validate_knowledge_location(knowledge_path, project_root)
if location_error is not None:
sys.stderr.write(f"{location_error}\n")
return 2
try:
project_root_fd = os.open(
project_root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except OSError as exc:
sys.stderr.write(f"无法安全打开项目根目录: {exc}\n")
return 2
try:
stable_root_path = _fd_directory_path(project_root_fd)
if stable_root_path is None:
sys.stderr.write("当前平台无法固定项目根目录文件描述符\n")
return 2
data, knowledge_error = load_authoritative_knowledge(project_root_fd)
if knowledge_error is not None or data is None:
sys.stderr.write(f"{knowledge_error or '无法读取权威知识库'}\n")
return 2
schema_path = (
Path(__file__).resolve().parent.parent
/ "templates"
/ "knowledge.schema.json"
)
errors, mode = validate_all(
data,
schema_path,
project_root=Path(stable_root_path),
)
if errors:
sys.stderr.write(f"知识库校验失败({mode}),拒绝执行:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
target_fd, target_args, error = open_target(
data,
args.verification_ref,
project_root_fd,
)
if error is not None or target_fd is None or target_args is None:
sys.stderr.write(f"{error or '无法解析检查目标'}\n")
return 2
try:
executable_path = _fd_executable_path(target_fd)
if executable_path is None:
sys.stderr.write(
"当前平台无法从已打开的文件描述符安全执行检查\n"
)
return 2
environment = os.environ.copy()
environment["ACK_PROJECT_ROOT"] = stable_root_path
environment["ACK_PROJECT_ROOT_DISPLAY"] = str(project_root)
environment["ACK_VERIFICATION_REF"] = args.verification_ref
registry = data.get("verificationRegistry")
target = (
registry.get(args.verification_ref)
if isinstance(registry, dict)
else None
)
if isinstance(target, dict) and isinstance(target.get("path"), str):
environment["ACK_VERIFICATION_PATH"] = target["path"]
try:
completed = subprocess.run(
[executable_path, *target_args],
cwd=stable_root_path,
env=environment,
shell=False,
check=False,
pass_fds=(project_root_fd, target_fd),
)
except OSError as exc:
sys.stderr.write(f"检查启动失败: {exc}\n")
return 1
finally:
os.close(target_fd)
finally:
os.close(project_root_fd)
if 0 <= completed.returncode <= 125:
return completed.returncode
return 1
if __name__ == "__main__":
raise SystemExit(main())
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""按显式项目上下文确定性选择 active ACK 知识。
匹配采用大小写敏感 glob;entry 中每个非空 scope 维度都必须被查询上下文命中。
scope.all=true 的条目始终命中并优先占用 --limit,数量超过预算时显式失败。其余
结果按作用域具体程度及稳定引用排序。本脚本只输出数据,绝不执行 knowledge.yaml
中的任何文本。
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any
from validate_knowledge import ( # type: ignore
SCOPE_FIELDS,
infer_project_root,
load_yaml,
stable_ref,
validate_builtin_structure,
validate_semantics,
)
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
def _path_glob_matches(value: str, pattern: str) -> bool:
"""路径 glob:* 只匹配单段,只有完整的 ** 段可以跨越 /。"""
value_parts = tuple(value.split("/"))
pattern_parts = tuple(pattern.split("/"))
@lru_cache(maxsize=None)
def match(pattern_index: int, value_index: int) -> bool:
if pattern_index == len(pattern_parts):
return value_index == len(value_parts)
pattern_part = pattern_parts[pattern_index]
if pattern_part == "**":
return match(pattern_index + 1, value_index) or (
value_index < len(value_parts)
and match(pattern_index, value_index + 1)
)
return (
value_index < len(value_parts)
and fnmatch.fnmatchcase(value_parts[value_index], pattern_part)
and match(pattern_index + 1, value_index + 1)
)
return match(0, 0)
def scope_matches(
scope: dict[str, Any], context: dict[str, list[str]]
) -> bool:
if scope.get("all") is True:
return True
constrained = False
for field in SCOPE_FIELDS:
patterns = scope.get(field)
if not isinstance(patterns, list) or not patterns:
continue
constrained = True
values = context.get(field, [])
matcher = _path_glob_matches if field == "paths" else fnmatch.fnmatchcase
if not values or not any(
matcher(value, pattern)
for pattern in patterns
if isinstance(pattern, str)
for value in values
):
return False
return constrained
def _pattern_specificity(pattern: str) -> tuple[int, int, int, int, int]:
wildcard_count = sum(pattern.count(char) for char in ("*", "?", "["))
double_star_count = sum(1 for part in pattern.split("/") if part == "**")
literal_count = sum(char not in "*?[]!" for char in pattern)
exact = int(wildcard_count == 0)
depth = len(pattern.split("/"))
return (exact, literal_count, -double_star_count, -wildcard_count, depth)
def _specificity(entry: dict[str, Any]) -> tuple[int, int, int, int, int, int, int]:
scope = entry.get("scope")
if not isinstance(scope, dict) or scope.get("all") is True:
return (0, 0, 0, 0, 0, 0, 0)
populated = 0
exact = 0
literal = 0
double_star = 0
wildcard = 0
depth = 0
extra_or_patterns = 0
for field in SCOPE_FIELDS:
values = scope.get(field)
if isinstance(values, list) and values:
populated += 1
scores = [
_pattern_specificity(value)
for value in values
if isinstance(value, str)
]
if scores:
dimension_score = min(scores)
exact += dimension_score[0]
literal += dimension_score[1]
double_star += dimension_score[2]
wildcard += dimension_score[3]
depth += dimension_score[4]
extra_or_patterns += len(scores) - 1
return (
populated,
exact,
literal,
double_star,
wildcard,
depth,
-extra_or_patterns,
)
def select_entries(
data: dict[str, Any],
context: dict[str, list[str]],
*,
limit: int = DEFAULT_LIMIT,
) -> list[dict[str, Any]]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
raise ValueError(f"limit 必须在 1..{MAX_LIMIT} 之间")
entries = data.get("entries")
if not isinstance(entries, list):
return []
matched = [
entry
for entry in entries
if isinstance(entry, dict)
and entry.get("status") == "active"
and isinstance(entry.get("scope"), dict)
and scope_matches(entry["scope"], context)
and stable_ref(entry) is not None
]
global_entries = [
entry
for entry in matched
if isinstance(entry.get("scope"), dict)
and entry["scope"].get("all") is True
]
scoped_entries = [entry for entry in matched if entry not in global_entries]
global_entries.sort(key=lambda entry: stable_ref(entry) or "")
if len(global_entries) > limit:
raise ValueError(
f"命中的全项目知识有 {len(global_entries)} 条,超过 --limit={limit};"
"提高 limit 后重试,不能静默丢弃全项目护栏"
)
scoped_entries.sort(
key=lambda entry: (
*(-part for part in _specificity(entry)),
stable_ref(entry) or "",
)
)
return [
*global_entries,
*scoped_entries[: limit - len(global_entries)],
]
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="选择当前任务适用的 active ACK 知识")
parser.add_argument(
"knowledge", nargs="?", default="knowledge.yaml", help="知识库路径"
)
parser.add_argument("--component", action="append", default=[])
parser.add_argument("--path", action="append", default=[])
parser.add_argument("--dependency", action="append", default=[])
parser.add_argument("--version", action="append", default=[])
parser.add_argument("--tag", action="append", default=[])
parser.add_argument("--symbol", action="append", default=[])
parser.add_argument("--error-signature", action="append", default=[])
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument(
"--project-root",
help="可选项目根目录,用于 verificationRegistry symlink containment 校验",
)
parser.add_argument(
"--format", choices=("json", "refs"), default="json", dest="output_format"
)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
knowledge_path = Path(args.knowledge)
if not knowledge_path.is_file():
sys.stderr.write(f"找不到知识库文件: {knowledge_path}\n")
return 2
if not 1 <= args.limit <= MAX_LIMIT:
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
return 2
if args.project_root:
project_root = Path(args.project_root).expanduser()
if not project_root.is_dir():
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
return 2
project_root = project_root.resolve()
else:
project_root = infer_project_root(knowledge_path)
data = load_yaml(knowledge_path, "知识库")
errors = validate_builtin_structure(data)
errors.extend(validate_semantics(data, project_root=project_root))
registry = data.get("verificationRegistry")
if project_root is None and isinstance(registry, dict) and registry:
errors.append(
"verificationRegistry 非空但无法确定项目根目录;请传入 --project-root"
)
errors = list(dict.fromkeys(errors))
if errors:
sys.stderr.write(f"知识库无效,拒绝选择,共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
context = {
"components": args.component,
"paths": args.path,
"dependencies": args.dependency,
"versions": args.version,
"tags": args.tag,
"symbols": args.symbol,
"errorSignatures": args.error_signature,
}
try:
selected = select_entries(data, context, limit=args.limit)
except ValueError as exc:
sys.stderr.write(f"知识选择失败: {exc}\n")
return 1
refs = [stable_ref(entry) for entry in selected]
if args.output_format == "refs":
if refs:
sys.stdout.write("\n".join(ref for ref in refs if ref) + "\n")
return 0
payload = {
"count": len(selected),
"limit": args.limit,
"refs": refs,
"entries": [
{
"ref": stable_ref(entry),
**entry,
"verificationTarget": (
data.get("verificationRegistry", {}).get(
entry.get("verification", {}).get("ref")
)
if isinstance(data.get("verificationRegistry"), dict)
and isinstance(entry.get("verification"), dict)
else None
),
}
for entry in selected
],
}
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""选择 ACK 回归目录中的 active 用例。
默认返回 smoke 套件。本脚本只输出数据,不执行 steps 或 automationRef。
用法:
python3 select_regression.py .pouch/ack/regression.yaml
python3 select_regression.py .pouch/ack/regression.yaml --suite full
python3 select_regression.py .pouch/ack/regression.yaml --case-id REG-login-001
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from validate_regression import ID_RE, load_yaml, validate_builtin
DEFAULT_LIMIT = 50
MAX_LIMIT = 200
def select_cases(
data: dict[str, Any],
*,
suite: str,
case_ids: list[str],
limit: int,
) -> list[dict[str, Any]]:
cases = data.get("cases")
if not isinstance(cases, list):
raise ValueError("cases 必须是列表")
wanted = set(case_ids)
selected: list[dict[str, Any]] = []
for case in cases:
if not isinstance(case, dict):
continue
if case.get("status") != "active":
continue
case_id = case.get("id")
if wanted:
if case_id not in wanted:
continue
elif suite == "smoke" and case.get("suite") != "smoke":
continue
selected.append(case)
missing = wanted - {
case.get("id") for case in selected if isinstance(case.get("id"), str)
}
if missing:
raise ValueError("找不到 active 用例: " + ", ".join(sorted(missing)))
if len(selected) > limit:
raise ValueError(
f"命中 {len(selected)} 条,超过 --limit {limit};"
"请用 --suite / --case-id 缩小范围"
)
return selected
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="选择 ACK 回归用例")
parser.add_argument(
"regression", nargs="?", default=".pouch/ack/regression.yaml"
)
parser.add_argument(
"--suite",
choices=("smoke", "full"),
default="smoke",
help="smoke 只返回 suite=smoke;full 返回全部 active 用例",
)
parser.add_argument("--case-id", action="append", default=[], dest="case_ids")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument(
"--format", choices=("json", "ids"), default="json", dest="output_format"
)
args = parser.parse_args(argv)
regression_path = Path(args.regression)
if not regression_path.is_file():
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
return 2
if not 1 <= args.limit <= MAX_LIMIT:
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
return 2
for case_id in args.case_ids:
if ID_RE.fullmatch(case_id) is None:
sys.stderr.write(f"--case-id 必须使用 REG-<id> 格式: {case_id}\n")
return 2
data = load_yaml(regression_path, "回归目录")
errors = validate_builtin(data)
if errors:
sys.stderr.write(f"回归目录无效,拒绝选择,共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
try:
selected = select_cases(
data,
suite=args.suite,
case_ids=args.case_ids,
limit=args.limit,
)
except ValueError as exc:
sys.stderr.write(f"回归选择失败: {exc}\n")
return 1
ids = [case.get("id") for case in selected]
if args.output_format == "ids":
if ids:
sys.stdout.write("\n".join(str(item) for item in ids) + "\n")
return 0
payload = {
"count": len(selected),
"limit": args.limit,
"suite": args.suite,
"ids": ids,
"cases": selected,
}
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""为 ACK Coordinator 输出有预算的任务板上下文。
脚本会解析并校验完整 tasks.yaml,但只输出项目配置、摘要、可工作任务或显式任务,
以及这些任务引用的 worker receipt 和 delivery run。它不会修改任务板,也不会静默
截断超过预算的任务集合。
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from validate_tasks import STATUS_ENUM, load_document, validate_builtin # type: ignore
ACTIONABLE_STATUSES = (
"open",
"dispatched",
"fixed_by_dev",
"retesting",
"failed_retest",
)
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MAX_OUTPUT_BYTES = 512 * 1024
BOARD_METADATA_FIELDS = (
"version",
"updatedAt",
"source",
"ackVersion",
"kitVersion",
"testRecord",
"statusReference",
)
class SelectionError(ValueError):
pass
def _unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(values))
def select_tasks(
data: dict[str, Any],
*,
task_ids: list[str] | None = None,
statuses: list[str] | None = None,
limit: int = DEFAULT_LIMIT,
) -> tuple[str, list[str], list[dict[str, Any]]]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
raise SelectionError(f"limit 必须在 1..{MAX_LIMIT} 之间")
tasks = data.get("tasks")
if not isinstance(tasks, list):
raise SelectionError("tasks 必须是列表")
requested_ids = _unique(task_ids or [])
requested_statuses = _unique(statuses or [])
if requested_ids and requested_statuses:
raise SelectionError("--task-id 与 --status 不能同时使用")
if requested_ids:
by_id = {
task.get("id"): task
for task in tasks
if isinstance(task, dict) and isinstance(task.get("id"), str)
}
missing = [task_id for task_id in requested_ids if task_id not in by_id]
if missing:
raise SelectionError("找不到任务: " + ", ".join(missing))
selected = [by_id[task_id] for task_id in requested_ids]
mode = "task_ids"
criteria = requested_ids
else:
effective_statuses = requested_statuses or list(ACTIONABLE_STATUSES)
unknown = [status for status in effective_statuses if status not in STATUS_ENUM]
if unknown:
raise SelectionError("未知状态: " + ", ".join(unknown))
wanted = set(effective_statuses)
selected = [
task
for task in tasks
if isinstance(task, dict) and task.get("status") in wanted
]
mode = "statuses"
criteria = effective_statuses
if len(selected) > limit:
raise SelectionError(
f"命中 {len(selected)} 条任务,超过 --limit={limit};"
"请用 --task-id/--status 缩小范围或显式提高 limit"
)
return mode, criteria, selected
def select_referenced_receipts(
data: dict[str, Any], selected: list[dict[str, Any]]
) -> list[dict[str, Any]]:
receipt_ids: list[str] = []
for task in selected:
dispatch = task.get("dispatch")
if not isinstance(dispatch, dict):
continue
for role in ("developer", "test"):
role_dispatch = dispatch.get(role)
if not isinstance(role_dispatch, dict):
continue
receipt_id = role_dispatch.get("receiptId")
if isinstance(receipt_id, str) and receipt_id not in receipt_ids:
receipt_ids.append(receipt_id)
if not receipt_ids:
return []
receipts = data.get("workerReceipts")
if not isinstance(receipts, list):
raise SelectionError("选中任务引用了 receipt,但 workerReceipts 不是列表")
by_id = {
receipt.get("id"): receipt
for receipt in receipts
if isinstance(receipt, dict) and isinstance(receipt.get("id"), str)
}
missing = [receipt_id for receipt_id in receipt_ids if receipt_id not in by_id]
if missing:
raise SelectionError("选中任务引用了未知 receipt: " + ", ".join(missing))
return [by_id[receipt_id] for receipt_id in receipt_ids]
def select_delivery_runs(
data: dict[str, Any], selected: list[dict[str, Any]]
) -> list[dict[str, Any]]:
selected_ids = {
task.get("id")
for task in selected
if isinstance(task.get("id"), str)
}
runs = data.get("deliveryRuns")
if not selected_ids or not isinstance(runs, list):
return []
return [
run
for run in runs
if isinstance(run, dict)
and isinstance(run.get("taskIds"), list)
and any(task_id in selected_ids for task_id in run["taskIds"])
]
def build_payload(
data: dict[str, Any],
*,
mode: str,
criteria: list[str],
selected: list[dict[str, Any]],
) -> dict[str, Any]:
receipts = select_referenced_receipts(data, selected)
delivery_runs = select_delivery_runs(data, selected)
tasks = data.get("tasks")
metadata = {
field: data[field]
for field in BOARD_METADATA_FIELDS
if field in data
}
selected_ids = [
task["id"]
for task in selected
if isinstance(task.get("id"), str)
]
payload: dict[str, Any] = {
"board": metadata,
"project": data.get("project"),
"summary": data.get("summary"),
"selection": {
"mode": mode,
"criteria": criteria,
"count": len(selected),
"totalTasks": len(tasks) if isinstance(tasks, list) else 0,
"selectedTaskIds": selected_ids,
"referencedReceiptIds": [receipt.get("id") for receipt in receipts],
"deliveryRunIds": [run.get("id") for run in delivery_runs],
},
"tasks": selected,
"workerReceipts": receipts,
"deliveryRuns": delivery_runs,
}
return payload
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="选择 ACK 任务板的有预算 Coordinator 上下文"
)
parser.add_argument(
"tasks",
nargs="?",
default=".pouch/ack/tasks.yaml",
help="任务板路径",
)
parser.add_argument("--task-id", action="append", default=[])
parser.add_argument(
"--status",
action="append",
default=[],
choices=sorted(STATUS_ENUM),
)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
return 2
data = load_document(tasks_path)
errors = validate_builtin(data)
if errors:
sys.stderr.write(f"任务板无效,拒绝选择,共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
try:
mode, criteria, selected = select_tasks(
data,
task_ids=args.task_id,
statuses=args.status,
limit=args.limit,
)
payload = build_payload(
data,
mode=mode,
criteria=criteria,
selected=selected,
)
except SelectionError as exc:
sys.stderr.write(f"任务选择失败: {exc}\n")
return 1
if args.compact:
output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
else:
output = json.dumps(payload, ensure_ascii=False, indent=2)
if len(output.encode("utf-8")) > MAX_OUTPUT_BYTES:
sys.stderr.write(
f"任务选择输出超过 {MAX_OUTPUT_BYTES} bytes;请进一步缩小任务范围\n"
)
return 1
sys.stdout.write(output + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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())
+871
View File
@@ -0,0 +1,871 @@
#!/usr/bin/env python3
"""校验 ACK 项目交付契约。
权威结构位于 templates/delivery.schema.json。jsonschema 是可选依赖;内置规则始终
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
用法:
python3 validate_delivery.py .pouch/ack/delivery.yaml
python3 validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
"""
from __future__ import annotations
import argparse
import json
import re
import stat
import sys
from pathlib import Path, PurePosixPath
from typing import Any
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$")
REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
TOP_LEVEL_FIELDS = {
"version",
"updatedAt",
"project",
"enabled",
"defaultProfile",
"intents",
"entrypoints",
"artifacts",
"destinations",
"environments",
"profiles",
}
ENTRYPOINT_FIELDS = {
"kind",
"target",
"function",
"path",
"args",
"requiredSecrets",
"workingDirectory",
"timeoutSeconds",
}
ARTIFACT_FIELDS = {"type", "build", "outputs", "image", "platforms"}
DESTINATION_FIELDS = {
"type",
"channel",
"registry",
"repository",
"endpoint",
"artifactName",
"upload",
}
ENVIRONMENT_FIELDS = {
"type",
"classification",
"target",
"deploy",
"healthCheck",
"rollback",
"mutex",
}
PROFILE_FIELDS = {"stopAt", "steps"}
STEP_FIELDS = {
"id",
"action",
"entrypoint",
"artifact",
"destination",
"environment",
"gate",
"draft",
"remote",
"baseBranch",
}
ENTRYPOINT_KINDS = {"make", "just", "task", "dagger", "script"}
ARTIFACT_TYPES = {"deb", "oci-image", "file"}
DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
CHANNELS = {"preview", "staging", "stable"}
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
CLASSIFICATIONS = {"development", "staging", "production"}
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
INTENT_FIELDS = {"testEnvironment", "release"}
INTENT_STOP_AT = {
"release": "released",
}
TEST_ENVIRONMENT_FIELDS = {"via", "env"}
ACTIONS = {
"verify",
"pull-request",
"build",
"publish",
"deploy",
"health-check",
"approval",
"mark-ready",
}
ACTION_FIELDS = {
"verify": {"entrypoint"},
"pull-request": {"draft", "remote", "baseBranch"},
"build": {"artifact"},
"publish": {"artifact", "destination"},
"deploy": {"artifact", "environment"},
"health-check": {"environment"},
"approval": {"gate"},
"mark-ready": set(),
}
SECRET_PATTERNS = (
("private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
("OpenAI-style token", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
("URL credentials", re.compile(r"https?://[^/\s:@]+:[^/\s@]+@")),
(
"inline secret assignment",
re.compile(
r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)"
r"\s*[:=]\s*[\"']?[^\s,\"']{8,}"
),
),
)
def _nonempty(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _mapping(value: Any) -> bool:
return isinstance(value, dict)
def _reject_unknown(
value: dict[str, Any],
allowed: set[str],
where: str,
errors: list[str],
) -> None:
for field in sorted(set(value) - allowed):
errors.append(f"{where}: 未知字段 {field!r}")
def _load_document(path: Path, label: str) -> dict[str, Any]:
try:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
return data
def _safe_relative_path(value: Any, *, allow_glob: bool = False) -> bool:
if not _nonempty(value) or value.startswith("/") or "\\" in value:
return False
if not RELATIVE_PATH_RE.fullmatch(value):
return False
if not allow_glob and any(marker in value for marker in "*?"):
return False
parts = PurePosixPath(value).parts
return ".." not in parts and all(part not in {"", "/"} for part in parts)
def _safe_branch_name(value: Any) -> bool:
if not _nonempty(value) or len(value) > 255:
return False
if value == "@" or value.startswith(("/", ".", "-")):
return False
if value.endswith(("/", ".", ".lock")):
return False
if "@{" in value or ".." in value or "//" in value:
return False
return re.search(r"[\x00-\x20\x7f~^:?*\[\\]", value) is None
def _validate_path_binding(
project_root: Path,
relative_path: str,
where: str,
*,
expected: str,
) -> list[str]:
errors: list[str] = []
current = project_root
parts = PurePosixPath(relative_path).parts
if relative_path == ".":
parts = ()
for index, part in enumerate(parts):
current = current / part
try:
metadata = current.lstat()
except FileNotFoundError:
return [f"{where}: 路径不存在: {relative_path!r}"]
except OSError as exc:
return [f"{where}: 路径不可访问: {relative_path!r}: {exc}"]
if stat.S_ISLNK(metadata.st_mode):
return [f"{where}: 路径不能包含 symlink: {relative_path!r}"]
if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode):
return [f"{where}: 中间路径不是目录: {relative_path!r}"]
try:
metadata
except UnboundLocalError:
metadata = project_root.lstat()
if expected == "directory" and not stat.S_ISDIR(metadata.st_mode):
errors.append(f"{where}: 必须指向目录: {relative_path!r}")
if expected == "executable":
if not stat.S_ISREG(metadata.st_mode):
errors.append(f"{where}: 必须指向普通文件: {relative_path!r}")
elif metadata.st_mode & 0o111 == 0:
errors.append(f"{where}: 脚本不可执行: {relative_path!r}")
return errors
def _scan_secrets(value: Any, where: str, errors: list[str]) -> None:
if isinstance(value, dict):
for key, item in value.items():
_scan_secrets(item, f"{where}.{key}", errors)
return
if isinstance(value, list):
for index, item in enumerate(value):
_scan_secrets(item, f"{where}[{index}]", errors)
return
if not isinstance(value, str):
return
for label, pattern in SECRET_PATTERNS:
if pattern.search(value):
errors.append(f"{where}: 疑似包含敏感信息({label})")
def _validate_ids(values: Any, where: str, errors: list[str]) -> dict[str, Any]:
if not isinstance(values, dict):
errors.append(f"{where}: 必须是对象")
return {}
for key in values:
if not isinstance(key, str) or ID_RE.fullmatch(key) is None:
errors.append(f"{where}: ID {key!r} 必须使用小写连字符格式")
return values
def _validate_entrypoints(
values: dict[str, Any],
errors: list[str],
project_root: Path | None,
) -> None:
for entrypoint_id, value in values.items():
where = f"entrypoints.{entrypoint_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ENTRYPOINT_FIELDS, where, errors)
kind = value.get("kind")
if kind not in ENTRYPOINT_KINDS:
errors.append(f"{where}.kind: 必须是 {sorted(ENTRYPOINT_KINDS)}")
required_selector = {
"make": "target",
"just": "target",
"task": "target",
"dagger": "function",
"script": "path",
}.get(kind)
for selector in ("target", "function", "path"):
if selector == required_selector:
if not _nonempty(value.get(selector)):
errors.append(f"{where}.{selector}: {kind} 入口必须填写非空值")
elif selector in value:
errors.append(f"{where}.{selector}: kind={kind!r} 不允许此字段")
args = value.get("args")
if not isinstance(args, list) or any(not isinstance(item, str) for item in args):
errors.append(f"{where}.args: 必须是字符串列表")
required_secrets = value.get("requiredSecrets")
if (
not isinstance(required_secrets, list)
or any(
not isinstance(item, str) or SECRET_NAME_RE.fullmatch(item) is None
for item in required_secrets
)
or (
isinstance(required_secrets, list)
and len(required_secrets) != len(set(required_secrets))
)
):
errors.append(
f"{where}.requiredSecrets: 必须是唯一的大写 secret 名称列表"
)
working_directory = value.get("workingDirectory")
if not _safe_relative_path(working_directory):
errors.append(f"{where}.workingDirectory: 必须是安全的仓库内相对路径")
timeout = value.get("timeoutSeconds")
if (
not isinstance(timeout, int)
or isinstance(timeout, bool)
or not 1 <= timeout <= 86400
):
errors.append(f"{where}.timeoutSeconds: 必须是 1..86400 的整数")
if kind == "script" and not _safe_relative_path(value.get("path")):
errors.append(f"{where}.path: 必须是安全的仓库内相对路径")
if project_root is not None:
if _safe_relative_path(working_directory):
errors.extend(
_validate_path_binding(
project_root,
working_directory,
f"{where}.workingDirectory",
expected="directory",
)
)
if kind == "script" and _safe_relative_path(value.get("path")):
errors.extend(
_validate_path_binding(
project_root,
value["path"],
f"{where}.path",
expected="executable",
)
)
def _validate_artifacts(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for artifact_id, value in values.items():
where = f"artifacts.{artifact_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ARTIFACT_FIELDS, where, errors)
artifact_type = value.get("type")
if artifact_type not in ARTIFACT_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(ARTIFACT_TYPES)}")
build = value.get("build")
if build not in entrypoints:
errors.append(f"{where}.build: 未定义 entrypoint {build!r}")
outputs = value.get("outputs")
if artifact_type in {"deb", "file"}:
if (
not isinstance(outputs, list)
or not outputs
or any(not _safe_relative_path(item, allow_glob=True) for item in outputs)
):
errors.append(f"{where}.outputs: deb/file 必须填写安全的产物路径列表")
if "image" in value or "platforms" in value:
errors.append(f"{where}: deb/file 不允许 image 或 platforms")
if artifact_type == "oci-image":
if not _nonempty(value.get("image")):
errors.append(f"{where}.image: oci-image 必须填写镜像名")
platforms = value.get("platforms")
if (
not isinstance(platforms, list)
or not platforms
or any(not isinstance(item, str) or PLATFORM_RE.fullmatch(item) is None for item in platforms)
or len(platforms) != len(set(platforms))
):
errors.append(f"{where}.platforms: 必须是唯一的 os/arch 列表")
if "outputs" in value:
errors.append(f"{where}: oci-image 不允许 outputs")
def _validate_destinations(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for destination_id, value in values.items():
where = f"destinations.{destination_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, DESTINATION_FIELDS, where, errors)
destination_type = value.get("type")
if destination_type not in DESTINATION_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(DESTINATION_TYPES)}")
type_fields = {
"apt-repository": {"endpoint", "repository"},
"oci-registry": {"registry", "repository"},
"ci-artifact": {"artifactName"},
}.get(destination_type, set())
allowed_fields = {"type", "channel", "upload"} | type_fields
for field in sorted(set(value) - allowed_fields):
errors.append(f"{where}.{field}: type={destination_type!r} 不允许此字段")
if value.get("channel") not in CHANNELS:
errors.append(f"{where}.channel: 必须是 {sorted(CHANNELS)}")
upload = value.get("upload")
if upload is not None and upload not in entrypoints:
errors.append(f"{where}.upload: 未定义 entrypoint {upload!r}")
if destination_type == "apt-repository":
if not _nonempty(value.get("endpoint")):
errors.append(f"{where}.endpoint: APT 目标必须填写服务地址")
if not _nonempty(value.get("repository")):
errors.append(f"{where}.repository: APT 目标必须填写仓库名")
if destination_type == "oci-registry":
for field in ("registry", "repository"):
if not _nonempty(value.get(field)):
errors.append(f"{where}.{field}: OCI 目标必须填写非空值")
if destination_type == "ci-artifact" and not _nonempty(value.get("artifactName")):
errors.append(f"{where}.artifactName: CI artifact 必须填写名称")
def _validate_environments(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for environment_id, value in values.items():
where = f"environments.{environment_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ENVIRONMENT_FIELDS, where, errors)
if value.get("type") not in ENVIRONMENT_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(ENVIRONMENT_TYPES)}")
classification = value.get("classification")
if classification not in CLASSIFICATIONS:
errors.append(f"{where}.classification: 必须是 {sorted(CLASSIFICATIONS)}")
if not _nonempty(value.get("target")):
errors.append(f"{where}.target: 必须是非空目标别名")
for field in ("deploy", "healthCheck"):
reference = value.get(field)
if reference not in entrypoints:
errors.append(f"{where}.{field}: 未定义 entrypoint {reference!r}")
rollback = value.get("rollback")
if rollback is not None and rollback not in entrypoints:
errors.append(f"{where}.rollback: 未定义 entrypoint {rollback!r}")
if classification == "production" and rollback is None:
errors.append(f"{where}.rollback: production 环境必须提供回滚入口")
if not _nonempty(value.get("mutex")):
errors.append(f"{where}.mutex: 必须填写部署互斥锁 ID")
def _artifact_destination_compatible(artifact_type: str, destination_type: str) -> bool:
return destination_type in {
"deb": {"apt-repository", "ci-artifact"},
"oci-image": {"oci-registry", "ci-artifact"},
"file": {"ci-artifact"},
}.get(artifact_type, set())
def _validate_profiles(
values: dict[str, Any],
*,
default_profile: Any,
entrypoints: dict[str, Any],
artifacts: dict[str, Any],
destinations: dict[str, Any],
environments: dict[str, Any],
errors: list[str],
) -> None:
for profile_id, value in values.items():
where = f"profiles.{profile_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, PROFILE_FIELDS, where, errors)
stop_at = value.get("stopAt")
if stop_at not in STOP_POINTS:
errors.append(f"{where}.stopAt: 必须是 {sorted(STOP_POINTS)}")
steps = value.get("steps")
if not isinstance(steps, list):
errors.append(f"{where}.steps: 必须是列表")
continue
seen_step_ids: set[str] = set()
built_artifacts: set[str] = set()
published_artifacts: set[str] = set()
deployed_environments: set[str] = set()
checked_environments: set[str] = set()
approvals: set[str] = set()
has_pull_request = False
has_mark_ready = False
for index, step in enumerate(steps):
step_where = f"{where}.steps[{index}]"
if not _mapping(step):
errors.append(f"{step_where}: 必须是对象")
continue
_reject_unknown(step, STEP_FIELDS, step_where, errors)
step_id = step.get("id")
if not isinstance(step_id, str) or ID_RE.fullmatch(step_id) is None:
errors.append(f"{step_where}.id: 必须使用小写连字符格式")
elif step_id in seen_step_ids:
errors.append(f"{step_where}.id: 不能重复 {step_id!r}")
else:
seen_step_ids.add(step_id)
action = step.get("action")
if action not in ACTIONS:
errors.append(f"{step_where}.action: 必须是 {sorted(ACTIONS)}")
continue
required_fields = ACTION_FIELDS[action]
for field in sorted(required_fields):
if field not in step:
errors.append(f"{step_where}.{field}: action={action!r} 时必填")
allowed_fields = {"id", "action"} | required_fields
for field in sorted(set(step) - allowed_fields):
errors.append(f"{step_where}.{field}: action={action!r} 不允许此字段")
if action == "verify" and step.get("entrypoint") not in entrypoints:
errors.append(
f"{step_where}.entrypoint: 未定义 entrypoint {step.get('entrypoint')!r}"
)
if action == "pull-request":
if not isinstance(step.get("draft"), bool):
errors.append(f"{step_where}.draft: 必须是布尔值")
if (
not isinstance(step.get("remote"), str)
or REMOTE_RE.fullmatch(step["remote"]) is None
):
errors.append(f"{step_where}.remote: 必须是安全的 Git remote 名称")
if not _safe_branch_name(step.get("baseBranch")):
errors.append(f"{step_where}.baseBranch: 必须是安全的 Git 分支名")
has_pull_request = True
if action == "build":
artifact_id = step.get("artifact")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
else:
built_artifacts.add(artifact_id)
if action == "publish":
artifact_id = step.get("artifact")
destination_id = step.get("destination")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
elif artifact_id not in built_artifacts:
errors.append(f"{step_where}: publish 前必须先 build {artifact_id!r}")
if destination_id not in destinations:
errors.append(
f"{step_where}.destination: 未定义 destination {destination_id!r}"
)
elif artifact_id in artifacts:
artifact_type = artifacts[artifact_id].get("type")
destination_type = destinations[destination_id].get("type")
if not _artifact_destination_compatible(artifact_type, destination_type):
errors.append(
f"{step_where}: artifact {artifact_type!r} 不能发布到 "
f"{destination_type!r}"
)
if destinations[destination_id].get("channel") == "stable" and "release" not in approvals:
errors.append(f"{step_where}: stable 发布前必须有 release approval")
published_artifacts.add(artifact_id)
if action == "deploy":
artifact_id = step.get("artifact")
environment_id = step.get("environment")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
elif artifact_id not in built_artifacts:
errors.append(f"{step_where}: deploy 前必须先 build {artifact_id!r}")
if environment_id not in environments:
errors.append(
f"{step_where}.environment: 未定义 environment {environment_id!r}"
)
else:
classification = environments[environment_id].get("classification")
if classification == "production" and "production" not in approvals:
errors.append(f"{step_where}: production 部署前必须有 production approval")
if (
classification == "production"
and artifact_id not in published_artifacts
):
errors.append(f"{step_where}: production 部署前必须先 publish 同一产物")
deployed_environments.add(environment_id)
if action == "health-check":
environment_id = step.get("environment")
if environment_id not in environments:
errors.append(
f"{step_where}.environment: 未定义 environment {environment_id!r}"
)
elif environment_id not in deployed_environments:
errors.append(
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
)
else:
checked_environments.add(environment_id)
if action == "approval":
gate = step.get("gate")
if gate not in {"release", "production"}:
errors.append(f"{step_where}.gate: 必须是 release/production")
else:
if gate == "release" and not built_artifacts:
errors.append(f"{step_where}: release approval 前必须先 build 产物")
if gate == "production" and not published_artifacts:
errors.append(f"{step_where}: production approval 前必须先 publish 产物")
approvals.add(gate)
if action == "mark-ready":
if not has_pull_request:
errors.append(f"{step_where}: mark-ready 前必须先创建 pull-request")
has_mark_ready = True
if index != len(steps) - 1:
errors.append(f"{step_where}: mark-ready 必须是 profile 最后一步")
if stop_at in {"review_ready", "released"} and (
not has_pull_request or not has_mark_ready
):
errors.append(
f"{where}: {stop_at} 必须包含 pull-request 和末尾 mark-ready"
)
if stop_at == "released" and not ({"release", "production"} & approvals):
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
if stop_at == "validation_ready":
if not deployed_environments:
errors.append(f"{where}: validation_ready 必须至少部署一个环境")
missing_health = deployed_environments - checked_environments
if missing_health:
errors.append(
f"{where}: validation_ready 的部署环境必须全部完成 health-check: "
f"{sorted(missing_health)}"
)
if profile_id == default_profile:
if stop_at not in {"validation_ready", "review_ready"}:
errors.append(
f"{where}: defaultProfile 必须停在 validation_ready 或 review_ready"
)
used_destinations = {
step.get("destination")
for step in steps
if isinstance(step, dict) and step.get("action") == "publish"
}
used_environments = {
step.get("environment")
for step in steps
if isinstance(step, dict) and step.get("action") == "deploy"
}
if any(
destinations.get(item, {}).get("channel") == "stable"
for item in used_destinations
):
errors.append(f"{where}: defaultProfile 不能发布 stable 目标")
if any(
environments.get(item, {}).get("classification") == "production"
for item in used_environments
):
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
def _validate_test_environment_intent(
value: Any,
errors: list[str],
project_root: Path | None,
) -> None:
if value is None:
return
if isinstance(value, str):
errors.append(
"intents.testEnvironment: 已改为 deployer 绑定 "
"{via: deployer, env: <env>},不能再使用 profile ID "
f"{value!r}。请迁移后由 ACK 内部调用 deployer skill"
)
return
if not _mapping(value):
errors.append("intents.testEnvironment: 必须是 null 或 {via, env} 对象")
return
_reject_unknown(value, TEST_ENVIRONMENT_FIELDS, "intents.testEnvironment", errors)
if value.get("via") != "deployer":
errors.append("intents.testEnvironment.via 必须是 'deployer'")
env = value.get("env")
if not isinstance(env, str) or ID_RE.fullmatch(env) is None:
errors.append("intents.testEnvironment.env 必须是小写连字符环境名")
return
if project_root is None:
return
env_dir = project_root / ".pouch" / "deployer" / env
if not env_dir.is_dir():
env_dir = project_root / ".skiff" / "deployer" / env
if not env_dir.is_dir():
errors.append(
"intents.testEnvironment.env: 找不到 "
f".pouch/deployer/{env};先按 deployer skill 配置项目测试环境"
)
def _validate_intents(
values: Any,
profiles: dict[str, Any],
errors: list[str],
project_root: Path | None = None,
) -> None:
if values is None:
return
if not _mapping(values):
errors.append("intents: 必须是对象")
return
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
if "testEnvironment" not in values:
errors.append("intents.testEnvironment: 必填")
else:
_validate_test_environment_intent(
values.get("testEnvironment"), errors, project_root
)
if "release" not in values:
errors.append("intents.release: 必填")
return
profile_id = values["release"]
if profile_id is None:
return
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
errors.append("intents.release: 必须是 null 或小写连字符 profile ID")
return
profile = profiles.get(profile_id)
if profile is None:
errors.append(f"intents.release: 未定义 profile {profile_id!r}")
return
if _mapping(profile) and profile.get("stopAt") != INTENT_STOP_AT["release"]:
errors.append(
f"intents.release: profile {profile_id!r} 必须 stopAt released"
)
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
errors: list[str] = []
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
if data.get("version") != 1 or isinstance(data.get("version"), bool):
errors.append("version 必须是整数 1")
if "updatedAt" in data and not _nonempty(data.get("updatedAt")):
errors.append("updatedAt 必须是非空字符串")
project = data.get("project")
if not _mapping(project):
errors.append("project 必须是对象")
project = {}
else:
_reject_unknown(project, {"name"}, "project", errors)
if not _nonempty(project.get("name")):
errors.append("project.name 必须是非空字符串")
enabled = data.get("enabled")
if not isinstance(enabled, bool):
errors.append("enabled 必须是布尔值")
default_profile = data.get("defaultProfile")
if default_profile is not None and (
not isinstance(default_profile, str) or ID_RE.fullmatch(default_profile) is None
):
errors.append("defaultProfile 必须是 null 或小写连字符 ID")
entrypoints = _validate_ids(data.get("entrypoints"), "entrypoints", errors)
artifacts = _validate_ids(data.get("artifacts"), "artifacts", errors)
destinations = _validate_ids(data.get("destinations"), "destinations", errors)
environments = _validate_ids(data.get("environments"), "environments", errors)
profiles = _validate_ids(data.get("profiles"), "profiles", errors)
_validate_entrypoints(entrypoints, errors, project_root)
_validate_artifacts(artifacts, entrypoints, errors)
_validate_destinations(destinations, entrypoints, errors)
_validate_environments(environments, entrypoints, errors)
_validate_profiles(
profiles,
default_profile=default_profile,
entrypoints=entrypoints,
artifacts=artifacts,
destinations=destinations,
environments=environments,
errors=errors,
)
_validate_intents(data.get("intents"), profiles, errors, project_root)
if enabled:
if default_profile not in profiles:
errors.append("enabled=true 时 defaultProfile 必须引用已定义 profile")
elif not profiles[default_profile].get("steps"):
errors.append("enabled=true 时 defaultProfile.steps 不能为空")
elif default_profile is not None and default_profile not in profiles:
errors.append("defaultProfile 必须引用已定义 profile")
_scan_secrets(data, "<root>", errors)
return errors
def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list[str]:
errors: list[str] = []
project = tasks.get("project")
if not isinstance(project, dict):
return ["tasks.project 必须是对象"]
if project.get("deliveryFile") != ".pouch/ack/delivery.yaml":
errors.append("tasks.project.deliveryFile 必须固定为 .pouch/ack/delivery.yaml")
delivery_project = delivery.get("project")
if (
isinstance(delivery_project, dict)
and _nonempty(delivery_project.get("name"))
and _nonempty(project.get("name"))
and delivery_project["name"] != project["name"]
):
errors.append("delivery.project.name 必须与 tasks.project.name 一致")
if not isinstance(tasks.get("deliveryRuns"), list):
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
return errors
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
import jsonschema # type: ignore
schema = json.loads(schema_path.read_text(encoding="utf-8"))
validator = jsonschema.Draft7Validator(schema)
errors = []
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
location = "/".join(str(part) for part in error.path) or "<root>"
errors.append(f"[schema] {location}: {error.message}")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
parser.add_argument("delivery", nargs="?", default=".pouch/ack/delivery.yaml")
parser.add_argument("--tasks", help="关联的 .pouch/ack/tasks.yaml")
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
args = parser.parse_args(argv)
delivery_path = Path(args.delivery)
if not delivery_path.is_file():
sys.stderr.write(f"找不到交付契约: {delivery_path}\n")
return 2
project_root = Path(args.project_root).resolve() if args.project_root else None
if project_root is not None and not project_root.is_dir():
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
return 2
delivery = _load_document(delivery_path, "交付契约")
errors = validate_builtin(delivery, project_root)
if args.tasks:
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
return 2
tasks = _load_document(tasks_path, "任务板")
errors.extend(validate_tasks_link(delivery, tasks))
schema_path = (
Path(args.schema)
if args.schema
else Path(__file__).resolve().parent.parent / "templates" / "delivery.schema.json"
)
if args.schema and not schema_path.is_file():
sys.stderr.write(f"找不到 schema: {schema_path}\n")
return 2
if schema_path.is_file():
try:
errors.extend(validate_with_schema(delivery, schema_path))
except ImportError:
sys.stderr.write("提示: 未安装 jsonschema,仅执行内置语义规则\n")
except (OSError, json.JSONDecodeError) as exc:
sys.stderr.write(f"schema 读取失败: {exc}\n")
return 2
if errors:
for error in errors:
sys.stderr.write(f"- {error}\n")
return 1
sys.stdout.write("交付契约校验通过\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""校验 ACK 项目回归目录及其任务引用。
权威结构位于 templates/regression.schema.json。jsonschema 是可选依赖;内置规则
始终检查用例 ID、验收信号和跨文件引用。本脚本只解析数据,不执行 steps。
用法:
python3 validate_regression.py .pouch/ack/regression.yaml
python3 validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
from task_store import TaskStoreError, is_task_store_manifest, materialize_board, parse_document
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
STATUSES = {"active", "retired"}
SUITES = {"smoke", "full"}
SURFACES = {"browser", "api"}
SOURCE_KINDS = {"feature", "bug"}
EXPECTED_KINDS = {"visible-text", "api-status", "api-field", "url", "interaction"}
CASE_FIELDS = {
"id",
"title",
"status",
"source",
"suite",
"surface",
"setup",
"steps",
"expected",
"automationRef",
}
SOURCE_FIELDS = {"taskId", "kind"}
EXPECTED_FIELDS = {"kind", "value"}
TOP_LEVEL_FIELDS = {"version", "updatedAt", "project", "cases"}
def _nonempty(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _mapping(value: Any) -> bool:
return isinstance(value, dict)
def _reject_unknown(
value: dict[str, Any],
allowed: set[str],
where: str,
errors: list[str],
) -> None:
for field in sorted(set(value) - allowed):
errors.append(f"{where}: 未知字段 {field!r}")
def load_yaml(path: Path, label: str) -> dict[str, Any]:
try:
data = parse_document(path)
if is_task_store_manifest(data):
data = materialize_board(data, path)
except TaskStoreError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
return data
def infer_project_root(document_path: Path) -> Path | None:
lexical = document_path.expanduser().absolute()
parent = lexical.parent
if parent.name == "ack" and parent.parent.name == "docs":
return parent.parent.parent.resolve()
for candidate in (parent, *parent.parents):
if (candidate / ".git").exists():
return candidate.resolve()
return None
def _validate_expected(value: Any, where: str, errors: list[str]) -> None:
if not isinstance(value, list) or not value:
errors.append(f"{where}: 必须是非空列表")
return
for index, item in enumerate(value):
item_where = f"{where}[{index}]"
if not _mapping(item):
errors.append(f"{item_where}: 必须是对象")
continue
_reject_unknown(item, EXPECTED_FIELDS, item_where, errors)
if item.get("kind") not in EXPECTED_KINDS:
errors.append(f"{item_where}.kind: 必须是 {sorted(EXPECTED_KINDS)}")
if not _nonempty(item.get("value")):
errors.append(f"{item_where}.value: 必须是非空字符串")
def validate_builtin(data: dict[str, Any]) -> list[str]:
errors: list[str] = []
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
if data.get("version") != 1 or isinstance(data.get("version"), bool):
errors.append("version 必须是整数 1")
if not _nonempty(data.get("updatedAt")):
errors.append("updatedAt 必须是非空字符串")
project = data.get("project")
if not _mapping(project):
errors.append("project 必须是对象")
else:
_reject_unknown(project, {"name"}, "project", errors)
if not _nonempty(project.get("name")):
errors.append("project.name 必须是非空字符串")
cases = data.get("cases")
if not isinstance(cases, list):
errors.append("cases 必须是列表")
return errors
seen: set[str] = set()
for index, case in enumerate(cases):
where = f"cases[{index}]"
if not _mapping(case):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(case, CASE_FIELDS, where, errors)
missing = sorted(CASE_FIELDS - {"automationRef"} - set(case))
for field in missing:
errors.append(f"{where}.{field}: 必填")
case_id = case.get("id")
if not isinstance(case_id, str) or ID_RE.fullmatch(case_id) is None:
errors.append(f"{where}.id: 必须使用 REG-<id> 格式")
elif case_id in seen:
errors.append(f"{where}.id: 不能重复 {case_id!r}")
else:
seen.add(case_id)
if not _nonempty(case.get("title")):
errors.append(f"{where}.title: 必须是非空字符串")
if case.get("status") not in STATUSES:
errors.append(f"{where}.status: 必须是 {sorted(STATUSES)}")
if case.get("suite") not in SUITES:
errors.append(f"{where}.suite: 必须是 {sorted(SUITES)}")
if case.get("surface") not in SURFACES:
errors.append(f"{where}.surface: 必须是 {sorted(SURFACES)}")
if not _nonempty(case.get("setup")):
errors.append(f"{where}.setup: 必须是非空字符串")
source = case.get("source")
if not _mapping(source):
errors.append(f"{where}.source: 必须是对象")
else:
_reject_unknown(source, SOURCE_FIELDS, f"{where}.source", errors)
task_id = source.get("taskId")
if not isinstance(task_id, str) or TASK_ID_RE.fullmatch(task_id) is None:
errors.append(f"{where}.source.taskId: 必须是任务 ID")
if source.get("kind") not in SOURCE_KINDS:
errors.append(f"{where}.source.kind: 必须是 {sorted(SOURCE_KINDS)}")
steps = case.get("steps")
if (
not isinstance(steps, list)
or not steps
or any(not _nonempty(step) for step in steps)
):
errors.append(f"{where}.steps: 必须是非空字符串列表")
_validate_expected(case.get("expected"), f"{where}.expected", errors)
automation_ref = case.get("automationRef")
if automation_ref is not None:
if (
not isinstance(automation_ref, str)
or automation_ref.startswith("/")
or "\\" in automation_ref
or ".." in automation_ref.split("/")
or RELATIVE_PATH_RE.fullmatch(automation_ref) is None
):
errors.append(f"{where}.automationRef: 必须是项目内相对路径")
return errors
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
import jsonschema # type: ignore
schema = json.loads(schema_path.read_text(encoding="utf-8"))
validator = jsonschema.Draft7Validator(schema)
errors = []
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
location = "/".join(str(part) for part in error.path) or "<root>"
errors.append(f"[schema] {location}: {error.message}")
return errors
def validate_tasks_link(
regression: dict[str, Any],
tasks: dict[str, Any],
) -> list[str]:
errors: list[str] = []
project = tasks.get("project")
if not isinstance(project, dict):
return ["[tasks] project 必须是对象"]
regression_file = project.get("regressionFile")
if regression_file != ".pouch/ack/regression.yaml":
errors.append(
"[tasks] project.regressionFile 必须固定为 .pouch/ack/regression.yaml"
)
if not isinstance(tasks.get("regressionRuns"), list):
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
regression_project = regression.get("project")
if (
isinstance(regression_project, dict)
and _nonempty(regression_project.get("name"))
and _nonempty(project.get("name"))
and regression_project["name"] != project["name"]
):
errors.append("regression.project.name 必须与 tasks.project.name 一致")
case_ids = {
case.get("id")
for case in regression.get("cases") or []
if isinstance(case, dict) and isinstance(case.get("id"), str)
}
task_ids = {
task.get("id")
for task in tasks.get("tasks") or []
if isinstance(task, dict) and isinstance(task.get("id"), str)
}
for index, task in enumerate(tasks.get("tasks") or []):
if not isinstance(task, dict):
continue
where = f"[tasks] tasks[{index}]"
refs = task.get("regressionRefs")
if refs is None:
continue
if not isinstance(refs, list):
errors.append(f"{where}.regressionRefs: 必须是列表")
continue
seen: set[str] = set()
for ref_index, ref in enumerate(refs):
ref_where = f"{where}.regressionRefs[{ref_index}]"
if not isinstance(ref, str) or ID_RE.fullmatch(ref) is None:
errors.append(f"{ref_where}: 必须使用 REG-<id> 格式")
continue
if ref in seen:
errors.append(f"{ref_where}: 不能重复 {ref!r}")
seen.add(ref)
if ref not in case_ids:
errors.append(f"{ref_where}: 未知用例 {ref!r}")
for index, run in enumerate(tasks.get("regressionRuns") or []):
if not isinstance(run, dict):
continue
where = f"[tasks] regressionRuns[{index}]"
for case_id in run.get("caseIds") or []:
if isinstance(case_id, str) and case_id not in case_ids:
errors.append(f"{where}.caseIds: 未知用例 {case_id!r}")
source_task_ids = run.get("taskIds") or []
if isinstance(source_task_ids, list):
for task_id in source_task_ids:
if isinstance(task_id, str) and task_id not in task_ids:
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
return errors
def validate_all(
data: dict[str, Any],
schema_path: Path,
*,
use_schema: bool | None = None,
) -> tuple[list[str], str]:
if use_schema is None:
try:
import jsonschema # type: ignore # noqa: F401
except ImportError:
use_schema = False
else:
use_schema = schema_path.is_file()
errors = validate_builtin(data)
if use_schema and schema_path.is_file():
errors = validate_with_schema(data, schema_path) + errors
mode = f"schema ({schema_path.name}) + 内置结构"
else:
mode = "内置结构"
return list(dict.fromkeys(errors)), mode
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 ACK 项目回归目录")
parser.add_argument(
"regression", nargs="?", default=".pouch/ack/regression.yaml"
)
parser.add_argument("--tasks", help="关联的 .pouch/ack/tasks.yaml")
parser.add_argument("--schema", help="regression.schema.json 路径(默认自动探测)")
args = parser.parse_args(argv)
regression_path = Path(args.regression)
if not regression_path.is_file():
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
return 2
schema_path = (
Path(args.schema)
if args.schema
else Path(__file__).resolve().parent.parent
/ "templates"
/ "regression.schema.json"
)
if args.schema and not schema_path.is_file():
sys.stderr.write(f"找不到 schema: {schema_path}\n")
return 2
data = load_yaml(regression_path, "回归目录")
errors, mode = validate_all(data, schema_path)
if args.tasks:
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
return 2
tasks = load_yaml(tasks_path, "任务板")
errors.extend(validate_tasks_link(data, tasks))
mode += " + tasks 引用"
if errors:
sys.stderr.write(f"回归目录校验失败({mode}),共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f"- {error}\n")
return 1
sys.stdout.write(f"回归目录校验通过({mode})\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1447
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Fail-closed migration shim for ACK's removed free-form command validator.
ACK v0.10 no longer accepts a shell command as worker configuration. Keep this
file only so older documentation or automation fails with a precise migration
message instead of silently retaining the unsafe path.
"""
from __future__ import annotations
import sys
MIGRATION_MESSAGE = (
"ACK v0.10 已停用自由 worker command 校验器;请在 "
".pouch/ack/agents.yaml(优先)或 .pouch/ack/tasks.yaml 的 project.orchestration 中声明 profile,并仅调用 "
"scripts/launch_worker.py profile-hash|plan|launch。"
)
def main(argv: list[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments == ["--self-test"]:
print("legacy worker command 路径已 fail-closed")
return 0
sys.stderr.write(MIGRATION_MESSAGE + "\n")
return 2
if __name__ == "__main__":
raise SystemExit(main())
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Probe one dispatched ACK worker's liveness and emit a single JSON status.
Read-only supervision helper for the coordinator's wait loop. It never sends
input, never mutates dispatch or terminal state, and never marks a task
outcome. The coordinator runs it between rolling ``check --wait`` windows to
detect workers that never started, stalled on an approval/choice prompt, hit a
usage limit, or lost heartbeat.
Output (single JSON document on stdout):
{
"probedAt": "<RFC3339>",
"taskId": "<task-id>",
"dispatchId": "<dispatch-id>",
"terminal": "<handle>",
"status": "running | progress | stall | not-started | unknown",
"stallReason": "<label> | null",
"heartbeatAt": "<value> | null",
"heartbeatAgeSeconds": "<number> | null",
"evidence": "<bounded terminal tail>"
}
Exit code is always 0 for a probe attempt: a failed probe is ``unknown`` for
the coordinator to reconcile, never an automatic retry trigger.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from launch_worker import ( # noqa: E402
LaunchError,
parse_timestamp,
resolve_executable,
run_json,
utc_now,
)
# Conservative stall patterns: an interactive prompt the worker is waiting on.
# Matching only means "evidence of a stall to inspect", never a verdict alone.
STALL_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("approval", re.compile(r"(?i)approv(e|al)|allow tool|permission|批准|允许")),
("usage-limit", re.compile(r"(?i)usage limit|rate limit|额度|quota")),
("model-switch", re.compile(r"(?i)switch to|keep current model|choose an action|切换")),
("press-enter", re.compile(r"(?i)press enter|回车|按回车")),
)
WORKING_PATTERN = re.compile(r"(?i)working|•working|running|执行中|正在")
IDLE_TAIL_PATTERN = re.compile(r"(?i)welcome to|type help|fish, the friendly|>\\s*$")
MAX_EVIDENCE_CHARS = 500
HEARTBEAT_INTERVAL_SECONDS = 5 * 60
HEARTBEAT_MAX_AGE_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 3
def heartbeat_age_seconds(
heartbeat: object, now: datetime | None = None
) -> tuple[float | None, str | None]:
"""Return heartbeat age and a diagnostic for malformed/stale timestamps."""
if heartbeat is None:
return None, None
try:
heartbeat_at = parse_timestamp(heartbeat, "last_heartbeat_at")
except LaunchError:
return None, "invalid-heartbeat"
current = now if now is not None else utc_now()
age = (current - heartbeat_at).total_seconds()
if age < -60:
return None, "future-heartbeat"
return max(age, 0.0), None
def _result(
*,
status: str,
stall_reason: str | None,
heartbeat: object,
heartbeat_age: float | None,
evidence: str,
) -> dict[str, object]:
return {
"status": status,
"stallReason": stall_reason,
"heartbeatAt": heartbeat,
"heartbeatAgeSeconds": heartbeat_age,
"evidence": evidence[:MAX_EVIDENCE_CHARS],
}
def classify(
tail: str | list[str],
heartbeat: object,
*,
now: datetime | None = None,
) -> dict[str, object]:
if isinstance(tail, list):
tail = "\n".join(tail)
heartbeat_age, heartbeat_error = heartbeat_age_seconds(heartbeat, now)
for label, pattern in STALL_PATTERNS:
if pattern.search(tail):
return _result(
status="stall",
stall_reason=label,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if heartbeat_error:
return _result(
status="unknown",
stall_reason=heartbeat_error,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if heartbeat_age is not None and heartbeat_age <= HEARTBEAT_MAX_AGE_SECONDS:
return _result(
status="progress",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if heartbeat_age is not None:
return _result(
status="unknown",
stall_reason="stale-heartbeat",
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
if WORKING_PATTERN.search(tail):
return _result(
status="running",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
# No heartbeat and no working marker: the terminal may still be sitting at
# a welcome/idle prompt (task never started) or have unclassified output.
if IDLE_TAIL_PATTERN.search(tail) or not tail.strip():
return _result(
status="not-started",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
return _result(
status="unknown",
stall_reason=None,
heartbeat=heartbeat,
heartbeat_age=heartbeat_age,
evidence=tail,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--task-id", required=True, help="Orca orchestration task ID")
parser.add_argument("--terminal", required=True, help="worker terminal handle")
args = parser.parse_args(argv)
orca = resolve_executable("orca")
try:
show = run_json(
[str(orca), "orchestration", "dispatch-show", "--task", args.task_id, "--json"],
"dispatch-show",
)
dispatch = show["result"]["dispatch"]
read_response = run_json(
[str(orca), "terminal", "read", "--terminal", args.terminal, "--json"],
"terminal read",
)
terminal = read_response["result"]["terminal"]
except (LaunchError, KeyError, TypeError, IndexError) as exc:
print(
json.dumps(
{
"probedAt": utc_now().isoformat().replace("+00:00", "Z"),
"taskId": args.task_id,
"dispatchId": None,
"terminal": args.terminal,
"status": "unknown",
"stallReason": None,
"heartbeatAt": None,
"heartbeatAgeSeconds": None,
"evidence": f"probe failed: {type(exc).__name__}: {exc}",
},
ensure_ascii=False,
)
)
return 0
dispatch_id = dispatch.get("id")
heartbeat = dispatch.get("last_heartbeat_at")
tail = "\n".join(terminal.get("tail") or [])
result = classify(tail, heartbeat)
print(
json.dumps(
{
"probedAt": utc_now().isoformat().replace("+00:00", "Z"),
"taskId": args.task_id,
"dispatchId": dispatch_id,
"terminal": args.terminal,
**result,
},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+686
View File
@@ -0,0 +1,686 @@
#!/usr/bin/env python3
"""ACK YAML 的零依赖、fail-closed 子集解析器。
这不是通用 YAML 实现。它只覆盖 ACK 状态文件所需的 mapping、
sequence、flow collection、标量和 ``>`` / ``|`` block scalar。锚点、
alias、tag、多文档和其它未实现语法会显式失败,不会猜测或静默误解析。
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Any
class YamlSubsetError(ValueError):
"""YAML 超出 ACK 子集或语法无效。"""
class DuplicateKeyError(ValueError):
"""JSON/YAML mapping 包含重复键。"""
@dataclass(frozen=True)
class _Line:
number: int
indent: int
content: str
_DECIMAL_INT_RE = re.compile(r"[-+]?(?:0|[1-9][0-9]*)\Z")
_AMBIGUOUS_NUMBER_RE = re.compile(
r"[-+]?(?:"
r"[0-9][0-9_]*\.[0-9_]*(?:[eE][-+]?[0-9]+)?|"
r"[0-9][0-9_]*(?:[eE][-+]?[0-9]+)|"
r"0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|"
r"0[0-9_]+|[0-9][0-9_]*:[0-9_:]+"
r")\Z"
)
_PLAIN_KEY_FORBIDDEN_RE = re.compile(r"[\[\]{},#]")
_ANCHOR_OR_ALIAS_RE = re.compile(
r"(?:^|\s)[&*][A-Za-z0-9_-]+(?:\s|$)"
)
def load_json_unique(content: str) -> Any:
"""Parse JSON while rejecting duplicate object keys at every depth."""
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise DuplicateKeyError(f"JSON 存在重复键 {key!r}")
result[key] = value
return result
return json.loads(content, object_pairs_hook=unique_object)
def make_unique_pyyaml_loader(yaml_module: Any) -> type:
"""Build a SafeLoader that rejects duplicate keys and graph features.
ACK documents are trees. Anchors/aliases can introduce shared identity or
cycles, which are unnecessary here and make recursive validation unsafe.
Explicit tags are also outside the fallback grammar, so both code paths
reject them consistently.
"""
class UniqueKeySafeLoader(yaml_module.SafeLoader): # type: ignore[misc]
def compose_node(self, parent: Any, index: Any) -> Any:
if self.check_event(yaml_module.events.AliasEvent):
event = self.peek_event()
raise yaml_module.YAMLError(
f"ACK YAML 不支持 alias: *{event.anchor}"
)
event = self.peek_event()
if getattr(event, "anchor", None) is not None:
raise yaml_module.YAMLError(
f"ACK YAML 不支持 anchor: &{event.anchor}"
)
if getattr(event, "tag", None) is not None:
raise yaml_module.YAMLError(
f"ACK YAML 不支持显式 tag: {event.tag}"
)
return super().compose_node(parent, index)
def construct_unique_mapping(
loader: Any,
node: Any,
deep: bool = False,
) -> dict[str, Any]:
for key_node, _ in node.value:
if (
getattr(key_node, "tag", None) == "tag:yaml.org,2002:merge"
or getattr(key_node, "value", None) == "<<"
):
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"merge keys are not supported",
key_node.start_mark,
)
loader.flatten_mapping(node)
mapping: dict[str, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if not isinstance(key, str):
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"mapping key must be a string",
key_node.start_mark,
)
if key == "<<":
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
"merge keys are not supported",
key_node.start_mark,
)
if key in mapping:
raise yaml_module.constructor.ConstructorError(
"while constructing an ACK mapping",
node.start_mark,
f"found duplicate key {key!r}",
key_node.start_mark,
)
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueKeySafeLoader.add_constructor(
yaml_module.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
construct_unique_mapping,
)
return UniqueKeySafeLoader
def load_yaml_subset(content: str) -> Any:
"""Parse the deliberately small YAML subset used by ACK files."""
return _SubsetParser(content).parse()
class _SubsetParser:
def __init__(self, content: str) -> None:
if content.startswith("\ufeff"):
content = content[1:]
if "\t" in content:
raise YamlSubsetError("ACK YAML 子集不支持 Tab,请使用空格")
self.lines = [
_Line(number, len(raw) - len(raw.lstrip(" ")), raw.lstrip(" "))
for number, raw in enumerate(content.splitlines(), start=1)
]
self.index = 0
def parse(self) -> Any:
self._skip_insignificant()
if self.index >= len(self.lines):
return None
first = self.lines[self.index]
if first.indent != 0:
self._error(first, "顶层不能缩进")
value = self._parse_node(0)
self._skip_insignificant()
if self.index != len(self.lines):
line = self.lines[self.index]
self._error(line, "文档尾部存在无法解析的内容")
return value
def _parse_node(self, indent: int) -> Any:
self._skip_insignificant()
if self.index >= len(self.lines):
return None
line = self.lines[self.index]
if line.indent != indent:
self._error(line, f"期望 {indent} 个空格的缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if self._is_sequence_marker(content):
return self._parse_sequence(indent)
return self._parse_mapping(indent)
def _parse_mapping(self, indent: int) -> dict[str, Any]:
result: dict[str, Any] = {}
while True:
self._skip_insignificant()
if self.index >= len(self.lines):
break
line = self.lines[self.index]
if line.indent < indent:
break
if line.indent > indent:
self._error(line, "mapping 存在意外缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if self._is_sequence_marker(content):
break
self.index += 1
self._consume_mapping_entry(
result,
content,
mapping_indent=indent,
line=line,
)
return result
def _parse_sequence(self, indent: int) -> list[Any]:
result: list[Any] = []
while True:
self._skip_insignificant()
if self.index >= len(self.lines):
break
line = self.lines[self.index]
if line.indent < indent:
break
if line.indent > indent:
self._error(line, "sequence 存在意外缩进")
content = self._without_comment(line.content).rstrip()
self._reject_document_syntax(content, line)
if not self._is_sequence_marker(content):
break
rest = content[1:].lstrip(" ")
self.index += 1
if not rest:
next_line = self._peek_significant()
if next_line is not None and next_line.indent > indent:
result.append(self._parse_node(next_line.indent))
else:
result.append(None)
continue
if self._is_sequence_marker(rest):
self._error(
line,
"不支持紧凑嵌套 sequence,请把内层 '-' 放到下一行",
)
if self._looks_like_mapping_entry(rest):
mapping_indent = indent + 2
item: dict[str, Any] = {}
self._consume_mapping_entry(
item,
rest,
mapping_indent=mapping_indent,
line=line,
)
while True:
self._skip_insignificant()
continuation = self._peek_significant()
if continuation is None or continuation.indent < mapping_indent:
break
if continuation.indent > mapping_indent:
self._error(
continuation,
"sequence mapping 存在意外缩进",
)
continuation_content = self._without_comment(
continuation.content
).rstrip()
if self._is_sequence_marker(continuation_content):
self._error(
continuation,
"sequence mapping 中需要 key: value",
)
self.index += 1
self._consume_mapping_entry(
item,
continuation_content,
mapping_indent=mapping_indent,
line=continuation,
)
result.append(item)
continue
if rest in {">", "|"}:
result.append(self._parse_block_scalar(indent, rest, line))
else:
result.append(self._parse_inline_value(rest, line))
next_line = self._peek_significant()
if next_line is not None and next_line.indent > indent:
self._error(next_line, "scalar sequence 项后存在意外缩进")
return result
def _consume_mapping_entry(
self,
result: dict[str, Any],
content: str,
*,
mapping_indent: int,
line: _Line,
) -> None:
key_text, value_text = self._split_mapping_entry(content, line)
key = self._parse_key(key_text, line)
if key in result:
self._error(line, f"mapping 存在重复键 {key!r}")
value_text = value_text.strip()
if value_text in {">", "|"}:
value = self._parse_block_scalar(mapping_indent, value_text, line)
elif value_text:
if value_text.startswith((">", "|")):
self._error(
line,
"block scalar 只支持 '>' 或 '|',不支持 chomping/indent 指示符",
)
value = self._parse_inline_value(value_text, line)
next_line = self._peek_significant()
if next_line is not None and next_line.indent > mapping_indent:
self._error(next_line, f"{key!r} 的 scalar 后存在意外缩进")
else:
next_line = self._peek_significant()
if next_line is not None and next_line.indent > mapping_indent:
value = self._parse_node(next_line.indent)
else:
value = None
result[key] = value
def _parse_block_scalar(
self,
parent_indent: int,
style: str,
header: _Line,
) -> str:
probe = self.index
while probe < len(self.lines) and not self.lines[probe].content.strip():
probe += 1
if probe >= len(self.lines) or self.lines[probe].indent <= parent_indent:
return ""
block_indent = self.lines[probe].indent
if block_indent <= parent_indent:
self._error(header, "block scalar 内容必须比键更深缩进")
values: list[str] = []
while self.index < len(self.lines):
line = self.lines[self.index]
if not line.content.strip():
values.append("")
self.index += 1
continue
if line.indent < block_indent:
break
values.append(" " * (line.indent - block_indent) + line.content)
self.index += 1
while values and values[-1] == "":
values.pop()
if not values:
return ""
if style == "|":
return "\n".join(values) + "\n"
output = ""
previous: str | None = None
blank_count = 0
for value in values:
if value == "":
blank_count += 1
continue
if previous is None:
output = "\n" * blank_count + value
elif blank_count:
output += "\n" * blank_count + value
elif previous.startswith(" ") or value.startswith(" "):
output += "\n" + value
else:
output += " " + value
previous = value
blank_count = 0
return output + "\n"
def _parse_key(self, text: str, line: _Line) -> str:
text = text.strip()
if not text:
self._error(line, "mapping key 不能为空")
if text[0] in {'"', "'"}:
parsed = _FlowParser(text, line.number).parse_complete_value()
if not isinstance(parsed, str):
self._error(line, "mapping key 必须是字符串")
return parsed
if text == "<<":
self._error(line, "ACK YAML 子集不支持 merge key '<<'")
if text[0] in "-?:!&*%@`" or _PLAIN_KEY_FORBIDDEN_RE.search(text):
self._error(line, f"不支持的 plain mapping key {text!r}")
parsed = _plain_scalar(text, line.number)
if not isinstance(parsed, str):
self._error(line, "mapping key 必须是字符串,特殊标量请加引号")
return text
def _parse_inline_value(self, text: str, line: _Line) -> Any:
parser = _FlowParser(text, line.number)
return parser.parse_complete_value()
def _split_mapping_entry(self, content: str, line: _Line) -> tuple[str, str]:
quote: str | None = None
escaped = False
depth = 0
index = 0
while index < len(content):
char = content[index]
if quote == '"':
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
index += 1
continue
if quote == "'":
if char == "'":
if index + 1 < len(content) and content[index + 1] == "'":
index += 2
continue
quote = None
index += 1
continue
if char in {'"', "'"}:
quote = char
elif char in "[{":
depth += 1
elif char in "]}":
depth -= 1
if depth < 0:
self._error(line, "flow collection 括号不匹配")
elif (
char == ":"
and depth == 0
and (index + 1 == len(content) or content[index + 1].isspace())
):
return content[:index], content[index + 1 :]
index += 1
self._error(line, "mapping 项必须使用 'key: value'")
def _looks_like_mapping_entry(self, content: str) -> bool:
try:
self._split_mapping_entry(content, self.lines[self.index - 1])
except YamlSubsetError:
return False
return True
@staticmethod
def _is_sequence_marker(content: str) -> bool:
return content == "-" or content.startswith("- ")
def _peek_significant(self) -> _Line | None:
probe = self.index
while probe < len(self.lines):
line = self.lines[probe]
if line.content.strip() and not line.content.lstrip().startswith("#"):
return line
probe += 1
return None
def _skip_insignificant(self) -> None:
while self.index < len(self.lines):
content = self.lines[self.index].content
if content.strip() and not content.lstrip().startswith("#"):
break
self.index += 1
def _without_comment(self, content: str) -> str:
quote: str | None = None
escaped = False
index = 0
while index < len(content):
char = content[index]
if quote == '"':
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == quote:
quote = None
elif quote == "'":
if char == "'":
if index + 1 < len(content) and content[index + 1] == "'":
index += 1
else:
quote = None
elif char in {'"', "'"}:
quote = char
elif char == "#" and (index == 0 or content[index - 1].isspace()):
return content[:index]
index += 1
if quote is not None:
raise YamlSubsetError("未结束的引号标量")
return content
def _reject_document_syntax(self, content: str, line: _Line) -> None:
if content in {"---", "..."} or content.startswith("%"):
self._error(line, "ACK YAML 子集只支持单文档,不支持 directive/marker")
if content.startswith(("!", "&", "*")):
self._error(line, "ACK YAML 子集不支持 tag/anchor/alias")
@staticmethod
def _error(line: _Line, message: str) -> None:
raise YamlSubsetError(f"第 {line.number} 行: {message}")
class _FlowParser:
def __init__(self, text: str, line_number: int) -> None:
self.text = text
self.line_number = line_number
self.index = 0
def parse_complete_value(self) -> Any:
value = self._parse_value()
self._skip_space()
if self.index != len(self.text):
self._error(f"标量后存在未支持内容: {self.text[self.index:]!r}")
return value
def _parse_value(self) -> Any:
self._skip_space()
if self.index >= len(self.text):
self._error("缺少标量")
char = self.text[self.index]
if char == "[":
return self._parse_list()
if char == "{":
return self._parse_map()
if char in {'"', "'"}:
return self._parse_quoted()
if char in "]},":
self._error(f"意外字符 {char!r}")
return self._parse_plain({",", "]", "}"})
def _parse_list(self) -> list[Any]:
self.index += 1
result: list[Any] = []
self._skip_space()
if self._consume("]"):
return result
while True:
result.append(self._parse_value())
self._skip_space()
if self._consume("]"):
return result
if not self._consume(","):
self._error("flow list 项之间必须用 ',' 分隔")
self._skip_space()
if self.index >= len(self.text) or self.text[self.index] == "]":
self._error("flow list 不支持尾随逗号")
def _parse_map(self) -> dict[str, Any]:
self.index += 1
result: dict[str, Any] = {}
self._skip_space()
if self._consume("}"):
return result
while True:
self._skip_space()
if self.index >= len(self.text):
self._error("flow mapping 未结束")
if self.text[self.index] in {'"', "'"}:
key = self._parse_quoted()
else:
key = self._parse_plain({":"}, convert=False)
if not isinstance(key, str) or not key:
self._error("flow mapping key 必须是非空字符串")
if key == "<<":
self._error("ACK YAML 子集不支持 merge key '<<'")
self._skip_space()
if not self._consume(":"):
self._error("flow mapping key 后必须是 ':'")
value = self._parse_value()
if key in result:
self._error(f"flow mapping 存在重复键 {key!r}")
result[key] = value
self._skip_space()
if self._consume("}"):
return result
if not self._consume(","):
self._error("flow mapping 项之间必须用 ',' 分隔")
self._skip_space()
if self.index >= len(self.text) or self.text[self.index] == "}":
self._error("flow mapping 不支持尾随逗号")
def _parse_quoted(self) -> str:
quote = self.text[self.index]
self.index += 1
result: list[str] = []
while self.index < len(self.text):
char = self.text[self.index]
self.index += 1
if char == quote:
if quote == "'" and self.index < len(self.text) and self.text[
self.index
] == "'":
result.append("'")
self.index += 1
continue
return "".join(result)
if quote == "'" or char != "\\":
result.append(char)
continue
if self.index >= len(self.text):
self._error("双引号标量以转义符结尾")
escape = self.text[self.index]
self.index += 1
simple = {
"0": "\0",
"a": "\a",
"b": "\b",
"t": "\t",
"n": "\n",
"v": "\v",
"f": "\f",
"r": "\r",
"e": "\x1b",
" ": " ",
'"': '"',
"/": "/",
"\\": "\\",
}
if escape in simple:
result.append(simple[escape])
continue
widths = {"x": 2, "u": 4, "U": 8}
if escape in widths:
width = widths[escape]
digits = self.text[self.index : self.index + width]
if len(digits) != width or not re.fullmatch(
rf"[0-9a-fA-F]{{{width}}}", digits
):
self._error(f"无效 Unicode 转义 \\{escape}{digits}")
codepoint = int(digits, 16)
try:
result.append(chr(codepoint))
except ValueError as exc:
raise YamlSubsetError(
f"第 {self.line_number} 行: 无效 Unicode 码点"
) from exc
self.index += width
continue
self._error(f"不支持的双引号转义 \\{escape}")
self._error("引号标量未结束")
def _parse_plain(
self,
delimiters: set[str],
*,
convert: bool = True,
) -> Any:
start = self.index
while self.index < len(self.text) and self.text[self.index] not in delimiters:
self.index += 1
token = self.text[start : self.index].strip()
if not token:
self._error("空 plain scalar")
if "#" in token:
self._error("flow collection 内的注释不受支持")
if ": " in token:
self._error("plain scalar 中的 ': ' 必须加引号")
if token[0] in "!&*%@`?" or _ANCHOR_OR_ALIAS_RE.search(token):
self._error("ACK YAML 子集不支持 tag/anchor/alias/directive")
return _plain_scalar(token, self.line_number) if convert else token
def _skip_space(self) -> None:
while self.index < len(self.text) and self.text[self.index] == " ":
self.index += 1
def _consume(self, expected: str) -> bool:
if self.index < len(self.text) and self.text[self.index] == expected:
self.index += 1
return True
return False
def _error(self, message: str) -> None:
raise YamlSubsetError(f"第 {self.line_number} 行: {message}")
def _plain_scalar(token: str, line_number: int) -> Any:
lowered = token.lower()
if lowered in {"null", "~"}:
return None
if lowered in {"true", "yes", "on"}:
return True
if lowered in {"false", "no", "off"}:
return False
if _DECIMAL_INT_RE.fullmatch(token):
return int(token, 10)
if _AMBIGUOUS_NUMBER_RE.fullmatch(token):
raise YamlSubsetError(
f"第 {line_number} 行: ACK YAML 子集不支持该数字格式 {token!r},"
"如需字符串请加引号"
)
return token
+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"
+254
View File
@@ -0,0 +1,254 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/delivery.schema.json",
"title": "ACK project delivery contract",
"description": ".pouch/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
"type": "object",
"required": [
"version",
"project",
"enabled",
"defaultProfile",
"entrypoints",
"artifacts",
"destinations",
"environments",
"profiles"
],
"additionalProperties": false,
"properties": {
"version": { "type": "integer", "const": 1 },
"updatedAt": { "type": "string" },
"project": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1, "pattern": "\\S" }
}
},
"enabled": { "type": "boolean" },
"defaultProfile": {
"type": ["string", "null"],
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
"intents": { "$ref": "#/definitions/intents" },
"entrypoints": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/entrypoint" }
},
"artifacts": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/artifact" }
},
"destinations": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/destination" }
},
"environments": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/environment" }
},
"profiles": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/profile" }
}
},
"definitions": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
"intents": {
"type": "object",
"required": ["testEnvironment", "release"],
"additionalProperties": false,
"properties": {
"testEnvironment": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"required": ["via", "env"],
"properties": {
"via": { "const": "deployer" },
"env": { "$ref": "#/definitions/id" }
}
}
]
},
"release": {
"type": ["string", "null"],
"pattern": "^[a-z][a-z0-9-]{0,63}$"
}
}
},
"relativePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/*?+-]+$"
},
"stringList": {
"type": "array",
"items": { "type": "string" }
},
"secretName": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{0,127}$"
},
"entrypoint": {
"type": "object",
"required": [
"kind",
"args",
"requiredSecrets",
"workingDirectory",
"timeoutSeconds"
],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["make", "just", "task", "dagger", "script"]
},
"target": { "type": "string", "minLength": 1 },
"function": { "type": "string", "minLength": 1 },
"path": { "$ref": "#/definitions/relativePath" },
"args": { "$ref": "#/definitions/stringList" },
"requiredSecrets": {
"type": "array",
"uniqueItems": true,
"items": { "$ref": "#/definitions/secretName" }
},
"workingDirectory": { "$ref": "#/definitions/relativePath" },
"timeoutSeconds": {
"type": "integer",
"minimum": 1,
"maximum": 86400
}
}
},
"artifact": {
"type": "object",
"required": ["type", "build"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["deb", "oci-image", "file"] },
"build": { "$ref": "#/definitions/id" },
"outputs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/relativePath" }
},
"image": { "type": "string", "minLength": 1 },
"platforms": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z0-9]+/[A-Za-z0-9._-]+$" }
}
}
},
"destination": {
"type": "object",
"required": ["type", "channel"],
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["apt-repository", "oci-registry", "ci-artifact"]
},
"channel": {
"type": "string",
"enum": ["preview", "staging", "stable"]
},
"registry": { "type": "string", "minLength": 1 },
"repository": { "type": "string", "minLength": 1 },
"endpoint": { "type": "string", "minLength": 1 },
"artifactName": { "type": "string", "minLength": 1 },
"upload": { "$ref": "#/definitions/id" }
}
},
"environment": {
"type": "object",
"required": [
"type",
"classification",
"target",
"deploy",
"healthCheck",
"mutex"
],
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["ssh-host", "docker-compose", "kubernetes", "custom"]
},
"classification": {
"type": "string",
"enum": ["development", "staging", "production"]
},
"target": { "type": "string", "minLength": 1 },
"deploy": { "$ref": "#/definitions/id" },
"healthCheck": { "$ref": "#/definitions/id" },
"rollback": { "$ref": "#/definitions/id" },
"mutex": { "type": "string", "minLength": 1 }
}
},
"step": {
"type": "object",
"required": ["id", "action"],
"additionalProperties": false,
"properties": {
"id": { "$ref": "#/definitions/id" },
"action": {
"type": "string",
"enum": [
"verify",
"pull-request",
"build",
"publish",
"deploy",
"health-check",
"approval",
"mark-ready"
]
},
"entrypoint": { "$ref": "#/definitions/id" },
"artifact": { "$ref": "#/definitions/id" },
"destination": { "$ref": "#/definitions/id" },
"environment": { "$ref": "#/definitions/id" },
"gate": { "type": "string", "enum": ["release", "production"] },
"draft": { "type": "boolean" },
"remote": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
},
"baseBranch": { "type": "string", "minLength": 1, "maxLength": 255 }
}
},
"profile": {
"type": "object",
"required": ["stopAt", "steps"],
"additionalProperties": false,
"properties": {
"stopAt": {
"type": "string",
"enum": ["verified", "validation_ready", "review_ready", "released"]
},
"steps": {
"type": "array",
"items": { "$ref": "#/definitions/step" }
}
}
}
}
}
@@ -0,0 +1,30 @@
# 复制为 .pouch/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
project:
name: "<project_name>"
enabled: false
defaultProfile: null
# 测试环境由 ACK 内部调用 deployer;发版仍指向本文件的 profile。
# null 表示用户尚未说明该操作。
intents:
testEnvironment: null
release: null
# 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。
# 不在此保存 shell、env 或凭据正文。
entrypoints: {}
# 支持 deb、oci-image 和 file。复杂构建逻辑留在项目已有入口中。
artifacts: {}
# 支持 apt-repository、oci-registry 和 ci-artifact。
destinations: {}
# 支持 ssh-host、docker-compose、kubernetes 和 custom。
environments: {}
# enabled=true 时,defaultProfile 必须指向一个非空 profile。
profiles: {}
+367
View File
@@ -0,0 +1,367 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/pouch/skills/ack/templates/knowledge.schema.json",
"title": "ACK project knowledge guardrails",
"description": ".pouch/ack/knowledge.yaml 的权威结构。知识只描述约束和验证引用,不保存可执行命令。",
"type": "object",
"required": ["version", "updatedAt", "project", "verificationRegistry", "entries"],
"additionalProperties": false,
"properties": {
"version": {
"type": "integer",
"minimum": 1
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"project": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
"verificationRegistry": {
"type": "object",
"propertyNames": {
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"additionalProperties": {
"$ref": "#/definitions/verificationTarget"
}
},
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/entry"
}
}
},
"definitions": {
"stableRef": {
"type": "string",
"pattern": "^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$"
},
"stringSet": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"scope": {
"type": "object",
"required": [
"all",
"components",
"paths",
"dependencies",
"versions",
"tags",
"symbols",
"errorSignatures"
],
"additionalProperties": false,
"properties": {
"all": {
"type": "boolean"
},
"components": {
"$ref": "#/definitions/stringSet"
},
"paths": {
"$ref": "#/definitions/stringSet"
},
"dependencies": {
"$ref": "#/definitions/stringSet"
},
"versions": {
"$ref": "#/definitions/stringSet"
},
"tags": {
"$ref": "#/definitions/stringSet"
},
"symbols": {
"$ref": "#/definitions/stringSet"
},
"errorSignatures": {
"$ref": "#/definitions/stringSet"
}
}
},
"verification": {
"type": "object",
"required": ["ref", "expected"],
"additionalProperties": false,
"properties": {
"ref": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"expected": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
"verificationTarget": {
"type": "object",
"required": ["path", "args"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1,
"pattern": "^(?=.*\\S)[A-Za-z0-9][A-Za-z0-9._/-]*$",
"description": "项目内可执行文件;运行时 cwd 为项目根,脚本不得依赖自身文件路径定位资源"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"provenance": {
"type": "object",
"required": ["taskId", "attemptId", "codeRef", "evidenceRef"],
"additionalProperties": false,
"properties": {
"taskId": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"attemptId": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"codeRef": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"evidenceRef": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
"entry": {
"type": "object",
"required": [
"id",
"revision",
"kind",
"status",
"title",
"subject",
"scope",
"appliesWhen",
"directive",
"rationale",
"verification",
"provenance",
"owner",
"author",
"reviewer",
"approval",
"createdAt",
"lastValidatedAt",
"reviewAfter",
"temporary",
"removalCondition",
"statusReason",
"supersedes",
"conflictsWith"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^K-[A-Z0-9][A-Z0-9-]*$"
},
"revision": {
"type": "integer",
"minimum": 1
},
"kind": {
"type": "string",
"enum": ["guardrail", "pitfall", "verification"]
},
"status": {
"type": "string",
"enum": ["active", "stale", "superseded", "archived"]
},
"title": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"subject": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"scope": {
"$ref": "#/definitions/scope"
},
"appliesWhen": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"directive": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"rationale": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"verification": {
"$ref": "#/definitions/verification"
},
"provenance": {
"$ref": "#/definitions/provenance"
},
"owner": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"author": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"reviewer": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"approval": {
"oneOf": [
{ "type": "null" },
{ "$ref": "#/definitions/approval" }
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"lastValidatedAt": {
"type": "string",
"format": "date-time"
},
"reviewAfter": {
"type": ["string", "null"],
"format": "date-time"
},
"temporary": {
"type": "boolean"
},
"removalCondition": {
"type": ["string", "null"],
"minLength": 1,
"pattern": "\\S"
},
"statusReason": {
"type": ["string", "null"],
"minLength": 1,
"pattern": "\\S"
},
"supersedes": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/definitions/stableRef"
}
},
"conflictsWith": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/definitions/stableRef"
}
}
},
"allOf": [
{
"if": {
"properties": {
"temporary": {
"const": true
}
}
},
"then": {
"properties": {
"reviewAfter": {
"type": "string",
"format": "date-time"
},
"removalCondition": {
"type": "string",
"minLength": 1
}
}
}
},
{
"if": {
"properties": {
"status": {
"enum": ["stale", "superseded", "archived"]
}
}
},
"then": {
"properties": {
"statusReason": {
"type": "string",
"minLength": 1
}
}
}
}
]
},
"approval": {
"type": "object",
"required": ["approvedBy", "approvedAt", "evidenceRef"],
"additionalProperties": false,
"properties": {
"approvedBy": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"approvedAt": {
"type": "string",
"format": "date-time"
},
"evidenceRef": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
}
}
@@ -0,0 +1,9 @@
# 复制为 .pouch/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
project:
name: "<project_name>"
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
verificationRegistry: {}
entries: []
+136
View File
@@ -0,0 +1,136 @@
# <项目名> Agent 协作协议(项目覆盖层)
> 本项目基于 ACK Skill v<ack_version>。通用规范由 `/ack` 从 Skill 自身的
> `references/` 读取;本文件只保存项目差异。
>
> **本文件是「项目覆盖层」,文件名可配置。** 默认放 `.pouch/ack/project.md`,
> 不占用 `AGENTS.md`,避免与团队已有的 `AGENTS.md` 约定冲突。
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`task-records/`、`knowledge.yaml`、默认关闭的
> `delivery.yaml`、空的 `regression.yaml`,以及可选的 `agents.yaml` 角色启动覆盖,
> 不复制或链接 Skill。
## 项目概览
- 项目:`<project_name>`
- 技术栈:`<tech_stack>`
- 运行命令:`<run_command>`
- Base URL:`<base_url>`
- 任务板配置:`.pouch/ack/tasks.yaml`
- 任务记录:`.pouch/ack/task-records/<recordId>.yaml`(追加式)
- 项目知识:`.pouch/ack/knowledge.yaml`
- 交付契约:`.pouch/ack/delivery.yaml`(默认关闭)
- 回归目录:`.pouch/ack/regression.yaml`
- 角色启动覆盖(可选):`.pouch/ack/agents.yaml`
- 覆盖层文件:`<overlay_file_path>`(默认 `.pouch/ack/project.md`)
## 通用规范(由 ACK Skill 按需读取)
- 开始需求:`references/kickoff.md`
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令(可选):`references/orca-adapter.md`
- 验证后交付与配置维护(可选):`references/delivery.md`
- 回归目录与运行(可选):`references/regression.md`
## Worker 路由
机器可校验的模型、reasoning effort、权限模式、默认 profile、允许 worktree 和启动
receipt 以 `.pouch/ack/agents.yaml`(存在时)或 `.pouch/ack/tasks.yaml` 的
`project.orchestration` 与顶层 `workerReceipts` 为准。本文件不保存可执行 worker 命令。
默认 profile:
| 角色 | profile ID | 档位 |
|------|------------|------|
| Developer | `codex-dev-standard` | standard |
| Test | `codex-test-standard` | standard |
| Developer 升级 | `codex-dev-strong` | strong |
项目如改用 Cursor 或 Grok,应修改结构化 profile、allowlist 和 defaults,再运行任务板
校验;不能在这里粘贴 `orca --command`、Agent CLI 参数或自由 shell。
每次自动派发必须先审阅 launcher plan,再把其中的 `launchFingerprint` 作为
`--expected-launch-fingerprint` 启动 fresh worker。`receiptHash` 只作审计 checksum,
不授权自动复用旧终端;只有同轮空闲、身份匹配且历史消息可信清理后才可复用。
## 路径权限(项目覆盖层,必须填实际路径)
| 路径 | Coordinator | Test | Developer | 说明 |
|------|:-----------:|:----:|:---------:|------|
| `<spec_paths>` | R/W | Read-only | Read-only | 产品规格、API 文档、计划(PM 拥有) |
| `<integration_test_paths>` | Read-only | R/W | Read-only | 浏览器/API 回归(Test 拥有) |
| `<test_records_path>` | Read-only | R/W | Read-only | 复测记录 |
| `<source_paths>` | Read-only | Read-only | R/W | 应用源码 |
| `<unit_test_paths>` | Read-only | Read-only | R/W | 单元测试 |
| `<shared_config_templates>` | Read-only | Read-only | R/W | 可提交配置模板 |
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 稳定项目配置;任务状态见 `task-records/` |
| `task-records/` | R/W | Read-only | Read-only | 只有 Coordinator 追加 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
## 命令(项目覆盖层)
Developer 白盒验证:
```bash
<unit_test_command>
<build_command>
<local_run_command>
```
Test 黑盒复测(服务启动以 deployer 绑定的 `intents.testEnvironment` 为准):
```bash
<preflight_command>
<api_smoke_command>
<browser_regression_command>
```
知识项的 `verification.ref` 只能引用 `knowledge.yaml.verificationRegistry` 中已
审查的检查入口。Registry 只保存仓库内相对 path 和结构化 args,不保存或执行自由
shell 命令;新项目没有知识时保持空对象。Developer/Test 只能把 registry ID 交给
Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检查脚本从项目
根 cwd 或 `ACK_PROJECT_ROOT` 定位资源,不能依赖自身文件路径。
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
`ACK_PROJECT_ROOT_DISPLAY`。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
`scripts/validate_regression.py` 执行。
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
测试环境由 ACK 内部调用 deployer。本文件不维护第二套交付命令。
## 硬规则(其余见 references/)
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
等第二配置面。
- v0.10 自动 launcher 只支持 `read-only` 和 `workspace-write`。full-access、
bypass、YOLO/force 与关闭 sandbox 均 fail closed;需要可信平台审批通道后再开放。
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
- 只有 Coordinator 写 `task-records/`、`knowledge.yaml` 和 `regression.yaml`;
Developer 与 Test 都只读,通过消息回报。
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境
绑定 deployer,发版写在 `intents.release`;用户明确要求重新部署测试环境或发布
版本时才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后
运行。回归目录在 `.pouch/ack/regression.yaml`,由用户明确要求「回归」时运行。
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
@@ -0,0 +1,92 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/regression.schema.json",
"title": "ACK project regression catalog",
"description": ".pouch/ack/regression.yaml 的权威结构。用例是给 Test worker 的可观测信号说明书,不是可执行 DSL。",
"type": "object",
"required": ["version", "updatedAt", "project", "cases"],
"additionalProperties": false,
"properties": {
"version": { "type": "integer", "const": 1 },
"updatedAt": { "type": "string" },
"project": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1, "pattern": "\\S" }
}
},
"cases": {
"type": "array",
"items": { "$ref": "#/definitions/case" }
}
},
"definitions": {
"caseId": {
"type": "string",
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
},
"stringList": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
},
"relativePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
},
"expectedSignal": {
"type": "object",
"required": ["kind", "value"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["visible-text", "api-status", "api-field", "url", "interaction"]
},
"value": { "type": "string", "minLength": 1, "pattern": "\\S" }
}
},
"case": {
"type": "object",
"required": [
"id",
"title",
"status",
"source",
"suite",
"surface",
"setup",
"steps",
"expected"
],
"additionalProperties": false,
"properties": {
"id": { "$ref": "#/definitions/caseId" },
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
"status": { "type": "string", "enum": ["active", "retired"] },
"source": {
"type": "object",
"required": ["taskId", "kind"],
"additionalProperties": false,
"properties": {
"taskId": { "type": "string", "minLength": 1, "pattern": "\\S" },
"kind": { "type": "string", "enum": ["feature", "bug"] }
}
},
"suite": { "type": "string", "enum": ["smoke", "full"] },
"surface": { "type": "string", "enum": ["browser", "api"] },
"setup": { "type": "string", "minLength": 1, "pattern": "\\S" },
"steps": { "$ref": "#/definitions/stringList" },
"expected": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/expectedSignal" }
},
"automationRef": { "$ref": "#/definitions/relativePath" }
}
}
}
}
@@ -0,0 +1,7 @@
# 复制为 .pouch/ack/regression.yaml。只有 Coordinator 写入;Test 通过回报提名。
# 结构见 templates/regression.schema.json。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
project:
name: "<project_name>"
cases: []
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
# 复制为 .pouch/ack/tasks.yaml,替换占位符。结构见 templates/tasks.schema.json。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
source: "Coordinator (PM) Agent"
ackVersion: "<接入时的 ack skill 版本>"
# 任务板只保存项目配置;执行记录追加到 task-records/,避免多个工作区共同改写本文件。
taskStore:
version: 1
directory: "task-records"
project:
name: "<project_name>"
baseUrl: "<base_url>"
overlayFile: ".pouch/ack/project.md"
knowledgeFile: ".pouch/ack/knowledge.yaml"
deliveryFile: ".pouch/ack/delivery.yaml"
regressionFile: ".pouch/ack/regression.yaml"
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
# bugIntake:
# provider: "feishu-base"
# workflow: "clarified-writeback-v1"
# profile: "project-feishu"
# baseToken: "<base_token>"
# tableId: "<table_id>"
# viewId: "<ack_ready_view_id>"
# fields:
# title: "<field_id>"
# details: "<field_id>"
# problemStatement: "<field_id>"
# expectedOutcome: "<field_id>"
# acceptance: "<field_id>"
# intakeStatus: "<field_id>"
# ackTaskId: "<field_id>"
# attachments: "<field_id>"
# updatedAt: "<field_id>"
orchestration:
# 可选:临时角色路由写到 .pouch/ack/agents.yaml;存在时由 launcher 优先读取。
# worker 默认在 --project-root(权威状态目录)工作;不再配置 allowedWorktrees 白名单
# (v0.19 起废弃)。需要隔离 worktree 时由 Coordinator 在派发时显式指定。
profileVersion: 1
mode: "orca"
modelAllowlist:
codex:
developer:
standard: ["gpt-5.6-terra"]
strong: ["gpt-5.6-sol"]
test:
standard: ["gpt-5.6-luna"]
cursor-agent:
developer:
standard: ["auto"]
test:
standard: ["auto"]
grok:
developer:
standard: ["grok-4.5"]
strong: ["grok-4.6"]
test:
standard: ["grok-4.5"]
profiles:
codex-dev-standard:
role: "developer"
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
codex-test-standard:
role: "test"
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
codex-dev-strong:
role: "developer"
cli: "codex"
tier: "strong"
model: "gpt-5.6-sol"
reasoningEffort: "high"
permissionMode: "workspace-write"
cursor-dev-standard:
role: "developer"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
cursor-test-standard:
role: "test"
cli: "cursor-agent"
tier: "standard"
model: "auto"
reasoningEffort: null
permissionMode: "workspace-write"
grok-dev-standard:
role: "developer"
cli: "grok"
tier: "standard"
model: "grok-4.5"
reasoningEffort: "medium"
permissionMode: "workspace-write"
grok-test-standard:
role: "test"
cli: "grok"
tier: "standard"
model: "grok-4.5"
reasoningEffort: "low"
permissionMode: "workspace-write"
grok-dev-strong:
role: "developer"
cli: "grok"
tier: "strong"
model: "grok-4.6"
reasoningEffort: "high"
permissionMode: "workspace-write"
defaults:
developer: "codex-dev-standard"
test: "codex-test-standard"
developerUpgraded: "codex-dev-strong"
workerReceipts: []
deliveryRuns: []
regressionRuns: []
summary:
verified: []
open: []
failedRetest: []
leftovers: []
tasks:
- id: "BUG-001"
type: "bug"
title: "<short title>"
priority: "P0"
status: "open"
assignee: "developer"
component: "<source file or module>"
specRefs:
- "<docs/spec.md#section>"
testRefs:
- "<tests/browser/cases/01-case.md>"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
regressionRefs: []
regressionCandidates: []
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
# source:
# kind: "feishu-base"
# workflow: "reviewed-writeback-v1"
# ref: "feishu-base:sha256:<64-lowercase-hex>"
# recordId: "<record-id>"
# updatedAt: "<source-updated-at>"
# approvedRevision: "sha256:<approved-draft-digest>"
# approvedPayloadHash: "sha256:<canonical-task-payload-digest>"
description: >
<What is wrong, in user-visible terms.>
# 飞书审核通过后写入批准版本;审核前不创建这条 ACK 任务。
fixLogic: >
<Approved repair logic, affected behavior, invariants, and regression risks.>
acceptanceCriteria:
- "<observable acceptance signal 1>"
- "<observable acceptance signal 2>"
stepsToReproduce:
- "<step 1>"
- "<step 2>"
expected: >
<Expected behavior.>
actual: >
<Actual behavior observed by Test.>
evidence:
browser: "<snapshot text, screenshot path, or UI finding>"
api: "<curl result or API mismatch>"
logs: "<relevant log line if any>"
verification:
commands:
- "<unit/build command>"
- "<browser/API smoke command>"
browser:
page: "<page path>"
checks:
- "<visible text or interaction expected>"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
test:
profileId: "codex-test-standard"
receiptId: null
attemptId: null
taskId: null
dispatchId: null
rounds: []
# 环境失败不占产品复验轮次;按 BUG-001-ENV-1... 连续编号,并写清 userAction。
environmentIncidents: []
resolution:
fixedBy: null
verifiedBy: null
verifiedAt: null
leftoverReason: null
+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。
+59
View File
@@ -0,0 +1,59 @@
---
name: agent-runtime
description: >-
维护项目通用 Agent 运行配置、环境绑定、Orca worktree 与跨 session terminal;
适用于 builder.android、builder.ios、tester.test、ack.dev 等可扩展绑定。
不负责具体构建、测试或 ACK 闭环。
---
# Agent Runtime:统一环境、Agent 与工作区
本 skill 是 builder、tester、ACK 和 iOS tester 共用的配置层。每个 Git 项目在自己的
根目录维护 `.pouch/agent.yaml`;父仓库与 submodule 各自拥有配置,不跨仓库继承。机器
专属覆盖放 `.pouch/agent.local.yaml`,运行记录放 `.pouch/agent/runs/`,两者都不提交。
## 配置模型
- `locations`:执行环境、Orca project、目标机器上的绝对路径;不放模型和命令。
- `profiles`:CLI、模型、reasoning effort、权限和档位;不放路径和 secrets。
- `bindings`:任意安全的 `namespace.name`,绑定一个 adapter、location、profile 和
adapter 目标。`builder.android`、`builder.ios`、`builder.harmony` 不需要修改代码即可并存。
- `adapter` 目前允许 `builder`、`tester`、`ack`。adapter 的具体执行规则仍由对应 skill
负责;本层只提供解析、worktree 和 session 生命周期。
- `reasoningEffort` 是规范字段;不要新增 `effect`、自由 `command`、`argv`、`shell`、
`env` 或 token 字段。
完整字段、合并和安全规则见 [references/config.md](references/config.md)。需要实际解析或
创建工作区时使用 `scripts/agent.py`,不要手工拼接 Orca JSON:
```bash
python3 <skill-dir>/scripts/agent.py validate --root <project-root>
python3 <skill-dir>/scripts/agent.py show --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py worktree resolve --root <project-root> --binding builder.ios
python3 <skill-dir>/scripts/agent.py worktree create --root <project-root> \
--binding builder.ios --name writes-ios --base main
```
## Session
`builder` 绑定可通过统一入口创建持久 terminal;返回的 run 记录包含 binding、profile
hash、环境、worktree、terminal handle 和 source commit。跨 session 只能显式 `status`、
`attach`、`stop` 或 `resume`,不能自动复用不明 terminal。tester 的每轮 terminal 仍然
是临时的,ACK 仍然必须走其带 fingerprint/receipt 的 launcher。
```bash
python3 <skill-dir>/scripts/agent.py run start --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py run status --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run attach --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run resume --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run stop --root <project-root> --run <run-id>
```
`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill
适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。
## 验证
- `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。
- remote binding 必须能按 `environment + host + path + project` 精确找到 Orca worktree。
- 新 binding 只增加配置和对应 adapter 支持,不复制一套 skill 或修改固定枚举。
+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 清理语义。

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