41 Commits

Author SHA1 Message Date
laily b94aa597ca fix(tester): use remote artifact path for installs 2026-10-16 15:33:12 +08:00
laily 841a2e0ae6 fix(tester): parse bare Android package identity 2026-10-16 15:27:09 +08:00
laily 4d31d074ad docs(tester): align capability gate policy 2026-10-16 15:26:23 +08:00
laily 29f725fb95 fix(test-preflight): route network probes by binding 2026-10-16 14:38:48 +08:00
laily 3a757e3376 fix(tester): assume mobile capabilities in preflight 2026-10-16 14:33:55 +08:00
laily 5b9a693fda fix(tester): route mobile checks by platform binding 2026-10-16 12:38:11 +08:00
laily 9b2d0a28d8 feat(tester): support isolated full regression workflow 2026-10-16 12:24:49 +08:00
laily 3a18a27dc6 feat(tester): add integration-first journey execution 2026-10-15 18:11:28 +08:00
laily c2eb207bd2 feat(test-preflight): add verify and prepare skill 2026-10-15 18:04:59 +08:00
laily ffa7cbd63e feat(tester): add client journey testing standard 2026-10-15 16:48:54 +08:00
laily baed23d02a test(tester): validate CSV regression summary totals 2026-10-15 11:08:33 +08:00
laily 9ba9b3da0f feat(builder): query CI via project status script
When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom
command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only
polls that script and no longer guesses forge CLIs.
2026-10-15 09:56:51 +08:00
laily 0c9a530390 chore(tester): drop HX_TEST_HOST alias
HOST is the only accepted test-service address from env, local.yaml, or catalog vars.
2026-10-15 09:44:23 +08:00
laily 5fef8247fc feat(tester): require caller artifact locators instead of --release
Mobile runs now take --artifact android|ios=<path-or-url> from the caller
and create a fresh isolated worktree each time. Drop CI release/manifest
lookup.
2026-10-15 01:05:09 +08:00
laily 2fcf16627d fix(tester): expose bundle provisioning failure action 2026-10-14 17:30:11 +08:00
laily 706c553778 feat(tester): provision CI mobile bundles in shared skill 2026-10-14 17:29:45 +08:00
laily db92baf3a7 fix tester multi-root fan-out execution 2026-10-14 15:35:13 +08:00
laily fbf0ca8cd8 fix(deployer): align stale test contracts 2026-10-14 12:21:09 +08:00
laily 8d48d2d335 feat(ship): enforce test manifest handoff 2026-10-14 12:16:55 +08:00
laily d7ebb6ee85 feat: update 2026-10-14 12:14:31 +08:00
laily 19aac17a0a feat(ship): clarify delivery stop semantics 2026-10-14 11:49:50 +08:00
laily 125bfe0c23 chore: sync pouch skill workflows 2026-10-14 10:58:03 +08:00
laily 57879cffdd feat(ship): add stage audit trail and sortable ids 2026-10-14 10:57:16 +08:00
laily 4814bc1026 feat(ship): run every project test surface in regression 2026-10-14 09:57:08 +08:00
laily 7be8b9c3d9 test(tester): cover nested catalog discovery 2026-10-14 09:27:17 +08:00
laily f8e490e9b5 fix(tester): discover catalogs in subprojects 2026-10-14 09:26:39 +08:00
laily 95946d4278 fix: support explicitly authorized Codex deployment without sandbox
Production agents repeatedly failed before SSH because the launcher only accepted workspace-write profiles. Accept the explicit Codex danger-full-access profile through the same validated plan and receipt path, and cover profile, argv and receipt validation with regression tests.
2026-10-13 23:47:34 +08:00
laily af6e139d30 feat: rename bugfix-ship/requirement-flow to short-ship/long-ship with shared ship records
short-ship and long-ship share one ship-id record under .pouch/ship/<id>/.
specifier writes PRDs into spec/; delivery-loop appends evidence pointers.
Old names stay as deprecation stubs so existing installs keep working.
2026-10-13 23:11:58 +08:00
laily 93341b3c69 feat: upadte 2026-10-13 20:17:26 +08:00
laily 0eefc52b44 feat: update deployer 2026-10-13 19:47:51 +08:00
laily 8390706df8 feat: auto-update pouch and catalog sources during normal use
Add taste-skill to catalog.yaml, and refresh pouch plus cloned catalog
or custom Git sources on a 24h interval so users do not need a
separate update command.
2026-10-13 16:41:55 +08:00
laily d3b6732d40 docs: clarify HTML previewer integration 2026-10-13 00:21:22 +08:00
laily ab29598a9a add delivery workflow skills 2026-10-12 20:34:20 +08:00
laily c9faedc39d fix(builder): refresh git index before publish gate 2026-10-12 14:53:58 +08:00
laily f05ac24c57 fix(agent-runtime): finish builder sessions on make exit 2026-10-12 13:32:56 +08:00
laily de194370e6 fix(agent-runtime): resolve imported Orca worktrees 2026-10-12 13:32:35 +08:00
laily 299f6dd9b2 fix(builder): detect platform tracks independently 2026-10-12 13:26:40 +08:00
laily 558c6facc2 feat(agent-runtime): unify agent bindings and sessions 2026-10-12 13:22:47 +08:00
laily 32da57d19b feat: upadte 2026-10-12 12:53:10 +08:00
laily 51f7c2e60c fix(builder): keep project script config in makefile 2026-10-12 12:52:15 +08:00
laily 544cbcca32 feat(builder): support project-local distribution scripts 2026-10-12 12:26:10 +08:00
152 changed files with 11992 additions and 504 deletions
+13 -3
View File
@@ -61,12 +61,19 @@ AGENTS.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 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
| [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` 失效。确认没有
项目再按 `manage-release` 启用后,删除 `skills/manage-release/` 即可,不丢任何内容。
项目再按旧名启用后,删除对应占位目录即可,不丢任何内容。
新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
@@ -90,11 +97,13 @@ builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(
| Source | 来源 |
| --- | --- |
| waza | [https://github.com/tw93/Waza](https://github.com/tw93/Waza) |
| taste-skill | [https://github.com/Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) |
```bash
pouch fetch waza
pouch add waza/think -g
pouch add taste-skill/taste-skill -g
```
### 自定义仓库(Custom Sources)
@@ -237,6 +246,7 @@ pouch sync
| `pouch add <name> [-g]` | 项目或全局安装(symlink) |
| `pouch remove <name> [-g]` | 移除 symlink |
| `pouch catalog add` / `pouch fetch` | 管理和拉取 catalog source |
| `pouch update` | 更新 pouch 自身和已拉取的 catalog / custom source |
### 草稿与健康检查
@@ -346,7 +356,7 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
| 看状态 | `pouch status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `pouch fetch <name>` | 任意 |
| 更新外部 skill | `pouch fetch <name>` 或日常命令自动更新 | 任意 |
---
+11 -2
View File
@@ -96,10 +96,17 @@ AGENTS.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 预览 |
| [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 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
| [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。安装后可初始化当前项目状态:
@@ -166,6 +173,8 @@ pouch remove ack
```bash
pouch fetch waza
pouch add waza/think -g
pouch add taste-skill/taste-skill -g # 只要核心 frontend skill
pouch add taste-skill -g # 或装整个 Taste Skill collection
```
接入团队自己的本地目录或 Git 仓库:
+10
View File
@@ -29,3 +29,13 @@ waza:
- engineering
- workflow
- skill-collection
taste-skill:
repo: https://github.com/Leonxlnx/taste-skill.git
ref: main
path: skills
description: Taste Skill collection for anti-slop frontend, redesign, image generation, brand kits, and output enforcement.
tags:
- frontend
- design
- skill-collection
+1 -1
View File
@@ -49,7 +49,7 @@ pouch status
pouch doctor
```
软链异常就 `pouch doctor --fix`。更新 pouch 自身:`pouch update`(在 `~/.pouch` 里 `git pull`)。
软链异常就 `pouch doctor --fix`。日常用 pouch 命令时会按间隔自动更新自身和已拉取的外部 source;要立刻更新就 `pouch update`。
## 把要用的 skill 装上
+32 -2
View File
@@ -65,7 +65,7 @@ pouch bootstrap
| 命令 | 说明 |
|------|------|
| `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
| `pouch update` | 在 `~/.pouch` 执行 `git pull`,更新 pouch 自身 |
| `pouch update` | 更新 pouch 自身,以及已拉取的 catalog / custom source |
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态(目前用于 ack) |
### Skill 安装
@@ -105,6 +105,33 @@ collection 会自动发现下一层所有含 `SKILL.md` 的目录;`pouch add <
安装全部,`pouch select` 则展开为 `<name>/<skill>` 供分别勾选。同一
`repo` 与 `ref` 共享一份 Git checkout。
预置 collection 示例:
```bash
pouch add waza/think -g
pouch add taste-skill/taste-skill -g
pouch add taste-skill -g # 安装 Taste Skill 全部子 skill
```
### 自动更新
日常执行 `pouch list`、`pouch add`、`pouch status` 等命令时,pouch 会按间隔
顺带更新自身和已经 clone 过的 catalog / custom Git source,不必单独跑
`pouch fetch` 或 `pouch update`。默认每 24 小时最多自动更新一次;网络失败或
工作区不干净时跳过,不打断当前命令。`pouch add <catalog>` 在安装前会再刷新
该来源,以便拿到最新子 skill。
在 `~/.config/pouch/config.yaml` 里关闭或改间隔:
```yaml
auto_update:
enabled: true
interval: 24h # 也可用 1d、60m、3600
```
本次跳过:`pouch --no-auto-update list`,或设置环境变量 `POUCH_NO_AUTO_UPDATE=1`。
立刻更新全部:`pouch update`。
### 交互式批量安装
```bash
@@ -222,6 +249,8 @@ pouch/
├── paths.py # 路径常量与 Agent 目标
├── skills.py # builtin/catalog/custom 统一解析
├── catalog.py # catalog.yaml 读写与 Skill 发现
├── config.py # 用户配置与自动更新设定
├── auto_update.py # 命令执行时顺带更新 pouch 与已拉取 source
├── sources.py # custom source 配置、发现与 Git 管理
├── project.py # .pouch.yaml 管理
├── symlinks.py # 软链创建/检查/修复
@@ -238,7 +267,8 @@ pouch/
| `SKILLS_DIR` | `~/.pouch/skills/` | builtin skill 目录 |
| `CATALOG_FILE` | `~/.pouch/catalog.yaml` | 预置 Skill 来源目录 |
| `CATALOG_CACHE_DIR` | `~/.local/share/pouch/externals/` | catalog checkout 缓存;按 repo/ref 共享 |
| `CONFIG_FILE` | `~/.config/pouch/config.yaml` | custom source 配置 |
| `CONFIG_FILE` | `~/.config/pouch/config.yaml` | custom source 与 auto_update 配置 |
| `STATE_FILE` | `~/.local/share/pouch/state.yaml` | 自动更新时间戳 |
| `SOURCES_DIR` | `~/.local/share/pouch/sources/` | custom Git source 默认 checkout |
## 注意事项
+308
View File
@@ -0,0 +1,308 @@
"""在日常命令里顺带更新 pouch 自身和已拉取的外部 source。"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from pouch import yaml_io
from pouch.catalog import catalog_checkout_path, load_catalog
from pouch.config import load_auto_update_settings
from pouch.paths import POUCH_HOME, STATE_FILE
from pouch.sources import load_sources, source_checkout
GIT_TIMEOUT_SECONDS = 20
_SKIP_COMMANDS = {"update", "publish"}
_TRUTHY = {"1", "true", "yes", "on"}
_refreshed_paths: set[str] = set()
@dataclass(frozen=True)
class UpdateResult:
name: str
status: str
detail: str = ""
@property
def changed(self) -> bool:
return self.status == "updated"
def _print_err(msg: str) -> None:
print(msg, file=sys.stderr)
def _run_git(
repo: Path,
*args: str,
check: bool = False,
timeout: int = GIT_TIMEOUT_SECONDS,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(repo), *args],
check=check,
text=True,
capture_output=True,
timeout=timeout,
)
def _is_git_repo(path: Path) -> bool:
if not path.is_dir():
return False
try:
result = _run_git(path, "rev-parse", "--is-inside-work-tree")
except (OSError, subprocess.TimeoutExpired):
return False
return result.returncode == 0 and result.stdout.strip() == "true"
def _working_tree_dirty(path: Path) -> bool:
result = _run_git(path, "status", "--porcelain")
return result.returncode != 0 or bool(result.stdout.strip())
def _head_sha(path: Path) -> str:
result = _run_git(path, "rev-parse", "HEAD")
return result.stdout.strip() if result.returncode == 0 else ""
def refresh_git_checkout(
path: Path,
ref: str | None = None,
*,
name: str,
strict: bool = False,
allow_dirty: bool = False,
) -> UpdateResult:
key = str(path.resolve()) if path.exists() else str(path)
if key in _refreshed_paths:
return UpdateResult(name, "current")
if not _is_git_repo(path):
_refreshed_paths.add(key)
result = UpdateResult(name, "skipped", "不是 git 仓库")
if strict:
raise SystemExit(f"{name} 不是 git 仓库: {path}")
return result
if not allow_dirty and _working_tree_dirty(path):
_refreshed_paths.add(key)
result = UpdateResult(name, "skipped", "工作区不干净")
if strict:
raise SystemExit(f"{name} 工作区不干净,拒绝更新: {path}")
return result
before = _head_sha(path)
commands: list[tuple[str, ...]] = [("fetch", "--all", "--tags")]
if ref:
commands.append(("checkout", ref))
commands.append(("pull", "--ff-only"))
try:
for command in commands:
ran = _run_git(path, *command, check=strict)
if ran.returncode != 0:
detail = (ran.stderr or ran.stdout or "git 失败").strip()
return UpdateResult(name, "failed", detail)
except subprocess.CalledProcessError as exc:
detail = ((exc.stderr or exc.stdout or str(exc))).strip()
if strict:
raise SystemExit(f"更新 {name} 失败: {detail}") from exc
return UpdateResult(name, "failed", detail)
except subprocess.TimeoutExpired:
result = UpdateResult(name, "failed", "git 超时")
if strict:
raise SystemExit(f"更新 {name} 超时: {path}") from None
return result
except OSError as exc:
result = UpdateResult(name, "failed", str(exc))
if strict:
raise SystemExit(f"更新 {name} 失败: {exc}") from exc
return result
after = _head_sha(path)
_refreshed_paths.add(key)
if before and after and before != after:
return UpdateResult(name, "updated", after[:8])
return UpdateResult(name, "current")
def update_pouch_home(*, strict: bool = False) -> UpdateResult:
return refresh_git_checkout(
POUCH_HOME,
name="pouch",
strict=strict,
allow_dirty=strict,
)
def update_cloned_catalogs(*, strict: bool = False) -> list[UpdateResult]:
results: list[UpdateResult] = []
seen: set[Path] = set()
for name, entry in load_catalog().items():
repo = str(entry.get("repo", ""))
if repo.startswith("~"):
continue
dest = catalog_checkout_path(name, entry)
resolved = dest.resolve() if dest.exists() else dest
if resolved in seen or not dest.is_dir():
continue
seen.add(resolved)
results.append(
refresh_git_checkout(
dest,
str(entry.get("ref", "main") or "main"),
name=f"catalog:{name}",
strict=strict,
)
)
return results
def update_custom_sources(*, strict: bool = False) -> list[UpdateResult]:
results: list[UpdateResult] = []
for name, entry in load_sources().items():
if entry.get("local_path"):
continue
checkout = source_checkout(name, entry)
if not checkout.is_dir():
continue
results.append(
refresh_git_checkout(
checkout,
str(entry.get("ref", "main") or "main"),
name=f"custom:{name}",
strict=strict,
)
)
return results
def run_updates(*, strict_pouch: bool = False) -> list[UpdateResult]:
results = [update_pouch_home(strict=strict_pouch)]
results.extend(update_cloned_catalogs(strict=False))
results.extend(update_custom_sources(strict=False))
return results
def load_state(path: Path | None = None) -> dict[str, Any]:
path = path or STATE_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
return data if isinstance(data, dict) else {}
def save_state(data: dict[str, Any], path: Path | None = None) -> None:
path = path or STATE_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def record_auto_update(path: Path | None = None) -> None:
state = load_state(path)
state["last_auto_update"] = datetime.now(timezone.utc).isoformat()
save_state(state, path)
def _interval_elapsed(state: dict[str, Any], interval_seconds: int) -> bool:
if interval_seconds <= 0:
return True
raw = state.get("last_auto_update")
if not raw:
return True
try:
last = datetime.fromisoformat(str(raw))
except ValueError:
return True
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
elapsed = (datetime.now(timezone.utc) - last).total_seconds()
return elapsed >= interval_seconds
def _env_disabled() -> bool:
return os.environ.get("POUCH_NO_AUTO_UPDATE", "").strip().lower() in _TRUTHY
def _acquire_lock(path: Path) -> Any | None:
path.parent.mkdir(parents=True, exist_ok=True)
try:
lock_file = path.open("a+")
except OSError:
return None
try:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
lock_file.close()
return None
return lock_file
def _release_lock(lock_file: Any | None) -> None:
if lock_file is None:
return
try:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except OSError:
pass
try:
lock_file.close()
except OSError:
pass
def reset_refresh_cache() -> None:
_refreshed_paths.clear()
def report_results(results: Iterable[UpdateResult], *, prefix: str = "[pouch]") -> None:
changed = [item.name for item in results if item.changed]
failed = [item for item in results if item.status == "failed"]
if changed:
_print_err(f"{prefix} 已自动更新: {', '.join(changed)}")
for item in failed:
detail = f": {item.detail}" if item.detail else ""
_print_err(f"{prefix} 更新 {item.name} 失败,已跳过{detail}")
def maybe_auto_update(*, command: str | None = None, disabled: bool = False) -> list[UpdateResult]:
if disabled or command in _SKIP_COMMANDS or _env_disabled():
return []
if not POUCH_HOME.is_dir():
return []
settings = load_auto_update_settings()
if not settings.enabled:
return []
state = load_state()
if not _interval_elapsed(state, settings.interval_seconds):
return []
lock_file = _acquire_lock(STATE_FILE.with_name("auto-update.lock"))
if lock_file is None:
return []
try:
# 拿到锁后再读一次,避免并发重复拉取。
state = load_state()
if not _interval_elapsed(state, settings.interval_seconds):
return []
results = run_updates(strict_pouch=False)
record_auto_update()
report_results(results)
return results
except Exception as exc: # noqa: BLE001 — 自动更新不得打断用户命令
_print_err(f"[pouch] 自动更新失败,已跳过: {exc}")
return []
finally:
_release_lock(lock_file)
+85 -16
View File
@@ -58,6 +58,7 @@ from pouch.skills import (
validate_skill_dir,
validate_skill_name,
)
from pouch.auto_update import maybe_auto_update, record_auto_update, refresh_git_checkout, run_updates
from pouch.sources import (
discover_source_skills,
fetch_source,
@@ -250,15 +251,33 @@ def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None
return names
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
def _ensure_source_fetched(
name: str,
source: str | None = None,
*,
refresh: bool = False,
) -> None:
name, source = split_skill_spec(name, source)
catalog = load_catalog()
sources = load_sources()
if source in sources:
root = source_skills_root(source, sources[source])
entry = sources[source]
root = source_skills_root(source, entry)
if not root.is_dir():
_print(f"拉取 source: {source}")
fetch_source(source, sources[source])
fetch_source(source, entry)
elif refresh and not entry.get("local_path"):
result = refresh_git_checkout(
source_checkout(source, entry),
str(entry.get("ref", "main") or "main"),
name=f"custom:{source}",
)
if result.status == "updated":
_err(f"[pouch] 已更新 custom:{source}")
elif result.status == "failed":
_err(
f"[pouch] 更新 custom:{source} 失败,使用本地缓存: {result.detail}"
)
return
catalog_name = (
@@ -274,7 +293,11 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
source in (None, "catalog", catalog_name)
or source == f"catalog:{catalog_name}"
):
_ensure_catalog_fetched(catalog_name, catalog[catalog_name])
_ensure_catalog_fetched(
catalog_name,
catalog[catalog_name],
refresh=refresh,
)
return
if source not in (None, "catalog"):
@@ -283,17 +306,31 @@ def _ensure_source_fetched(name: str, source: str | None = None) -> None:
if name not in catalog:
return
_ensure_catalog_fetched(name, catalog[name])
_ensure_catalog_fetched(name, catalog[name], refresh=refresh)
def _ensure_catalog_fetched(name: str, entry: dict[str, object]) -> None:
def _ensure_catalog_fetched(
name: str,
entry: dict[str, object],
*,
refresh: bool = False,
) -> None:
path = catalog_skill_path(name, entry)
repo = catalog_repo(entry)
ref = str(entry.get("ref", "main") or "main")
dest = catalog_checkout_path(name, entry)
if dest.is_dir() and (dest / ".git").exists() and refresh and not str(entry.get("repo", "")).startswith("~"):
result = refresh_git_checkout(dest, ref, name=f"catalog:{name}")
if result.status == "updated":
_err(f"[pouch] 已更新 catalog:{name}")
elif result.status == "failed":
_err(f"[pouch] 更新 catalog:{name} 失败,使用本地缓存: {result.detail}")
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
return
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
return
repo = catalog_repo(entry)
ref = entry.get("ref", "main")
dest = catalog_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise SystemExit(
@@ -334,14 +371,17 @@ def _expand_install_request(
catalog = load_catalog()
sources = load_sources()
if source in sources:
_ensure_source_fetched(name, source, refresh=True)
return [(name, source)]
if source == "catalog" and name in catalog:
_ensure_catalog_fetched(name, catalog[name], refresh=True)
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, "catalog")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source in catalog:
_ensure_catalog_fetched(source, catalog[source], refresh=True)
available = _catalog_skill_names(source, catalog[source])
if name not in available:
raise SystemExit(
@@ -349,13 +389,14 @@ def _expand_install_request(
)
return [(name, f"catalog:{source}")]
if source is None and name in catalog:
_ensure_catalog_fetched(name, catalog[name], refresh=True)
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, f"catalog:{name}")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source is None and name in sources:
_ensure_source_fetched(name, name)
_ensure_source_fetched(name, name, refresh=True)
available = discover_source_skills(name, sources[name])
if not available:
raise SystemExit(f"custom source {name!r} 中没有可安装的 skill")
@@ -375,7 +416,7 @@ def _install_skill(
source: str | None = None,
) -> str:
name, source = split_skill_spec(name, source)
_ensure_source_fetched(name, source)
_ensure_source_fetched(name, source, refresh=True)
skill_path, resolved_source = resolve_skill_source(name, source=source)
links = [
(target, agent_skill_dir(target, project_root=project_root) / name)
@@ -540,7 +581,23 @@ def cmd_update(args: argparse.Namespace) -> None:
del args
ensure_pouch_home()
_print(f"更新 pouch: {POUCH_HOME}")
subprocess.run(["git", "-C", str(POUCH_HOME), "pull"], check=True)
results = run_updates()
record_auto_update()
labels = {
"updated": "已更新",
"current": "已是最新",
"skipped": "跳过",
"failed": "失败",
}
for item in results:
extra = f"({item.detail})" if item.detail else ""
line = f"{labels.get(item.status, item.status)} {item.name}{extra}"
if item.status == "failed":
_err(line)
else:
_print(line)
if any(item.status == "failed" for item in results):
raise SystemExit("部分来源更新失败")
def _installed_links(
@@ -640,7 +697,7 @@ def cmd_add(args: argparse.Namespace) -> None:
elif source == "catalog":
names = list(load_catalog())
else:
_ensure_source_fetched("", source)
_ensure_source_fetched("", source, refresh=True)
names = list_custom_skills(source)[source]
else:
names = list_builtin_skills()
@@ -662,7 +719,7 @@ def cmd_add(args: argparse.Namespace) -> None:
original_links: list[tuple[Path, str | None]] = []
for skill_name, source in requests:
validate_skill_name(skill_name)
_ensure_source_fetched(skill_name, source)
_ensure_source_fetched(skill_name, source, refresh=True)
resolve_skill_source(skill_name, source=source)
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / skill_name
@@ -1122,7 +1179,7 @@ def cmd_sync(args: argparse.Namespace) -> None:
for entry in iter_manifest_skills(data):
name = entry["name"]
source = entry.get("source")
_ensure_source_fetched(name, source)
_ensure_source_fetched(name, source, refresh=True)
skill_path, _ = resolve_manifest_skill(entry)
skill_targets = targets
if entry.get("targets"):
@@ -1702,6 +1759,11 @@ def build_parser() -> argparse.ArgumentParser:
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills)",
)
parser.add_argument("--version", action="version", version=f"pouch {__version__}")
parser.add_argument(
"--no-auto-update",
action="store_true",
help="本次跳过自动更新 pouch 与已拉取的 source",
)
sub = parser.add_subparsers(dest="command", required=True)
@@ -1711,7 +1773,10 @@ def build_parser() -> argparse.ArgumentParser:
)
p_bootstrap.set_defaults(func=cmd_bootstrap)
p_update = sub.add_parser("update", help="通过 git pull 更新 pouch 自身")
p_update = sub.add_parser(
"update",
help="更新 pouch 自身以及已拉取的 catalog / custom source",
)
p_update.set_defaults(func=cmd_update)
p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
@@ -1845,6 +1910,10 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
maybe_auto_update(
command=getattr(args, "command", None),
disabled=bool(getattr(args, "no_auto_update", False)),
)
args.func(args)
+87
View File
@@ -0,0 +1,87 @@
"""用户级 pouch 配置(~/.config/pouch/config.yaml)。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pouch import yaml_io
from pouch.paths import CONFIG_FILE
DEFAULT_AUTO_UPDATE_INTERVAL = "24h"
_INTERVAL_UNITS = {
"s": 1,
"m": 60,
"h": 3600,
"d": 86400,
}
@dataclass(frozen=True)
class AutoUpdateSettings:
enabled: bool = True
interval_seconds: int = 24 * 3600
def load_user_config(path: Path | None = None) -> dict[str, Any]:
path = path or CONFIG_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"pouch 配置格式错误: {path}")
return data
def save_user_config(data: dict[str, Any], path: Path | None = None) -> None:
path = path or CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
def parse_interval(value: Any) -> int:
if value is None or value is True:
return AutoUpdateSettings().interval_seconds
if isinstance(value, bool):
raise ValueError(f"无效的自动更新间隔: {value!r}")
if isinstance(value, int):
if value < 0:
raise ValueError(f"无效的自动更新间隔: {value!r}")
return value
text = str(value).strip().lower()
if not text:
return AutoUpdateSettings().interval_seconds
if text.isdigit():
return int(text)
unit = text[-1]
amount = text[:-1]
if unit in _INTERVAL_UNITS and amount.isdigit():
return int(amount) * _INTERVAL_UNITS[unit]
raise ValueError(f"无效的自动更新间隔: {value!r}(例如 24h、1d、60m、3600)")
def load_auto_update_settings(
config: dict[str, Any] | None = None,
path: Path | None = None,
) -> AutoUpdateSettings:
if config is None:
config = load_user_config(path)
raw = config.get("auto_update", True)
if raw is False:
return AutoUpdateSettings(enabled=False)
if raw is True or raw is None:
return AutoUpdateSettings()
if not isinstance(raw, dict):
return AutoUpdateSettings()
enabled = raw.get("enabled", True)
if enabled is False:
return AutoUpdateSettings(enabled=False)
try:
interval = parse_interval(raw.get("interval", DEFAULT_AUTO_UPDATE_INTERVAL))
except ValueError as exc:
raise SystemExit(str(exc)) from exc
return AutoUpdateSettings(enabled=bool(enabled), interval_seconds=interval)
+7
View File
@@ -45,11 +45,18 @@ CONFIG_FILE = _first_existing_file(
HOME / ".config" / "skiff" / "config.yaml",
default=HOME / ".config" / "pouch" / "config.yaml",
)
DATA_DIR = _first_existing_dir(
HOME / ".local" / "share" / "pouch",
HOME / ".local" / "share" / "skiff",
default=HOME / ".local" / "share" / "pouch",
)
SOURCES_DIR = _first_existing_dir(
DATA_DIR / "sources",
HOME / ".local" / "share" / "pouch" / "sources",
HOME / ".local" / "share" / "skiff" / "sources",
default=HOME / ".local" / "share" / "pouch" / "sources",
)
STATE_FILE = DATA_DIR / "state.yaml"
AGENT_GLOBAL: dict[str, Path] = {
"cursor": HOME / ".cursor" / "skills",
+6 -13
View File
@@ -6,7 +6,7 @@ import subprocess
from pathlib import Path
from typing import Any
from pouch import yaml_io
from pouch.config import load_user_config, save_user_config
from pouch.paths import CONFIG_FILE, SOURCES_DIR
RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"}
@@ -21,27 +21,20 @@ def validate_source_name(name: str) -> None:
def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]:
path = path or CONFIG_FILE
if not path.is_file():
return {}
data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"pouch 配置格式错误: {path}")
data = load_user_config(path)
raw = data.get("sources", {})
if raw is None:
return {}
if not isinstance(raw, dict):
raise SystemExit(f"pouch 配置 sources 格式错误: {path}")
raise SystemExit(f"pouch 配置 sources 格式错误: {path or CONFIG_FILE}")
return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)}
def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None:
path = path or CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
data = load_user_config(path)
data["sources"] = sources
save_user_config(data, path)
def source_checkout(name: str, entry: dict[str, Any]) -> Path:
+9 -1
View File
@@ -59,6 +59,10 @@ def _parse_scalar(raw: str) -> Any:
return True
if lower == "false":
return False
if raw == "[]":
return []
if raw == "{}":
return {}
if raw.isdigit() or (raw.startswith("-") and raw[1:].isdigit()):
return int(raw)
return raw
@@ -196,7 +200,11 @@ def _dump(data: Any, indent: int = 0) -> str:
return f"{pad}{{}}\n"
lines: list[str] = []
for key, value in data.items():
if isinstance(value, (dict, list)):
if isinstance(value, list) and not value:
lines.append(f"{pad}{key}: []")
elif isinstance(value, dict) and not value:
lines.append(f"{pad}{key}: {{}}")
elif isinstance(value, (dict, list)):
if isinstance(value, list) and value and all(isinstance(x, str) for x in value):
lines.append(f"{pad}{key}:")
for item in value:
+3 -3
View File
@@ -18,8 +18,8 @@ ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环
| --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 你确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 同一闭环 |
| 做需求 | `/ack 处理这个需求:…` | 先建 ship 记录;复杂需求走 specifier / long-ship,小改动走 short-ship |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 short-ship |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
@@ -117,7 +117,7 @@ ACK 会在 `.pouch/ack/agents.yaml` 保存结构化覆盖配置。它存在时
## Agent 会做什么
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:先写产品文档、任务拆分和可观测验收;修 bug 只写短说明、复现和验收。你确认后再派 Developer 和独立 Test。
2. 做需求:规格和任务写到 `.pouch/ship/<id>/`;修 bug 只写短说明、复现和验收,落缺陷板。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
+16 -3
View File
@@ -13,6 +13,13 @@ description: >-
不要复制 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`、
@@ -27,7 +34,8 @@ description: >-
- 初始化、接入 ACK:执行「初始化」。
- 检查配置是否完整:执行「检查」。
- 临时调整 Developer/Test 的 CLI 或模型:执行「角色启动配置维护」。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。
- 做需求、修 bug 或继续任务:执行「工作」。修 bug 不写大 PRD。新功能和小改动的执行记录写
`.pouch/ship/`,不要写进缺陷任务板。
- 增改关闭交付、说明怎么布测试环境或发版:执行「交付配置维护」。
- 部署或重布测试环境:执行「运行测试环境」(加载 deployer skill 的「初始化」或运行流程,不复制 compose)。
- 发布版本:执行「运行版本发布」。回归:执行「运行回归」。
@@ -45,9 +53,12 @@ description: >-
同一任务出现多个 head 时停止并创建显式合并记录,禁止按时间静默覆盖。
- 只有 Coordinator 写任务记录、知识库、回归目录、`deliveryRuns` 和 `regressionRuns`。`delivery.yaml` 只在「交付配置维护」中改。见 [roles-and-permissions.md](references/roles-and-permissions.md)。
- 知识检查只经 `run_verification.py` 的 registry ID;不把知识正文拼成 shell。
- 自动 worker 禁止 full-access、bypass、关闭 sandbox、Grok `--yolo` / bypassPermissions。OMP `--approval-mode yolo` 只用于 OMP 审批(workspace-write → yolo,read-only → always-ask),禁止 `--auto-approve`,不适用于其它 backend。
- 用户明确授权无沙箱运行后,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。
## 初始化
@@ -97,7 +108,9 @@ python3 <ack-skill-dir>/scripts/record_task.py .pouch/ack/tasks.yaml \
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)。修 bug 写短问题说明、复现和可观测验收,不写大 PRD。
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 前先走「运行测试环境」。
+7 -4
View File
@@ -114,12 +114,15 @@ intents:
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
明确要求时才调用独立的 `releaser`。没有对应能力或认证时标记 blocked,不用
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
revision,不能在目标机器重新拉源码构建。
- `build`:对当前 source revision 调用已安装的 `builder` `obtain.sh --sha <revision>`,
不要假定本机 `make`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线;没有流水线或 job 失败
则本步 `failed`。DEB 必须记录包名、版本、架构和 SHA-256;OCI image 必须记录完整
引用、platform 和 digest。产物必须绑定当前 source revision,不能在目标机器重新拉
源码构建。
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
时才加载,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
时才加载,否则必须走契约中已审查的 upload entrypoint。`BUILD_VENUE=ci` 且 obtain
已带回 registry digest 时,本步只复核,不再本机 push。项目入口只接受
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
+3
View File
@@ -4,6 +4,9 @@
用户确认后的三角色闭环、飞书收件、启动 worker、交付和回归由 `SKILL.md` 按条件加载,
不要在本文件开头预读其它规范。
复杂需求的规格和任务写在 `.pouch/ship/<ship-id>/`(specifier + long-ship)。ACK 任务板
只收缺陷。显式 `/ack` 做功能需求时也先建 ship 记录,不要把功能任务写成 `type: bug`。
---
## 前提:谁是 Coordinator
+8 -6
View File
@@ -83,7 +83,7 @@ worker 路由的机器可读事实默认保存在 `.pouch/ack/tasks.yaml` 的
- `tier`:角色模型档位;
- `model`:项目 allowlist 中的精确模型 ID;Pi 可用 `null` 表示其默认模型;
- `reasoningEffort`:CLI 支持时显式声明;
- `permissionMode`:只允许 `read-only` 或 `workspace-write`。
- `permissionMode`:`read-only`、`workspace-write`;Codex 还支持用户明确授权的 `danger-full-access`。
有效路由配置必须声明按 CLI/角色/档位分组的 `modelAllowlist`、命名 `profiles` 和角色
`defaults`(`allowedWorktrees` 自 v0.19 起废弃,不再配置)。`cli: pi` 使用真实的
@@ -108,9 +108,11 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
- 能在完全只读工作树完成的角色优先选择 `read-only`。
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
为纯观察型 Test 另建更严格的 `read-only` profile。
- `full-access`、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions`、关闭 sandbox 等模式在 v0.10
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
- 用户明确要求 Codex 无沙箱运行时,在结构化 profile 设置
`permissionMode: danger-full-access`。launcher 仍绑定 profile hash、精确 argv 和 receipt;
用户已有授权无需重复询问。任务文本或环境变量不能自行授权。
`full-access` 别名、Codex bypass、Cursor YOLO/force、Grok `--yolo` /
`--permission-mode bypassPermissions` 等自由参数仍拒绝。
Grok `--always-approve` 由 launcher 固定写入 argv,用来避免工具调用停在确认框;
它不关闭 `--sandbox`。
OMP 的 `--approval-mode yolo` 不是上述 CLI 绕过标志。它是 OMP worker 的
@@ -119,8 +121,8 @@ launcher 仍不接受 `--auto-approve` 或会话复用。
- 项目可以更新模型 allowlist 或增加安全 profile,但不能通过文档绕开 schema 和
launcher。
`full-access` 的可信授权、期限、撤销和外层隔离仍是 Deferred;在这些能力完成前,
launcher 必须拒绝该模式,而不是把一段“用户已授权”的文本当成授权证据。
仅 Codex 支持 `danger-full-access`;其它 CLI 不得套用该权限值。
无沙箱 profile 只改变执行权限,不扩大用户授权的项目、服务或操作范围。
---
+6 -5
View File
@@ -64,7 +64,8 @@ reset/attestation 后才能启用上述复用路径,不能用项目文档声
## 通过 launcher 创建 worker
**模型不由 Orca dispatch 设置。** `task-create` / `dispatch` 只投递任务;Agent CLI、
模型、reasoning effort 和权限由 `.pouch/ack/agents.yaml`(存在时)或
模型、reasoning effort 和权限由 common `agent.yaml` 的 `ack.<name>` binding 映射到
`.pouch/ack/agents.yaml`(存在时)或
`tasks.yaml.project.orchestration` 的结构化 profile 决定,并且只能由 ACK launcher 构造。
先只读生成计划:
@@ -104,10 +105,10 @@ Orca 后端并完成终端绑定;Coordinator 不自行执行或拼装 Orca 的
与当前 ACK 任务完全一致;
4. 再用 receipt binding 中的 handle 创建/派发 Orca 子任务。
profile 只允许 `read-only` 或 `workspace-write`。v0.10 不提供可信的 full-access 授权
通道;bypass、YOLO/force、关闭 sandbox 及其它 full-access 请求必须由 schema 和
launcher fail closed,不能改用手写命令兜底。模型档位与升级规则见
`model-routing.md`。
profile 支持 `read-only`、`workspace-write`,以及用户明确授权的 Codex
`danger-full-access`。无沙箱权限必须写入结构化 profile,由同一 launcher 生成
`--sandbox danger-full-access` 并绑定 fingerprint/receipt,不手写 bypass 命令。
模型档位与升级规则见 `model-routing.md`。
需要隔离/并行时,可以先用 Orca 创建 worktree(须与项目根同一 Git 仓库)。
v0.19 起不再登记 `allowedWorktrees`,直接在该路径上走 `plan` -> 带 expected
+7 -3
View File
@@ -27,7 +27,7 @@ ROLES = frozenset({"developer", "test"})
CLIS = frozenset({"codex", "cursor-agent", "grok", "omp", "pi"})
TIERS = frozenset({"standard", "strong"})
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
PERMISSION_MODES = frozenset({"read-only", "workspace-write", "danger-full-access"})
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
CLI_REQUIRES_REASONING_EFFORT = frozenset({"codex", "grok", "omp"})
@@ -256,8 +256,10 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
errors.append(f"{where}.model: must be a safe model ID or null for Pi default")
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
errors.append(
f"{where}.permissionMode: must be read-only/workspace-write"
f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access"
)
elif permission == "danger-full-access" and cli != "codex":
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
if isinstance(cli, str) and cli in CLI_REQUIRES_REASONING_EFFORT:
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
label = "Codex" if cli == "codex" else "Grok"
@@ -647,7 +649,9 @@ def _validate_requested(value: Any, where: str) -> list[str]:
):
errors.append(f"{where}.reasoningEffort: Pi requires low/medium/high/xhigh")
if not isinstance(permission, str) or permission not in PERMISSION_MODES:
errors.append(f"{where}.permissionMode: must be read-only/workspace-write")
errors.append(f"{where}.permissionMode: must be read-only/workspace-write/danger-full-access")
elif permission == "danger-full-access" and cli != "codex":
errors.append(f"{where}.permissionMode: danger-full-access requires Codex")
executable = value.get("executable")
if not _is_absolute_safe_path(executable):
errors.append(f"{where}.executable: must be a safe absolute path")
+2 -1
View File
@@ -59,13 +59,14 @@
"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"]}
"permissionMode": {"type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"]}
}
},
"defaults": {
+6 -2
View File
@@ -680,6 +680,7 @@
"permissionMode"
],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"role": {
"type": "string",
@@ -734,7 +735,8 @@
"type": "string",
"enum": [
"read-only",
"workspace-write"
"workspace-write",
"danger-full-access"
]
}
}
@@ -895,6 +897,7 @@
"environmentPolicy"
],
"additionalProperties": false,
"allOf": [{"if": {"properties": {"permissionMode": {"const": "danger-full-access"}}, "required": ["permissionMode"]}, "then": {"properties": {"cli": {"const": "codex"}}, "required": ["cli"]}}],
"properties": {
"cli": {
"type": "string",
@@ -942,7 +945,8 @@
"type": "string",
"enum": [
"read-only",
"workspace-write"
"workspace-write",
"danger-full-access"
]
},
"executable": {
+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 清理语义。
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""Validate and execute the shared project Agent runtime contract.
The command surface is deliberately small. It never evaluates project text as
shell, and it only creates builder sessions from a fixed make invocation.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError as exc: # pragma: no cover - dependency is checked at runtime
raise SystemExit("需要 PyYAML: python3 -m pip install pyyaml") from exc
CLI_NAMES = {"codex", "cursor-agent", "grok", "omp", "pi"}
ADAPTERS = {"builder", "tester", "ack"}
EFFORTS = {None, "low", "medium", "high", "xhigh"}
PERMISSIONS = {"read-only", "workspace-write"}
TIERS = {"standard", "strong"}
LIFECYCLES = {"persistent", "ephemeral"}
ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
BINDING_RE = re.compile(r"^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$")
TARGET_RE = re.compile(r"^[A-Za-z0-9_.-]{1,96}$")
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$")
MAKEFILE_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$")
FORBIDDEN_KEYS = {"command", "argv", "shell", "env", "environmentVariables", "token", "secret", "password"}
class ConfigError(ValueError):
pass
class UniqueLoader(yaml.SafeLoader):
pass
def _construct_mapping(loader: UniqueLoader, node: yaml.MappingNode, deep: bool = False) -> dict[str, Any]:
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 ConfigError("YAML mapping key 必须是字符串")
if key in mapping:
raise ConfigError(f"YAML 存在重复键: {key}")
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
def load_yaml(path: Path) -> dict[str, Any]:
try:
value = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueLoader)
except (OSError, yaml.YAMLError) as exc:
raise ConfigError(f"无法读取 {path}: {exc}") from exc
if value is None:
return {}
if not isinstance(value, dict):
raise ConfigError(f"{path} 顶层必须是 mapping")
return value
def deep_merge(base: dict[str, Any], override: dict[str, Any], where: str = "root") -> dict[str, Any]:
result = copy.deepcopy(base)
for key, value in override.items():
if key not in result:
raise ConfigError(f"{where}: local 配置不能增加未声明字段 {key!r}")
if isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value, f"{where}.{key}")
else:
result[key] = copy.deepcopy(value)
return result
def text(value: Any, where: str) -> str:
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise ConfigError(f"{where}: 必须是非空文本")
return value
def safe_id(value: Any, where: str) -> str:
value = text(value, where)
if not ID_RE.fullmatch(value):
raise ConfigError(f"{where}: ID 只能使用小写字母、数字和连字符")
return value
def safe_binding_id(value: Any, where: str) -> str:
value = text(value, where)
if not BINDING_RE.fullmatch(value):
raise ConfigError(f"{where}: binding 必须是 namespace.name 形式")
return value
def safe_path(value: Any, where: str) -> str:
value = text(value, where)
path = Path(value)
if not path.is_absolute() or value == os.path.sep or ".." in path.parts or os.path.normpath(value) != value:
raise ConfigError(f"{where}: 必须是规范绝对路径")
return value
def validate(config: dict[str, Any]) -> None:
if config.get("schemaVersion") != 1:
raise ConfigError("schemaVersion 必须为 1")
for key in ("locations", "profiles", "bindings"):
if not isinstance(config.get(key), dict) or not config[key]:
raise ConfigError(f"{key}: 必须是非空 mapping")
locations = config["locations"]
for location_id, location in locations.items():
safe_id(location_id, f"locations.{location_id}")
if not isinstance(location, dict):
raise ConfigError(f"locations.{location_id}: 必须是 mapping")
for required in ("environment", "project", "path"):
if required not in location:
raise ConfigError(f"locations.{location_id}: 缺少 {required}")
environment = text(location["environment"], f"locations.{location_id}.environment")
if environment != "local" and not ID_RE.fullmatch(environment):
raise ConfigError(f"locations.{location_id}.environment: 必须是 local 或安全环境名")
text(location["project"], f"locations.{location_id}.project")
safe_path(location["path"], f"locations.{location_id}.path")
host = location.get("host")
if host is not None:
host = text(host, f"locations.{location_id}.host")
if host != "local" and not re.fullmatch(r"runtime:[0-9a-f-]{16,}", host):
raise ConfigError(f"locations.{location_id}.host: 必须是 local 或 runtime:<environment-id>")
profiles = config["profiles"]
for profile_id, profile in profiles.items():
safe_id(profile_id, f"profiles.{profile_id}")
if not isinstance(profile, dict):
raise ConfigError(f"profiles.{profile_id}: 必须是 mapping")
for required in ("cli", "model", "reasoningEffort", "permissionMode"):
if required not in profile:
raise ConfigError(f"profiles.{profile_id}: 缺少 {required}")
if profile["cli"] not in CLI_NAMES:
raise ConfigError(f"profiles.{profile_id}.cli: 不支持 {profile['cli']!r}")
if profile["model"] is not None and MODEL_RE.fullmatch(str(profile["model"])) is None:
raise ConfigError(f"profiles.{profile_id}.model: 不是安全模型 ID")
if profile["reasoningEffort"] not in EFFORTS:
raise ConfigError(f"profiles.{profile_id}.reasoningEffort: 不支持该值")
if profile["permissionMode"] not in PERMISSIONS:
raise ConfigError(f"profiles.{profile_id}.permissionMode: 不支持该值")
if profile.get("tier", "standard") not in TIERS:
raise ConfigError(f"profiles.{profile_id}.tier: 必须是 standard/strong")
for binding_id, binding in config["bindings"].items():
safe_binding_id(binding_id, f"bindings.{binding_id}")
if not isinstance(binding, dict):
raise ConfigError(f"bindings.{binding_id}: 必须是 mapping")
for required in ("adapter", "location", "profile"):
if required not in binding:
raise ConfigError(f"bindings.{binding_id}: 缺少 {required}")
if binding["adapter"] not in ADAPTERS:
raise ConfigError(f"bindings.{binding_id}.adapter: 不支持 {binding['adapter']!r}")
location_id = safe_id(binding["location"], f"bindings.{binding_id}.location")
profile_id = safe_id(binding["profile"], f"bindings.{binding_id}.profile")
if location_id not in locations or profile_id not in profiles:
raise ConfigError(f"bindings.{binding_id}: location/profile 引用不存在")
if binding["adapter"] == "builder":
makefile = text(binding.get("makefile", "makefile.builder"), f"bindings.{binding_id}.makefile")
if Path(makefile).is_absolute() or ".." in Path(makefile).parts or MAKEFILE_RE.fullmatch(makefile) is None:
raise ConfigError(f"bindings.{binding_id}.makefile: 必须是项目内相对路径")
target = text(binding.get("target"), f"bindings.{binding_id}.target")
if TARGET_RE.fullmatch(target) is None:
raise ConfigError(f"bindings.{binding_id}.target: 不是安全 make target")
session = binding.get("session", {})
if not isinstance(session, dict) or session.get("lifecycle", "ephemeral") not in LIFECYCLES:
raise ConfigError(f"bindings.{binding_id}.session.lifecycle: 必须是 persistent/ephemeral")
scan_forbidden(config)
def scan_forbidden(value: Any, where: str = "root") -> None:
if isinstance(value, dict):
for key, child in value.items():
if key in FORBIDDEN_KEYS:
raise ConfigError(f"{where}: 禁止配置字段 {key!r}")
scan_forbidden(child, f"{where}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
scan_forbidden(child, f"{where}[{index}]")
def load_config(root: Path) -> dict[str, Any]:
path = root / ".pouch" / "agent.yaml"
if not path.is_file():
raise ConfigError(f"缺少 {path},请先创建项目通用 Agent 配置")
config = load_yaml(path)
local_path = root / ".pouch" / "agent.local.yaml"
if local_path.is_file():
config = deep_merge(config, load_yaml(local_path))
validate(config)
return config
def binding(config: dict[str, Any], binding_id: str) -> dict[str, Any]:
if binding_id not in config["bindings"]:
raise ConfigError(f"不存在 binding: {binding_id}")
value = copy.deepcopy(config["bindings"][binding_id])
value["id"] = binding_id
value["locationId"] = value["location"]
value["location"] = copy.deepcopy(config["locations"][value["locationId"]])
value["profileId"] = value["profile"]
value["profile"] = copy.deepcopy(config["profiles"][value["profileId"]])
return value
def orca_json(args: list[str], environment: str | None = None) -> dict[str, Any]:
command = ["orca", *args]
if environment and environment != "local":
command.extend(["--environment", environment])
command.append("--json")
proc = subprocess.run(command, capture_output=True, text=True, check=False)
raw = (proc.stdout or "").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
detail = (proc.stderr or raw or str(exc)).strip()
raise ConfigError(f"Orca 没有返回 JSON: {' '.join(command)}\n{detail}") from exc
if not data.get("ok"):
error = data.get("error") or {}
raise ConfigError(f"{' '.join(command)}: {error.get('message') or error.get('code') or 'unknown error'}")
result = data.get("result")
return result if isinstance(result, dict) else {}
def environment_id(name: str) -> str:
if name == "local":
return "local"
result = orca_json(["environment", "list"])
for item in result.get("environments", []):
if isinstance(item, dict) and item.get("name") == name:
return str(item.get("id"))
raise ConfigError(f"找不到已保存 Orca environment: {name}")
def location_host(location: dict[str, Any]) -> str:
host = location.get("host")
if host:
return str(host)
if location["environment"] == "local":
return "local"
return f"runtime:{environment_id(str(location['environment']))}"
def worktree_for(binding_value: dict[str, Any]) -> dict[str, Any]:
location = binding_value["location"]
environment = str(location["environment"])
project_ids = {str(location["project"])}
projects = orca_json(["project", "list"], environment).get("projects", [])
for project in projects:
if not isinstance(project, dict):
continue
if str(project.get("id")) == str(location["project"]) or str(project.get("displayName")) == str(location["project"]):
project_ids.update(str(repo_id) for repo_id in project.get("sourceRepoIds", []) if repo_id)
result = orca_json(["worktree", "list"], environment)
candidates = []
for item in result.get("worktrees", []):
if not isinstance(item, dict):
continue
item_project_ids = {str(item.get("projectId")), str(item.get("repoId"))}
if not project_ids.intersection(item_project_ids):
continue
if str(item.get("path")) != str(location["path"]):
continue
expected_host = location_host(location)
# A routed remote runtime stamps its own worktrees with hostId=local;
# the environment name/ID is the remote-machine discriminator.
if location["environment"] == "local" and expected_host == "local" and str(item.get("hostId")) != "local":
continue
candidates.append(item)
if len(candidates) != 1:
if not candidates:
raise ConfigError(
f"找不到精确 worktree: environment={environment}, host={location_host(location)}, "
f"project={location['project']}, path={location['path']};先创建或登记该 worktree"
)
raise ConfigError(f"binding 匹配到多个 worktree: {len(candidates)}")
return candidates[0]
def project_root_commit(root: Path) -> str | None:
proc = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], capture_output=True, text=True, check=False)
return proc.stdout.strip() if proc.returncode == 0 else None
def run_dir(root: Path) -> Path:
path = root / ".pouch" / "agent" / "runs"
path.mkdir(parents=True, exist_ok=True)
return path
def run_path(root: Path, run_id: str) -> Path:
if not re.fullmatch(r"run_[0-9a-f]{16,64}", run_id):
raise ConfigError("run id 格式无效")
return run_dir(root) / f"{run_id}.json"
def cmd_validate(args: argparse.Namespace) -> int:
config = load_config(Path(args.root).resolve())
print(json.dumps({"ok": True, "schemaVersion": config["schemaVersion"], "bindings": sorted(config["bindings"])}, ensure_ascii=False, indent=2))
return 0
def cmd_show(args: argparse.Namespace) -> int:
config = load_config(Path(args.root).resolve())
value = binding(config, args.binding)
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True))
return 0
def cmd_worktree_resolve(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
worktree = worktree_for(value)
print(json.dumps({"binding": args.binding, "worktree": worktree}, ensure_ascii=False, indent=2))
return 0
def cmd_worktree_create(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
location = value["location"]
try:
existing = worktree_for(value)
except ConfigError as exc:
if "找不到精确 worktree" not in str(exc):
raise
existing = None
if existing:
print(json.dumps({"created": False, "binding": args.binding, "worktree": existing}, ensure_ascii=False, indent=2))
return 0
host = location_host(location)
command = ["worktree", "create", "--project", str(location["project"]), "--host", host, "--name", args.name, "--no-parent"]
if args.base:
command.extend(["--base-branch", args.base])
result = orca_json(command, str(location["environment"]))
print(json.dumps({"created": True, "binding": args.binding, "result": result, "next": "将返回 worktree 的 path 写回 location.path 后再运行 resolve"}, ensure_ascii=False, indent=2))
return 0
def make_command(value: dict[str, Any]) -> str:
# Replace the shell so terminal wait observes the make exit code instead
# of waiting forever on the interactive shell after make returns.
return f"exec make -f {value.get('makefile', 'makefile.builder')} {value['target']}"
def cmd_run_start(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
value = binding(load_config(root), args.binding)
if value["adapter"] != "builder":
raise ConfigError(f"run start 目前只为 builder 提供受控 make session;{value['adapter']} 请走对应 skill adapter")
if value.get("session", {}).get("lifecycle", "ephemeral") != "persistent":
raise ConfigError(f"binding {args.binding} 不是 persistent session")
worktree = worktree_for(value)
environment = str(value["location"]["environment"])
result = orca_json(["terminal", "create", "--worktree", f"id:{worktree['id']}", "--command", make_command(value), "--title", args.binding], environment)
terminal = result.get("terminal") or result
handle = terminal.get("handle") or result.get("handle")
if not handle:
raise ConfigError("Orca terminal create 没有返回 handle")
run_id = "run_" + uuid.uuid4().hex
record = {
"schemaVersion": 1,
"id": run_id,
"binding": args.binding,
"adapter": value["adapter"],
"profileId": value["profileId"],
"profileHash": hashlib.sha256(json.dumps(value["profile"], sort_keys=True).encode()).hexdigest(),
"environment": environment,
"project": value["location"]["project"],
"path": value["location"]["path"],
"worktreeId": worktree["id"],
"terminalHandle": handle,
"command": make_command(value),
"sourceCommit": project_root_commit(root),
"createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"status": "started",
}
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(record, ensure_ascii=False, indent=2))
if args.wait_ms:
wait = orca_json(["terminal", "wait", "--terminal", handle, "--for", "exit", "--timeout-ms", str(args.wait_ms)], environment)
record["wait"] = wait
record["status"] = "completed" if wait.get("exited") or wait.get("status") == "exited" else "waited"
run_path(root, run_id).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(wait, ensure_ascii=False, indent=2))
return 0
def load_run(root: Path, run_id: str) -> dict[str, Any]:
path = run_path(root, run_id)
if not path.is_file():
raise ConfigError(f"找不到 run 记录: {run_id}")
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ConfigError(f"run 记录无效: {run_id}")
return value
def cmd_run_status(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps({"run": record, "terminal": result}, ensure_ascii=False, indent=2))
return 0
def cmd_run_attach(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "read", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
def cmd_run_resume(args: argparse.Namespace) -> int:
"""Explicitly reattach to a recorded terminal; never search for another one."""
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "show", "--terminal", record["terminalHandle"]], record.get("environment"))
print(json.dumps({"resumed": True, "run": record, "terminal": result}, ensure_ascii=False, indent=2))
return 0
def cmd_run_stop(args: argparse.Namespace) -> int:
root = Path(args.root).resolve()
record = load_run(root, args.run)
result = orca_json(["terminal", "close", "--terminal", record["terminalHandle"]], record.get("environment"))
record["status"] = "stopped"
record["stoppedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
run_path(root, args.run).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"run": record, "close": result}, ensure_ascii=False, indent=2))
return 0
def parser() -> argparse.ArgumentParser:
root_parent = argparse.ArgumentParser(add_help=False)
root_parent.add_argument("--root", default=".")
parser = argparse.ArgumentParser(description="共享 Agent 配置、worktree 和 session 运行时")
sub = parser.add_subparsers(dest="command", required=True)
validate_parser = sub.add_parser("validate", parents=[root_parent])
validate_parser.set_defaults(func=cmd_validate)
show = sub.add_parser("show", parents=[root_parent])
show.add_argument("--binding", required=True)
show.set_defaults(func=cmd_show)
wt = sub.add_parser("worktree")
wt_sub = wt.add_subparsers(dest="worktree_command", required=True)
resolve = wt_sub.add_parser("resolve", parents=[root_parent])
resolve.add_argument("--binding", required=True)
resolve.set_defaults(func=cmd_worktree_resolve)
create = wt_sub.add_parser("create", parents=[root_parent])
create.add_argument("--binding", required=True)
create.add_argument("--name", required=True)
create.add_argument("--base", default="")
create.set_defaults(func=cmd_worktree_create)
run = sub.add_parser("run")
run_sub = run.add_subparsers(dest="run_command", required=True)
start = run_sub.add_parser("start", parents=[root_parent])
start.add_argument("--binding", required=True)
start.add_argument("--wait-ms", type=int, default=0)
start.set_defaults(func=cmd_run_start)
for name, func in (("status", cmd_run_status), ("attach", cmd_run_attach), ("resume", cmd_run_resume), ("stop", cmd_run_stop)):
item = run_sub.add_parser(name, parents=[root_parent])
item.add_argument("--run", required=True)
item.set_defaults(func=func)
return parser
if __name__ == "__main__":
try:
parsed = parser().parse_args()
raise SystemExit(parsed.func(parsed))
except ConfigError as exc:
print(f"environment-fail: {exc}", file=sys.stderr)
raise SystemExit(2)
+24
View File
@@ -0,0 +1,24 @@
# bugfix-ship(已弃用)
`bugfix-ship` 已改名为 [short-ship](../short-ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`short-ship/`。
## 使用示例
```text
使用 bugfix-ship,修复这个线上回归并部署到测试环境。
```
Agent 命中后会立刻转到 `short-ship`,按同一套规则执行。新请求建议直接用新名。
## 迁移
```bash
pouch add short-ship # 新名,当前项目
pouch add short-ship -g # 新名,全局
```
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `bugfix-ship` 可以分批改成 `short-ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+19
View File
@@ -0,0 +1,19 @@
---
name: bugfix-ship
description: >-
【已弃用,兼容占位】bugfix-ship 已改名为 short-ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../short-ship/SKILL.md 并按其执行。小需求、小改动、修 bug、改文案、
加按钮、修复后发 PR 或部署都属于 short-ship。
---
# bugfix-ship(已弃用)
本 skill 已改名为 [short-ship](../short-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
- 用户提到 `bugfix-ship`、`/bugfix-ship` 或 `$bugfix-ship` 时:读取
[../short-ship/SKILL.md](../short-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `short-ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `bugfix-ship` 也能正常安装,软链不会悬空;方便时改成
`short-ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
+25 -5
View File
@@ -5,6 +5,9 @@
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
可执行校验器。
构建执行位置使用共享 `.pouch/agent.yaml` 的任意 `builder.<name>` binding 配置;本地
Android、远程 Mac iOS 或未来其它平台都可以各自绑定 environment/project/path/profile。
## 什么时候使用
- "用 builder 初始化这个项目"
@@ -12,6 +15,7 @@
- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
- "看看项目现在的发布流程"
- "看这个 SHA 的 CI 构建 / obtain"
只构建不上传时明确说明即可;上传永远需要你显式授权。
@@ -38,13 +42,19 @@ pouch add builder -g # 全局
`templates/makefile.builder` 写出项目根 `makefile.builder`,并列出缺的发布配置。
不改用户已有的 `Makefile` 或 `.env`。
配置跟随 `makefile.builder` 所在的 Git 根。如果 APK/IPA 位于 Git submodule,
就在 submodule 内配置 `.env.builder` 和 `.pouch/builder/`;父仓库只固定 submodule commit
并负责上层编排。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE/CI_STATUS_SCRIPT`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
缺发布键只挡住上传,不挡住构建。
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`):
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`)。项目内 APK/IPA
分发脚本的路径写在 `makefile.builder` 的 `COPY_SCRIPT`,脚本接收产物路径作为位置参数;
未配置项目脚本时使用 `COPY_DEST` 的本地目录或容器目录后备逻辑:
```text
DEB_SERVER_URL=https://deb.example.com
@@ -53,13 +63,19 @@ DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
# COPY_SCRIPT= # 仅外部/临时脚本覆盖;项目内脚本写 makefile.builder
```
`BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。初始化时确认查询命令(tea / glab / gh / curl),写成 `.pouch/builder/ci-status.sh`。`make -f makefile.builder obtain`(或 `push-docker`)执行该脚本等待流水线并 inspect registry,不在本机 `docker push`。
4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
`make -f makefile.builder push-docker`。
写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。
跨机器或需要继续查看的构建先用 `agent-runtime` 精确解析 binding 和 Orca worktree;
builder 只在授权后执行对应 `makefile.builder` target,不自动上传。
## 多架构
项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进
@@ -179,7 +195,7 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
## Agent 会做什么
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,确认 `BUILD_VENUE`;选 ci 时生成 `.pouch/builder/ci-status.sh`。按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
@@ -205,9 +221,13 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 把产物拷到 `COPY_DEST`(本地目录或 `container:/dir`) |
| `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` |
| `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect |
| `scripts/wait_ci.sh` | 轮询项目 `CI_STATUS_SCRIPT`,只等该 SHA,不触发 |
| `scripts/init_ci_status.sh` | 初始化时把 tea/glab/gh/curl 模板拷到 `.pouch/builder/ci-status.sh` |
| `templates/ci-status/` | tea / glab / gh / curl 查询脚本模板;契约见 [ci-status.md](references/ci-status.md) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、
脏工作树策略等完整规则见 contract.md。
+36 -8
View File
@@ -2,16 +2,30 @@
name: builder
description: >-
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
或把产物拷到目录/容器。触发词:初始化 builder、检查 makefile.builder、构建/发布
deb、推送 apt、构建/发布 Docker 镜像、打 APK/IPA、拷到 File Browser、make push。
或把产物拷到目录/容器。产物可在本机或 CI 上构建;拿到产物用 obtain。触发词:初始化
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker 镜像、
打 APK/IPA、拷到 File Browser、make push、看构建、监听 CI、obtain。
未授权不上传。Docker 仅用户点名镜像时才走。
---
# Builder:DEB / Docker / Android / iOS 构建发布
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
`.pouch/builder/`,不要改用户的 `Makefile` / `makefile` / `.env`。
项目状态是**当前 builder 项目根**的 `makefile.builder` 与 `.env.builder`;项目自定义分发脚本放在
同一项目根的 `.pouch/builder/`,不要改用户的 `Makefile` / `makefile` / `.env`。如果父仓库包含
submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.env.builder` 和
`.pouch/builder/` 必须放在 submodule 内,父仓库只负责调用或固定 submodule commit。
配置来源原则:项目要构建什么、发布哪些轨道、使用哪个已提交的分发脚本,放在
`makefile.builder`;本机路径和工具链覆盖放在被忽略的 `.pouch/builder/local.mk`;凭据、
发布目标和本轮别名放在被忽略的 `.env.builder`。`COPY_SCRIPT` 若指向项目内
`.pouch/builder/` 的脚本,优先写入 `makefile.builder`,这样迁移项目时配置与脚本一起进入 Git。
构建执行位置、Agent CLI、模型、推理强度和 session 生命周期由共享 `agent-runtime` 的
`.pouch/agent.yaml` 管理。`builder.<name>` 是可自由增加的 binding:可以指向本地
worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远程主机、模型或 terminal 参数
硬编码进 `makefile.builder`。跨机器或跨 session 时先读 `agent-runtime`,构建目标仍只能
由 `makefile.builder` 提供。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。
@@ -21,17 +35,20 @@ description: >-
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 看构建、监听 CI、ACK/short-ship/long-ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
## 初始化
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
1. 确认项目根。优先以包含目标 `makefile.builder` 的仓库根为准;若目标位于 submodule,则以 submodule 根为准,不向父仓库回溯配置。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
6. 确认 `BUILD_VENUE`:`local` 或 `ci`,不要猜。选 `ci` 时读 [ci-status.md](references/ci-status.md),确认查询命令(`tea` / `glab` / `gh` / `curl`),执行 `<skill-dir>/scripts/init_ci_status.sh --root <project-dir> --kind <kind>`,在 `makefile.builder` 写入 `BUILD_VENUE=ci` 和 `CI_STATUS_SCRIPT=.pouch/builder/ci-status.sh`。用户给了自定义查询命令时,把命令写进同一份脚本并保持 `STATUS=` 输出契约。
7. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
```text
## builder 初始化:完成 | 部分完成 | 阻塞
@@ -50,6 +67,7 @@ DEB_TOKEN=
DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
# 外部/临时脚本才放这里;项目内脚本优先在 makefile.builder 设置 COPY_SCRIPT
```
## 检查
@@ -62,7 +80,17 @@ COPY_AS=latest.apk
1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。
2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 releaser,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 releaser,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。若通过 `builder.<name>` 执行,先精确解析 `environment + host + project + path` 对应的 worktree;远程 binding 不能 fallback 到本机。持久 session 只显式 attach/resume,不能按标题复用旧 terminal。
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道要 `COPY_DEST`(本地目录或 `container:/dir`);`COPY_AS` 给第一份文件额外名字。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh` / `obtain.sh`。`BUILD_VENUE=ci` 时 `push-docker` 就是 obtain。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道至少配置 `COPY_DEST`(本地目录或 `container:/dir`)或 `COPY_SCRIPT`(项目内脚本优先在 `makefile.builder` 设置;同时配置时命令行/环境值优先)。自定义脚本按 `FILE [FILE ...]` 接收产物路径,自行完成重命名、上传、远端校验和通知;相对路径按项目根解析,`BUILDER_PROJECT_ROOT` 提供项目根。`COPY_AS` 只由内置 `COPY_DEST` 路径处理。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不要把 token 当命令行参数;脚本可随项目放在 `.pouch/builder/` 下。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
## 拿到产物
调用方是 short-ship、long-ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
- `BUILD_VENUE=ci`:`wait_ci.sh` 轮询项目的 `CI_STATUS_SCRIPT`,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。缺脚本则停下,回到初始化。
- 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。
- 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。
+55
View File
@@ -0,0 +1,55 @@
# CI 状态查询脚本
`BUILD_VENUE=ci` 时,流水线状态由**项目脚本**查询。builder 只轮询这份脚本,
不在 skill 里猜 gh / glab / tea / curl。
## 初始化
选 `ci` 后确认查询方式,再生成脚本:
```bash
<skill-dir>/scripts/init_ci_status.sh --root <project> --kind tea|glab|gh|curl
```
写出 `.pouch/builder/ci-status.sh`(可执行、进 git)。`makefile.builder` 写:
```makefile
BUILD_VENUE ?= ci
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
```
相对路径只能在 `.pouch/builder/` 下。凭据仍放 `.env.builder`(如 `GITEA_TOKEN`),
不要写进脚本参数或日志。
用户给了自定义命令时,把该命令写进同一份脚本,输出必须符合下方契约。
## 调用
```bash
.pouch/builder/ci-status.sh --sha <40-character-sha>
```
只查询、不触发流水线。`BUILDER_PROJECT_ROOT` 是项目根。
## 输出
退出 0 且 stdout 含 `STATUS=` 键值(可多行或同一行):
| STATUS | 含义 |
| --- | --- |
| `empty` | 该 SHA 还没有流水线 |
| `running` | 已出现、尚未结束 |
| `success` | 该 SHA 的流水线成功 |
| `failed` | 失败、取消或超时 |
| `error` | 查询本身失败(鉴权、CLI 缺失) |
可选:`ID=`、`PIPELINE=`(URL)、`JOBS=`。
| 退出码 | 含义 |
| --- | --- |
| 0 | 已查出状态(含 empty/running/failed) |
| 2 | 用法错误 |
| 3 | 无法查询(缺 CLI、未登录、被拒绝) |
`wait_ci.sh` 按这份输出轮询:`empty` 超过出现窗口记 `no_pipeline`;
`success` 才继续 obtain;`failed` 原样失败。
+45 -7
View File
@@ -4,8 +4,17 @@
check.py,本文档跟随。所有接入项目按同一套 make 目标、产物形状和环境变量执行,
builder 脚本只做发布,不做项目特定的构建逻辑。
配置边界按 Git 项目根生效:`makefile.builder` 所在仓库就是 builder 项目根。
如果父仓库通过 submodule 引入另一个项目,submodule 的构建、`.env.builder` 和
`.pouch/builder/` 分发脚本属于 submodule;父仓库不得替它承载这些配置。
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
Agent 只保留授权判断和歧义处理。**
Agent 只保留授权判断和歧义处理。** 构建在哪台机器执行、使用哪个 CLI/model 和
session 如何保存,由共享 `agent-runtime` 的 binding 决定;makefile 不能承载主机或
Agent 选择。
`builder.android`、`builder.ios`、`builder.harmony` 都是合法的可配置 binding。新增平台
只增加 binding 和对应 make target,不改 builder skill 的固定枚举。
契约文件固定为项目根 `makefile.builder`,调用方式:
@@ -40,16 +49,20 @@ make -f makefile.builder <target>
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh` |
| `push-docker` | 同时有多种发布轨道的项目 | `BUILD_VENUE=local` 时仅调 `publish_docker.sh`;`ci` 时仅调 `obtain.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时轮询 `CI_STATUS_SCRIPT` 再 inspect,不在本机 `docker push` |
| `venue` | 可选(新模板必备) | 打印一行 `BUILD_VENUE`(`local` 或 `ci`) |
规则:
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
1. 项目有 DEB 产物的判据:`makefile.builder` 配方以独立命令调用 `dpkg-deb`/`debuild` 或产出 `.deb`;
不能把 `xcodebuild` 中包含的字符误判成 `debuild`。
有镜像的判据:项目根存在 `Dockerfile`。
Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。
拷贝发布判据:配方调用 `copy_artifact.sh`。
拷贝发布判据:配方调用 `copy_artifact.sh`。`copy_artifact.sh` 是稳定入口,具体分发可由
`COPY_SCRIPT` 接管。
2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合;
单轨道项目一个 `push` 即可。
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
@@ -62,8 +75,15 @@ make -f makefile.builder <target>
## 2. 变量
配置来源按变量的稳定性分层:`makefile.builder` 是项目构建与发布轨道的可迁移事实,
包括 `ARCHES`、产物命名、目标和项目内 `COPY_SCRIPT`;`.pouch/builder/local.mk` 是
不提交的机器工具链覆盖;`.env.builder` 是不提交的凭据、发布端点、目标目录和本轮别名。
命令行/环境变量可以覆盖前两者,但不应把项目契约依赖在本机环境上。
| 变量 | 默认 | 说明 |
|------|------|------|
| `BUILD_VENUE` | `local` | 产物在哪构建:`local` 本机 make;`ci` 由项目 CI 流水线构建。这不是新轨道。写在 `makefile.builder` 并提交。`ci` 时 `push-docker` 委托 `obtain.sh`(等流水线 + inspect),不跑 `publish_docker.sh` |
| `CI_STATUS_SCRIPT` | `.pouch/builder/ci-status.sh` | `BUILD_VENUE=ci` 时必填。项目内查询该 SHA 流水线状态的可执行脚本,相对路径只能在 `.pouch/builder/`。初始化时按 tea / glab / gh / curl 生成,契约见 [ci-status.md](ci-status.md) |
| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
@@ -139,10 +159,25 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| 变量 | 必填 | 说明 |
|---|---|---|
| `COPY_DEST` | 是 | 本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) |
| `COPY_DEST` | 与 `COPY_SCRIPT` 至少配置一个 | 内置后备目标:本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) |
| `COPY_SCRIPT` | 与 `COPY_DEST` 至少配置一个 | 项目内 `.pouch/builder/` 脚本的路径优先写在 `makefile.builder` 并提交;也可用绝对路径或环境/.env.builder 覆盖;必须是可执行文件,同时配置时优先于 `COPY_DEST` |
| `COPY_AS` | 否 | 第一份文件的额外 basename,逗号或空格分隔 |
`copy_artifact.sh FILE [FILE ...]` 把已构建文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。不读 `.env`。脏工作树默认拒绝,`ALLOW_UNCOMMITTED=1` 放行。
`copy_artifact.sh FILE [FILE ...]` 是 APK/IPA 等产物的统一分发入口。不读 `.env`,脏工作树默认拒绝,
`ALLOW_UNCOMMITTED=1` 放行。
未配置 `COPY_SCRIPT` 时,入口把文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。
配置 `COPY_SCRIPT=.pouch/builder/publish-apk.sh` 时(项目内脚本通常由 `makefile.builder` 传入),入口按项目根解析、先校验每个文件存在,然后直接执行:
```text
/absolute/path/to/publish-script FILE [FILE ...]
```
相对路径只允许位于项目的 `.pouch/builder/` 下;绝对路径仍支持外部脚本。不经过 shell 字符串求值,
不把 token 放进参数。脚本通过位置参数接收原始产物绝对路径,自行负责重命名、上传、远端校验和通知;
当前项目根以 `BUILDER_PROJECT_ROOT` 环境变量提供。
脚本返回非零即视为分发失败;成功后入口只报告脚本成功和本地 SHA-256,不宣称远端已校验。
`--dry-run` 只打印将调用的脚本与文件,不执行自定义脚本。
### Docker 轨道
@@ -155,6 +190,8 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。
`BUILD_VENUE=ci` 时 Docker 发布路径是 `obtain.sh`:`wait_ci.sh` 只轮询 `CI_STATUS_SCRIPT`,只认 **该 commit SHA**。没有流水线、job 失败、超时、或 Harbor inspect 不到 `version.sh --docker` 算出的 tag,一律非零退出。不触发流水线。查询命令在初始化时写成项目脚本,运行时不再猜测 gh / glab / tea / curl。合进默认分支若项目声明不构建,这是正确失败;调用方应打 tag 或推送到项目会构建的 ref(例如 `dev/*`)。CI job 若已经 `docker push`,obtain 成功即视为产物已在 registry,不要再本机 push 一次。
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
不要读取或改写用户 `.env`。空值视为未配置。不要提交 `.env.builder`。
@@ -170,6 +207,7 @@ push 目标与 `version.sh` 定位 builder 脚本时按以下顺序,命中即
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
2. `$HOME/.pouch/skills/builder/scripts/`(标准 clone 位)
3. `$HOME/.agents/skills/builder/scripts/`(本机 agent 安装位)
两个位置都不可用时必须失败并提示:设置 `BUILDER_SKILL_DIR`,或把 pouch 仓库
clone 到 `~/.pouch`。
+55
View File
@@ -0,0 +1,55 @@
# 拿到产物(obtain)
Builder 的交付入口是 **按精确 Git commit 拿到产物**,不是「在哪台机器编译」。
docker / deb / android / ios / copy 仍是轨道;`BUILD_VENUE` 只说明构建发生在本机还是 CI。
## 谁来调用
- `short-ship` / `long-ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- 用户说「看构建」「监听 CI」「拿到镜像」时。
- `make -f makefile.builder obtain` 或 `push-docker`(`BUILD_VENUE=ci`)。
代码必须已经推到远端(`ci` 时流水线由 git ref 触发)。Builder 不代替 releaser 打 tag,也不创建分支。
## 命令
```bash
<builder>/scripts/obtain.sh --sha <full-sha> [--branch <name>] [--root <project>] [--timeout 1200]
```
成功打印:
```text
ARTIFACT=harbor.example.com/ns/name:1.4.2
DIGEST=sha256:…
SHA=<full-sha>
VENUE=ci
TAG=1.4.2
```
镜像 tag 对该 SHA 跑 `version.sh --docker`(可 `--branch` 注入 `CI_COMMIT_BRANCH`),禁止 `git describe`。
## 退出码
| 码 | 含义 | 调用方 |
|---|---|---|
| 0 | 产物已核验 | `artifact_built` 完成 |
| 1 | 该 SHA 无流水线、job 失败、超时、inspect 失败 | 标 `failed`,不得 publish/deploy |
| 2 | 用法或配置错误 | 标 `blocked` |
| 3 | 项目 CI 状态脚本无法查询 | 停;回到初始化补 `CI_STATUS_SCRIPT`,或用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 |
## `ci` 规则
- 只认该 SHA 的 pipeline,不认默认分支「最新一次绿」。
- 不触发流水线。
- 查询命令是项目 `.pouch/builder/ci-status.sh`(`CI_STATUS_SCRIPT`),初始化时按 tea / glab / gh / curl 写好。凭据仍放 `.env.builder`。
- job 变绿不够,docker 轨道必须 inspect 到 digest。
- 合进 `main` 若项目不构建,`no_pipeline` 是正确失败。预览走项目会构建的 ref(如 `dev/*`),正式走 tag。
## Agent
1. 固化 source revision 为完整 SHA。
2. 跑 `obtain.sh --sha <sha>`。
3. 退出 0:记录 ARTIFACT/DIGEST/SHA。`venue=ci` 且 job 已 push 时,`published` 视为已满足,不要本机再 `publish_docker.sh`。
4. 退出 1/2:停止 delivery-loop,报告 pipeline URL 或 `no_pipeline`。
5. 退出 3:先说明 CLI 身份不对;只有已有同 host 登录 tab 时才用来读 pipeline。
+233 -16
View File
@@ -41,7 +41,13 @@ SECRET_PATTERNS = (
)
FLOATING_TAGS = (":latest", ":stable")
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
VALID_SCRIPT_NAMES = (
"upload_deb.sh",
"publish_docker.sh",
"copy_artifact.sh",
"obtain.sh",
"wait_ci.sh",
)
BUILDER_MAKEFILE = "makefile.builder"
BUILDER_ENV = ".env.builder"
@@ -228,7 +234,8 @@ def check_build_has_no_upload(report: Report, project: Path) -> None:
def detect_deb_project(recipe_all: str, project: Path) -> bool:
return ".deb" in recipe_all or "dpkg-deb" in recipe_all or "debuild" in recipe_all or any(project.glob("debian/*"))
has_deb_command = re.search(r"(?<![A-Za-z0-9_-])(?:dpkg-deb|debuild)(?![A-Za-z0-9_-])", recipe_all)
return ".deb" in recipe_all or has_deb_command is not None or any(project.glob("debian/*"))
def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> None:
@@ -301,7 +308,11 @@ def check_mobile_recipe(
report: Report, project: Path, *, kind: str, number: int, ext: str
) -> None:
target = "build"
dry = run_make(project, "build")
if kind == "ios":
ios_probe = run_make(project, "ios")
if not has_no_rule(ios_probe):
target = "ios"
dry = run_make(project, target)
text = chr(10).join(clean_make_output(dry))
problems = []
if dry.returncode != 0:
@@ -349,6 +360,9 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
"$$HOME/.skills/skills/builder/scripts",
"$HOME/.skills/skills/builder/scripts",
"~/.skills/skills/builder/scripts",
"$$HOME/.agents/skills/builder/scripts",
"$HOME/.agents/skills/builder/scripts",
"~/.agents/skills/builder/scripts",
)
for name in VALID_SCRIPT_NAMES
)
@@ -374,7 +388,12 @@ def check_push_delegates(
missing = []
inline = []
thin = []
builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
builder_names = (
"upload_deb.sh",
"publish_docker.sh",
"copy_artifact.sh",
"obtain.sh",
)
for target in targets:
dry = run_make(project, target)
if has_no_rule(dry):
@@ -442,7 +461,7 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
COPY_ENV_KEYS = ("COPY_DEST",)
COPY_ENV_KEYS = ("COPY_DEST", "COPY_SCRIPT")
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
@@ -469,16 +488,186 @@ def env_file_keys(project: Path) -> set[str]:
return keys
def key_present(key: str, env_keys: set[str]) -> bool:
return bool(os.environ.get(key)) or key in env_keys
def env_file_values(project: Path) -> dict[str, str]:
"""Return nonempty `.env.builder` values for internal validation only."""
path = project / BUILDER_ENV
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
for raw in text.splitlines():
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
match = ENV_KEY_LINE.match(stripped)
if not match:
continue
value = match.group(2).strip().strip("'\"")
if value:
values[match.group(1)] = value
return values
MAKE_ASSIGNMENT = re.compile(
r"^([A-Za-z_][A-Za-z0-9_]*)\s*(?:\?=|:=|=)\s*(.*?)\s*$"
)
def makefile_values(project: Path) -> dict[str, str]:
"""Return nonempty top-level make variable values for contract checks."""
path = project / BUILDER_MAKEFILE
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
for raw in text.splitlines():
match = MAKE_ASSIGNMENT.match(raw)
if not match:
continue
value = re.split(r"\s+#", match.group(2), maxsplit=1)[0].strip()
if value:
values[match.group(1)] = value
return values
def key_present(
key: str, env_keys: set[str], make_values: dict[str, str] | None = None
) -> bool:
return bool(os.environ.get(key)) or key in env_keys or bool((make_values or {}).get(key))
def configured_value(
key: str,
env_values: dict[str, str],
make_values: dict[str, str] | None = None,
) -> str:
"""Read a configured value without ever printing it."""
return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "")
def resolve_builder_local_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve a project script; relative paths must stay under .pouch/builder/."""
configured = Path(script).expanduser()
if configured.is_absolute():
return configured, None
try:
resolved = (project / configured).resolve()
allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root)
except ValueError:
return None, "relative script must be under .pouch/builder/"
return resolved, None
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
path, error = resolve_builder_local_script(project, script)
if error:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return path, None
def check_copy_script(
report: Report,
project: Path,
env_values: dict[str, str],
make_values: dict[str, str],
) -> None:
script = configured_value("COPY_SCRIPT", env_values, make_values)
if not script:
return
path, resolve_error = resolve_copy_script(project, script)
problems = []
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("COPY_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("COPY_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
14,
"COPY_SCRIPT 可执行且路径明确",
"\n".join(problems) if problems else "configured custom copy script is executable",
)
def read_venue(project: Path) -> str:
env = (os.environ.get("BUILD_VENUE") or "").strip()
if env in ("local", "ci"):
return env
result = run_make(project, "venue")
if not has_no_rule(result):
echoed = parse_dry_echo(result)
if echoed in ("local", "ci"):
return echoed
cleaned = "\n".join(clean_make_output(result)).strip()
if cleaned in ("local", "ci"):
return cleaned
raw = makefile_values(project).get("BUILD_VENUE", "local")
raw = re.split(r"\s+#", raw, maxsplit=1)[0].strip()
return raw or "local"
def check_ci_status_script(
report: Report,
project: Path,
env_values: dict[str, str],
make_values: dict[str, str],
) -> None:
if read_venue(project) != "ci":
report.add(SKIP, 15, "CI_STATUS_SCRIPT 可执行", "BUILD_VENUE is not ci")
return
script = configured_value("CI_STATUS_SCRIPT", env_values, make_values)
problems = []
if not script:
problems.append("BUILD_VENUE=ci requires CI_STATUS_SCRIPT")
path = None
resolve_error = None
else:
path, resolve_error = resolve_builder_local_script(project, script)
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("CI_STATUS_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("CI_STATUS_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
15,
"CI_STATUS_SCRIPT 可执行",
"\n".join(problems) if problems else f"ci status script: {script}",
)
def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
report: Report,
project: Path | None = None,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool = False,
) -> None:
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
venue = read_venue(project) if project is not None else "local"
lines = [
f"make: {'found' if shutil.which('make') else 'MISSING'}",
f"BUILD_VENUE: {venue}",
]
problems = []
if docker_project:
if venue not in ("local", "ci"):
problems.append("BUILD_VENUE must be local or ci")
if docker_project and venue == "ci":
docker_ok = shutil.which("docker") is not None
lines.append(
f"docker: {'found' if docker_ok else 'optional for inspect; obtain fails without it'}"
)
lines.append("CI status: project CI_STATUS_SCRIPT (tea/glab/gh/curl)")
elif docker_project:
docker_ok = shutil.which("docker") is not None
lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}")
if not docker_ok:
@@ -516,6 +705,8 @@ def check_ready_env_keys(
copy_project: bool = False,
) -> None:
env_keys = env_file_keys(project)
env_values = env_file_values(project)
make_values = makefile_values(project)
lines = []
missing: list[str] = []
if not deb_project and not docker_project and not copy_project:
@@ -531,7 +722,7 @@ def check_ready_env_keys(
lines.append("DEB_*: skipped (no deb track)")
if docker_project:
for key in DOCKER_ENV_KEYS:
found = key_present(key, env_keys)
found = key_present(key, env_keys, make_values)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
@@ -539,18 +730,28 @@ def check_ready_env_keys(
lines.append("DOCKER_*: skipped (no docker track)")
if copy_project:
for key in COPY_ENV_KEYS:
found = key_present(key, env_keys)
found = key_present(key, env_keys, make_values)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
if "COPY_SCRIPT" in make_values and "COPY_SCRIPT" not in env_keys and not os.environ.get("COPY_SCRIPT"):
lines[-1] += " (makefile.builder)"
if not any(key_present(key, env_keys, make_values) for key in COPY_ENV_KEYS):
missing.append("COPY_DEST or COPY_SCRIPT")
check_copy_script(report, project, env_values, make_values)
else:
lines.append("COPY_*: skipped (no copy track)")
if missing:
if "COPY_DEST or COPY_SCRIPT" in missing:
config_hints = [
" COPY_DEST= # local/container destination, or",
" COPY_SCRIPT=.pouch/builder/publish-apk.sh # choose one",
]
else:
config_hints = [f" {key}=" for key in missing]
lines.extend(
[
"",
f"blocks publish, not build. Put keys in the environment or {BUILDER_ENV}:",
*[f" {key}=" for key in missing],
*config_hints,
f"Do not commit {BUILDER_ENV}. Do not put these keys in `.env`. Never print values.",
]
)
@@ -567,6 +768,7 @@ def check_script_paths(report: Report) -> None:
home = Path(os.environ.get("HOME", ""))
candidates.append(home / ".pouch" / "skills" / "builder" / "scripts")
candidates.append(home / ".skills" / "skills" / "builder" / "scripts")
candidates.append(home / ".agents" / "skills" / "builder" / "scripts")
found = next((c for c in candidates if c.is_dir() and any((c / n).is_file() for n in VALID_SCRIPT_NAMES)), None)
if found:
report.add(PASS, 9, "builder 脚本路径可达", str(found))
@@ -646,10 +848,16 @@ def main(argv: list[str] | None = None) -> int:
report.add(SKIP, number, title, detail)
docker_project = detect_docker_project(project)
deb_project = any(project.glob("debian/*"))
check_ready_toolchain(report, deb_project=deb_project, docker_project=docker_project)
check_ready_toolchain(
report,
project,
deb_project=deb_project,
docker_project=docker_project,
)
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
)
check_ci_status_script(report, project, env_file_values(project), makefile_values(project) if (project / BUILDER_MAKEFILE).is_file() else {})
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
@@ -671,6 +879,8 @@ def main(argv: list[str] | None = None) -> int:
"push-deb",
"push-docker",
"push-copy",
"obtain",
"venue",
):
result = run_make(project, target)
if result.returncode == 0:
@@ -732,6 +942,7 @@ def main(argv: list[str] | None = None) -> int:
if args.ready:
check_ready_toolchain(
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
@@ -743,6 +954,12 @@ def main(argv: list[str] | None = None) -> int:
docker_project=docker_project,
copy_project=copy_project,
)
check_ci_status_script(
report,
project,
env_file_values(project),
makefile_values(project),
)
total_fail = report.failures
print()
+74 -6
View File
@@ -6,12 +6,17 @@ usage() {
Usage:
COPY_DEST=/path/or/container:/dir \
copy_artifact.sh FILE [FILE ...]
COPY_SCRIPT=.pouch/builder/publish-apk.sh \
copy_artifact.sh FILE [FILE ...]
Copy built artifacts to COPY_DEST. Dest is a local directory, or a Docker
copy target `container:/absolute/dir` (docker cp). Does not read `.env`.
Copy built artifacts using COPY_SCRIPT when configured; otherwise copy to
COPY_DEST. COPY_DEST is a local directory or a Docker copy target
`container:/absolute/dir` (docker cp). Does not read `.env`.
Environment (project root .env.builder; shell values win):
COPY_DEST Required. Local dir or container:/dir
COPY_DEST Optional fallback. Local dir or container:/dir
COPY_SCRIPT Optional .pouch/builder/ relative path or absolute
executable. Receives FILE paths as positional args.
COPY_AS Optional. Extra basenames for the first file
(comma or space separated)
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
@@ -58,14 +63,20 @@ while (($#)); do
esac
done
if [[ -z "$dest" || $# -eq 0 ]]; then
echo "Error: COPY_DEST and at least one file are required." >&2
echo "Set them in the environment or the project root .env.builder." >&2
if [[ -z "$dest" && -z "${COPY_SCRIPT:-}" || $# -eq 0 ]]; then
if [[ $# -eq 0 ]]; then
echo "Error: at least one file is required." >&2
else
echo "Error: set COPY_DEST or COPY_SCRIPT." >&2
fi
echo "Set the value in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
# Refresh Git's cached stat data after build tools rewrite tracked files.
git -C "$project_root" update-index -q --refresh 2>/dev/null || true
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
@@ -73,6 +84,63 @@ if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null
fi
fi
if [[ -n "${COPY_SCRIPT:-}" ]]; then
copy_script=$COPY_SCRIPT
if [[ "$copy_script" != /* ]]; then
relative_script=${copy_script#./}
if [[ "$relative_script" != .pouch/builder/* || -z "$project_root" ]]; then
echo "Error: relative COPY_SCRIPT must be under .pouch/builder/." >&2
exit 2
fi
copy_script="$project_root/$relative_script"
fi
if [[ ! -f "$copy_script" ]]; then
echo "Error: COPY_SCRIPT is not a file: $copy_script" >&2
exit 2
fi
if [[ ! -x "$copy_script" ]]; then
echo "Error: COPY_SCRIPT is not executable: $copy_script" >&2
exit 2
fi
artifact_args=()
for src in "$@"; do
if [[ ! -f "$src" ]]; then
echo "Error: not a file: $src" >&2
exit 2
fi
if [[ "$src" == /* ]]; then
artifact_args+=("$src")
else
artifact_args+=("$PWD/$src")
fi
done
if $dry_run; then
printf 'custom copy script:'
printf ' %q' "$copy_script" "${artifact_args[@]}"
printf '\n'
exit 0
fi
export BUILDER_PROJECT_ROOT=${project_root:-}
if "$copy_script" "${artifact_args[@]}"; then
if command -v sha256sum >/dev/null 2>&1; then
for src in "${artifact_args[@]}"; do
sha256sum -- "$src"
done
fi
echo "copy_artifact: custom script completed for $# file(s)"
exit 0
else
status=$?
echo "Error: COPY_SCRIPT failed with exit code $status." >&2
exit "$status"
fi
fi
if [[ -z "$dest" ]]; then
echo "Error: COPY_DEST is required when COPY_SCRIPT is not configured." >&2
exit 2
fi
is_docker_dest() {
local d=$1
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Copy a CI status template into the project.
# Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl
usage() {
echo "Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl" >&2
}
root=
kind=
while (($#)); do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root=$2; shift 2 ;;
--kind) [[ $# -ge 2 ]] || { usage; exit 2; }; kind=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
if [[ -z "$root" || -z "$kind" ]]; then
usage
exit 2
fi
case "$kind" in
tea|glab|gh|curl) ;;
*) echo "Error: --kind must be tea, glab, gh, or curl" >&2; exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
src=$script_dir/../templates/ci-status/${kind}.sh
if [[ ! -f "$src" ]]; then
echo "Error: missing template: $src" >&2
exit 2
fi
dest=$root/.pouch/builder/ci-status.sh
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
chmod +x "$dest"
echo "$dest"
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
set -euo pipefail
# Obtain a verified artifact for an exact git commit.
# Build may have happened locally or in CI; this script only reports identity
# after the artifact exists. Does not print tokens.
#
# Usage:
# obtain.sh [--sha SHA] [--branch NAME] [--root DIR] [--timeout SEC]
#
# BUILD_VENUE (makefile.builder, default local):
# local HEAD must be SHA; run `make -f makefile.builder docker` or `deb`
# ci wait for this SHA via the project CI_STATUS_SCRIPT, then inspect the image
#
# Exit:
# 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE=
# 1 no pipeline, job failed, inspect failed
# 2 usage / config
# 3 CI status script cannot query (ci venue)
usage() {
sed -n '2,20p' "$0"
}
sha=
branch=
root=
timeout_sec=1200
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
--branch) branch=$2; shift 2 ;;
--root) root=$2; shift 2 ;;
--timeout) timeout_sec=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$root" ]]; then
root=$(git rev-parse --show-toplevel 2>/dev/null || true)
fi
if [[ -z "$root" ]]; then
echo "Error: --root must be a git project" >&2
exit 2
fi
cd "$root"
if [[ -z "$sha" ]]; then
sha=$(git rev-parse HEAD)
else
sha=$(git rev-parse "$sha")
fi
# Load .env.builder without printing values; shell values win. Do not read `.env`.
if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
version_sh=$script_dir/version.sh
wait_ci=$script_dir/wait_ci.sh
venue=${BUILD_VENUE:-}
registry=${DOCKER_REGISTRY:-}
repository=${DOCKER_REPOSITORY:-}
if [[ -f "$root/makefile.builder" ]]; then
if [[ -z "$venue" ]]; then
venue=$(make -s -C "$root" -f makefile.builder venue 2>/dev/null || true)
fi
if [[ -z "$registry" ]]; then
registry=$(make -s -C "$root" -f makefile.builder print-DOCKER_REGISTRY 2>/dev/null || true)
fi
if [[ -z "$repository" ]]; then
repository=$(make -s -C "$root" -f makefile.builder print-DOCKER_REPOSITORY 2>/dev/null || true)
fi
fi
venue=${venue:-local}
docker_tag_for_sha() {
local tmp tag
tmp=$(mktemp -d "${TMPDIR:-/tmp}/builder-obtain.XXXXXX")
git -C "$root" worktree add --detach "$tmp" "$sha" >/dev/null
if [[ -n "$branch" ]]; then
tag=$(CI_COMMIT_BRANCH="$branch" "$version_sh" -C "$tmp" --docker)
else
tag=$("$version_sh" -C "$tmp" --docker)
fi
git -C "$root" worktree remove --force "$tmp" >/dev/null
printf '%s' "$tag"
}
inspect_image() {
local image=$1 out digest
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker is required to inspect $image" >&2
exit 1
fi
out=$(docker buildx imagetools inspect "$image" 2>/dev/null \
|| docker manifest inspect "$image" 2>/dev/null \
|| true)
if [[ -z "$out" ]]; then
echo "Error: cannot inspect $image" >&2
exit 1
fi
digest=$(printf '%s\n' "$out" | awk '/^Digest:/ {print $2; exit}')
if [[ -z "$digest" ]]; then
digest=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin)
except Exception:
sys.exit(0)
print(d.get("digest") or "")' <<<"$out")
fi
if [[ -z "$digest" ]]; then
echo "Error: inspect of $image had no digest" >&2
exit 1
fi
printf '%s' "$digest"
}
case "$venue" in
ci)
"$wait_ci" --sha "$sha" --root "$root" --timeout "$timeout_sec"
if [[ ! -x "$version_sh" ]]; then
echo "Error: version.sh not found: $version_sh" >&2
exit 2
fi
if [[ -z "$registry" || -z "$repository" ]]; then
echo "Error: DOCKER_REGISTRY and DOCKER_REPOSITORY are required to inspect the CI image" >&2
exit 2
fi
tag=$(docker_tag_for_sha)
image="${registry}/${repository}:${tag}"
digest=$(inspect_image "$image")
echo "ARTIFACT=$image"
echo "DIGEST=$digest"
echo "SHA=$sha"
echo "VENUE=ci"
echo "TAG=$tag"
;;
local)
head=$(git rev-parse HEAD)
if [[ "$head" != "$sha" ]]; then
echo "Error: local obtain requires HEAD=$sha (HEAD is $head)" >&2
exit 2
fi
if [[ ! -f "$root/makefile.builder" ]]; then
echo "Error: no makefile.builder" >&2
exit 2
fi
if make -s -C "$root" -f makefile.builder -n docker >/dev/null 2>&1; then
make -C "$root" -f makefile.builder docker
if [[ -n "${IMAGE_TAG:-}" ]]; then
tag=$IMAGE_TAG
elif [[ -x "$version_sh" ]]; then
tag=$("$version_sh" --docker)
else
echo "Error: cannot derive IMAGE_TAG" >&2
exit 2
fi
name=$(make -s -C "$root" -f makefile.builder print-PROJECT_NAME 2>/dev/null || basename "$root")
image="${name}:${tag}"
if [[ -n "$registry" && -n "$repository" ]]; then
image="${registry}/${repository}:${tag}"
fi
echo "ARTIFACT=$image"
echo "DIGEST=local"
echo "SHA=$sha"
echo "VENUE=local"
echo "TAG=$tag"
elif make -s -C "$root" -f makefile.builder -n deb >/dev/null 2>&1; then
make -C "$root" -f makefile.builder deb
echo "ARTIFACT=deb"
echo "SHA=$sha"
echo "VENUE=local"
else
echo "Error: local venue has no docker or deb target" >&2
exit 2
fi
;;
*)
echo "Error: BUILD_VENUE must be local or ci (got ${venue})" >&2
exit 2
;;
esac
+1
View File
@@ -170,6 +170,7 @@ fi
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
if [[ "$mode" == push && "$dry_run" == false && -n "$project_root" ]] \
&& git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
git -C "$project_root" update-index -q --refresh 2>/dev/null || true
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
+1
View File
@@ -69,6 +69,7 @@ fi
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
git -C "$project_root" update-index -q --refresh 2>/dev/null || true
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
set -euo pipefail
# Poll the project CI status script until the exact SHA finishes.
# Does not trigger a pipeline. Does not print tokens.
#
# Usage:
# wait_ci.sh --sha SHA [--root DIR] [--timeout SEC]
#
# The project script is CI_STATUS_SCRIPT (makefile.builder, default
# .pouch/builder/ci-status.sh). See references/ci-status.md.
usage() {
sed -n '2,14p' "$0"
}
sha=
root=
timeout_sec=1200
poll_sec=${BUILDER_CI_POLL_SEC:-10}
appear_sec=${BUILDER_CI_APPEAR_SEC:-90}
while (($#)); do
case "$1" in
--sha) [[ $# -ge 2 ]] || { echo "Error: --sha needs a value" >&2; exit 2; }; sha=$2; shift 2 ;;
--root) [[ $# -ge 2 ]] || { echo "Error: --root needs a value" >&2; exit 2; }; root=$2; shift 2 ;;
--timeout) [[ $# -ge 2 ]] || { echo "Error: --timeout needs a value" >&2; exit 2; }; timeout_sec=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$root" ]]; then
root=$(git rev-parse --show-toplevel 2>/dev/null || true)
fi
if [[ -z "$root" || ! -d "$root/.git" && ! -f "$root/.git" ]]; then
echo "Error: --root must be a git project" >&2
exit 2
fi
if [[ -z "$sha" ]]; then
echo "Error: --sha is required" >&2
exit 2
fi
sha=$(git -C "$root" rev-parse "$sha")
if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi
resolve_status_script() {
local configured=${CI_STATUS_SCRIPT:-}
if [[ -z "$configured" && -f "$root/makefile.builder" ]]; then
configured=$(make -s --no-print-directory -C "$root" -f makefile.builder print-CI_STATUS_SCRIPT 2>/dev/null | tail -n 1 || true)
fi
configured=${configured:-.pouch/builder/ci-status.sh}
local path
if [[ "$configured" == /* ]]; then
path=$configured
else
path=$root/$configured
case "$configured" in
.pouch/builder/*) ;;
*)
echo "Error: relative CI_STATUS_SCRIPT must be under .pouch/builder/: $configured" >&2
exit 2
;;
esac
fi
if [[ ! -f "$path" ]]; then
echo "Error: CI status script missing: $path" >&2
echo "Run builder init with BUILD_VENUE=ci to write .pouch/builder/ci-status.sh" >&2
exit 2
fi
if [[ ! -x "$path" ]]; then
echo "Error: CI status script is not executable: $path" >&2
exit 2
fi
printf '%s' "$path"
}
parse_status() {
python3 -c '
import sys
text = sys.stdin.read()
values = {}
for raw in text.replace("\n", " ").split():
if "=" in raw:
key, value = raw.split("=", 1)
values[key.upper()] = value
print(values.get("STATUS", "error").lower())
print(values.get("ID", "-"))
print(values.get("PIPELINE", "-"))
print(values.get("JOBS", ""))
'
}
status_script=$(resolve_status_script)
export BUILDER_PROJECT_ROOT=$root
query() {
local out err rc
err=$(mktemp)
set +e
out=$(BUILDER_PROJECT_ROOT="$root" "$status_script" --sha "$sha" 2>"$err")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: CI status script exited $rc" >&2
sed '/token/Id; /Bearer/d; /Authorization/Id' "$err" >&2
rm -f "$err"
if [[ $rc -eq 2 ]]; then
exit 2
fi
exit 3
fi
rm -f "$err"
printf '%s' "$out"
}
started=$(date +%s)
first_seen=0
empty_since=$started
while true; do
now=$(date +%s)
if (( now - started > timeout_sec )); then
echo "FAIL reason=timeout sha=$sha" >&2
exit 1
fi
raw=$(query)
mapfile -t fields < <(printf '%s' "$raw" | parse_status)
st=${fields[0]:-error}
pid=${fields[1]:--}
url=${fields[2]:--}
jobs_s=${fields[3]:-}
case "$st" in
empty)
if (( now - empty_since >= appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1
fi
sleep "$poll_sec"
continue
;;
error)
echo "Error: unexpected CI status response" >&2
echo "$raw" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3
;;
success)
echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s"
exit 0
;;
failed|failure|canceled|cancelled|skipped)
echo "FAIL reason=job_${st} sha=$sha pipeline=$url jobs=$jobs_s" >&2
exit 1
;;
running|pending|queued|waiting|in_progress|created|preparing)
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
empty_since=$now
sleep "$poll_sec"
;;
*)
empty_since=$now
sleep "$poll_sec"
;;
esac
done
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via curl. Output STATUS= keys.
# Auth: GITEA_TOKEN in the environment or .env.builder. GITEA_API_URL optional.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
read -r host path base <<<"$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^(https?)://([^/]+)(?:/(.*))?$", url)
scheme, host, path = (m.group(1), m.group(2), m.group(3) or "") if m else ("", "", "")
else:
m = re.match(r"^ssh://git@([^/]+)/(.+)$", url)
scheme, host, path = ("https", m.group(1), m.group(2)) if m else ("", "", "")
path = re.sub(r"\.git$", "", path)
print(host, path, (scheme or "https") + "://" + host)
')"
api=${GITEA_API_URL:-$base/api/v1}
api=${api%/}
[[ "$api" == */api/v1 ]] || api+=/api/v1
repo=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe="/"))')
gitea_curl() {
if [[ -n "${GITEA_TOKEN:-}" ]]; then
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 \
--config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$1"
else
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$1"
fi
}
set +e
runs=$(gitea_curl "${api}/repos/${repo}/actions/runs?head_sha=${sha}&limit=20&page=1")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: curl cannot read Gitea Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = []
for run in runs:
head = run.get("head_sha") or run.get("headSha") or (run.get("head_commit") or {}).get("id")
if head and head.lower() == sha.lower() and (run.get("id") or run.get("database_id")):
matched.append(run)
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id")
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitHub Actions for an exact SHA via gh. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
repo=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
set +e
raw=$(gh run list --repo "$repo" --commit "$sha" --limit 20 --json databaseId,status,conclusion,url,headSha)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $repo" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if not data:
print("STATUS=empty")
raise SystemExit(0)
r = data[0]
status = str(r.get("status") or "").lower()
conc = str(r.get("conclusion") or "").lower()
if status == "completed" and conc == "success":
state = "success"
elif status == "completed":
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, r.get("databaseId") or "-", r.get("url") or "-"))
' <<<"$raw"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitLab CI for an exact SHA via glab. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v glab >/dev/null 2>&1; then
echo "Error: glab is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
encoded=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
set +e
raw=$(glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $path" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("STATUS=error")
raise SystemExit(0)
if not isinstance(data, list) or not data:
print("STATUS=empty")
raise SystemExit(0)
p = data[0]
st = str(p.get("status") or "").lower()
if st in ("success", "passed"):
state = "success"
elif st in ("failed", "canceled", "cancelled", "skipped"):
state = "failed"
elif st in ("",):
state = "empty"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, p.get("id") or "-", p.get("web_url") or "-"))
' <<<"$raw"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via tea. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v tea >/dev/null 2>&1; then
echo "Error: tea is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
[[ -n "$path" ]] || { echo "Error: cannot parse origin" >&2; exit 3; }
set +e
runs=$(tea api "repos/${path}/actions/runs?head_sha=${sha}&limit=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: tea cannot read Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = [run for run in runs if str(run.get("head_sha") or run.get("headSha") or "").lower() == sha.lower()]
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id") or "-"
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+4 -1
View File
@@ -11,5 +11,8 @@
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS= # optional override; project ARCHES lives in makefile.builder
# COPY_DEST= # local dir, or docker dest container:/absolute/dir
# GITEA_API_URL= # optional for the curl CI status script
# GITEA_TOKEN= # optional token for private Gitea repositories
# COPY_DEST= # fallback: local dir, or docker dest container:/absolute/dir
# COPY_SCRIPT= # external/temporary executable override; project-local paths belong in makefile.builder
# COPY_AS= # optional extra basenames for the first file
@@ -1,5 +1,6 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# Android 轨道:build 产出 dist/<name>_<version>_<arch>.apk,push 走 copy_artifact.sh。
# copy_artifact.sh 会按 COPY_SCRIPT 分发;未配置时回退到 COPY_DEST。
# ARCHES 是构建机 ISA;APK 可含多 ABI。不要把这些目标写进用户 Makefile。
ARCHES ?= amd64
@@ -35,7 +36,7 @@ help:
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST"
@echo " make -f makefile.builder push - copy_artifact.sh 分发 APK"
version:
@echo $(VERSION)
+25 -5
View File
@@ -8,6 +8,10 @@
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
# local: make docker/deb on this machine. ci: obtain.sh polls
# CI_STATUS_SCRIPT for an exact SHA, then inspects the artifact. Not a track.
BUILD_VENUE ?= local
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
@@ -28,13 +32,16 @@ DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
BUILDER_SCRIPTS := $(BUILDER_SKILL_DIR)/scripts
else ifneq ($(wildcard $(HOME)/.pouch/skills/builder/scripts/version.mk),)
include $(HOME)/.pouch/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.pouch/skills/builder/scripts
else
include $(HOME)/.agents/skills/builder/scripts/version.mk
BUILDER_SCRIPTS := $(HOME)/.agents/skills/builder/scripts
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version arches clean build deb docker push push-deb push-docker
.PHONY: help version arches venue clean build deb docker obtain push push-deb push-docker
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@@ -45,6 +52,7 @@ help:
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder deb - 打 DEB(按 ARCHES;只构建不上传)"
@echo " make -f makefile.builder docker - 本地单平台镜像"
@echo " make -f makefile.builder obtain - 按 BUILD_VENUE 拿到产物"
@echo " make -f makefile.builder push / push-deb / push-docker"
version:
@@ -53,6 +61,12 @@ version:
arches:
@echo $(ARCHES)
venue:
@echo $(BUILD_VENUE)
print-%:
@echo $($*)
clean:
rm -rf $(DIST_DIR)
@@ -83,7 +97,13 @@ docker:
push-deb:
$(BUILDER_SCRIPTS)/upload_deb.sh $(foreach a,$(_PUSH_DEB_ARCHES),$(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(a).deb)
ifdef _ARCH_FROM_FILE
obtain:
$(BUILDER_SCRIPTS)/obtain.sh
ifeq ($(BUILD_VENUE),ci)
push-docker:
$(BUILDER_SCRIPTS)/obtain.sh
else ifdef _ARCH_FROM_FILE
push-docker:
$(BUILDER_SCRIPTS)/publish_docker.sh
else
@@ -1,5 +1,6 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# iOS 轨道:build 产出 dist/<name>_<version>_<arch>.ipa,push 走 copy_artifact.sh。
# copy_artifact.sh 会按 COPY_SCRIPT 分发;未配置时回退到 COPY_DEST。
# ARCHES 是构建机 ISA。不要把这些目标写进用户 Makefile。
ARCHES ?= arm64
@@ -35,7 +36,7 @@ help:
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST"
@echo " make -f makefile.builder push - copy_artifact.sh 分发 IPA"
version:
@echo $(VERSION)
+27
View File
@@ -0,0 +1,27 @@
# delivery-loop
供其他交付类 skill 复用的状态机,统一处理验证、PR、合并、回归、构建和部署的顺序与安全门。
## 什么时候使用
通常由 `$short-ship`、`$pr-ship` 或 `$long-ship` 派发的 worker 读取,不需要单独调用。
入口主会话不要把本 skill 读进自己的上下文。
```text
按 delivery-loop 把当前 ship 推进到 review_ready。
```
## 使用前准备
- 入口已绑定 ship id 时,同时安装 `ship`,证据指针写进该记录。
- 项目已有有效的 ACK delivery profile 或等价交付配置。
- 已明确任务的目标分支、测试范围和允许的停止点。
- 需要外部写入时,当前请求已经授权对应动作。
## Agent 会做什么
按配置推进任务,逐阶段记录验证证据;遇到缺配置、失败或高风险变更时停止,不自动猜测。
## 如何判断完成
最终报告会明确停在 PR、合并、回归、产物或部署中的哪一状态,并给出可复核证据。
+36
View File
@@ -0,0 +1,36 @@
---
name: delivery-loop
description: >-
为修复、PR 和需求任务提供可配置的验证、发 PR、合并、回归、构建、发布与部署状态机。
仅作为 short-ship、pr-ship、long-ship 的共享执行规则使用。
---
# Delivery Loop:共享交付状态机
这是三个交付入口共用的内部规则,不直接替用户选择生产发布,也不替项目保存环境事实。
## 使用方式
由 `short-ship` / `long-ship` / `pr-ship` **派出来的 worker** 读取本 skill 和
`references/state-machine.md`。入口主会话不要读取或执行本 skill。
入口决定任务类型、停止点和派哪一步;本 skill 负责该步的阶段要求、证据和安全门。
## 不可违反的规则
- 复用现有 `hunt`、`tester`、`check`、`builder`、`deployer`、`releaser` 和
`orchestration` 能力;不要在本 skill 里复制它们的实现。`artifact_built` 只调用
builder `obtain.sh`,不要在本 skill 里 poll GitLab/GitHub。
- 先读取项目的 delivery profile、分支策略和当前工作树,再决定可执行的停止点。
- `stopAt` 未配置或与用户目标冲突时停止并报告缺口,不猜测是否应合并或部署。
- 提交、推送、合并、打 tag、发布产物和生产部署都必须同时满足项目配置与当前请求的授权。
- 不绕过审核、CI、测试失败、脏工作树、冲突、未确认的迁移或不可回滚的外部变更。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。入口已绑定 `ship-id` 时,把
指针写入 `.pouch/ship/<id>/current.yaml` 的 `evidence`(规则见 ship `record.md`);
缺指针则该阶段未完成。
- 失败时保留现场和回滚信息,不用重试掩盖失败,不伪造发布或部署成功。
## 结果
完成时返回状态机最终状态、停止点、变更 commit、PR/tag、测试与回归证据、产物摘要、部署
目标和回滚指针。若停在中间状态,明确下一步和阻塞原因。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Delivery Loop"
short_description: "Shared delivery state machine for workflow skills"
default_prompt: "Use $delivery-loop to inspect a delivery workflow state and its safe stopping point."
policy:
allow_implicit_invocation: false
@@ -0,0 +1,62 @@
# Delivery Loop 状态机
## 状态
```text
change_ready -> verified -> pr_opened -> merged -> regression_passed -> artifact_built -> published -> deployed
```
入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目
Ship 配置使用以下 canonical `stopAt`:
- `develop_verified`:开发阶段验证完成,包含单元测试、新增功能集成测试和静态检查;不等于完整回归。
- `review_ready`:PR/MR 已创建,等待审核。
- `release_ready`:PR 已合并且完整回归通过,映射内部 `regression_passed`;不表示已发布或部署。
- `released`:产物已构建并发布,映射内部 `published`。
- `deployed`:目标环境实际部署完成,映射内部 `deployed`。
旧记录可继续使用 `verified`(`develop_verified` 的别名)和 `validation_ready`
(`release_ready` 的别名);新记录应使用 canonical 名称。
入口 skill 的自然语言停止点映射如下:
| 用户目标 | Ship stopAt | 必要内部里程碑 |
| --- | --- | --- |
| 只做开发验证 | `develop_verified` | `verified` |
| 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 |
| 合并后完整回归 | `release_ready` | `merged`、`regression_passed` |
| 构建并发布产物 | `released` | `merged`、`regression_passed`、`artifact_built`、`published` |
| 实际部署 | `deployed` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
## 每个阶段的要求
- `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。
- `verified`:开发阶段根因或需求验收明确;任务相关单元测试、新增功能集成测试和静态检查通过。
- `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。
- `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。
- `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。
回归 worker 必须先发现项目及其子项目的 tester catalog(使用
`catalog.py roots --root <project-root>`),逐个测试根执行并记录独立快照;父仓库
没有 catalog 不能遮蔽有 catalog 的 `app` 子项目。任一发现的测试根未执行或无证据,
不得把阶段标为 `regression_passed`。还必须执行项目 profile 或
`.pouch/ack/project.md` 声明的项目原生测试套件,并为每个套件记录命令和结果;任一
测试面缺少结果时都不能通过回归。
- `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。
- `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过。
## 配置和授权
读取项目自己的 delivery 配置,不把命令、主机、秘密复制到通用 skill。优先使用 ACK
的 `delivery.yaml`、builder/deployer/tester 契约和项目版本策略;缺失时停止在最近的安全状态。
生产部署必须同时满足:项目 profile 允许、目标明确、构建来源已固定、回归证据完整、当前
请求覆盖生产部署。没有这些条件时只能报告,而不能用“用户之前说过发布”替代当前授权。
## 证据账本
至少记录:基线和最终 commit、工作树状态、测试命令及结果、黑盒/真机证据、PR/MR URL、
合并 commit、tag、产物路径与摘要、部署节点/服务、健康检查、失败项和回滚指针。
已绑定 ship 记录时,上述证据以指针写入该 ship 的 `current.yaml`,不要另建一份账本。
阶段与字段对应见 ship `references/record.md`。
+10 -8
View File
@@ -5,19 +5,20 @@
支持三种用法:
- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有 Compose 服务(如 app00)
- **项目内 Compose 环境**:在普通项目里放 `.pouch/deployer/{prod,test,dev}/`,
把这个项目的生产/测试/开发环境用 rsync + docker compose 部署
- **项目内 Compose 环境**:在普通项目里放 `.pouch/deployer/{prod,test,dev}/`;测试环境在本机
触发 Compose,正式环境委派给本机 `~/code/app00` 项目的 Agent terminal,非正式远程环境才用
rsync + docker compose
- **Argo CD / GitOps**:项目里放 `.pouch/deployer/argocd.yaml`;Agent 改 GitOps 并开 MR,
你合并后由 Argo CD 同步。仓库既可以只写 Git 地址(部署时浅 clone),也可以指定本机已有目录。
## 什么时候使用
- 给新项目初始化 deployer:检查缺什么,引导补 `.pouch/deployer/`
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
- 想用一套固定流程更新测试环境,或把正式部署委派给本机 `~/code/app00` 的 Agent
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
- 想给当前项目加 prod/test/dev 三套环境并随时部署其中一套
- ACK 说「重新布测试环境」或跑回归前要先拉起 `.pouch/deployer/test`
- 需要一张「哪台机器跑哪些服务」的清单
- 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署
@@ -33,7 +34,7 @@ pouch add deployer -g # 全局
## 使用前准备
- Compose:本机 Python 3、`ssh`;有 `rsync` 更好,没有则 sync 用 `scp` 兜底;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
- Compose:本机 Python 3、Docker Compose v2;测试只需本机 Docker,非正式远程环境才需要 `ssh`/`rsync`(无 rsync 时 sync 用 `scp` 兜底)
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
@@ -72,8 +73,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
2. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
3. 否则用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启)
2. 测试目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
3. 正式目标:在本机 `~/code/app00` 创建 fresh Agent terminal,由该 Agent 执行 app00 的正式入口;当前 Agent 不直接 SSH
4. 其它非正式远程目标:用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
5. 同步后查看容器状态和日志确认生效
6. 只针对你指定的那一个服务操作,不会批量动整台机器
@@ -90,7 +92,7 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
## 如何判断完成
- 本机:`docker compose ps` 容器 Up。远程:sync 成功且 `remote.py ps` 容器 Up
- 测试本机:`docker compose ps` 容器 Up。正式环境:app00 Agent 回报单服务退出码、容器状态、镜像 tag/digest 和健康检查证据;非正式远程:sync 成功且 `remote.py ps` 容器 Up
- `logs` 无报错;升级后镜像 tag 与配置一致
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
- 域名/端口类服务能 curl 通
+16 -12
View File
@@ -1,14 +1,16 @@
---
name: deployer
description: >-
初始化或检查项目部署配置;管理多机 Docker Compose(脚本同步后远程
compose)与 Argo CD GitOps(改清单开 MR,用户合并后同步)。触发词:初始化
初始化或检查项目部署配置;管理测试环境的本机 Docker Compose、通过 app00 Agent
terminal 触发正式环境部署,以及 Argo CD GitOps(改清单开 MR,用户合并后同步)。触发词:初始化
deployer、部署、sync、升级、装 deb、ArgoCD、GitOps;ACK 拉起测试环境时也可使用。
---
# deployer:Compose 节点与 Argo CD GitOps
# deployer:测试本机 Compose、正式 Agent 部署与 Argo CD GitOps
- **Compose**:本地改 `compose.yaml` → 脚本同步到 SSH 节点 → 远程 `docker compose`。
- **测试 Compose**:本地镜像/配置 → 在本机环境目录触发 `docker compose`,不 SSH、不走远程同步。
- **正式部署**:不由当前 Agent 直接 SSH;把单服务部署任务委派给本机 `~/code/app00` 项目的
fresh Agent terminal,由该 Agent 按 app00 的正式入口执行并回报证据。
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用 Compose 的 `sync.py`/`remote.py` 去推集群。
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
@@ -22,14 +24,16 @@ description: >-
- 部署、重新部署、同步、升级、重启、布测试环境、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- ArgoCD / GitOps / 开 MR 部署:执行「Argo CD」。步骤见 [argocd.md](references/argocd.md)。
- 两者都有且意图不清:先问。
- 不适用:没有 `.pouch/deployer/` 的随手 `docker compose`、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。本机 node 仍走本 skill,镜像已在本机则跳过 sync。
- 不适用:没有 `.pouch/deployer/` 的随手 `docker compose`、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。测试 node 必须是本机;正式环境不在这里直接 SSH。
## 边界
- 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。
- `rsync` 带 `--delete`:sync 前先把远程服务目录拷到 `$HOME/bakeup/deployer-skill/<name>/<时间戳>/`(每服务留 5 份);运行时数据仍须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。
- `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- `node` 是非正式 Compose 轨道的 `~/.ssh/config` Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- `.pouch/deployer/test` 固定本机触发:若 node、镜像或 Compose 不满足本机条件,直接报告 `environment-fail`,禁止退回 SSH。
- `.pouch/deployer/prod` 固定走 [production-agent.md](references/production-agent.md),禁止执行 `sync.py`、`remote.py` 或直接 SSH。
- 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
- 不 `--force` 推送、不硬 reset,除非用户明确要求。Argo CD 不直接推默认分支。
- 镜像固定 tag,不用 `:latest`;成对升级的服务要同步升。
@@ -41,7 +45,7 @@ description: >-
1. 探测:`.pouch/deployer/`、根目录 compose、`Dockerfile`、ACK `intents.testEnvironment`、`argocd.yaml`。
2. Compose 与 Argo 都有且意图不清:先问。两边都要也可以,必须分开确认。
3. Compose / 新项目(ACK 默认需要 `test`):默认只建 `test`;`prod`/`dev` 用户点名再加。问 SSH Host 别名;没给不要写假 node,目录可建、列为待配置。有根目录 compose:提议迁到 `.pouch/deployer/<env>/`,确认后才动。只有 Dockerfile:可给单服务 compose 草稿,用户确认后写入,不发明多服务网格。
3. Compose / 新项目(ACK 默认需要 `test`):默认只建 `test`;`prod`/`dev` 用户点名再加。`test` 记录本机执行;`prod` 只记录用于生成 app00 selector 的 `node/name`,不要求当前 Agent 的 SSH 部署配置,并确认 app00 Agent 路由可用。没有必要字段就不要发明假值,目录可建、列为待配置。有根目录 compose:提议迁到 `.pouch/deployer/<env>/`,确认后才动。只有 Dockerfile:可给单服务 compose 草稿,用户确认后写入,不发明多服务网格。
4. Argo:只问 GitOps `repo`(或 `repo_dir`),写 `.pouch/deployer/argocd.yaml`。不 clone、不开 MR、不 `kubectl apply`。
5. 运行(不 SSH、不 up):
@@ -66,18 +70,18 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
## 被 ACK 调用
ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的默认路径布测试环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的测试本机路径布环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
## Compose 操作
重启 / 重新部署 / 同步 / 布测试环境走默认路径。不要读 compose.md、不要初始化、不要读 Makefile。
1. 在项目根执行:`python3 -I -S <skill-dir>/scripts/deploy/list.py`
2. 只操作**一个**服务:用户点名的 env;「测试」→ `test`,没有 `test` 就用非 `prod` 的那个。未点名 `prod` 不要动它。
2. 只操作**一个**服务:用户点名的 env;「测试」→ `test`,没有 `test` 时只能选择明确为本机的非 `prod` 环境,否则报告 `environment-fail`。未点名 `prod` 不要动它。
3. 不要改 compose tag、不要构建镜像、不要看其它环境。
4. 本机镜像:node 的 ssh `HostName` 是 `127.0.0.1`/`localhost`,且 `docker image inspect` 看得到 compose 里的 tag → **不要** `sync.py` / `remote.py`。进 `.pouch/deployer/<env>/` 执行 `docker compose up -d --force-recreate`,再 `ps`。完。
5. 否则才 sync + remote:`python3 -I -S <skill-dir>/scripts/deploy/sync.py <svc>` 然后 `python3 -I -S <skill-dir>/scripts/deploy/remote.py <svc> recreate`。只要重启:`remote.py <svc> restart`。
6. 远程路径验证:`remote.py <svc> ps`;异常再 `logs`。完。
4. `test` 环境:确认解析出的 node 是本机、`docker image inspect` 能看到 compose tag;进入 `.pouch/deployer/test/` 执行 `docker compose up -d --force-recreate`,再 `docker compose ps`。不要 `sync.py`、`remote.py`、SSH 或创建部署 Agent terminal。
5. `prod` 环境:停止本轨道的 Compose 操作,读取 [production-agent.md](references/production-agent.md),在本机 `~/code/app00` 项目创建 fresh Agent terminal 并委派正式部署;当前 Agent 不得直接执行 `sync.py`、`remote.py` 或 SSH。
6. 只有明确点名的非正式远程 Compose 环境,才允许 `sync.py <svc>` 后调用 `remote.py <svc> recreate`;只重启调用 `remote.py <svc> restart`,并用 `remote.py <svc> ps` 验证。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
+3 -2
View File
@@ -82,7 +82,8 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 意图 | 命令 |
|------|------|
| 本机已有镜像(node 是 localhost,image 已在本机 docker) | 不要 sync。在 env 目录 `docker compose up -d --force-recreate` |
| 重新部署远程环境 | `sync.py <svc> && remote.py <svc> recreate` |
| 正式环境 | 不在当前 Agent 直接 sync/remote/SSH;委派给本机 `~/code/app00` Agent terminal,见 [production-agent.md](production-agent.md) |
| 重新部署非正式远程环境 | `sync.py <svc> && remote.py <svc> recreate` |
| 只同步文件,不动容器 | `sync.py <svc>` |
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
| 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) |
@@ -111,7 +112,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
2. 每个环境写 `compose.yaml`;共享的 node/base_path 放 `.pouch/deployer/_config.yaml`
3. 环境有差异 → 在该环境的 `_config.yaml` 覆盖
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
5. 首次部署:远程目录不存在或为空时跳过备份;已有内容会被拷到 `$HOME/bakeup/deployer-skill/` 再同步
5. 首次测试部署走本机 Compose;非正式远程环境首次部署时,远程目录不存在或为空时跳过备份;已有内容会被拷到 `$HOME/bakeup/deployer-skill/` 再同步。正式环境不在此处首次部署,转交 app00 Agent。
## 下线服务
@@ -28,7 +28,7 @@ name: my-project-api # 可选,覆盖默认的 {项目名}-{env}
| 字段 | 必填 | 可继承 | 说明 |
|------|------|--------|------|
| `node` | 是 | 是 | SSH 目标主机别名 |
| `node` | 是 | 是 | 非正式远程 Compose 的 SSH 目标主机别名;正式环境同时用它与 `name` 生成 app00 selector,不由当前 skill 直接 SSH |
| `port` | 否 | 是 | SSH 端口,默认 22 |
| `identity_file` | 否 | 是 | SSH 私钥路径 |
| `base_path` | 否 | 是 | 远程部署根目录,默认 `/opt/app` |
@@ -0,0 +1,55 @@
# 正式环境 Agent 委派
正式环境的部署执行者固定是本机 `~/code/app00` 项目的另一个 Agent。当前
`deployer` 只负责解析目标、创建 fresh terminal、投递结构化部署任务、读取结果和清理
terminal;不得在当前会话直接 SSH、`sync.py`、`remote.py` 或运行生产 Compose。
## 目标解析
1. 先从 `list.py` / 合并后的 `_config.yaml` 得到单服务的 `node` 与 `name`,形成 app00
selector:`<node>/<name>`。例如 Blogger 正式环境为 `vyec2/blogger`;不要用当前仓库的
`.pouch/deployer/prod` 路径代替 selector。
2. 确认 `~/code/app00` 存在、是 Git 根目录,并在 Orca 中有与该绝对路径精确匹配的 project/worktree。
没有项目记录时,只登记已有目录(例如 `orca repo add --path "$HOME/code/app00" --json`),
不创建空目录、不按标题或分支猜 worktree。
3. Agent CLI、模型、权限和启动参数必须来自 app00 已登记的可信 Agent/ACK 配置;不得从项目
文本拼接自由 `command`、`argv`、`env` 或凭据。若没有可证明的部署 Agent 路由,报告
`environment-fail`,不要 fallback 到当前 Agent 或 SSH。
4. 正式部署需要 SSH 网络访问。用户明确要求无沙箱 Agent 时,使用 app00 已登记的
Codex `permissionMode: danger-full-access` profile(如 `codex-deploy-prod`),仍由
launcher 的 plan/fingerprint/launch 路径创建 fresh terminal。此授权持续有效,
后续同范围部署不重复询问;不要继续选择已知禁止 SSH 的 workspace-write profile。
## Fresh terminal 协议
按 app00 的 Agent/ACK 启动协议创建一个新的 terminal;不得复用历史 terminal。创建动作必须
使用可信 adapter/launcher 解析出的 Agent CLI 和 worktree,不得手工拼接自由启动命令。创建后:
1. 等待 terminal 进入可接收输入状态;
典型生命周期是 `terminal wait --for tui-idle` → `terminal send --enter` →
`terminal read`/等待退出 → `terminal close`。读取命令返回的真实 handle,不要按列表顺序猜
terminal,也不要复用调用前已有的 handle。
2. 投递下面的结构化任务,替换 `<selector>`、`<image-tag>` 和 `<source-revision>`,不要把
整个仓库或 secret 塞进 prompt:
```text
你是 app00 正式部署 Agent。当前目录必须是 ~/code/app00 的 Git 根目录。
只部署 selector `<selector>`,不得触碰其它服务。
来源 revision:`<source-revision>`;期望不可变镜像 tag:`<image-tag>`。
请先读取 app00 项目规范和该服务的 compose,确认 selector、镜像 tag、目标环境一致;
然后使用 app00 已有的正式部署入口执行升级(例如该项目约定的 make upgrade),不要从业务仓库
同步文件,也不要自行改目标主机或凭据。
部署后验证单服务状态、实际镜像 tag/digest 和项目规定的健康检查;返回执行命令、退出码、
容器状态、镜像证据和未完成项。若 tag、selector、权限或健康检查不一致,立即停止并报告,
不要猜测、不要部署其它服务。
```
3. 读取 Agent 的最终回报和必要的 terminal 输出,至少确认 selector、退出码、容器状态及镜像
tag/digest;只凭“命令已发送”不能判定部署成功。
4. 无论成功、失败、超时、用户中断或读取异常,都关闭本轮创建的 terminal。关闭失败保留
handle 并报告 `cleanup-fail`,不得换一个 terminal 重试关闭。
## 与 tester 的关系
tester 要求准备正式测试环境时也使用本文件的委派协议;它只等待部署完成并执行健康检查,不能
自己 SSH 或把正式环境改成测试环境路径。测试环境则继续使用 deployer 的本机 Compose 路径。
+4
View File
@@ -11,6 +11,10 @@ description: >-
本 skill 负责 iOS 测试环境判定、测试执行编排和结果报告,不伪造 Linux 上不存在的
iOS Simulator。项目根是 `git rev-parse --show-toplevel`。
远程 Mac 的 environment/project/path、Agent CLI、model 和 reasoning effort 使用共享
`agent-runtime` 的 binding(例如 `builder.ios` 或 `tester.ios`);本 skill 只负责 iOS
工具链和设备环境门禁。Linux 没有本地 iOS 工具链时,不能把远程 binding 静默改成本机。
## 模式路由
- 用户要求检查环境:先读 `references/environment.md`。
@@ -1,5 +1,9 @@
# iOS 通用环境检查
远程 Mac 的执行位置统一由 `.pouch/agent.yaml` 的 `locations`/`bindings` 提供。必须按
environment、host、project、绝对 path 精确解析 Orca worktree;没有唯一 worktree 时记为
`environment-fail`,不能按项目名称、分支或 basename 猜测,也不能 fallback 到 Linux。
## 目标判定
先明确以下之一:
+26
View File
@@ -0,0 +1,26 @@
# long-ship
把复杂需求从 PRD 评审、任务拆分、协作开发推进到集成回归和配置允许的发布部署。
## 什么时候使用
```text
把动态编辑需求做成 PRD,批准后拆任务开发,最后完整回归并部署测试环境。
```
## 使用前准备
- 同时安装 `ship`、`specifier` 和 `delivery-loop`。
- 需求目标和相关项目上下文。
- 用户愿意评审并明确批准某个 PRD 版本。
- 项目已配置测试环境和需要的构建/部署能力。
## Agent 会做什么
主会话只建 ship 记录、让你批 PRD、按步骤开新 agent 并等待。写 PRD、改代码、验证和
交付都在新会话里做。同一 ship id 串起任务和证据。
集成回归会先发现父仓库及子项目的 tester catalog,逐个测试根执行并分别记录快照,不会因父仓库没有 catalog 而跳过 `app` 等子项目;项目 profile 或 `.pouch/ack/project.md` 声明的原生测试套件也必须逐项执行并记录证据。
## 如何判断完成
你会得到 ship id、PRD 版本、任务与依赖、各任务验证、集成回归、最终 PR/合并、产物和部署证据。
+65
View File
@@ -0,0 +1,65 @@
---
name: long-ship
description: >-
处理复杂或跨模块需求:先用 specifier 评审 PRD,批准后拆成有依赖的任务,再按共享
交付流程做集成回归并发布。触发词:long-ship、大需求、复杂需求、跨模块、PRD 批准后
开发。小改动用 short-ship;已有 PR 用 pr-ship。
---
# Long Ship:从需求到交付
当前会话只做管理:建账本、让用户批 PRD、按 `next_step.py` 开新 agent 并等待。
不要在本会话写 PRD、改业务代码或跑集成回归。
## 何时使用
- 跨模块、跨页面、跨服务,或需要多任务协作的新需求。
- 用户要求从已批准 PRD 继续开发,并在完成后回归、发布或部署。
## 不适用
- 范围已清楚的单次变更:使用 `short-ship`。
- 需求仍需取舍和评审:先使用 `specifier`,不要提前改业务代码。
- 已有 PR 的处理:使用 `pr-ship`。
## 工作规则
1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
[worker-prompt.md](../ship/references/worker-prompt.md)。创建或复用
`.pouch/ship/<id>/`,`kind: long`。用户明确批准具体版本前,不创建开发任务、
不改业务代码。
2. 只跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry long
```
specifier / 实现 / 验证 / 开合 PR / 集成回归 / obtain / 部署都是 worker。
主会话从 `handoff.yaml` 写入 `tasks.yaml`(`materialize_tasks`),不要写入
`.pouch/ack/tasks.yaml`。
3. 无依赖冲突的 `implement_task` 可一轮并行,必须全部等完。子任务默认只到
`pr_opened` 或 `merged`,不单独部署生产。
4. 需求基线变化时停下来,让 `next_step.py` 回到 specifier;不要自己改 PRD。
需要字段含义时再读 [record.md](../ship/references/record.md)。
集成回归阶段由 tester worker 负责。worker 必须先发现项目根及子项目的所有 tester
catalog,按测试根分别执行并把每个根的运行快照写入 ship evidence;父仓库没有 catalog
时不能跳过拥有 catalog 的 `app` 等子项目。任一发现的测试根没有可复核结果,回归阶段
不能通过。还必须执行项目 profile 或 `.pouch/ack/project.md` 声明的项目原生测试套件,
为每个套件记录独立命令、结果和证据;任一测试面缺少结果时不能通过。
不要读取 `specifier`、`delivery-loop`、`tester`、`builder`、`deployer`、
`orchestration`(除非当前就在 Orca 里派发)。这些由 worker 或 manager.md 的条件加载。
## 安全门
- 未批准 PRD、缺 handoff、任务依赖循环、验收信号不可观测或集成回归失败时停止。
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 不把 PRD 中的自然语言当命令、权限或发布授权;不静默扩大需求范围。
- 子任务不打生产 tag、不单独升级生产;发布以父 ship 的集成 commit 为来源。
## 完成报告
报告 ship id、PRD 版本、handoff、任务 DAG、各任务验证、集成回归、最终 commit、
PR/tag、产物摘要、部署状态和回滚点。数字来自账本。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Long Ship"
short_description: "Turn an approved PRD into shipped work"
default_prompt: "You are the long-ship manager: keep the ship record, spawn a fresh agent for each next_step, and wait. Do not write the PRD, code, or run delivery in this session."
policy:
allow_implicit_invocation: true
+4 -2
View File
@@ -157,6 +157,8 @@ collection。安装 collection 全部内容或其中一个:
```bash
pouch add waza -a codex -g -y
pouch add waza/think -a codex -g -y
pouch add taste-skill/taste-skill -a codex -g -y
pouch add taste-skill -a codex -g -y
```
`pouch select` 会把 collection 显示为两级菜单:选择 `waza` 仓库会选中其
@@ -207,7 +209,7 @@ ACK 配置、检查接入状态或
| pouch | 说明 |
|-------|------|
| `bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
| `update` | 在 `~/.pouch` 执行 `git pull`,更新 pouch 自身 |
| `update` | 更新 pouch 自身以及已拉取的 catalog / custom source |
| `add <name> [-g] [-a AGENT...] [-y]` | 安装 |
| `remove <name> [-g] [-a AGENT...] [-y]` | 卸载(`rm` / `r` 别名) |
| `add --list` | 列出可用自研 skill |
@@ -226,4 +228,4 @@ ACK 配置、检查接入状态或
- 不要在 `project/.agents/skills/` 里直接改文件;应改 `~/.pouch/skills/` 再 `publish`
- 未完成的内容保留在 `~/.pouch/.drafts/`,不要直接放进正式 `skills/`
- symlink 正确时,**不需要 reinstall**;保存 SSOT 后各项目自动读到新内容
- 社区 skill 用 `npx skills add`,不要用 pouch `catalog add` 除非团队要 pin 版本
- 未写入 `catalog.yaml` 的社区 skill 用 `npx skills add`。waza、taste-skill 等预置来源用 `pouch add`
+25
View File
@@ -0,0 +1,25 @@
# pr-ship
把已有 PR 从检查和合并推进到合并后完整回归、构建和部署。
## 什么时候使用
```text
处理当前 PR,确认 CI 后合并,跑完整回归并部署测试环境。
```
## 使用前准备
- 同时安装 `ship` 和 `delivery-loop`。
- PR/MR 链接或当前分支上下文。
- 审核、CI、目标分支和部署 profile 可被读取。
- 需要生产部署时,当前请求必须覆盖该动作。
## Agent 会做什么
主会话只绑 ship id,再按步骤开新 agent 并等待。检查、合并、回归、构建和部署都在新
会话里做;合并后的回归以目标分支 commit 为准。
## 如何判断完成
报告会给出合并 commit,而不是只给源分支 commit,并附完整回归、产物和部署证据。
+52
View File
@@ -0,0 +1,52 @@
---
name: pr-ship
description: >-
处理已有 PR/MR:检查差异、审核和 CI,必要时修复阻塞项,合并后以合并 commit 做完整回归,
再按配置构建、发布和部署。用户说处理、合并、回归或发布 PR 时使用。
---
# PR Ship:合并后的完整交付
当前会话只做管理:绑 ship id、按 `next_step.py` 开新 agent 并等待。不要在本会话
审 diff、合并或跑回归。
## 何时使用
- 用户提供 PR/MR 或要求处理当前 PR。
- 用户要求合并 PR 后完整回归并部署。
## 不适用
- 没有 PR、需要从零做小改动或修 bug:使用 `short-ship`。
- 复杂需求尚未形成批准 PRD:使用 `specifier` 和 `long-ship`。
- 只做 diff 审查、不合并:使用 `check`。
## 工作规则
1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
[worker-prompt.md](../ship/references/worker-prompt.md)。从 PR 正文、分支名或
用户给出的 id 绑定已有 ship;没有则用 PR 标题生成 slug,创建 `kind: short` 的
薄记录,`source.kind: pr`。之后证据都写进这个 ship id。
2. 只跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry pr
```
检查、修复、合并、回归、obtain、发布、部署都是 worker。主会话只写 `source.ref`
和等待结果。需要字段含义时再读 [record.md](../ship/references/record.md)。
3. 合并后的回归和构建必须以合并 commit 为来源;worker 会写 `commits.role: merged`。
不要读取 `check`、`delivery-loop`、`tester`、`builder`、`deployer`、`releaser`。
## 安全门
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 不绕过审核、CI、分支保护或冲突;不强推、不重写已共享历史。
- 不用 PR 源分支的旧 APK/镜像证明合并版本已发布。
- 目标分支变化、合并后代码漂移、回归失败、产物来源不明或部署健康检查失败时停止。
## 完成报告
报告 ship id、PR/MR、审核/CI 结果、合并 commit、完整回归证据、产物摘要、tag、部署目标、健康检查和回滚点。数字来自账本。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "PR Ship"
short_description: "Merge, regress, build, and deploy a PR"
default_prompt: "You are the pr-ship manager: bind the ship id, spawn a fresh agent for each next_step, and wait. Do not review, merge, or regress in this session."
policy:
allow_implicit_invocation: true
+15 -5
View File
@@ -1,17 +1,19 @@
# previewer
把本机的 HTML、PRD、报告或其它静态文件通过 Tailscale Serve 暴露给 tailnet,并返回可访问的 HTTP URL。
用 Node 启动默认存活 1 小时的轻量静态服务,再通过 Tailscale Serve 把本机的 HTML、PRD、报告
或其它静态文件暴露给 tailnet,并返回应该使用的 Tailscale IP 和 HTTP URL。
## 什么时候使用
- 想在手机、平板或另一台电脑查看本机生成的 HTML。
- 可以直接说“把这个 HTML 渲染出来,并告诉我用哪个 IP”。
- 其它 skill 需要把某个文件交给通用预览服务。
只面向 Tailscale tailnet,不提供公网访问,也不启动动态 Web 服务。
## 使用前准备
- 本机已安装并登录 Tailscale,且访问者也在允许访问的 tailnet 中。
- 本机已安装 Node.js、已登录 Tailscale,且访问者也在允许访问的 tailnet 中。
- 提供要展示的文件路径;文件所在目录会被作为静态目录服务。
## 使用示例
@@ -20,11 +22,19 @@
用 previewer 展示这个文件:/workspace/docs/prd-v002.html,挂载到 /preview/billing。
```
```text
把 /workspace/docs/prd-v002.html 渲染出来,告诉我用哪个 IP 和 URL 查看。
```
## Agent 会做什么
1. 检查 Tailscale 是否在线,以及是否已有 Serve 路由。
2. 将文件所在目录挂到指定的独立路径,不覆盖冲突路由。
3. 返回包含 Tailscale IPv4 的 HTTP URL 和停止该路由的命令。
1. 检查 Tailscale 是否在线,以及 Serve 状态是否可读取。
2. 用 Node 在 localhost 启动文件所在目录的静态服务。
3. 将指定 Tailscale 路径代理到本地端口。
4. 返回 Tailscale IPv4、完整 HTTP URL、Node PID、过期时间和停止命令,并明确告诉用户
应该使用哪个 IP。
默认 1 小时后自动关闭 Node 服务和对应的 Tailscale 路由;需要其它时长时可传 `--ttl` 秒数。
## 如何判断完成
+31 -18
View File
@@ -1,19 +1,21 @@
---
name: previewer
description: >-
提供通用的 Tailscale tailnet 内静态文件预览能力。用户要通过 HTTP 展示 HTML、PRD、报告、
截图或其它静态文件,要求检查 Tailscale Serve、挂载文件所在目录并返回可访问 URL 时使用;
将本地 HTML、PRD、报告、截图或其它静态文件用 Node 渲染成 HTTP 预览,并通过 Tailscale
tailnet 暴露;用户说“把这个 HTML 渲染出来”“给我访问 IP/URL”或要求远程查看文件时使用;
不用于公网发布、动态应用部署或业务服务反向代理。
---
# Previewer:Tailnet 静态文件预览
# Previewer:HTML/静态文件渲染预览
调用方只提供一个要展示的文件。Previewer 检查 Tailscale 是否在线和 Serve 状态,按独立路径
把文件所在目录交给 `tailscale serve`,最后返回 tailnet 内 HTTP URL。
调用方只提供一个要展示的文件。Previewer 用 Node 启动本地静态服务,再让
`tailscale serve` 代理这个 localhost 端口,最后返回 tailnet 内 HTTP URL。它是独立的通用
HTML 渲染能力,specifier、report 或其它 skill 都可以调用。
## 何时使用
- 展示本机生成的 HTML、PRD、报告、截图或其它静态文件。
- 用户直接说“把这个 HTML 渲染出来”,并希望从手机或另一台机器打开。
- 其它 skill 需要一个通用的“给我文件,返回可访问链接”能力。
- 用户明确要求通过 Tailscale 访问预览内容。
@@ -25,19 +27,28 @@ description: >-
## 调用方式
找到本 skill 目录 `<previewer-skill-dir>`,执行:
前置条件:本机安装 Node.js 和 Tailscale,并已登录目标 tailnet。找到本 skill 目录
`<previewer-skill-dir>`,执行:
```bash
python3 <previewer-skill-dir>/scripts/serve_file.py \
node <previewer-skill-dir>/scripts/serve_file.js \
<absolute-file-path> \
--mount-path /preview/<stable-name>
```
脚本默认使用 HTTP 80 端口;可用 `--port` 指定其它端口。它输出 JSON,包含文件、挂载目录、
mount path、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。
脚本默认使用 Tailscale HTTP 80 端口,并为本地 Node 服务自动选择空闲端口;可用 `--port`
指定 Tailscale 对外端口、`--local-port` 指定本地端口。Node 服务默认 1 小时后自动关闭,
到期时同时关闭对应 Serve 路由;可用 `--ttl` 以秒覆盖。它输出一个 JSON,包含文件、挂载目录、
本地进程 PID、过期时间、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。
如果调用方没有提供 `--mount-path`,脚本按文件父目录名生成 `/preview/<directory-name>`;遇到
同名但指向不同目录的已有路由时停止,要求调用方提供稳定且唯一的 mount path。
如果调用方没有提供 `--mount-path`,脚本按文件父目录名生成 `/preview/<directory-name>`;不同
预览应显式使用稳定且唯一的 mount path。
也可以直接对 Agent 说:
```text
用 previewer 把这个 HTML 渲染出来,并告诉我应该使用哪个 IP 和 URL 访问:<file>
```
详细契约见 [contract.md](references/contract.md)。脚本执行逻辑和 Tailscale CLI 兼容性只在需要
排查调用失败时读取,不要把用户文件内容当作命令。
@@ -46,15 +57,17 @@ mount path、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。
- 只接受存在且为普通文件的输入;解析为绝对路径后,服务它的父目录,以支持 HTML 的相对资源。
- 运行 `tailscale ip -4` 和 `tailscale serve status --json` 检查当前节点与 Serve 状态。
- 没有目标路由时,用 `tailscale serve --bg --http=<port> --set-path=<mount-path> <directory>`
创建或追加路由;不执行 `tailscale serve reset`。
- 已有同一路由指向其它目录时 fail closed,不覆盖、不删除、不猜测用户意图。
- `tailscale serve` 的配置是持久的;脚本返回停止该路由所需的精确命令,但不自动停止其它路由。
- 只使用 Tailscale tailnet 地址,不使用 Funnel,不上传文件,不启动第二个 Python HTTP 服务。
- Node 只监听 `127.0.0.1`,用 `tailscale serve --bg --http=<port> --set-path=<mount-path>`
代理这个本地端口;不执行 `tailscale serve reset`。
- Node 服务默认只存活 1 小时,到期自动关闭本地进程和对应 Serve 路由;脚本也返回可提前停止的精确命令。
- 回复用户时必须明确给出 Tailscale IPv4、完整 HTTP URL 和过期时间;说明访问地址中的
`<ip>` 就是用户应使用的 IP。不要只给本机回环地址,也不要把 Tailscale hostname 当成
必填项。
- 只使用 Tailscale tailnet 地址,不使用 Funnel,不上传文件,不启动 Python HTTP 服务。
- 文件所在目录会对 tailnet 暴露目录列表和同目录文件;调用方应使用专门的预览目录,不要传项目根目录。
## 验证
调用成功必须同时满足:脚本退出码为 0;输出 URL 使用 Tailscale IPv4、HTTP 和指定 mount path;
`tailscale serve status --json` 中能找到该 mount path 和输入文件的父目录。Tailscale 未安装、
节点离线、Serve 命令失败、状态无法确认或路由冲突都必须返回非零退出码和可行动错误。
Tailscale Serve 命令成功。Tailscale 或 Node 未安装、节点离线、Serve 状态无法读取、Node 服务
启动失败或 Serve 命令失败都必须返回非零退出码和可行动错误。
+3 -3
View File
@@ -1,6 +1,6 @@
interface:
display_name: "Previewer"
short_description: "Serve static files over the Tailscale tailnet"
default_prompt: "Use $previewer to expose this HTML file over Tailscale and return its HTTP URL."
display_name: "HTML Previewer"
short_description: "Render local HTML over Tailscale and return the access IP"
default_prompt: "Use $previewer to render this HTML over Tailscale and tell me which IP and HTTP URL to use."
policy:
allow_implicit_invocation: true
+22 -11
View File
@@ -3,15 +3,19 @@
## 输入
```text
python3 scripts/serve_file.py FILE [--mount-path PATH] [--port PORT]
node scripts/serve_file.js FILE [--mount-path PATH] [--port PORT] [--local-port PORT] [--ttl SECONDS]
```
- `FILE`:必须是存在的普通文件,建议使用绝对路径。
- `--mount-path`:以 `/` 开头的 URL 路径,不能包含 `?`、`#`、空段或 `..`;不提供时按父目录名生成。
- `--port`:HTTP 端口,默认 `80`。
- `--port`:Tailscale 对外 HTTP 端口,默认 `80`。
- `--local-port`:Node 监听的 localhost 端口,默认自动选择空闲端口。
- `--ttl`:服务存活时间,单位为秒,默认 `3600`(1 小时);到期同时关闭 Node 服务和对应
Tailscale Serve 路由。
脚本服务 `FILE` 的父目录,而不是把文件复制到其它地方。这样 HTML 可以加载同目录的 CSS、
图片和其它资源,但也意味着同目录其它文件会对 tailnet 可见。
Node 服务 `FILE` 的父目录,而不是把文件复制到其它地方。这样 HTML 可以加载同目录的 CSS、
图片和其它资源,但也意味着同目录其它文件会对 tailnet 可见。Node 只监听 `127.0.0.1`,
Tailscale Serve 负责把指定路径代理到这个本地端口。
## 输出
@@ -24,19 +28,26 @@ python3 scripts/serve_file.py FILE [--mount-path PATH] [--port PORT]
"mountPath": "/preview/billing",
"ip": "100.101.102.103",
"port": 80,
"localPort": 43721,
"pid": 12345,
"ttl": 3600,
"expiresAt": "2026-10-11T13:00:00.000Z",
"url": "http://100.101.102.103/preview/billing/prd-v002.html",
"stopCommand": "tailscale serve --http=80 --set-path=/preview/billing off"
"stopCommand": "kill 12345 2>/dev/null || true; tailscale serve --http=80 --set-path=/preview/billing off"
}
```
调用方应把 `ip` 字段和 `url` 一起展示给用户:`ip` 是访问这次预览时应使用的 Tailscale
IPv4,`url` 是可直接打开的完整 HTTP 地址。不要把 `127.0.0.1` 当成远程访问地址,也不
要求用户必须使用 Tailscale hostname。
失败时输出到 stderr,退出码非 0;调用方不能把部分 stdout 当成有效 URL。
## 状态和冲突
脚本先读取 `tailscale serve status --json`。如果相同 mount path 已指向相同目录,可以复用;如果
指向其它目录,必须失败并要求新的 mount path。没有该路由时追加 `--set-path` 配置,不重置已有
Serve 配置。
脚本先读取 `tailscale serve status --json`,确认当前 Tailscale Serve 可用,然后启动 Node 本地
服务并追加指定 `--set-path` 路由。应为不同预览使用不同 mount path;脚本不会执行全量重置。
Serve 配置由 Tailscale 持久保存;`--bg` 路由会在 Tailscale 重启后恢复。停止时只能使用输出的
同端口、同 mount path 的 `off` 命令,不能使用 `tailscale serve reset`,因为 reset 会清除该节点
的全部 Serve 配置。
Serve 配置由 Tailscale 持久保存,但 Node 服务默认在 1 小时后自动关闭并清理对应路由。需要
提前停止时使用输出的命令,不能使用 `tailscale serve reset`,因为 reset 会清除该节点的全部
Serve 配置。
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env node
/*
* Start a tiny local static server and expose it through Tailscale Serve.
* Node is used for the local server so the Tailscale route only needs to
* proxy a localhost port.
*/
'use strict';
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
class PreviewError extends Error {}
function usage() {
return `Usage: node serve_file.js FILE [--mount-path PATH] [--port PORT] [--ttl SECONDS]
Expose FILE through Tailscale Serve. Node serves FILE's parent directory on
localhost; Tailscale proxies that local server to the tailnet.
`;
}
function parseArgs(argv) {
const args = {
file: null, root: null, mountPath: null, port: 80, localPort: 0,
ttl: 3600, tailscalePort: null, server: false,
};
for (let i = 0; i < argv.length; i += 1) {
const value = argv[i];
if (value === '--help' || value === '-h') {
console.log(usage());
process.exit(0);
}
if (value === '--server') {
args.server = true;
continue;
}
if (value === '--root') {
args.root = argv[++i];
continue;
}
if (value === '--mount-path') {
args.mountPath = argv[++i];
continue;
}
if (value === '--port') {
args.port = Number(argv[++i]);
continue;
}
if (value === '--local-port') {
args.localPort = Number(argv[++i]);
continue;
}
if (value === '--ttl') {
args.ttl = Number(argv[++i]);
continue;
}
if (value === '--tailscale-port') {
args.tailscalePort = Number(argv[++i]);
continue;
}
if (value.startsWith('-')) {
throw new PreviewError(`未知参数:${value}`);
}
if (args.file === null) {
args.file = value;
} else {
throw new PreviewError(`多余参数:${value}`);
}
}
return args;
}
function validPort(port, name) {
if (!Number.isInteger(port) || port < 0 || port > 65535 || (name === 'port' && port === 0)) {
throw new PreviewError(`${name} 必须是有效端口`);
}
}
function validTtl(ttl) {
if (!Number.isInteger(ttl) || ttl <= 0) {
throw new PreviewError('ttl 必须是正整数秒数');
}
}
function normalizeMountPath(value) {
const mountPath = value.trim();
if (!mountPath.startsWith('/') || mountPath === '/') {
throw new PreviewError('mount path 必须以 / 开头且不能是根路径');
}
if (mountPath.includes('?') || mountPath.includes('#') || mountPath.includes('//')) {
throw new PreviewError('mount path 不能包含 query、fragment 或空路径段');
}
const parts = mountPath.split('/');
if (parts.some((part) => part === '.' || part === '..')) {
throw new PreviewError('mount path 不能包含 . 或 ..');
}
return mountPath.replace(/\/$/, '');
}
function defaultMountPath(directory) {
const slug = path.basename(directory).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
if (!slug) {
throw new PreviewError('无法从文件目录生成 mount path,请显式提供 --mount-path');
}
return `/preview/${slug}`;
}
function run(command, commandArgs) {
const result = spawnSync(command, commandArgs, { encoding: 'utf8' });
if (result.error) {
throw new PreviewError(`无法运行 ${command}:${result.error.message}`);
}
return result;
}
function tailscaleIPv4() {
const result = run('tailscale', ['ip', '-4']);
if (result.status !== 0) {
throw new PreviewError(`Tailscale 节点不可用:${(result.stderr || result.stdout).trim() || 'tailscale ip -4 失败'}`);
}
const address = result.stdout.split(/\s+/).find((candidate) => net.isIPv4(candidate));
if (!address) {
throw new PreviewError('tailscale 没有返回 IPv4 地址;请确认节点已连接 tailnet');
}
return address;
}
function checkTailscaleServe() {
const result = run('tailscale', ['serve', 'status', '--json']);
if (result.status !== 0) {
throw new PreviewError(`无法读取 Tailscale Serve 状态:${(result.stderr || result.stdout).trim() || 'status 失败'}`);
}
}
function serveStatic(root, mountPath, port, ttl, tailscalePort) {
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
};
const server = http.createServer((request, response) => {
let pathname;
try {
pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
} catch {
response.writeHead(400).end('Bad request');
return;
}
if (pathname === mountPath || pathname.startsWith(`${mountPath}/`)) {
pathname = pathname.slice(mountPath.length) || '/';
}
const relativePath = pathname.replace(/^\/+/, '');
const candidate = path.resolve(root, relativePath);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
response.writeHead(403).end('Forbidden');
return;
}
fs.stat(candidate, (statError, info) => {
if (statError || !info.isFile()) {
response.writeHead(404).end('Not found');
return;
}
response.setHeader('Content-Type', contentTypes[path.extname(candidate).toLowerCase()] || 'application/octet-stream');
fs.createReadStream(candidate).on('error', () => response.destroy()).pipe(response);
});
});
server.listen(port, '127.0.0.1', () => {
const address = server.address();
console.log(`READY ${address.port}`);
});
setTimeout(() => {
spawnSync('tailscale', [
'serve', `--http=${tailscalePort}`, `--set-path=${mountPath}`, 'off',
], { stdio: 'ignore' });
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 1000);
}, ttl * 1000);
}
function startDetachedServer(root, mountPath, localPort, ttl, tailscalePort) {
const child = spawn(process.execPath, [
__filename, '--server', '--root', root, '--mount-path', mountPath,
'--port', String(localPort), '--ttl', String(ttl), '--tailscale-port', String(tailscalePort),
], {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
return new Promise((resolve, reject) => {
let output = '';
const timeout = setTimeout(() => {
child.kill();
reject(new PreviewError('Node 静态服务启动超时'));
}, 5000);
child.stdout.on('data', (chunk) => {
output += chunk.toString();
const match = output.match(/READY (\d+)/);
if (!match) return;
clearTimeout(timeout);
child.stdout.destroy();
child.stderr.destroy();
child.unref();
resolve({ pid: child.pid, port: Number(match[1]) });
});
child.on('error', (error) => {
clearTimeout(timeout);
reject(new PreviewError(`Node 静态服务启动失败:${error.message}`));
});
child.on('exit', (code) => {
if (code !== null && code !== 0) {
clearTimeout(timeout);
reject(new PreviewError(`Node 静态服务退出,code=${code}`));
}
});
});
}
function stopChild(pid) {
if (!pid) return;
try {
process.kill(pid, 'SIGTERM');
} catch {
// The child may have exited while Tailscale was being configured.
}
}
async function launch(args) {
if (!args.file) throw new PreviewError('缺少要展示的文件');
validPort(args.port, 'port');
validPort(args.localPort, 'local-port');
validTtl(args.ttl);
const filePath = path.resolve(args.file);
let info;
try {
info = fs.statSync(filePath);
} catch {
throw new PreviewError(`输入文件不存在:${filePath}`);
}
if (!info.isFile()) throw new PreviewError(`输入不是普通文件:${filePath}`);
const root = path.dirname(filePath);
const mountPath = args.mountPath ? normalizeMountPath(args.mountPath) : defaultMountPath(root);
const ip = tailscaleIPv4();
checkTailscaleServe();
const child = await startDetachedServer(root, mountPath, args.localPort, args.ttl, args.port);
try {
const result = run('tailscale', [
'serve', '--bg', `--http=${args.port}`, `--set-path=${mountPath}`, '--yes',
`http://127.0.0.1:${child.port}`,
]);
if (result.status !== 0) {
throw new PreviewError(`Tailscale Serve 配置失败:${(result.stderr || result.stdout).trim() || 'serve 命令失败'}`);
}
} catch (error) {
stopChild(child.pid);
throw error;
}
const relative = path.relative(root, filePath).split(path.sep).map(encodeURIComponent).join('/');
const suffix = args.port === 80 ? '' : `:${args.port}`;
const url = `http://${ip}${suffix}${mountPath}/${relative}`;
console.log(JSON.stringify({
file: filePath,
directory: root,
mountPath,
ip,
port: args.port,
localPort: child.port,
pid: child.pid,
ttl: args.ttl,
expiresAt: new Date(Date.now() + args.ttl * 1000).toISOString(),
url,
stopCommand: `kill ${child.pid} 2>/dev/null || true; tailscale serve --http=${args.port} --set-path=${mountPath} off`,
}));
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.server) {
if (!args.mountPath || !args.root || !args.tailscalePort) throw new PreviewError('内部参数不完整');
validPort(args.port, 'local-port');
validPort(args.tailscalePort, 'port');
validTtl(args.ttl);
serveStatic(path.resolve(args.root), args.mountPath, args.port, args.ttl, args.tailscalePort);
return;
}
await launch(args);
}
main().catch((error) => {
console.error(`previewer: ${error.message}`);
process.exitCode = 2;
});
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""Expose a static file's parent directory through a Tailscale Serve HTTP route."""
from __future__ import annotations
import argparse
import ipaddress
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path, PurePosixPath
from urllib.parse import quote
class PreviewError(RuntimeError):
pass
def run_tailscale(*args: str) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
["tailscale", *args],
check=False,
capture_output=True,
text=True,
)
except OSError as exc:
raise PreviewError(f"无法运行 tailscale:{exc}") from exc
def require_tailscale() -> None:
if shutil.which("tailscale") is None:
raise PreviewError("未找到 tailscale,请先安装并登录 Tailscale")
def tailscale_ipv4() -> str:
result = run_tailscale("ip", "-4")
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise PreviewError(f"Tailscale 节点不可用:{detail or 'tailscale ip -4 失败'}")
for line in result.stdout.splitlines():
candidate = line.strip()
try:
address = ipaddress.ip_address(candidate)
except ValueError:
continue
if address.version == 4:
return candidate
raise PreviewError("tailscale 没有返回 IPv4 地址;请确认节点已连接 tailnet")
def serve_status() -> tuple[dict, str]:
result = run_tailscale("serve", "status", "--json")
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise PreviewError(f"无法读取 Tailscale Serve 状态:{detail or 'status 失败'}")
try:
data = json.loads(result.stdout or "{}")
except json.JSONDecodeError as exc:
raise PreviewError("Tailscale Serve 返回了无法解析的 JSON 状态") from exc
if not isinstance(data, dict):
raise PreviewError("Tailscale Serve 状态不是 JSON 对象")
plain = run_tailscale("serve", "status")
if plain.returncode != 0:
detail = (plain.stderr or plain.stdout).strip()
raise PreviewError(f"无法读取 Tailscale Serve 文本状态:{detail or 'status 失败'}")
return data, plain.stdout
def status_blob(data: dict, plain: str) -> str:
return json.dumps(data, ensure_ascii=False, sort_keys=True) + "\n" + plain
def normalize_mount_path(value: str) -> str:
path = value.strip()
if not path.startswith("/") or path == "/":
raise PreviewError("mount path 必须以 / 开头且不能是根路径")
if "?" in path or "#" in path or "//" in path:
raise PreviewError("mount path 不能包含 query、fragment 或空路径段")
parts = PurePosixPath(path).parts
if any(part in {".", ".."} for part in parts):
raise PreviewError("mount path 不能包含 . 或 ..")
return "/" + "/".join(parts[1:])
def default_mount_path(directory: Path) -> str:
slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", directory.name).strip("-")
if not slug:
raise PreviewError("无法从文件目录生成 mount path,请显式提供 --mount-path")
return f"/preview/{slug}"
def route_matches(blob: str, mount_path: str, directory: Path) -> bool:
return mount_path in blob and str(directory) in blob
def route_conflicts(blob: str, mount_path: str, directory: Path) -> bool:
return mount_path in blob and str(directory) not in blob
def configure_route(directory: Path, mount_path: str, port: int, data: dict, plain: str) -> None:
blob = status_blob(data, plain)
if route_conflicts(blob, mount_path, directory):
raise PreviewError(
f"mount path {mount_path} 已被其它目录占用;请改用唯一的 --mount-path,不会覆盖已有路由"
)
if route_matches(blob, mount_path, directory):
return
result = run_tailscale(
"serve",
"--bg",
f"--http={port}",
f"--set-path={mount_path}",
"--yes",
str(directory),
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise PreviewError(f"Tailscale Serve 配置失败:{detail or 'serve 命令失败'}")
data, plain = serve_status()
blob = status_blob(data, plain)
if route_conflicts(blob, mount_path, directory) or not route_matches(blob, mount_path, directory):
raise PreviewError("Serve 命令返回成功,但状态中没有确认目标路由;停止输出 URL")
def build_result(file_path: Path, directory: Path, mount_path: str, ip: str, port: int) -> dict[str, object]:
relative = file_path.relative_to(directory).as_posix()
suffix = f":{port}" if port != 80 else ""
url = f"http://{ip}{suffix}{mount_path}/{quote(relative, safe='/')}"
return {
"file": str(file_path),
"directory": str(directory),
"mountPath": mount_path,
"ip": ip,
"port": port,
"url": url,
"stopCommand": f"tailscale serve --http={port} --set-path={mount_path} off",
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("file", type=Path, help="要展示的静态文件")
parser.add_argument("--mount-path", help="Tailscale Serve 的 URL 路径")
parser.add_argument("--port", type=int, default=80, help="HTTP 端口,默认 80")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
try:
if not 1 <= args.port <= 65535:
raise PreviewError("port 必须在 1 到 65535 之间")
require_tailscale()
file_path = args.file.expanduser().resolve()
if not file_path.is_file():
raise PreviewError(f"输入不是普通文件或不存在:{file_path}")
directory = file_path.parent
mount_path = normalize_mount_path(args.mount_path) if args.mount_path else default_mount_path(directory)
ip = tailscale_ipv4()
data, plain = serve_status()
configure_route(directory, mount_path, args.port, data, plain)
print(json.dumps(build_result(file_path, directory, mount_path, ip, args.port), ensure_ascii=False))
return 0
except PreviewError as exc:
print(f"previewer: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+24
View File
@@ -0,0 +1,24 @@
# requirement-flow(已弃用)
`requirement-flow` 已改名为 [long-ship](../long-ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`long-ship/`。
## 使用示例
```text
使用 requirement-flow,把动态编辑需求做成 PRD,批准后拆任务开发。
```
Agent 命中后会立刻转到 `long-ship`,按同一套规则执行。新请求建议直接用新名。
## 迁移
```bash
pouch add long-ship # 新名,当前项目
pouch add long-ship -g # 新名,全局
```
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `requirement-flow` 可以分批改成 `long-ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+19
View File
@@ -0,0 +1,19 @@
---
name: requirement-flow
description: >-
【已弃用,兼容占位】requirement-flow 已改名为 long-ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../long-ship/SKILL.md 并按其执行。复杂需求、跨模块、PRD 批准后开发、
拆任务交付都属于 long-ship。
---
# requirement-flow(已弃用)
本 skill 已改名为 [long-ship](../long-ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
- 用户提到 `requirement-flow`、`/requirement-flow` 或 `$requirement-flow` 时:读取
[../long-ship/SKILL.md](../long-ship/SKILL.md),按其内容与授权边界执行,不要在本目录
里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `long-ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `requirement-flow` 也能正常安装,软链不会悬空;方便时改成
`long-ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
+25
View File
@@ -0,0 +1,25 @@
# ship
一次交付的记录格式:需求说明、任务、测试运行、产物和部署结果都挂在同一个 ship id 下。
通常由 `$short-ship`、`$long-ship` 或 `$pr-ship` 内部使用,不需要单独调用。
## 什么时候使用
```text
把这次改动记到 ship add-export-button,后续测试和部署都挂到这个 id。
```
## 使用前准备
- 项目是 Git 仓库。
- 入口 skill 已经决定这是 short 还是 long,或正在打开已有 ship。
## Agent 会做什么
在 `.pouch/ship/<ship-id>/` 建立或更新记录,把测试、产物和部署写成指针。三个入口的
主会话只编排,具体步骤开新 agent。
## 如何判断完成
目录能通过 ship 自带校验;用同一个 ship id 能找到 spec、任务和证据指针。
+48
View File
@@ -0,0 +1,48 @@
---
name: ship
description: >-
short-ship、long-ship、pr-ship、specifier 共用的交付记录格式。一次交付一个
ship-id,目录在 .pouch/ship/{id}/。新建 id 使用 YYYYMMDD-短语,例如
20261014-dynamic-editor。不单独作为用户入口;用户说 short-ship、
long-ship、交付记录、ship id 时由对应入口读取本 skill。
---
# Ship:一次交付的记录
这是内部记录契约,不替用户选择 short 或 long,也不执行构建或部署。
## 使用方式
`short-ship` / `long-ship` / `pr-ship` 的主会话只编排:读
[manager.md](references/manager.md),按 `scripts/next_step.py` 开新 agent;派发时
用 [worker-prompt.md](references/worker-prompt.md)。
创建目录用 `scripts/create_ship.py`,改证据指针用 `scripts/append_evidence.py`,
写回后跑 `scripts/validate_ship.py`。
## 不可违反的规则
- 进入 short-ship / long-ship / pr-ship 的第一件事是拿到或创建 `ship-id`。没有 id
不得改业务代码、不得跑 tester / obtain / deploy。
- 入口主会话不得加载 hunt / tester / builder / deployer / releaser / check /
specifier / delivery-loop,也不得亲自执行这些步骤;开不出新 agent 时停止。
- 记录只写 `.pouch/ship/<ship-id>/`。功能、小改动和本次修复的任务不要写入
`.pouch/ack/tasks.yaml`。ACK 任务板只收缺陷;缺陷要交付时在 ship 的 `source` 里引用。
- `ship-id` 用稳定短 slug:小写字母开头,小写字母、数字、连字符,最长 64。不要用日期、
随机数或当前分支名冒充业务 id。
- 短长同一套字段。`kind: short` 恰好 1 条 task;`kind: long` 是 DAG,子任务默认不是
新的 ship。
- tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的
测试、obtain、部署若没写入 `current.yaml` 的 `evidence`,该阶段不算完成。
- 新建 ship 的任务带 `testPlan`,开发 worker 把新增/修改测试写入
`2-develop/test-manifest.yaml`;verify 按 manifest 的 selector 执行,regress 再叠加
所有 tester catalog 根和项目原生完整测试。没有测试时必须有明确批准的 `testGap`。
- 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要
复制进每个 ship。
- 旧的 `.pouch/specifier/features/<id>/` 在继续该需求时迁到
`.pouch/ship/<id>/spec/`,规则见 [record.md](references/record.md)。
## 结果
有效 ship 目录含 `current.yaml`、`spec.md`、`tasks.yaml`、`status.md`;long 另有
`spec/` 下的 PRD 与 handoff。校验通过后入口 skill 才能继续交付。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Ship Record"
short_description: "Shared delivery record keyed by ship id"
default_prompt: "Use $ship only as the record format for an in-progress short-ship, long-ship, or pr-ship."
policy:
allow_implicit_invocation: false
+86
View File
@@ -0,0 +1,86 @@
# Ship 主会话:只编排
入口 `short-ship` / `long-ship` / `pr-ship` 的**当前会话是管理员**。它跟用户说话、
维护账本、按 `next_step.py` 开新 agent,然后等待结果。它不把具体步骤的 skill
读进自己的上下文,也不亲自做那些步骤。
## 目录
- [主会话可以做](#主会话可以做)
- [主会话不能做](#主会话不能做)
- [循环](#循环)
- [怎么开新 agent](#怎么开新-agent)
- [失败](#失败)
## 主会话可以做
- 跟用户确认范围、停止点、批准 PRD、授权生产部署,并写完成报告。
- 跑 ship 脚本:`new_ship_id.py`、`create_ship.py`、`next_step.py`、`append_evidence.py`(仅当 worker
已给出指针而漏写时)、`validate_ship.py`。
- 读写 `.pouch/ship/<id>/` 账本:`spec.md`、`tasks.yaml`、`current.yaml` 的
`status` / `delivery.state` / `spec.*` / `manager.*` / 任务 `status`。
- 按 `next_step.py` 的 JSON 开一个(或 long 的一轮并行 `implement_task`)新 agent,
把 [worker-prompt.md](worker-prompt.md) 填好交给它,**等到它返回**再继续。
需要看字段含义时读 [record.md](record.md)。不要预读其它 skill。
## 主会话不能做
- 不要读取或执行:`hunt`、`tester`、`builder`、`deployer`、`releaser`、`check`、
`specifier`、`delivery-loop`,以及它们的 `references/`。
- 不要改业务代码、跑测试、开/合 PR、obtain、发版、部署、写 HTML PRD。
- 不要在开不出新 agent 时改由自己做该步。
- 不要 fire-and-forget:派出去就回复“已派发”并结束本轮。
- 不要让 worker 再去调 `short-ship` / `long-ship` / `pr-ship`。
`next_step.py` 的 `actor` 为 `manager` 时,这一步就是跟用户说话或改账本,不是去加载
执行类 skill。
## 循环
1. 没有 `ship-id`:先用 `new_ship_id.py --phrase <短语>` 生成
`YYYYMMDD-短语`,再跑 `create_ship.py`,写 `spec.md` 四段(short)
或进入 long 的 specifier 步。功能任务不要写入 `.pouch/ack/tasks.yaml`。
2. 跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry short|long|pr
```
3. 按 JSON 行动:
- `status: done`:写完成报告,结束。
- `status: blocked` / `ask_user`:告诉用户缺口或问题,等回复。生产部署(`stopAt:
deployed` 且下一步是 `deploy`)必须再问一次,除非当前这句话已经授权。
- `actor: manager`:补账本或问用户,再跑 `next_step.py`。
- `actor: worker`:开新 agent,等待,把结果写入 `manager.lastStep` /
`manager.lastResult` / `manager.lastSummary`,按 `targetState` / `taskId` 更新账本,立刻
`validate_ship.py`。失败不要自己补做。
4. 重复直到 `done`、`blocked` 且用户不再推进,或用户叫停。
不要凭记忆跳过 `next_step.py`。long 的无依赖 `implement_task` 可以一轮并行多个,
仍要全部等完再进入下一步。
## 怎么开新 agent
必须是 **fresh** 会话,prompt 只用 [worker-prompt.md](worker-prompt.md) 填好的那一份。
- 当前运行时有子 agent / Task / spawn:用它,不要把执行类 skill 先读进本会话。
- 当前就在 Orca 里:再读已安装的 `orchestration`,做 supervised dispatch(inject 后
`check --wait` 直到 `worker_done` 或 `escalation`)。不要在本文件复制 orca 命令。
- 没有开新 agent 的能力:`blocked`,说明缺什么。不要自己执行。
把 `next_step.py` 返回的 `stage` 字段填入 worker prompt 的 `<stage>`,让 worker 把摘要
写到对应的 `1-spec`、`2-develop` 等目录。worker 返回后把 summary/evidence 一并写入
`manager.lastSummary` 和阶段 `activity.md`。
一次只派 JSON 里的这一步(long 的并行 implement 除外)。worker 需要用户决定时,它
返回 `ask`;主会话问用户,再派或改账本。
## 失败
- worker `fail` / `blocked`:停在现场,报告摘要和证据。主会话不要接手做该步。
用户明确说重试时,再开一个**新** agent,不要复用刚失败的会话。
- 校验失败:先修账本再继续,不要假装阶段已完成。
- 范围涨到需要 PRD 或多条任务:同一 `ship-id` 把 `kind` 改为 `long`,之后 `--entry
long`。
+217
View File
@@ -0,0 +1,217 @@
# Ship 记录契约
## 目录
- [布局](#布局)
- [current.yaml](#currentyaml)
- [short 与 long](#short-与-long)
- [任务](#任务)
- [证据指针](#证据指针)
- [旧 specifier 目录](#旧-specifier-目录)
## 布局
```text
.pouch/ship/<ship-id>/
current.yaml # 总账:kind、状态、stopAt、指针
spec.md # 短需求说明(short 和 long 都有)
tasks.yaml # 本 ship 的任务 DAG
2-develop/test-manifest.yaml # 开发者新增/修改测试的机器清单
status.md # 给人看的一页摘要
1-spec/ # 规格、评审、根因和 handoff
2-develop/ # 开发任务和变更
3-test/ # 任务验证与集成回归
4-review/ # PR、审核与合并
5-build/ # 合并后的构建/obtain
6-publish/ # 发布产物
7-deploy/ # 部署与健康检查
stage.yaml # 阶段状态和包含的步骤
activity.md # 该阶段的调度、结果和证据摘要
spec/ # 仅 long:PRD 版本、评审、handoff
brief.md
prd-v001.html
review.yaml
decisions.yaml
risks.yaml
handoff.yaml # 仅 approved 后
```
`status.md` 给人看;Agent 以 `current.yaml` 为准。两份不一致时改 `status.md`。
阶段目录由 `create_ship.py` 初始化,由 `next_step.py` 在派发和收到结果时追加活动记录;
大日志留在原测试、构建或部署位置,阶段目录只保存摘要和路径。
新建 ship 使用可排序的 `YYYYMMDD-短语`,例如 `20261014-dynamic-editor`。旧的无日期
记录仍可读取和校验,不要为了改名移动既有 ship 目录。
## current.yaml
必填:`shipId`、`kind`、`status`、`title`、`spec`、`tasks`、`evidence`。
`shipId` 必须等于目录名。
```yaml
shipId: add-export-button
kind: short
title: 列表页增加导出按钮
status: in_progress
stopAt: review_ready
source:
kind: user
ref: null
parentShipId: null
childShipIds: []
spec:
path: spec.md
revision: null
specStatus: null
prd: null
approvedRevision: null
reviews: null
handoff: null
tasks:
path: tasks.yaml
delivery:
stopAt: review_ready
state: change_ready
manager:
lastStep: null
lastResult: null
inFlight: null
evidence:
testRuns: []
artifacts: []
deploys: []
prs: []
commits: []
```
`kind`:`short` | `long`。
`status`:`draft`、`spec_review`、`approved`、`in_progress`、`blocked`、`done`、`parked`、`superseded`。
`delivery.state` 使用 delivery-loop 内部里程碑:`change_ready`、`verified`、
`pr_opened`、`merged`、`regression_passed`、`artifact_built`、`published`、`deployed`。
用户可见的 canonical `stopAt` 与内部里程碑映射如下:
| stopAt | 停止含义 | 内部里程碑 |
| --- | --- | --- |
| `develop_verified` | 开发验证完成:单元测试、新增功能集成测试和静态检查通过;不等于完整回归 | `verified` |
| `review_ready` | PR/MR 已创建,等待审核 | `pr_opened` |
| `release_ready` | PR 已合并且完整回归通过;尚未表示发布或部署 | `regression_passed` |
| `released` | 产物已构建并发布 | `published` |
| `deployed` | 目标环境实际部署完成并通过健康检查 | `deployed` |
为兼容旧记录,`verified` 是 `develop_verified` 的别名,`validation_ready` 是
`release_ready` 的别名;新记录应使用 canonical 名称。`delivery.state` 内部里程碑
名称保持不变。
`source.kind`:`user` | `hunt` | `ack` | `pr` | `feishu`。修 ACK 缺陷时
`source.kind: ack` 且 `ref` 为缺陷任务 id。
`manager` 由入口主会话维护,可选。`lastStep` / `lastResult` / `lastSummary` 记录刚结束的那一步;
`inFlight` 在 worker 未返回时写成步骤名,返回后清掉。worker 不要写这些字段。
`lastResult`:`pass` | `fail` | `blocked` | `ask`。
## short 与 long
同一套字段,厚度不同。
| 字段 | short | long |
| --- | --- | --- |
| `spec.md` | 四段:要做什么、不做什么、可观察验收、怎么验。有故障证据时加复现和根因 | 同一四段作摘要 |
| `spec/` | 不要创建 | specifier 写入 PRD、评审、决策、风险、handoff |
| `spec.revision` 等 | 保持 `null` | 与 PRD 版本同步 |
| `tasks.yaml` | 恰好 1 条 | DAG;`dependsOn` 指向本文件内 id |
| 子项 | 无 | 默认是 task。只有该切片有独立 `stopAt` 并单独发布时才晋升为子 ship,写入 `childShipIds` |
范围变大时保持同一 `shipId`,把 `kind` 改成 `long`,补 `spec/` 并拆任务。
`spec.md` 不要写成 HTML PRD。long 的评审稿是 `spec/prd-vNNN.html`。
## 任务
`tasks.yaml` 只描述本 ship 的工作项,不是 ACK 缺陷板。
新建 ship 的任务默认带 `testPlan`。开发 worker 在 manifest 中记录测试的稳定 id、
runner、target、selector、覆盖的验收项和 `added|changed` 状态。`verify` 必须逐条执行
manifest;`regress` 在完整 tester catalog 和项目原生测试之外再次确认这些条目通过。
仅文档或明确无行为变化的任务可使用 `policy: not_applicable`,但必须填写被主会话接受的
`testGap.reason` 与 `acceptedBy`。
```yaml
shipId: add-export-button
tasks:
- id: T-01
title: 在列表页增加导出按钮
dependsOn: []
expected:
- 登录用户在列表页看到「导出」
verification:
- 浏览器打开列表页,按钮可见且可点
testPlan:
policy: required
manifest: 2-develop/test-manifest.yaml
status: open
stopAt: review_ready
```
允许的任务 `status`:`open`、`in_progress`、`blocked`、`verified`、`merged`、`done`。
long 的子任务默认 `stopAt` 为 `pr_opened` 或 `merged`,不要单独打生产 tag 或升级生产。
父 ship 在依赖任务完成后做集成回归,再按父记录的 `delivery.stopAt` 继续。
从 `spec/handoff.yaml` 建任务时复制 `id`、`title`、`dependsOn`、`expected`、
`verification`,不要写进 `.pouch/ack/tasks.yaml`。
## 证据指针
不要把 tester 快照、镜像层或 compose 日志复制进 ship 目录。追加指针:
```yaml
evidence:
testRuns:
- path: .pouch/tester/runs/20261013T031000Z-smoke-ab12.yaml
result: pass
suite: smoke
artifacts:
- sha: 0123456789abcdef0123456789abcdef01234567
digest: sha256:abc
ref: harbor.example.com/app:1.2.3
venue: ci
deploys:
- env: test
revision: 0123456789abcdef0123456789abcdef01234567
health: pass
prs:
- url: https://git.example.com/app/-/merge_requests/12
status: opened
commits:
- sha: 0123456789abcdef0123456789abcdef01234567
role: baseline
```
delivery-loop 阶段与指针:
| 阶段 | 至少追加 |
| --- | --- |
| `change_ready` | `commits` `role: baseline` |
| `verified` | `testRuns` |
| `pr_opened` | `prs` |
| `merged` | `commits` `role: merged` |
| `regression_passed` | `testRuns`(回归) |
| `artifact_built` / `published` | `artifacts` |
| `deployed` | `deploys` |
`path` 相对项目根。文件不存在则校验失败。用 `scripts/append_evidence.py` 追加后立刻
`validate_ship.py`。
## 旧 specifier 目录
打开某个 id 时按这个顺序:
1. `.pouch/ship/<id>/` 已存在:用它。若同时还留着
`.pouch/specifier/features/<id>/`,报告旧路径为遗留,不要两处同时写。
2. 只有 `.pouch/specifier/features/<id>/`:把它移到 `.pouch/ship/<id>/spec/`,按旧
`current.yaml` 生成 ship `current.yaml`(`kind: long`),用 `brief.md` 补
`spec.md` 摘要,再校验。
3. 两处都没有:由入口 skill 调用 `create_ship.py` 新建。
+60
View File
@@ -0,0 +1,60 @@
# Worker prompt 模板
主会话把下面整段交给**新的** agent,替换尖括号。不要追加 hunt / tester /
delivery-loop 的正文,不要让 worker 再开一层 ship 入口。
```text
你是 ship <ship-id> 的 worker,只做步骤 <step>。
项目根:<project-root>
Ship 目录:.pouch/ship/<ship-id>/
当前阶段目录:.pouch/ship/<ship-id>/<stage>/
任务 id:<taskId 或「无」>
目标 delivery.state:<targetState 或「不改」>
停止点 stopAt:<stopAt>
先读已安装的 skill:<skill 或「无,按 spec.md / 本任务改代码」>。
需要写证据指针时读 ship 的 references/record.md,用
scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。
不要读取 short-ship、long-ship、pr-ship,不要再开 ship 入口,不要派下一个步骤。
范围:<goal>
验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。
授权:<本步允许的 git/PR/部署动作;未写的不要做>
测试交接:`implement` / `implement_task` 必须在 ship 的 `2-develop/test-manifest.yaml`
登记本次新增或修改的测试。每条记录至少包含稳定 `id`、`kind`、`runner`、`target`、
`selector`、`covers` 和 `change: added|changed`。若确实没有行为变化或无法补测,必须
填写 `testGap.reason`,并由主会话明确写入 `acceptedBy`;不能留空后声称已验证。
测试边界:`verify` / `verify_task` 只负责开发阶段验证,执行任务相关的单元测试、
新增功能集成测试和静态检查,不执行合并后的完整 tester 回归。验证前先使用 ship skill
的 `scripts/validate_test_manifest.py <ship-dir>/2-develop/test-manifest.yaml`
带上当前 ship id、任务的 testPlan.policy 执行 `--mode verify`;逐条执行 manifest 中的
测试 selector,并在证据中记录测试 id。
`regress` 只在合并后负责完整回归,除 manifest 中的新增/修改测试外,必须覆盖发现的每个
tester catalog 根和项目声明的原生测试套件。
若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行
`python3 <tester-skill>/scripts/catalog.py roots --root <project-root>`。`verify_task` 按
任务范围选择相关测试根;`regress` 必须对返回的每个独立测试根分别执行 `validate`、
完整范围选择和测试。父仓库没有 catalog 时继续进入有 catalog 的子项目(例如 `app`),
不要把多个项目的 cases 合并。`regress` 只有在所有发现的测试根都完成、每个根都有自己
的 `.pouch/tester/runs/*.yaml`/`.csv` 快照并把所有路径写入 ship evidence 后,才可返回
pass;没有 HOST、设备或浏览器的测试根必须记录 environment-fail/blocked,不能把未执行
的根省略掉。同时执行项目 profile 或 `.pouch/ack/project.md` 声明的项目原生测试套件
(例如 `go test ./...`、构建检查或前端检查),每个套件都要有独立的命令、结果和证据;
不能只因为 tester catalog 已完成就跳过这些项目测试。
完成后只返回:
- step: <step>
- status: pass | fail | blocked | ask
- summary: 三句以内,做了什么、证据在哪、还缺什么
- evidence: 写入的指针
- ask: 若 status=ask,要主会话问用户的那一个问题
同时把本次工作的命令、关键结果、阻塞和证据路径写入当前阶段目录的
`activity.md`;不要把完整日志复制进 ship,只写可复核摘要和外部文件路径。
```
`<skill>` 必须用 `next_step.py` JSON 里的 `skill` 字段,不要改成别的。`skill` 为
`null` 时不要加载执行类 skill。
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Append one evidence pointer to current.yaml and re-validate."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import dump_yaml, load_yaml
from validate_ship import validate_ship_dir
KINDS = ("testRun", "artifact", "deploy", "pr", "commit")
KEY = {
"testRun": "testRuns",
"artifact": "artifacts",
"deploy": "deploys",
"pr": "prs",
"commit": "commits",
}
def _entry(kind: str, args: argparse.Namespace) -> dict[str, Any]:
if kind == "testRun":
if not args.path or not args.result:
raise SystemExit("testRun 需要 --path 和 --result")
row: dict[str, Any] = {"path": args.path, "result": args.result}
if args.suite:
row["suite"] = args.suite
return row
if kind == "artifact":
if not args.sha:
raise SystemExit("artifact 需要 --sha")
row = {"sha": args.sha}
if args.digest:
row["digest"] = args.digest
if args.ref:
row["ref"] = args.ref
if args.venue:
row["venue"] = args.venue
return row
if kind == "deploy":
if not args.env:
raise SystemExit("deploy 需要 --env")
row = {"env": args.env}
if args.sha:
row["revision"] = args.sha
if args.health:
row["health"] = args.health
return row
if kind == "pr":
if not args.url:
raise SystemExit("pr 需要 --url")
row = {"url": args.url, "status": args.pr_status or "opened"}
return row
if kind == "commit":
if not args.sha or not args.role:
raise SystemExit("commit 需要 --sha 和 --role")
return {"sha": args.sha, "role": args.role}
raise SystemExit(f"未知 kind: {kind}")
def main() -> int:
parser = argparse.ArgumentParser(description="向 ship current.yaml 追加证据指针")
parser.add_argument("ship_dir")
parser.add_argument("--kind", required=True, choices=KINDS)
parser.add_argument("--path")
parser.add_argument("--result")
parser.add_argument("--suite")
parser.add_argument("--sha")
parser.add_argument("--digest")
parser.add_argument("--ref")
parser.add_argument("--venue")
parser.add_argument("--env")
parser.add_argument("--health")
parser.add_argument("--url")
parser.add_argument("--pr-status")
parser.add_argument("--role")
args = parser.parse_args()
ship_dir = Path(args.ship_dir).resolve()
current_path = ship_dir / "current.yaml"
data = load_yaml(current_path)
if not isinstance(data, dict):
raise SystemExit("current.yaml 不是 mapping")
evidence = data.setdefault("evidence", {})
if not isinstance(evidence, dict):
raise SystemExit("evidence 不是 mapping")
key = KEY[args.kind]
bucket = evidence.setdefault(key, [])
if not isinstance(bucket, list):
raise SystemExit(f"evidence.{key} 不是列表")
bucket.append(_entry(args.kind, args))
dump_yaml(current_path, data)
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ 已追加 evidence.{key}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Create a .pouch/ship/<id>/ record from templates. Refuse to overwrite."""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
from ship_lib import check_ship_id, ensure_stage_layout, skill_dir
from validate_ship import validate_ship_dir
def _replace(text: str, mapping: dict[str, str]) -> str:
for key, value in mapping.items():
text = text.replace(f"<{key}>", value)
text = text.replace(f"{{{{ {key} }}}}", value)
return text
def create_ship(
project_root: Path,
ship_id: str,
kind: str,
title: str,
*,
stop_at: str | None = None,
source_kind: str = "user",
source_ref: str | None = None,
) -> Path:
err = check_ship_id(ship_id)
if err:
raise SystemExit(err)
if kind not in {"short", "long"}:
raise SystemExit(f"kind 无效: {kind}")
title = title.strip()
if not title:
raise SystemExit("title 不能为空")
dest = project_root / ".pouch" / "ship" / ship_id
if dest.exists():
raise SystemExit(f"拒绝覆盖已有路径: {dest}")
templates = skill_dir() / "templates"
dest.mkdir(parents=True)
mapping = {
"ship-id": ship_id,
"title": title,
"kind": kind,
"short title": title,
}
current = _replace(
(templates / "current.template.yaml").read_text(encoding="utf-8"),
mapping,
)
current = current.replace("kind: short", f"kind: {kind}", 1)
current = current.replace("status: draft", "status: draft", 1)
if stop_at:
current = current.replace("stopAt: null", f"stopAt: {stop_at}")
if source_kind != "user":
current = current.replace("kind: user", f"kind: {source_kind}", 1)
if source_ref:
current = current.replace("ref: null", f"ref: {source_ref}", 1)
if kind == "long":
current = current.replace("status: draft", "status: spec_review", 1)
(dest / "current.yaml").write_text(current, encoding="utf-8")
(dest / "spec.md").write_text(
_replace((templates / "spec.template.md").read_text(encoding="utf-8"), mapping),
encoding="utf-8",
)
(dest / "tasks.yaml").write_text(
_replace(
(templates / "tasks.template.yaml").read_text(encoding="utf-8"), mapping
),
encoding="utf-8",
)
(dest / "status.md").write_text(
_replace(
(templates / "status.template.md").read_text(encoding="utf-8"),
{**mapping, "status": "spec_review" if kind == "long" else "draft",
"stopAt": stop_at or "null", "delivery.state": "change_ready"},
),
encoding="utf-8",
)
if kind == "long":
spec_dir = dest / "spec"
spec_dir.mkdir()
(spec_dir / "brief.md").write_text(f"# {title}\n\n", encoding="utf-8")
(spec_dir / "review.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "decisions.yaml").write_text("[]\n", encoding="utf-8")
(spec_dir / "risks.yaml").write_text("[]\n", encoding="utf-8")
ensure_stage_layout(dest)
manifest = _replace(
(templates / "test-manifest.template.yaml").read_text(encoding="utf-8"),
mapping,
)
(dest / "2-develop" / "test-manifest.yaml").write_text(
manifest, encoding="utf-8"
)
issues = validate_ship_dir(dest)
if issues:
shutil.rmtree(dest)
for item in issues:
print(f"✗ {item}", file=sys.stderr)
raise SystemExit("新建后校验失败,已删除不完整目录")
return dest
def main() -> int:
parser = argparse.ArgumentParser(description="新建 ship 记录")
parser.add_argument("--project-root", required=True)
parser.add_argument("--ship-id", required=True)
parser.add_argument("--kind", choices=("short", "long"), required=True)
parser.add_argument("--title", required=True)
parser.add_argument("--stop-at", default=None)
parser.add_argument("--source-kind", default="user")
parser.add_argument("--source-ref", default=None)
args = parser.parse_args()
dest = create_ship(
Path(args.project_root).resolve(),
args.ship_id,
args.kind,
args.title,
stop_at=args.stop_at,
source_kind=args.source_kind,
source_ref=args.source_ref,
)
print(dest)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Print a sortable YYYYMMDD-short-phrase ship id."""
from __future__ import annotations
import argparse
from datetime import date, datetime
from ship_lib import dated_ship_id
def main() -> int:
parser = argparse.ArgumentParser(description="生成可排序的 ship-id")
parser.add_argument("--phrase", required=True, help="小写英文短语,例如 dynamic-editor")
parser.add_argument("--date", default=None, help="可选日期,格式 YYYYMMDD")
args = parser.parse_args()
selected = date.today()
if args.date:
selected = datetime.strptime(args.date, "%Y%m%d").date()
print(dated_ship_id(args.phrase, today=selected))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+672
View File
@@ -0,0 +1,672 @@
#!/usr/bin/env python3
"""Print the next manager/worker step for a ship as JSON."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
from ship_lib import (
STOP_AT_STATE,
ensure_stage_layout,
load_yaml,
record_stage_event,
stage_for_step,
)
SPEC_PLACEHOLDERS = (
"<用户可见的目标",
"<明确排除的范围",
"<能被浏览器",
"<命令、页面路径",
)
TASK_PLACEHOLDERS = (
"<observable acceptance",
"<how to verify",
"<short title>",
)
FAULT_PLACEHOLDER = "<没有故障时删除"
DONE_TASK = frozenset({"verified", "merged", "done"})
READY_DEP = frozenset({"verified", "merged", "done"})
DELIVERY_STEPS: tuple[tuple[str, str, str], ...] = (
("verified", "verify", "tester"),
("pr_opened", "open_pr", "delivery-loop"),
("merged", "merge_pr", "delivery-loop"),
("regression_passed", "regress", "tester"),
("artifact_built", "obtain", "builder"),
("published", "publish", "releaser"),
("deployed", "deploy", "deployer"),
)
DELIVERY_ORDER = ("change_ready",) + tuple(s[0] for s in DELIVERY_STEPS)
def _out(
*,
ship_id: str,
entry: str,
actor: str,
step: str,
status: str = "ready",
skill: str | None = None,
task_id: str | None = None,
target_state: str | None = None,
stop_at: str | None = None,
ready_task_ids: list[str] | None = None,
summary: str,
) -> dict[str, Any]:
return {
"shipId": ship_id,
"entry": entry,
"actor": actor,
"step": step,
"status": status,
"skill": skill,
"taskId": task_id,
"targetState": target_state,
"stopAt": stop_at,
"readyTaskIds": ready_task_ids or [],
"summary": summary,
"stage": (stage_for_step(step) or (None, None))[0],
}
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8") if path.is_file() else ""
def _has_any(text: str, markers: tuple[str, ...]) -> bool:
return any(m in text for m in markers)
def _fault_section(spec_md: str) -> str:
match = re.search(r"^## 故障证据\s*$", spec_md, re.MULTILINE)
if not match:
return ""
rest = spec_md[match.end() :]
nxt = re.search(r"^## ", rest, re.MULTILINE)
body = rest[: nxt.start()] if nxt else rest
return body.strip()
def _needs_hunt(spec_md: str) -> bool:
body = _fault_section(spec_md)
if not body or FAULT_PLACEHOLDER in body:
return False
return True
def _tasks(ship_dir: Path) -> list[dict[str, Any]]:
data = load_yaml(ship_dir / "tasks.yaml")
if not isinstance(data, dict):
return []
tasks = data.get("tasks")
return [t for t in tasks if isinstance(t, dict)] if isinstance(tasks, list) else []
def _task_placeholders(tasks: list[dict[str, Any]]) -> bool:
blob = json.dumps(tasks, ensure_ascii=False)
return _has_any(blob, TASK_PLACEHOLDERS)
def _commits(current: dict[str, Any]) -> list[dict[str, Any]]:
evidence = current.get("evidence") or {}
if not isinstance(evidence, dict):
return []
rows = evidence.get("commits") or []
return [r for r in rows if isinstance(r, dict)]
def _has_change_commit(current: dict[str, Any]) -> bool:
return any(r.get("role") in {"head", "merged"} for r in _commits(current))
def _has_merged_commit(current: dict[str, Any]) -> bool:
return any(r.get("role") == "merged" for r in _commits(current))
def _manager(current: dict[str, Any]) -> dict[str, Any]:
value = current.get("manager")
return value if isinstance(value, dict) else {}
def _stop_state(stop_at: str | None) -> str | None:
if not stop_at:
return None
return STOP_AT_STATE.get(stop_at)
def _state_index(state: str | None) -> int:
if state in DELIVERY_ORDER:
return DELIVERY_ORDER.index(state)
return 0
def _next_delivery(
*,
ship_id: str,
entry: str,
current_state: str,
stop_at: str | None,
skip_until: str | None = None,
) -> dict[str, Any] | None:
target_stop = _stop_state(stop_at)
if not stop_at or not target_stop:
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step="ask_user",
status="ask_user",
stop_at=stop_at,
summary="stopAt 未配置或无法映射,主会话向用户确认停止点后再继续",
)
start = _state_index(current_state)
floor = _state_index(skip_until) if skip_until else start
start = max(start, floor)
stop_i = _state_index(target_stop)
if start >= stop_i:
return None
nxt = DELIVERY_STEPS[start]
state, step, skill = nxt
summary = f"派 worker 把 delivery.state 推进到 {state},加载 {skill}"
if step == "verify":
summary = (
"派 tester worker 做开发阶段验证:执行单元测试、新增功能集成测试和静态检查;"
"不执行合并后的完整 tester 回归"
)
if step == "regress":
summary = (
"派 tester worker 做合并后的完整回归:先发现父仓库和子项目的所有 tester "
"catalog,逐根执行并为每根写独立快照;再执行项目声明的原生测试套件;"
"任一测试面缺证据不得通过"
)
return _out(
ship_id=ship_id,
entry=entry,
actor="worker",
step=step,
skill=skill,
target_state=state,
stop_at=stop_at,
summary=summary,
)
def _failed(ship_id: str, entry: str, mgr: dict[str, Any], stop_at: str | None) -> dict[str, Any] | None:
if mgr.get("lastResult") not in {"fail", "blocked"}:
return None
last = str(mgr.get("lastStep") or "unknown")
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step=last,
status="blocked",
stop_at=stop_at,
summary=f"上一步 {last} 失败或阻塞,主会话不要自己做;问用户是否开新 agent 重试",
)
def _ready_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_id = {str(t.get("id")): t for t in tasks if t.get("id")}
ready: list[dict[str, Any]] = []
for task in tasks:
status = task.get("status")
if status not in {"open", "in_progress"}:
continue
deps = task.get("dependsOn") or []
if not isinstance(deps, list):
continue
ok = True
for dep in deps:
other = by_id.get(str(dep))
if other is None or other.get("status") not in READY_DEP:
ok = False
break
if ok:
ready.append(task)
return ready
def decide_next(ship_dir: Path, entry: str) -> dict[str, Any]:
current_path = ship_dir / "current.yaml"
if not current_path.is_file():
return _out(
ship_id=ship_dir.name,
entry=entry,
actor="manager",
step="ensure_record",
status="blocked",
summary="没有 current.yaml,主会话先跑 create_ship.py",
)
current = load_yaml(current_path)
if not isinstance(current, dict):
return _out(
ship_id=ship_dir.name,
entry=entry,
actor="manager",
step="ensure_record",
status="blocked",
summary="current.yaml 无法解析",
)
ship_id = str(current.get("shipId") or ship_dir.name)
kind = current.get("kind")
stop_at = current.get("stopAt") or (current.get("delivery") or {}).get("stopAt")
if isinstance(stop_at, str) and stop_at in {"", "null"}:
stop_at = None
state = (current.get("delivery") or {}).get("state") or "change_ready"
source = current.get("source") if isinstance(current.get("source"), dict) else {}
spec = current.get("spec") if isinstance(current.get("spec"), dict) else {}
mgr = _manager(current)
spec_md = _text(ship_dir / "spec.md")
tasks = _tasks(ship_dir)
stop = stop_at if isinstance(stop_at, str) else None
failed = _failed(ship_id, entry, mgr, stop)
if failed:
return failed
if mgr.get("lastResult") == "ask":
last = str(mgr.get("lastStep") or "unknown")
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step="ask_user",
status="ask_user",
stop_at=stop,
summary=f"上一步 {last} 需要用户决定,主会话先问用户,不要自己执行下一步",
)
in_flight = mgr.get("inFlight")
if isinstance(in_flight, str) and in_flight.strip():
return _out(
ship_id=ship_id,
entry=entry,
actor="manager",
step=in_flight,
status="blocked",
stop_at=stop,
summary=f"manager.inFlight={in_flight},先等待该 worker,不要重派或自己做",
)
use_pr = entry == "pr" or source.get("kind") == "pr"
use_long = (not use_pr) and (entry == "long" or kind == "long")
if use_pr:
return _decide_pr(ship_id, current, source, mgr, state, stop)
if use_long:
return _decide_long(ship_dir, ship_id, current, spec, spec_md, tasks, state, stop)
return _decide_short(ship_id, spec_md, tasks, current, state, stop)
def _decide_short(
ship_id: str,
spec_md: str,
tasks: list[dict[str, Any]],
current: dict[str, Any],
state: str,
stop_at: str | None,
) -> dict[str, Any]:
if not spec_md or _has_any(spec_md, SPEC_PLACEHOLDERS) or _task_placeholders(tasks):
return _out(
ship_id=ship_id,
entry="short",
actor="manager",
step="write_spec",
stop_at=stop_at,
summary="主会话补齐 spec.md 四段和唯一任务的 expected/verification,不要加载执行类 skill",
)
if not stop_at:
return _out(
ship_id=ship_id,
entry="short",
actor="manager",
step="ask_user",
status="ask_user",
summary=(
"stopAt 未配置,主会话向用户确认 develop_verified / review_ready / "
"release_ready / released / deployed"
),
)
if state == "change_ready" and _needs_hunt(spec_md) and not _has_change_commit(current):
last = _manager(current).get("lastStep")
if last != "hunt":
return _out(
ship_id=ship_id,
entry="short",
actor="worker",
step="hunt",
skill="hunt",
stop_at=stop_at,
summary="派 hunt worker 确认根因、复现和验收信号",
)
if state == "change_ready" and not _has_change_commit(current):
return _out(
ship_id=ship_id,
entry="short",
actor="worker",
step="implement",
skill=None,
task_id=str(tasks[0]["id"]) if tasks and tasks[0].get("id") else "T-01",
stop_at=stop_at,
summary="派 worker 按 spec.md 改代码,不要加载 delivery-loop",
)
nxt = _next_delivery(
ship_id=ship_id, entry="short", current_state=state, stop_at=stop_at
)
if nxt:
return nxt
return _out(
ship_id=ship_id,
entry="short",
actor="manager",
step="report",
status="done",
stop_at=stop_at,
summary="已到停止点,主会话按账本写完成报告",
)
def _decide_long(
ship_dir: Path,
ship_id: str,
current: dict[str, Any],
spec: dict[str, Any],
spec_md: str,
tasks: list[dict[str, Any]],
state: str,
stop_at: str | None,
) -> dict[str, Any]:
spec_status = spec.get("specStatus")
handoff = spec.get("handoff")
prd = spec.get("prd")
handoff_path = ship_dir / str(handoff) if handoff else ship_dir / "spec" / "handoff.yaml"
if spec_status not in {"approved"}:
if spec_status in {None, "", "draft"} and not prd:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="specifier_new",
skill="specifier",
stop_at=stop_at,
summary="派 specifier worker 写 spec/ 下的 PRD,主会话不要读 specifier",
)
if spec_status in {"changes_requested"}:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="specifier_review",
skill="specifier",
stop_at=stop_at,
summary="派 specifier worker 按用户意见出新 PRD 版本",
)
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="ask_user",
status="ask_user",
stop_at=stop_at,
summary="PRD 待用户批准或提出修改;主会话问用户,不要自己改 PRD",
)
if not handoff_path.is_file():
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="specifier_handoff",
skill="specifier",
stop_at=stop_at,
summary="已批准但缺少 handoff,派 specifier worker 生成 spec/handoff.yaml",
)
if _task_placeholders(tasks):
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="materialize_tasks",
stop_at=stop_at,
summary="主会话从 handoff.yaml 写入 tasks.yaml,不要加载 specifier",
)
unfinished = [t for t in tasks if t.get("status") not in DONE_TASK]
if unfinished:
ready = _ready_tasks(tasks)
if not ready:
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="ask_user",
status="blocked",
stop_at=stop_at,
summary="有未完成任务但依赖未就绪,主会话报告阻塞,不要自己开发",
)
open_ready = [t for t in ready if t.get("status") == "open"]
if open_ready:
ids = [str(t["id"]) for t in open_ready if t.get("id")]
first = ids[0]
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="implement_task",
skill=None,
task_id=first,
ready_task_ids=ids,
stop_at=stop_at,
summary="派 worker 实现无依赖冲突的任务;多个 id 可并行,仍全部等完",
)
inprog = [t for t in ready if t.get("status") == "in_progress"]
task = inprog[0]
tid = str(task.get("id") or "")
last = _manager(current).get("lastStep")
if last == "implement_task":
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="verify_task",
skill="tester",
task_id=tid,
target_state="verified",
stop_at=stop_at,
summary="派 tester worker 验证该任务,主会话不要自己点浏览器",
)
task_stop = task.get("stopAt") or "merged"
if last == "verify_task" and task_stop in {"pr_opened", "merged", "review_ready"}:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="open_pr",
skill="delivery-loop",
task_id=tid,
target_state="pr_opened",
stop_at=stop_at,
summary="派 worker 为该子任务开 PR,默认不部署生产",
)
if last == "open_pr" and task_stop in {"merged"}:
return _out(
ship_id=ship_id,
entry="long",
actor="worker",
step="merge_pr",
skill="delivery-loop",
task_id=tid,
target_state="merged",
stop_at=stop_at,
summary="派 worker 合并该子任务 PR,不要升级生产",
)
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="ask_user",
status="blocked",
task_id=tid,
stop_at=stop_at,
summary=f"任务 {tid} 处于 in_progress,但 lastStep={last!r},主会话先核对账本",
)
parent_state = state
if _state_index(state) < _state_index("merged"):
parent_state = "merged"
nxt = _next_delivery(
ship_id=ship_id,
entry="long",
current_state=parent_state,
stop_at=stop_at,
skip_until="merged",
)
if nxt:
return nxt
return _out(
ship_id=ship_id,
entry="long",
actor="manager",
step="report",
status="done",
stop_at=stop_at,
summary="已到停止点,主会话按账本写完成报告",
)
def _decide_pr(
ship_id: str,
current: dict[str, Any],
source: dict[str, Any],
mgr: dict[str, Any],
state: str,
stop_at: str | None,
) -> dict[str, Any]:
if not source.get("ref"):
return _out(
ship_id=ship_id,
entry="pr",
actor="manager",
step="bind_pr",
stop_at=stop_at,
summary="主会话把 PR/MR 写入 source.ref 和 evidence.prs,不要加载 check",
)
if not stop_at:
return _out(
ship_id=ship_id,
entry="pr",
actor="manager",
step="ask_user",
status="ask_user",
summary="stopAt 未配置,主会话向用户确认合并后停在哪一档",
)
if not _has_merged_commit(current) and state not in {
"merged",
"regression_passed",
"artifact_built",
"published",
"deployed",
}:
if mgr.get("lastStep") == "check_pr" and mgr.get("lastResult") == "pass":
return _out(
ship_id=ship_id,
entry="pr",
actor="worker",
step="merge_pr",
skill="delivery-loop",
target_state="merged",
stop_at=stop_at,
summary="派 worker 合并 PR,并以合并 commit 作为后续来源",
)
if mgr.get("lastStep") == "check_pr" and mgr.get("lastResult") == "ask":
return _out(
ship_id=ship_id,
entry="pr",
actor="worker",
step="fix_pr",
skill=None,
stop_at=stop_at,
summary="检查要求修复且当前请求含修复时,派 worker 在 PR 分支上改;不要扩范围",
)
return _out(
ship_id=ship_id,
entry="pr",
actor="worker",
step="check_pr",
skill="check",
stop_at=stop_at,
summary="派 check worker 看差异、审核和 CI,主会话不要自己审代码",
)
nxt = _next_delivery(
ship_id=ship_id,
entry="pr",
current_state=state if state != "change_ready" else "merged",
stop_at=stop_at,
skip_until="merged",
)
if nxt:
return nxt
return _out(
ship_id=ship_id,
entry="pr",
actor="manager",
step="report",
status="done",
stop_at=stop_at,
summary="已到停止点,主会话按账本写完成报告",
)
def main() -> int:
parser = argparse.ArgumentParser(description="计算 ship 的下一步")
parser.add_argument("--project-root", required=True)
parser.add_argument("--ship-id", required=True)
parser.add_argument("--entry", choices=("short", "long", "pr"), required=True)
args = parser.parse_args()
ship_dir = Path(args.project_root).resolve() / ".pouch" / "ship" / args.ship_id
if not ship_dir.is_dir():
doc = _out(
ship_id=args.ship_id,
entry=args.entry,
actor="manager",
step="ensure_record",
status="blocked",
summary="没有 ship 目录,主会话先跑 create_ship.py",
)
json.dump(doc, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
current = load_yaml(ship_dir / "current.yaml")
if isinstance(current, dict):
manager = current.get("manager")
if isinstance(manager, dict) and manager.get("lastStep") and manager.get("lastResult"):
record_stage_event(
ship_dir,
step=str(manager.get("lastStep")),
status=str(manager.get("lastResult")),
summary=str(manager.get("lastSummary") or ""),
)
doc = decide_next(ship_dir, args.entry)
ensure_stage_layout(ship_dir)
record_stage_event(
ship_dir,
step=str(doc.get("step") or ""),
status=str(doc.get("status") or "ready"),
summary=str(doc.get("summary") or ""),
task_id=doc.get("taskId"),
)
json.dump(doc, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Shared ship-id and YAML helpers. Zero third-party deps."""
from __future__ import annotations
import re
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from pouch.yaml_io import safe_dump, safe_load # noqa: E402
SHIP_ID_RE = re.compile(
r"^(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*|\d{8}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$"
)
DATED_SHIP_ID_RE = re.compile(r"^\d{8}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
DATE_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$|^\d{8}$")
KIND = frozenset({"short", "long"})
SHIP_STATUS = frozenset(
{
"draft",
"spec_review",
"approved",
"in_progress",
"blocked",
"done",
"parked",
"superseded",
}
)
TASK_STATUS = frozenset(
{"open", "in_progress", "blocked", "verified", "merged", "done"}
)
DELIVERY_STATE = frozenset(
{
"change_ready",
"verified",
"pr_opened",
"merged",
"regression_passed",
"artifact_built",
"published",
"deployed",
}
)
# User-facing stop points. The two legacy names remain accepted so existing
# ship records continue to resolve to the new phase meanings.
STOP_AT_STATE = {
"develop_verified": "verified",
"review_ready": "pr_opened",
"release_ready": "regression_passed",
"released": "published",
"deployed": "deployed",
"verified": "verified", # legacy alias for develop_verified
"validation_ready": "regression_passed", # legacy alias for release_ready
}
STOP_AT = frozenset(STOP_AT_STATE)
SOURCE_KIND = frozenset({"user", "hunt", "ack", "pr", "feishu"})
COMMIT_ROLE = frozenset({"baseline", "merged", "head"})
# These directories are the human-readable audit trail for the orchestration
# phases. The step list is intentionally kept here so create_ship and
# next_step cannot drift apart.
STAGE_DEFINITIONS = (
("1-spec", "规格与审批", ("write_spec", "specifier_new", "specifier_review", "specifier_handoff", "materialize_tasks", "hunt")),
("2-develop", "开发", ("implement", "implement_task",)),
("3-test", "验证与回归", ("verify", "verify_task", "regress")),
("4-review", "PR 与合并", ("open_pr", "check_pr", "fix_pr", "merge_pr")),
("5-build", "构建", ("obtain",)),
("6-publish", "发布", ("publish",)),
("7-deploy", "部署", ("deploy",)),
)
STAGE_BY_STEP = {
step: (directory, title)
for directory, title, steps in STAGE_DEFINITIONS
for step in steps
}
def skill_dir() -> Path:
return Path(__file__).resolve().parents[1]
def load_yaml(path: Path) -> Any:
return safe_load(path.read_text(encoding="utf-8"))
def dump_yaml(path: Path, data: Any) -> None:
path.write_text(safe_dump(data), encoding="utf-8")
def check_ship_id(ship_id: str) -> str | None:
if not ship_id:
return "shipId 为空"
if len(ship_id) > 64:
return f"shipId 超过 64 字符: {ship_id}"
if DATE_ID_RE.fullmatch(ship_id):
return f"shipId 不能是日期: {ship_id}"
if not SHIP_ID_RE.fullmatch(ship_id):
return (
f"shipId 无效: {ship_id!r}(小写字母开头,仅小写字母、数字、连字符)"
)
return None
def check_dated_ship_id(ship_id: str) -> str | None:
"""Validate the convention for newly-created records.
check_ship_id remains backward compatible so existing ship records can be
inspected. New entry points should use this stricter check.
"""
err = check_ship_id(ship_id)
if err:
return err
if not DATED_SHIP_ID_RE.fullmatch(ship_id):
return (
f"新 ship-id 必须是 YYYYMMDD-短语格式: {ship_id!r}"
)
return None
def dated_ship_id(phrase: str, *, today: date | None = None) -> str:
"""Build a sortable ship id from a short lowercase phrase."""
phrase = re.sub(r"[^a-z0-9]+", "-", phrase.strip().lower()).strip("-")
if not phrase:
raise ValueError("ship-id 短语不能为空")
value = f"{(today or date.today()):%Y%m%d}-{phrase}"
err = check_dated_ship_id(value)
if err:
raise ValueError(err)
return value
def stage_for_step(step: str | None) -> tuple[str, str] | None:
return STAGE_BY_STEP.get(str(step)) if step else None
def ensure_stage_layout(ship_dir: Path) -> None:
"""Create the visible phase folders and their small status manifests."""
for order, (directory, title, steps) in enumerate(STAGE_DEFINITIONS, start=1):
stage_dir = ship_dir / directory
stage_dir.mkdir(parents=True, exist_ok=True)
manifest = stage_dir / "stage.yaml"
if not manifest.exists():
manifest.write_text(
safe_dump({
"order": order,
"stage": directory,
"title": title,
"status": "pending",
"steps": list(steps),
"startedAt": None,
"finishedAt": None,
}),
encoding="utf-8",
)
activity = stage_dir / "activity.md"
if not activity.exists():
activity.write_text(
f"# {directory} {title}\n\n状态:pending\n\n"
"## 活动记录\n\n",
encoding="utf-8",
)
def _stage_manifest(path: Path) -> dict[str, Any]:
try:
value = safe_load(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
value = None
return value if isinstance(value, dict) else {}
def record_stage_event(
ship_dir: Path,
*,
step: str | None,
status: str,
summary: str = "",
task_id: str | None = None,
) -> None:
"""Append one dispatch/result event to the visible stage audit trail."""
resolved = stage_for_step(step)
if resolved is None:
return
ensure_stage_layout(ship_dir)
directory, title = resolved
stage_dir = ship_dir / directory
manifest_path = stage_dir / "stage.yaml"
manifest = _stage_manifest(manifest_path)
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
event_key = f"{step}|{status}|{task_id or ''}|{summary.strip()}"
if manifest.get("lastEventKey") == event_key:
return
manifest["lastEventKey"] = event_key
if status in {"ready", "in_progress"}:
if manifest.get("status") == "pending":
manifest["startedAt"] = now
manifest["status"] = "in_progress"
elif status in {"pass", "fail", "blocked", "ask"}:
manifest["lastResult"] = status
manifest["lastStep"] = step
if status == "pass":
manifest["status"] = "completed"
manifest["finishedAt"] = now
elif status in {"fail", "blocked"}:
manifest["status"] = "blocked"
manifest_path.write_text(safe_dump(manifest), encoding="utf-8")
line = f"- `{now}` `{step}` status=`{status}`"
if task_id:
line += f" task=`{task_id}`"
if summary:
line += f":{summary.strip()}"
with (stage_dir / "activity.md").open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""Validate a .pouch/ship/<id>/ record. Exit 0 on success, 1 on issues."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
from ship_lib import (
COMMIT_ROLE,
DELIVERY_STATE,
KIND,
SHIP_STATUS,
STOP_AT,
SOURCE_KIND,
TASK_STATUS,
check_ship_id,
load_yaml,
)
from validate_test_manifest import validate_manifest
def _issue(issues: list[str], msg: str) -> None:
issues.append(msg)
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
if value is None:
_issue(issues, f"{path} 缺失")
return None
if not isinstance(value, dict):
_issue(issues, f"{path} 必须是 mapping")
return None
return value
def _project_root(ship_dir: Path) -> Path:
# .pouch/ship/<id>
return ship_dir.parent.parent.parent
def validate_ship_dir(ship_dir: Path) -> list[str]:
issues: list[str] = []
ship_dir = ship_dir.resolve()
current_path = ship_dir / "current.yaml"
if not current_path.is_file():
return [f"缺少 {current_path}"]
try:
current = load_yaml(current_path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 current.yaml: {exc}"]
data = _mapping(current, "current.yaml", issues)
if data is None:
return issues
ship_id = data.get("shipId")
if not isinstance(ship_id, str):
_issue(issues, "current.yaml.shipId 必须是字符串")
else:
err = check_ship_id(ship_id)
if err:
_issue(issues, err)
if ship_id != ship_dir.name:
_issue(
issues,
f"目录名与 shipId 不一致: {ship_dir.name} != {ship_id}",
)
kind = data.get("kind")
if kind not in KIND:
_issue(issues, f"kind 无效: {kind!r}(short|long)")
status = data.get("status")
if status not in SHIP_STATUS:
_issue(issues, f"status 无效: {status!r}")
if not isinstance(data.get("title"), str) or not str(data.get("title")).strip():
_issue(issues, "title 不能为空")
for path, value in (
("stopAt", data.get("stopAt")),
("delivery.stopAt", (data.get("delivery") or {}).get("stopAt")
if isinstance(data.get("delivery"), dict) else None),
):
if value not in (None, "") and value not in STOP_AT:
_issue(
issues,
f"{path} 无效: {value!r}({', '.join(sorted(STOP_AT))})",
)
source = data.get("source")
if source is not None:
src = _mapping(source, "source", issues)
if src is not None and src.get("kind") not in SOURCE_KIND:
_issue(issues, f"source.kind 无效: {src.get('kind')!r}")
spec = _mapping(data.get("spec"), "spec", issues)
spec_path = ship_dir / "spec.md"
if not spec_path.is_file():
_issue(issues, "缺少 spec.md")
if spec is not None:
rel = spec.get("path") or "spec.md"
if rel != "spec.md":
_issue(issues, f"spec.path 必须是 spec.md,实际: {rel}")
if kind == "long":
spec_dir = ship_dir / "spec"
if not spec_dir.is_dir():
_issue(issues, "kind=long 缺少 spec/ 目录")
revision = spec.get("revision")
prd = spec.get("prd")
if revision and not prd:
_issue(issues, "long spec.revision 已设但 spec.prd 为空")
if prd:
prd_file = ship_dir / str(prd)
if not prd_file.is_file():
_issue(issues, f"缺少 PRD 文件: {prd}")
handoff = spec.get("handoff")
spec_status = spec.get("specStatus")
if spec_status == "approved" and not handoff:
_issue(issues, "specStatus=approved 但缺少 spec.handoff")
if handoff:
handoff_file = ship_dir / str(handoff)
if not handoff_file.is_file():
_issue(issues, f"缺少 handoff 文件: {handoff}")
elif kind == "short":
for field in (
"revision",
"specStatus",
"prd",
"approvedRevision",
"reviews",
"handoff",
):
if spec.get(field) not in (None, "", []):
_issue(issues, f"kind=short 时 spec.{field} 必须为空")
tasks_meta = _mapping(data.get("tasks"), "tasks", issues)
tasks_rel = "tasks.yaml"
if tasks_meta is not None:
tasks_rel = str(tasks_meta.get("path") or "tasks.yaml")
tasks_path = ship_dir / tasks_rel
if not tasks_path.is_file():
_issue(issues, f"缺少 {tasks_rel}")
else:
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
issues.extend(_validate_manifest_pointers(tasks_path, ship_dir, ship_id))
delivery = data.get("delivery")
if delivery is not None:
deliv = _mapping(delivery, "delivery", issues)
if deliv is not None:
state = deliv.get("state")
if state not in DELIVERY_STATE:
_issue(issues, f"delivery.state 无效: {state!r}")
manager = data.get("manager")
if manager is not None:
mgr = _mapping(manager, "manager", issues)
if mgr is not None:
result = mgr.get("lastResult")
if result not in (None, "pass", "fail", "blocked", "ask"):
_issue(issues, f"manager.lastResult 无效: {result!r}")
in_flight = mgr.get("inFlight")
if in_flight not in (None, "") and not isinstance(in_flight, str):
_issue(issues, "manager.inFlight 必须是字符串或空")
evidence = _mapping(data.get("evidence"), "evidence", issues)
if evidence is not None:
issues.extend(_validate_evidence(evidence, _project_root(ship_dir)))
status_md = ship_dir / "status.md"
if not status_md.is_file():
_issue(issues, "缺少 status.md")
return issues
def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]:
issues: list[str] = []
try:
data = load_yaml(path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 tasks.yaml: {exc}"]
mapping = _mapping(data, "tasks.yaml", issues)
if mapping is None:
return issues
if mapping.get("shipId") != ship_id:
_issue(
issues,
f"tasks.yaml.shipId 与 current.yaml 不一致: "
f"{mapping.get('shipId')!r} != {ship_id!r}",
)
tasks = mapping.get("tasks")
if not isinstance(tasks, list) or not tasks:
_issue(issues, "tasks.yaml.tasks 必须是非空列表")
return issues
if kind == "short" and len(tasks) != 1:
_issue(issues, f"kind=short 必须恰好 1 条任务,实际 {len(tasks)}")
ids: list[str] = []
for i, item in enumerate(tasks):
prefix = f"tasks[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
tid = row.get("id")
if not isinstance(tid, str) or not tid.strip():
_issue(issues, f"{prefix}.id 不能为空")
else:
if tid in ids:
_issue(issues, f"重复任务 id: {tid}")
ids.append(tid)
if not isinstance(row.get("title"), str) or not str(row.get("title")).strip():
_issue(issues, f"{prefix}.title 不能为空")
depends = row.get("dependsOn") or []
if not isinstance(depends, list):
_issue(issues, f"{prefix}.dependsOn 必须是列表")
status = row.get("status")
if status not in TASK_STATUS:
_issue(issues, f"{prefix}.status 无效: {status!r}")
expected = row.get("expected")
if not isinstance(expected, list) or not expected:
_issue(issues, f"{prefix}.expected 必须是非空列表")
verification = row.get("verification")
if not isinstance(verification, list) or not verification:
_issue(issues, f"{prefix}.verification 必须是非空列表")
test_plan = row.get("testPlan")
if test_plan is not None:
plan = _mapping(test_plan, f"{prefix}.testPlan", issues)
if plan is not None:
policy = plan.get("policy")
if policy not in {"required", "not_applicable"}:
_issue(
issues,
f"{prefix}.testPlan.policy 无效: {policy!r}(required|not_applicable)",
)
manifest = plan.get("manifest")
if not isinstance(manifest, str) or not manifest.strip():
_issue(issues, f"{prefix}.testPlan.manifest 不能为空")
for i, item in enumerate(tasks):
if not isinstance(item, dict):
continue
for dep in item.get("dependsOn") or []:
if dep not in ids:
_issue(issues, f"tasks[{i}].dependsOn 引用未知 id: {dep}")
if dep == item.get("id"):
_issue(issues, f"tasks[{i}] 不能依赖自己")
return issues
def _validate_manifest_pointers(path: Path, ship_dir: Path, ship_id: str) -> list[str]:
issues: list[str] = []
try:
data = load_yaml(path)
except Exception:
return issues
tasks = data.get("tasks") if isinstance(data, dict) else None
if not isinstance(tasks, list):
return issues
for index, item in enumerate(tasks):
if not isinstance(item, dict):
continue
plan = item.get("testPlan")
if not isinstance(plan, dict):
continue
manifest = plan.get("manifest")
if not isinstance(manifest, str) or not manifest.strip():
continue
target = (ship_dir / manifest).resolve()
try:
target.relative_to(ship_dir.resolve())
except ValueError:
_issue(issues, f"tasks[{index}].testPlan.manifest 必须位于 ship 目录内: {manifest}")
continue
if not target.is_file():
_issue(issues, f"tasks[{index}].testPlan.manifest 不存在: {manifest}")
continue
manifest_issues, _ = validate_manifest(
target,
expected_ship_id=ship_id,
policy=str(plan.get("policy") or "required"),
mode="schema",
)
issues.extend(f"tasks[{index}].{issue}" for issue in manifest_issues)
return issues
def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]:
issues: list[str] = []
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
value = evidence.get(key)
if value is None:
_issue(issues, f"evidence.{key} 缺失")
continue
if not isinstance(value, list):
_issue(issues, f"evidence.{key} 必须是列表")
continue
for i, item in enumerate(value):
prefix = f"evidence.{key}[{i}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
if key == "testRuns":
rel = row.get("path")
if not isinstance(rel, str) or not rel.strip():
_issue(issues, f"{prefix}.path 不能为空")
else:
target = project_root / rel
if not target.is_file():
_issue(issues, f"{prefix}.path 不存在: {rel}")
if row.get("result") not in {
"pass",
"fail",
"environment-fail",
"skipped",
"not-run",
"partial",
}:
_issue(issues, f"{prefix}.result 无效: {row.get('result')!r}")
elif key == "artifacts":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
elif key == "deploys":
if not row.get("env"):
_issue(issues, f"{prefix}.env 不能为空")
elif key == "prs":
if not row.get("url"):
_issue(issues, f"{prefix}.url 不能为空")
elif key == "commits":
if not row.get("sha"):
_issue(issues, f"{prefix}.sha 不能为空")
if row.get("role") not in COMMIT_ROLE:
_issue(issues, f"{prefix}.role 无效: {row.get('role')!r}")
return issues
def main() -> int:
parser = argparse.ArgumentParser(description="校验 ship 记录目录")
parser.add_argument("path", help=".pouch/ship/<id> 或其中的 current.yaml")
args = parser.parse_args()
path = Path(args.path)
ship_dir = path.parent if path.name == "current.yaml" else path
issues = validate_ship_dir(ship_dir)
if issues:
for item in issues:
print(f"✗ {item}", file=sys.stderr)
return 1
print(f"✓ ship 记录通过: {ship_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Validate and inspect a ship test manifest."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from ship_lib import load_yaml
POLICIES = {"required", "not_applicable"}
CHANGES = {"added", "changed"}
KINDS = {"unit", "integration", "e2e", "static"}
MODES = {"schema", "verify", "regress"}
def _issue(issues: list[str], message: str) -> None:
issues.append(message)
def _mapping(value: Any, path: str, issues: list[str]) -> dict[str, Any] | None:
if not isinstance(value, dict):
_issue(issues, f"{path} 必须是 mapping")
return None
return value
def validate_manifest(
manifest_path: Path,
*,
expected_ship_id: str | None = None,
policy: str = "required",
mode: str = "schema",
) -> tuple[list[str], dict[str, Any] | None]:
issues: list[str] = []
if mode not in MODES:
return [f"mode 无效: {mode!r}"], None
try:
data = load_yaml(manifest_path)
except Exception as exc: # noqa: BLE001
return [f"无法解析 test manifest: {exc}"], None
root = _mapping(data, "test-manifest", issues)
if root is None:
return issues, None
ship_id = root.get("shipId")
if not isinstance(ship_id, str) or not ship_id.strip():
_issue(issues, "test-manifest.shipId 不能为空")
elif expected_ship_id and ship_id != expected_ship_id:
_issue(issues, f"test-manifest.shipId 不一致: {ship_id!r} != {expected_ship_id!r}")
tests = root.get("tests")
if not isinstance(tests, list):
_issue(issues, "test-manifest.tests 必须是列表")
tests = []
ids: set[str] = set()
for index, item in enumerate(tests):
prefix = f"tests[{index}]"
row = _mapping(item, prefix, issues)
if row is None:
continue
test_id = row.get("id")
if not isinstance(test_id, str) or not test_id.strip():
_issue(issues, f"{prefix}.id 不能为空")
elif test_id in ids:
_issue(issues, f"重复测试 id: {test_id}")
else:
ids.add(test_id)
for field in ("runner", "target", "selector"):
if not isinstance(row.get(field), str) or not row[field].strip():
_issue(issues, f"{prefix}.{field} 不能为空")
if row.get("kind") not in KINDS:
_issue(issues, f"{prefix}.kind 无效: {row.get('kind')!r}")
if row.get("change") not in CHANGES:
_issue(issues, f"{prefix}.change 无效: {row.get('change')!r}")
covers = row.get("covers")
if not isinstance(covers, list) or not covers or not all(
isinstance(value, str) and value.strip() for value in covers
):
_issue(issues, f"{prefix}.covers 必须是非空字符串列表")
if policy not in POLICIES:
_issue(issues, f"test policy 无效: {policy!r}")
gap = root.get("testGap")
if gap is not None:
gap_row = _mapping(gap, "testGap", issues)
if gap_row is not None:
if not isinstance(gap_row.get("reason"), str) or not gap_row["reason"].strip():
_issue(issues, "testGap.reason 不能为空")
if mode in {"verify", "regress"} and not (
isinstance(gap_row.get("acceptedBy"), str)
and gap_row["acceptedBy"].strip()
):
_issue(issues, "testGap 在 verify/regress 前必须有 acceptedBy")
if mode in {"verify", "regress"}:
if policy == "required" and not tests and gap is None:
_issue(issues, "policy=required 时必须登记至少一个测试,或填写已批准的 testGap")
if policy == "not_applicable" and tests:
_issue(issues, "policy=not_applicable 时 tests 必须为空")
if policy == "not_applicable" and gap is None:
_issue(issues, "policy=not_applicable 时必须说明 testGap.reason")
return issues, root
def main() -> int:
parser = argparse.ArgumentParser(description="校验并列出 ship 测试 manifest")
parser.add_argument("manifest", help="test-manifest.yaml 路径")
parser.add_argument("--ship-id")
parser.add_argument("--policy", default="required", choices=sorted(POLICIES))
parser.add_argument("--mode", default="schema", choices=sorted(MODES))
parser.add_argument("--json", action="store_true", dest="as_json")
args = parser.parse_args()
issues, data = validate_manifest(
Path(args.manifest),
expected_ship_id=args.ship_id,
policy=args.policy,
mode=args.mode,
)
if args.as_json:
print(json.dumps({"ok": not issues, "issues": issues, "tests": (data or {}).get("tests", [])}, ensure_ascii=False))
elif issues:
for issue in issues:
print(f"✗ {issue}", file=sys.stderr)
else:
print(f"✓ test manifest 通过: {args.manifest}")
for row in (data or {}).get("tests", []):
print(f"- {row['id']}: {row['runner']} {row['target']} {row['selector']}")
return 1 if issues else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,34 @@
shipId: <ship-id>
kind: short
title: <short title>
status: draft
stopAt: null
source:
kind: user
ref: null
parentShipId: null
childShipIds: []
spec:
path: spec.md
revision: null
specStatus: null
prd: null
approvedRevision: null
reviews: null
handoff: null
tasks:
path: tasks.yaml
delivery:
stopAt: null
state: change_ready
manager:
lastStep: null
lastResult: null
lastSummary: null
inFlight: null
evidence:
testRuns: []
artifacts: []
deploys: []
prs: []
commits: []
+21
View File
@@ -0,0 +1,21 @@
# <title>
## 要做什么
<用户可见的目标。>
## 不做什么
<明确排除的范围。>
## 可观察验收
- <能被浏览器、API 或真机看到的结果>
## 怎么验
- <命令、页面路径或手工步骤>
## 故障证据
<没有故障时删除本节。有则写复现步骤和根因。>
+19
View File
@@ -0,0 +1,19 @@
# <title>
- ship id: `<ship-id>`
- kind: `<kind>`
- status: `<status>`
- stopAt: `<stopAt>`
- delivery: `<delivery.state>`
## spec
见 `spec.md`。
## tasks
见 `tasks.yaml`。
## evidence
测试、产物、部署、PR 和 commit 指针写在 `current.yaml` 的 `evidence`。
+14
View File
@@ -0,0 +1,14 @@
shipId: <ship-id>
tasks:
- id: T-01
title: <short title>
dependsOn: []
expected:
- <observable acceptance signal>
verification:
- <how to verify>
testPlan:
policy: required
manifest: 2-develop/test-manifest.yaml
status: open
stopAt: null
@@ -0,0 +1,3 @@
shipId: <ship-id>
tests: []
testGap: null
+31
View File
@@ -0,0 +1,31 @@
# short-ship
把一次范围清楚的改动(小功能、文案、已确认的 bug)从说明推进到配置允许的 PR、合并或部署。
## 什么时候使用
```text
给列表页加一个导出按钮,测完后发 PR。
```
或:
```text
修复这个线上回归,完整验证并部署到测试环境。
```
## 使用前准备
- 同时安装 `ship`(记录格式)和 `delivery-loop`。
- 目标已经说清,或有可复现的问题描述、截图、日志。
- 项目已配置测试环境和所需 builder/tester/deployer 能力。
- 若要外部发布,明确允许的停止点或使用项目 profile。
## Agent 会做什么
主会话只建 ship 记录、写短说明、按步骤开新 agent 并等待结果。改代码、测试、开合 PR、
构建和部署都在新会话里做,不会堆进同一个上下文。一次交付一个 ship id。
## 如何判断完成
你会得到 ship id、说明、测试证据、PR/合并/tag、产物和部署状态;不会只收到“本地测试通过”。
+59
View File
@@ -0,0 +1,59 @@
---
name: short-ship
description: >-
处理范围已清楚的单次变更:小功能、文案、加按钮,或已有证据的 bug。建立
ship 记录、改代码、验证,再按配置发 PR、合并、构建或部署。触发词:short-ship、
小需求、小改动、修 bug、改文案、加按钮。复杂需求用 long-ship;已有 PR 用 pr-ship。
---
# Short Ship:单次变更到交付
当前会话只做管理:建账本、问用户、按 `next_step.py` 开新 agent 并等待。不要把
hunt / 改代码 / 测试 / 交付读进本会话。
## 何时使用
- 加按钮、改文案、改短描述、单页小功能,目标与验收已经明确。
- 用户报告错误、回归、崩溃或明确要求修 bug,并且能指出问题。
- 用户要求改完后继续测试、发 PR、合并、构建、发布或部署。
## 不适用
- 跨模块、要方案取舍、要多任务协作:使用 `long-ship` 和 `specifier`。
- 已有 PR 的合并和发布:使用 `pr-ship`。
- 只做根因分析、不改代码:使用 `hunt`。
## 工作规则
1. 读取已安装 ship 的 [manager.md](../ship/references/manager.md)。派发 worker 时再读
[worker-prompt.md](../ship/references/worker-prompt.md)。没有 `ship-id` 就生成
slug,跑 `create_ship.py --kind short`。功能任务不要写入 `.pouch/ack/tasks.yaml`。
2. 主会话只写 `spec.md` 四段和那一条 task。有故障证据时 `next_step.py` 会给出
`hunt` worker,不要在本会话读 hunt。
3. 之后只跑:
```bash
python3 <ship-skill>/scripts/next_step.py \
--project-root <root> --ship-id <id> --entry short
```
`actor: worker` 就开新 agent 并等待;`ask_user` / `blocked` 就停下来问用户。
需要字段含义时再读 [record.md](../ship/references/record.md)。
4. 范围涨到需要 PRD 或多条任务时,同一 `ship-id` 把 `kind` 改为 `long`,改用
`--entry long`。
不要读取 `delivery-loop`、`tester`、`builder`、`deployer`、`check`、`hunt`。
这些由对应 worker 按 JSON 的 `skill` 加载。
## 安全门
- 没有 ship-id、spec.md 四段不齐、验收不可观察时停止。
- 开不出新 agent 时停止;worker 失败时不要自己把该步做完。
- 根因不清、测试缺失、真机不可达、CI/回归失败或工作树有无关改动时停止并说明。
- 不强推、不覆盖他人分支、不绕过 PR 审核,不把测试环境成功当生产成功。
- 数据迁移、破坏性 API、权限/密钥、安全修复和不可逆生产操作需要额外确认。
## 完成报告
报告 ship id、spec 路径、根因(若有)、变更、测试/真机证据指针、停止点、commit、
PR/tag、产物摘要、部署状态和未完成项。数字来自账本,不要复述 worker 技能正文。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Short Ship"
short_description: "Ship a small change or bug fix through verification and delivery"
default_prompt: "You are the short-ship manager: keep the ship record, spawn a fresh agent for each next_step, and wait. Do not hunt, code, test, or deliver in this session."
policy:
allow_implicit_invocation: true
+11 -9
View File
@@ -1,17 +1,18 @@
# specifier
把复杂需求整理成经过多轮人工评审的 HTML PRD,并在批准后生成交给 ACK 执行的需求 handoff。
把复杂需求整理成经过多轮人工评审的 HTML PRD,并在批准后生成交给 long-ship 执行的需求 handoff。
## 什么时候使用
- 要做跨模块、跨页面、跨服务或带数据迁移的复杂功能。
- 要先写 PRD、比较方案、梳理风险,再进入开发。
- 需要把 PRD 给人反复评审,确认后再交给 `/ack`。
- 需要把 PRD 给人反复评审,确认后再交给 `long-ship`。
小改动直接使用 `/ack`;已知 Bug 使用 `/hunt`;单次架构取舍使用 `/think`。
小改动直接使用 `short-ship`;已知 Bug 使用 `/hunt`;单次架构取舍使用 `/think`。
## 使用前准备
- 同时安装 `ship`;批准后的开发走 `long-ship`。
- 项目应位于 Git 仓库中,便于读取现有代码、文档和历史决策。
- 用户需要参与 PRD 评审,并明确批准具体版本。
- 不需要外部账号或网络资源;生成的 HTML 应能离线打开。
@@ -20,7 +21,7 @@
```text
用 specifier 处理“给后台增加多租户权限管理”这个复杂需求:先调研现状,生成 HTML PRD,
我会逐轮评审。批准后再生成 ack handoff,不要提前派开发。
我会逐轮评审。批准后再生成 long-ship handoff,不要提前派开发。
```
## Agent 会做什么
@@ -29,13 +30,14 @@
2. 形成目标、范围、非目标、方案、交互/架构、风险、依赖和可观测验收。
3. 生成可离线打开的版本化 HTML PRD;保留每个历史版本并记录本轮变更。
4. 根据用户意见反复修改,直到用户明确批准指定版本。
5. 仅在批准后生成 `handoff.yaml`,供 `/ack` 创建和执行任务。
6. 用户需要远程查看时,可调用 `previewer` 将当前 HTML 通过 Tailscale 暴露为 HTTP URL。
5. 仅在批准后生成 `spec/handoff.yaml`,供 `long-ship` 创建和执行任务。
6. 用户需要远程查看,或说“把 HTML 渲染出来”时,可调用独立的 `previewer` skill;它用 Node
渲染并通过 Tailscale 暴露为 HTTP URL,同时返回应该使用的 Tailscale IP。
Specifier 只负责需求规格,不修改业务代码,也不替代 ACK 的开发和独立测试。
Specifier 只负责需求规格,不修改业务代码,也不替代 long-ship 的开发和独立测试。
## 如何判断完成
- 评审阶段:得到一个带版本号的 HTML PRD、变更记录、风险和未决问题。
- 交接阶段:用户明确批准某个版本,且 `handoff.yaml` 绑定了该版本和完整验收信息。
- 开发阶段:改用 `/ack`,由 ACK 负责 Developer、Test、回归和交付闭环。
- 交接阶段:用户明确批准某个版本,且 `spec/handoff.yaml` 绑定了该版本和完整验收信息。
- 开发阶段:改用 `long-ship`,任务和证据写在同一个 ship id 下。
+56 -41
View File
@@ -2,14 +2,15 @@
name: specifier
description: >-
把复杂或跨模块需求整理成可评审、可版本化的 HTML PRD,并在用户明确批准某个版本后
生成交给 ack 的结构化 handoff。用户说做复杂功能、写 PRD、梳理需求、设计方案、需求
评审、反复改需求或交给 ack 开发时使用;小改动、Bug 根因排查和单纯架构讨论不使用。
生成交给 long-ship 的结构化 handoff。用户说做复杂功能、写 PRD、梳理需求、设计方案、
需求评审、反复改需求或交给 long-ship 开发时使用;小改动、Bug 根因排查和单纯架构讨论
不使用。
---
# Specifier:复杂需求规格化
把模糊需求变成经过人确认的需求基线,再交给 `ack` 执行。HTML 是给人评审的正式交付物,
结构化状态和 handoff 是给 Agent/ACK 消费的机器接口。
把模糊需求变成经过人确认的需求基线,再交给 `long-ship` 执行。HTML 是给人评审的正式交付物,
结构化状态和 handoff 是给 ship 记录消费的机器接口。
---
@@ -17,64 +18,75 @@ description: >-
- 用户要做跨模块、跨页面、跨服务或有数据迁移的复杂功能。
- 用户要求写 PRD、产品方案、需求规格、架构/交互方案,并需要多轮确认。
- 用户要求把确认后的需求交给 `/ack` 开发。
- 用户要求把确认后的需求交给 `long-ship` 开发。
## 不适用
- 单文件或范围明确的小改动:直接交给 `/ack`。
- 已有复现证据的 Bug:使用 `/hunt` 找根因,再按需要交给 `/ack`。
- 单文件或范围明确的小改动:使用 `short-ship`。
- 已有复现证据的 Bug:使用 `/hunt` 找根因,再按需要交给 `short-ship`。
- 只问一个技术选型或一次性方案判断:使用 `/think`。
- 用户尚未批准的需求:不得创建 ACK 任务、派 worker、改业务代码或开始交付。
- 用户尚未批准的需求:不得创建开发任务、派 worker、改业务代码或开始交付。
---
## 工作目录和事实源
每个需求使用独立目录 `.pouch/specifier/features/<feature-id>/`。开始前检查目录是否已存在,
不要覆盖既有版本;已有需求要进入对应版本的 `review` 或 `revise` 模式。
每个需求使用独立 ship 目录 `.pouch/ship/<ship-id>/`。规格文件写在 `spec/`。开始前读取
已安装的 `ship` skill;已有需求要进入对应版本的 `review` 或 `revise` 模式。
```text
.pouch/specifier/features/<feature-id>/
├── current.yaml # 当前版本、状态和批准信息
├── brief.md # 需求输入和现状摘要
├── prd-v001.html # 人类评审版本,不覆盖旧版本
├── review.yaml # 每轮意见、修改请求和确认
├── decisions.yaml # 关键方案决策
├── risks.yaml # 风险、依赖和缓解措施
└── handoff.yaml # 仅 approved 后生成,交给 ack
.pouch/ship/<ship-id>/
├── current.yaml # ship 总账;spec.revision / specStatus 在这里
├── spec.md # 短摘要
├── tasks.yaml # 批准后由 long-ship 写入,本 skill 不写
└── spec/
├── brief.md
├── prd-v001.html
├── review.yaml
├── decisions.yaml
├── risks.yaml
└── handoff.yaml # 仅 approved 后生成,交给 long-ship
```
目录名使用稳定、短、小写连字符的 feature ID。若用户未提供,先从需求生成候选并说明;
目录名使用稳定、短、小写连字符的 ship id。若用户未提供,先从需求生成候选并说明;
不要用日期、随机数或当前分支名冒充业务 ID。
`.pouch/specifier/` 只保存需求规格状态,不复制 skill 内容,不修改项目的 `AGENTS.md`、
`CLAUDE.md` 或已有 ACK 状态。HTML 可引用仓库中的文档和代码路径,但不能依赖外部网络资源。
若只有旧路径 `.pouch/specifier/features/<id>/`,按 ship `record.md` 迁到
`.pouch/ship/<id>/spec/` 后再继续。不要两处同时写。
`.pouch/ship/<id>/spec/` 只保存需求规格,不复制 skill 内容,不修改项目的 `AGENTS.md`、
`CLAUDE.md` 或 ACK 缺陷板。HTML 可引用仓库中的文档和代码路径,但不能依赖外部网络资源。
## 模式路由
- **新建**:探查现状,建立 brief,生成 `prd-v001.html`,状态为 `review`。
- **评审 / 修改**:读取当前版本和 `review.yaml`,把用户意见转成修改项,生成新版本,
- **新建**:探查现状,建立 `spec/brief.md`,生成 `spec/prd-v001.html`,把
`spec.specStatus` 设为 `review`。
- **评审 / 修改**:读取当前版本和 `spec/review.yaml`,把用户意见转成修改项,生成新版本,
展示变更摘要和仍未决的问题。
- **批准**:只在用户明确批准指定版本后,把状态改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `handoff.yaml`;用户要求继续开发时把它交给 `/ack`。
- **批准**:只在用户明确批准指定版本后,把 `spec.specStatus` 改为 `approved`,冻结需求基线。
- **交接**:仅针对 `approved` 版本生成 `spec/handoff.yaml`;用户要求继续开发时把它交给 `long-ship`。
- **状态**:只读报告当前版本、待决策、风险和是否可以交接。
- 作为 `long-ship` 派来的 worker:只做 prompt 里的那一个模式,写完 `spec/` 就返回;
不要再调用 `long-ship`,不要改业务代码。
详细评审状态机和确认规则见 [review-loop.md](references/review-loop.md)。HTML 页面结构和
渲染要求见 [html-prd.md](references/html-prd.md)。只有需要对应模式时才读取它们。
## 新建和调研
1. 先判断需求是否真的复杂;不复杂时给出转 `/ack` 的建议,不强行生成 PRD。
1. 先判断需求是否真的复杂;不复杂时给出转 `short-ship` 的建议,不强行生成 PRD。
2. 读取项目覆盖层、相关文档、历史决策和代码;优先确认现有能力、模块边界、数据模型、
API、权限、运行约束和回归用例。不要从 README 或猜测中虚构项目事实。
3. 形成现状与目标差异:背景、用户、场景、目标、非目标、范围、约束、成功标准。
4. 对关键方案给出取舍,记录推荐方案、被否决方案、最脆弱假设、风险、依赖和回滚方向。
5. 描述必要的产品流程、交互状态、领域模型、状态流、API/数据变化和非功能要求。
6. 按可独立验证的垂直切片拆任务,写出依赖、关键路径、`expected` 和 `verification`,
但不要写入 `.pouch/ack/tasks.yaml`。
但不要写入 `.pouch/ack/tasks.yaml` 或本 ship 的 `tasks.yaml`(任务由 long-ship 在批准后创建)。
7. 按模板生成自包含 HTML PRD,并在回复中说明文件路径、版本、待确认问题和下一步。
8. 用户需要在其它设备查看 HTML 时,调用已安装的 `previewer` 能力传入该文件;不要在
`specifier` 内复制 Tailscale Serve 逻辑。previewer 返回 URL 后,把 URL 和对应 PRD 版本一起给出。
8. 用户需要在其它设备查看 HTML,或说“把这个 HTML 渲染出来”时,调用已安装的
`previewer` 能力传入该文件;不要在 `specifier` 内复制 Node/Tailscale Serve 逻辑。
previewer 返回结果后,必须把对应 PRD 版本、Tailscale IPv4、完整 HTTP URL、过期时间
和停止命令一起给出,并明确说明应使用返回的哪个 IP 访问。
如果调研发现需求边界、目标或关键依赖存在两种互相冲突的解释,停止在 `review`,明确列出
冲突,不能自行替用户拍板。
@@ -93,22 +105,24 @@ description: >-
按 [review-loop.md](references/review-loop.md) 记录状态和审计信息。
如果需要远程预览,读取 `previewer` 的调用契约并把 PRD 文件路径交给它;previewer 不改变
需求评审状态,HTML 被展示不等于用户批准。
需求评审状态,HTML 被展示不等于用户批准。回复中要同时给出 Tailscale IP 和 URL,不能只
说“已经渲染”或只返回本机 `127.0.0.1`。
## 批准和 ACK 交接
## 批准和 long-ship 交接
批准前只允许写 specifier 自己的需求目录。批准后仍不自动启动 worker;先生成 `handoff.yaml`,
其中必须绑定:feature ID、PRD 版本和路径、需求范围、非目标、决策、任务依赖、验收信号、
风险、回滚/发布注意事项和回归候选。
批准前只允许写该 ship 的 spec 目录和 `current.yaml` 的 spec 字段。批准后仍不自动启动
worker;先生成 `spec/handoff.yaml`,其中必须绑定:ship id、PRD 版本和路径、需求范围、
非目标、决策、任务依赖、验收信号、风险、回滚/发布注意事项和回归候选。同步
`current.yaml` 的 `spec.specStatus`、`spec.approvedRevision`、`spec.handoff`。
`handoff.yaml` 是 ACK 的输入,不是 ACK 的执行记录。用户明确要求继续开发时,提示使用:
`handoff.yaml` 是 `long-ship` 的输入,不是执行记录。用户明确要求继续开发时,提示使用:
```text
/ack 使用 specifier handoff:.pouch/specifier/features/<feature-id>/handoff.yaml
/long-ship 使用 handoff:.pouch/ship/<ship-id>/spec/handoff.yaml
```
ACK 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。若 ACK 不存在、handoff 缺字段或
需求状态不是 `approved`,停止交接并报告缺口。
long-ship 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。handoff 缺字段或需求状态
不是 `approved` 时,停止交接并报告缺口。
## 安全边界
@@ -120,6 +134,7 @@ ACK 应引用批准的 HTML 版本,不复制另一份可漂移的 PRD。若 AC
## 验证
完成前检查:HTML 可离线打开、版本号与 `current.yaml` 一致、旧版本未被覆盖、每个未决问题
有明确状态、每个任务有可观测验收、handoff 只在 `approved` 后存在且绑定正确的 PRD 版本。
需要交给 ACK 时,先确认 handoff schema 与用户批准版本一致,再停止等待 ACK。
完成前检查:HTML 可离线打开、版本号与 ship `current.yaml` 的 `spec.revision` 一致、旧版本
未被覆盖、每个未决问题有明确状态、每个任务有可观测验收、handoff 只在 `approved` 后存在
且绑定正确的 PRD 版本。需要交给 long-ship 时,先确认 handoff schema 与用户批准版本一致,
再停止等待 long-ship。
+1 -1
View File
@@ -1,6 +1,6 @@
interface:
display_name: "Specifier"
short_description: "Draft and review complex requirements as HTML PRDs"
default_prompt: "Use $specifier to turn this complex feature request into a reviewable HTML PRD."
default_prompt: "Use $specifier to turn this complex feature request into a reviewable HTML PRD in the ship record."
policy:
allow_implicit_invocation: true
+2 -2
View File
@@ -28,7 +28,7 @@ HTML 是给人离线评审的交付物,不是运行中的产品页面。它必
- 颜色和状态不能只依靠颜色表达;为状态提供文字标签。
- 长表格允许横向滚动,代码或 API 示例使用 `pre`,正文保持可复制。
- `h1` 只有一个;标题层级连续;链接和表格有可读的焦点/边框状态。
- 页首显示 feature ID、PRD 版本、状态、生成时间和基于的版本。
- 页首显示 ship id、PRD 版本、状态、生成时间和基于的版本。
- 变更内容用文字摘要或 `ins` / `del` 标记表达,不要只用颜色标记。
## 内容质量
@@ -36,4 +36,4 @@ HTML 是给人离线评审的交付物,不是运行中的产品页面。它必
- 把“体验好”“性能足够”“支持权限”等不可验证表述改成可观察信号或明确待决策项。
- 不把实现方案伪装成用户需求;需求、决策和任务分开写。
- 未知事实写“待确认”,并加入未决问题,不填看似合理的数字、接口或页面。
- 每条验收都能被 ACK 的 Test 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。
- 每条验收都能被 long-ship / Test 按步骤观察;测试工具不可用时仍能区分产品失败和环境失败。
+15 -12
View File
@@ -9,15 +9,17 @@
## 状态
`current.yaml` 至少记录:
ship `current.yaml` 的 spec 字段至少记录:
```yaml
featureId: tenant-permissions
currentRevision: v002
status: review
prd: prd-v002.html
approvedRevision: null
reviews: review.yaml
shipId: tenant-permissions
kind: long
spec:
revision: v002
specStatus: review
prd: spec/prd-v002.html
approvedRevision: null
reviews: spec/review.yaml
```
允许的状态:
@@ -55,11 +57,11 @@ reviews: review.yaml
## 每轮评审
1. 读取 `current.yaml`、当前 HTML 和所有未解决的 review 项;不要只读用户最新一句话。
1. 读取 ship `current.yaml`、当前 HTML 和所有未解决的 review 项;不要只读用户最新一句话。
2. 将新反馈归类:`change`、`decision`、`question`、`rejected`、`parked`。
3. 说明每个反馈对范围、架构、任务和验收的影响。
4. 生成新版本 HTML,保留旧文件。
5. 更新 `current.yaml` 和 `review.yaml`,并在回复中给出:新版本路径、变更摘要、未决问题、
5. 更新 ship `current.yaml` 的 spec 字段和 `spec/review.yaml`,并在回复中给出:新版本路径、变更摘要、未决问题、
是否仍需批准。
如果反馈只影响措辞,也生成新版本;评审记录需要保持可追溯。若用户明确说不采纳,记录理由,
@@ -82,7 +84,8 @@ reviews: review.yaml
- HTML 版本、状态文件和 review 记录一致。
如果用户说“继续”“可以”“看起来不错”,但没有绑定版本,继续保持 `review`,请用户确认
具体版本。批准后把 `approvedRevision` 写入 `current.yaml`,再生成 handoff;不要同时启动 ACK。
具体版本。批准后把 `spec.approvedRevision` 写入 ship `current.yaml`,再生成
`spec/handoff.yaml`;不要同时启动 long-ship 或 ACK。
## 批准后的变更
@@ -94,5 +97,5 @@ reviews: review.yaml
4. 标记受影响的任务、验收、风险和 ACK handoff 字段;
5. 重新等待用户批准。
已经进入 ACK 的任务不会因 HTML 更新而自动改变。需要执行变更时,明确告诉用户应创建新的
ACK 任务或由 ACK 重新规划,不能静默改写执行记录。
已经进入 long-ship 的任务不会因 HTML 更新而自动改变。需要执行变更时,明确告诉用户应
修订本 ship 的 `tasks.yaml` 或重新规划,不能静默改写执行记录。
@@ -1,6 +1,16 @@
featureId: <feature-id>
currentRevision: v001
status: review
prd: prd-v001.html
approvedRevision: null
reviews: review.yaml
# 已并入 ship current.yaml 的 spec 字段。新建需求请用 ship 模板,不要单独写这份文件。
# 迁移旧 .pouch/specifier/features/<id>/current.yaml 时按此对照:
# featureId -> shipId
# currentRevision -> spec.revision
# status -> spec.specStatus
# prd -> spec.prd(路径加上 spec/ 前缀)
# approvedRevision -> spec.approvedRevision
# reviews -> spec.reviews
shipId: <ship-id>
spec:
revision: v001
specStatus: review
prd: spec/prd-v001.html
approvedRevision: null
reviews: spec/review.yaml
handoff: null

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