39 Commits

Author SHA1 Message Date
laily 0918f2c916 add what-is-it 2026-11-02 15:51:41 +08:00
laily a75f130c7e catalog: add ego-lite source 2026-10-31 15:20:11 +08:00
laily a15a046e4e feat(ship): merge short-ship and long-ship into one entry
Promote ship to the user-facing delivery skill. Kind is short or long
from an explicit mode, then self-judgment, then a question, defaulting
to short. Old names remain deprecation stubs.
2026-10-24 14:27:19 +08:00
laily 7d6f3fe8e5 add knowledge-explainer 2026-10-20 16:58:29 +08:00
laily 181cce6bfe feat: rename prototype workspace and document versioning 2026-10-19 10:54:02 +08:00
laily 49cd594233 feat: group prototype journeys and simplify HTML preview 2026-10-19 10:37:56 +08:00
laily adaa8e4edd feat: add test-preflight runner and deployer prepare adapters
agent-runtime executes fixed tester node operations over JSON stdin.
deployer prepare brings up the local test Compose service the same way.
test-preflight now passes the tester binding; tester select skips
unauthorized cases before creating a runner.
2026-10-19 09:28:50 +08:00
laily 9075ff6a5a feat: add product prototype skill and studio 2026-10-19 09:26:02 +08:00
laily 3a18a27dc6 feat(tester): add integration-first journey execution 2026-10-15 18:11:28 +08:00
laily c2eb207bd2 feat(test-preflight): add verify and prepare skill 2026-10-15 18:04:59 +08:00
laily ffa7cbd63e feat(tester): add client journey testing standard 2026-10-15 16:48:54 +08:00
laily baed23d02a test(tester): validate CSV regression summary totals 2026-10-15 11:08:33 +08:00
laily 9ba9b3da0f feat(builder): query CI via project status script
When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom
command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only
polls that script and no longer guesses forge CLIs.
2026-10-15 09:56:51 +08:00
laily 0c9a530390 chore(tester): drop HX_TEST_HOST alias
HOST is the only accepted test-service address from env, local.yaml, or catalog vars.
2026-10-15 09:44:23 +08:00
laily 5fef8247fc feat(tester): require caller artifact locators instead of --release
Mobile runs now take --artifact android|ios=<path-or-url> from the caller
and create a fresh isolated worktree each time. Drop CI release/manifest
lookup.
2026-10-15 01:05:09 +08:00
laily 2fcf16627d fix(tester): expose bundle provisioning failure action 2026-10-14 17:30:11 +08:00
laily 706c553778 feat(tester): provision CI mobile bundles in shared skill 2026-10-14 17:29:45 +08:00
laily db92baf3a7 fix tester multi-root fan-out execution 2026-10-14 15:35:13 +08:00
laily fbf0ca8cd8 fix(deployer): align stale test contracts 2026-10-14 12:21:09 +08:00
laily 8d48d2d335 feat(ship): enforce test manifest handoff 2026-10-14 12:16:55 +08:00
laily d7ebb6ee85 feat: update 2026-10-14 12:14:31 +08:00
laily 19aac17a0a feat(ship): clarify delivery stop semantics 2026-10-14 11:49:50 +08:00
laily 125bfe0c23 chore: sync pouch skill workflows 2026-10-14 10:58:03 +08:00
laily 57879cffdd feat(ship): add stage audit trail and sortable ids 2026-10-14 10:57:16 +08:00
laily 4814bc1026 feat(ship): run every project test surface in regression 2026-10-14 09:57:08 +08:00
laily 7be8b9c3d9 test(tester): cover nested catalog discovery 2026-10-14 09:27:17 +08:00
laily f8e490e9b5 fix(tester): discover catalogs in subprojects 2026-10-14 09:26:39 +08:00
laily 95946d4278 fix: support explicitly authorized Codex deployment without sandbox
Production agents repeatedly failed before SSH because the launcher only accepted workspace-write profiles. Accept the explicit Codex danger-full-access profile through the same validated plan and receipt path, and cover profile, argv and receipt validation with regression tests.
2026-10-13 23:47:34 +08:00
laily af6e139d30 feat: rename bugfix-ship/requirement-flow to short-ship/long-ship with shared ship records
short-ship and long-ship share one ship-id record under .pouch/ship/<id>/.
specifier writes PRDs into spec/; delivery-loop appends evidence pointers.
Old names stay as deprecation stubs so existing installs keep working.
2026-10-13 23:11:58 +08:00
laily 93341b3c69 feat: upadte 2026-10-13 20:17:26 +08:00
laily 0eefc52b44 feat: update deployer 2026-10-13 19:47:51 +08:00
laily 8390706df8 feat: auto-update pouch and catalog sources during normal use
Add taste-skill to catalog.yaml, and refresh pouch plus cloned catalog
or custom Git sources on a 24h interval so users do not need a
separate update command.
2026-10-13 16:41:55 +08:00
laily d3b6732d40 docs: clarify HTML previewer integration 2026-10-13 00:21:22 +08:00
laily ab29598a9a add delivery workflow skills 2026-10-12 20:34:20 +08:00
laily c9faedc39d fix(builder): refresh git index before publish gate 2026-10-12 14:53:58 +08:00
laily f05ac24c57 fix(agent-runtime): finish builder sessions on make exit 2026-10-12 13:32:56 +08:00
laily de194370e6 fix(agent-runtime): resolve imported Orca worktrees 2026-10-12 13:32:35 +08:00
laily 299f6dd9b2 fix(builder): detect platform tracks independently 2026-10-12 13:26:40 +08:00
laily 558c6facc2 feat(agent-runtime): unify agent bindings and sessions 2026-10-12 13:22:47 +08:00
279 changed files with 17592 additions and 625 deletions
+14 -4
View File
@@ -60,13 +60,20 @@ 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/静态文件预览,返回 URL 并复用已有服务 |
| [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) | 把一次变更从说明推进到配置允许的 PR、合并或部署;short 或 long |
| [short-ship](skills/short-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [long-ship](skills/long-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 ship |
| [delivery-loop](skills/delivery-loop/README.md) | ship 与 pr-ship 共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
改名后旧名保留一个纯占位 skill,避免已装软链悬空和旧项目 `.pouch.yaml` 失效。确认没有
项目再按 `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/静态文件预览,返回 URL 并复用已有服务 |
| [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) | 把一次变更从说明推进到配置允许的 PR、合并或部署;short 或 long |
| [short-ship](skills/short-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [long-ship](skills/long-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,批准后交给 ship |
| [delivery-loop](skills/delivery-loop/README.md) | ship 与 pr-ship 共用的验证到部署状态机 |
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
| [bugfix-ship](skills/bugfix-ship/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
| [requirement-flow](skills/requirement-flow/README.md) | 已弃用:`ship` 的旧名兼容占位,不含流程 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
@@ -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 仓库:
+20
View File
@@ -29,3 +29,23 @@ waza:
- engineering
- workflow
- skill-collection
taste-skill:
repo: https://github.com/Leonxlnx/taste-skill.git
ref: main
path: skills
description: Taste Skill collection for anti-slop frontend, redesign, image generation, brand kits, and output enforcement.
tags:
- frontend
- design
- skill-collection
ego-lite:
repo: https://github.com/citrolabs/ego-lite.git
ref: main
path: skills
description: ego lite browser automation skill for opening, operating, testing, and reviewing websites with the user's browser context.
tags:
- browser
- automation
- skill-collection
+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 / ship long 模式,小改动走 ship |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 缺陷板闭环,交付链接 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)。范围清楚的小改动转
`ship`;复杂需求转 `specifier` 或 `ship` long 模式。修 bug 写短问题说明、复现和可观测
验收,不写大 PRD;要交付时链接 `ship`。
4. 用户已确认后,先读取并按 [closed-loop.md](references/closed-loop.md) 执行;使用 Orca 时同时读取 [orca-adapter.md](references/orca-adapter.md)。当前会话担任 Coordinator,不亲自写代码或跑测试;worker profile 按 `agents.yaml`(若存在)否则按任务板配置选择。
这不是 fire-and-forget:Coordinator 不得在 `launch` / `dispatch` 后只回复“已派发”并结束当前会话。每个任务都必须在同一轮持续执行默认 60 秒窗口的 `check --wait`,并按 60–120 秒节奏运行 `worker_probe.py`,直到 Developer 回报 `worker_done` 或 `escalation`;随后写回 `fixed_by_dev`,主动派发 Test,并继续等待 `retest_result`。只有完成 Test 终检、重试/`leftover` 决策、回归决定和 worker 回收后,才可以结束本轮 ACK。
5. 存在 `project.bugIntake` 时先完成「飞书收件」。创建或更换 worker 走「启动 worker」。若 `intents.testEnvironment` 已启用,派 Test 前先走「运行测试环境」。
+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 + ship long 模式)。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。
+75
View File
@@ -0,0 +1,75 @@
---
name: agent-runtime
description: >-
维护项目通用 Agent 运行配置、环境绑定、Orca worktree 与跨 session terminal;
适用于 builder.android、builder.ios、tester.test、ack.dev 等可扩展绑定。
不负责具体构建、测试或 ACK 闭环。
---
# Agent Runtime:统一环境、Agent 与工作区
本 skill 是 builder、tester、ACK 和 iOS tester 共用的配置层。每个 Git 项目在自己的
根目录维护 `.pouch/agent.yaml`;父仓库与 submodule 各自拥有配置,不跨仓库继承。机器
专属覆盖放 `.pouch/agent.local.yaml`,运行记录放 `.pouch/agent/runs/`,两者都不提交。
## 配置模型
- `locations`:执行环境、Orca project、目标机器上的绝对路径;不放模型和命令。
- `profiles`:CLI、模型、reasoning effort、权限和档位;不放路径和 secrets。
- `bindings`:任意安全的 `namespace.name`,绑定一个 adapter、location、profile 和
adapter 目标。`builder.android`、`builder.ios`、`builder.harmony` 不需要修改代码即可并存。
- `adapter` 目前允许 `builder`、`tester`、`ack`。adapter 的具体执行规则仍由对应 skill
负责;本层只提供解析、worktree 和 session 生命周期。
- `reasoningEffort` 是规范字段;不要新增 `effect`、自由 `command`、`argv`、`shell`、
`env` 或 token 字段。
完整字段、合并和安全规则见 [references/config.md](references/config.md)。需要实际解析或
创建工作区时使用 `scripts/agent.py`,不要手工拼接 Orca JSON:
```bash
python3 <skill-dir>/scripts/agent.py validate --root <project-root>
python3 <skill-dir>/scripts/agent.py show --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py worktree resolve --root <project-root> --binding builder.ios
python3 <skill-dir>/scripts/agent.py worktree create --root <project-root> \
--binding builder.ios --name writes-ios --base main
```
## Session
`builder` 绑定可通过统一入口创建持久 terminal;返回的 run 记录包含 binding、profile
hash、环境、worktree、terminal handle 和 source commit。跨 session 只能显式 `status`、
`attach`、`stop` 或 `resume`,不能自动复用不明 terminal。tester 的每轮 terminal 仍然
是临时的,ACK 仍然必须走其带 fingerprint/receipt 的 launcher。
```bash
python3 <skill-dir>/scripts/agent.py run start --root <project-root> --binding builder.android
python3 <skill-dir>/scripts/agent.py run status --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run attach --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run resume --root <project-root> --run <run-id>
python3 <skill-dir>/scripts/agent.py run stop --root <project-root> --run <run-id>
```
`run start` 只为 builder 生成受控的 make 命令;tester/ACK 的执行入口仍由对应 skill
适配器提供。环境、项目或精确 worktree 缺失时 fail closed,并报告需要补的字段。
tester 的 test-preflight 一次性节点入口使用 JSON stdin/stdout:
```bash
printf '%s\n' '{"binding":"tester.android","operation":"platform_probe","platform":"android","node":"local"}' \
| python3 <skill-dir>/scripts/agent.py run exec --root <project-root>
```
入口只接受 `platform_probe`、`network_probe`、`install`、`start` 四种受控操作,命令
由 adapter 固定生成,不读取配置中的 command/argv/shell/env/token。local binding 可执行
固定的只读平台/网络探针和 test-preflight 已授权的 Android 安装/启动;非 local binding
先按 `environment + host + project + path` 解析 Orca worktree,再通过 Orca 的临时 terminal
执行固定的节点 runner,并从协议标记读取一个 JSON 结果,结束后关闭该 terminal。远程 iOS
的安装/启动要求调用方把产物放在 Mac 节点可读路径,并在 matrix 的 `artifact.remote_path`
中声明;协调机上的本地路径不会被错误地当成 Mac 路径。任何 transport、节点工具链或远端
产物缺失都返回结构化 `environment-fail`,绝不在协调节点执行平台命令或伪造 `ready`。
## 验证
- `python3 <skill-dir>/scripts/agent.py validate --root <project-root>` 通过。
- remote binding 必须能按 `environment + host + path + project` 精确找到 Orca worktree。
- 新 binding 只增加配置和对应 adapter 支持,不复制一套 skill 或修改固定枚举。
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Agent Runtime"
short_description: "统一 Agent 环境、worktree、profile 与跨 session 运行配置"
default_prompt: "Use $agent-runtime to validate shared agent config, resolve or create a worktree, or inspect a persistent session."
+73
View File
@@ -0,0 +1,73 @@
# Agent Runtime 配置
## 文件与合并
提交 `.pouch/agent.yaml`。若存在 `.pouch/agent.local.yaml`,按 mapping 深度合并;local
文件只能覆盖同名字段,不能增加未声明的顶层结构。合并后的配置必须再次完整校验。
```yaml
schemaVersion: 1
locations:
local-app:
environment: local
host: local
project: git:git.yumee.top/bloggarden/app
path: /home/ace/code/draft2/blogger/app
remote-ios:
environment: macmini
host: runtime:14b6d8ce-4528-49f4-8d46-56b266a5ad69
project: git:git.yumee.top/bloggarden/app
path: /Users/laily/workrepo/bloggarden-app
profiles:
android-builder:
role: build
cli: codex
tier: standard
model: gpt-5
reasoningEffort: medium
permissionMode: workspace-write
ios-builder:
role: build
cli: codex
tier: strong
model: gpt-5
reasoningEffort: high
permissionMode: workspace-write
bindings:
builder.android:
adapter: builder
location: local-app
profile: android-builder
makefile: makefile.builder
target: android
session:
lifecycle: persistent
builder.ios:
adapter: builder
location: remote-ios
profile: ios-builder
makefile: makefile.builder
target: ios
session:
lifecycle: persistent
```
## 字段原则
- binding、location、profile ID 只允许小写字母、数字和 `-`,binding 用一个或多个点
分隔;ID 是路由名,不是任意 shell 文本。
- `environment: local` 使用当前 Orca runtime;远程环境使用已保存的名称。`host` 必须
是 `local` 或 `runtime:<environment-id>`,由 Orca 查询结果证明,不能凭主机名猜测。
- `project` 必须是 Orca project id 或精确 display name;`path` 必须是目标机器上的绝对
路径。解析 worktree 时四者必须同时匹配,标题、分支和 basename 不能消歧。
- `profile` 的 CLI 只能来自 ACK/tester 共同 allowlist:`codex`、`cursor-agent`、`grok`、
`omp`、`pi`。`model` 为安全模型 ID 或 null;`reasoningEffort` 为 `low|medium|high|xhigh`
或 null;权限只允许 `read-only|workspace-write`。
- builder 的 `target` 只能是 make target 名;`makefile` 只能是项目内相对文件名。脚本、
远端命令、环境变量和凭据由 builder/Orca adapter 提供,不能写入此配置。
- `session.lifecycle` 为 `persistent` 或 `ephemeral`。persistent 只表示允许显式跨
session attach/resume,不表示自动复用旧 terminal。
旧版 `.pouch/tester/local.yaml` 和 `.pouch/ack/agents.yaml` 继续兼容。迁移时应让它们
由 adapter 从 common profile/location 生成或覆盖,不能删除旧 receipt、fingerprint 或
tester 的每轮 terminal 清理语义。
+1188
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import base64
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "agent.py"
SPEC = importlib.util.spec_from_file_location("agent_runtime_runner", SCRIPT)
assert SPEC and SPEC.loader
agent = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(agent)
class RunnerTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
pouch = self.root / ".pouch"
pouch.mkdir()
(pouch / "agent.yaml").write_text(
"""schemaVersion: 1
locations:
local-app:
environment: local
host: local
project: local-project
path: /tmp/writes
remote-mac:
environment: macmini
host: runtime:0123456789abcdef0123
project: remote-project
path: /Users/tester/writes
profiles:
test-agent:
cli: codex
model: null
reasoningEffort: medium
permissionMode: workspace-write
bindings:
tester.android:
adapter: tester
location: local-app
profile: test-agent
tester.ios:
adapter: tester
location: remote-mac
profile: test-agent
""",
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp.cleanup()
def test_local_binding_routes_to_fixed_probe(self):
def command(argv, **_kwargs):
if argv == ["adb", "version"]:
return 0, "Android Debug Bridge version 1"
if argv == ["adb", "devices"]:
return 0, "List of devices attached\nemulator-5554\tdevice\n"
self.assertEqual(argv, ["adb", "-s", "emulator-5554", "shell", "getprop", "sys.boot_completed"])
return 0, "1"
with mock.patch.object(agent, "run_fixed_command", side_effect=command) as runner:
result = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "platform_probe", "platform": "android", "node": "local",
})
self.assertEqual(result["status"], "ready")
self.assertEqual(set(result["checks"]), {"adb", "device", "boot"})
self.assertEqual(runner.call_count, 3)
def test_remote_binding_uses_orca_terminal_transport(self):
marker = "POUCH_RUNNER_RESULT_" + ("0" * 31) + "1"
payload = {"status": "ready", "observed": "Darwin/xcrun/simctl probe completed", "checks": {"macos": {"status": "ready"}}}
def fake_orca(argv, _environment=None):
if argv[:2] == ["terminal", "create"]:
return {"terminal": {"handle": "term-1"}}
if argv[:2] == ["terminal", "read"]:
return {"terminal": {"tail": [marker + base64.b64encode(json.dumps(payload).encode()).decode()]}}
return {}
with mock.patch.object(agent, "worktree_for", return_value={"id": "wt-1", "path": "/Users/tester/writes"}) as resolve, \
mock.patch.object(agent, "orca_json", side_effect=fake_orca), \
mock.patch.object(agent.uuid, "uuid4", return_value=mock.Mock(hex="0" * 31 + "1")):
result = agent.execute_runner_request(self.root, {
"binding": "tester.ios", "operation": "platform_probe", "platform": "ios", "node": "macmini",
})
self.assertEqual(result["status"], "ready")
self.assertEqual(result["checks"]["binding"]["status"], "ready")
resolve.assert_called_once()
def test_remote_transport_submits_startup_command_when_first_read_has_no_marker(self):
marker = "POUCH_RUNNER_RESULT_" + ("0" * 31) + "1"
payload = {"status": "ready", "observed": "Darwin/xcrun/simctl probe completed", "checks": {"macos": {"status": "ready"}}}
reads = [
{"terminal": {"tail": ["Welcome to fish"]}},
{"terminal": {"tail": [marker + base64.b64encode(json.dumps(payload).encode()).decode()]}},
]
def fake_orca(argv, _environment=None):
if argv[:2] == ["terminal", "create"]:
return {"terminal": {"handle": "term-1"}}
if argv[:2] == ["terminal", "read"]:
return reads.pop(0)
return {}
with mock.patch.object(agent, "worktree_for", return_value={"id": "wt-1", "path": "/Users/tester/writes"}), \
mock.patch.object(agent, "orca_json", side_effect=fake_orca) as orca, \
mock.patch.object(agent.uuid, "uuid4", return_value=mock.Mock(hex="0" * 31 + "1")):
result = agent.execute_runner_request(self.root, {
"binding": "tester.ios", "operation": "platform_probe", "platform": "ios", "node": "macmini",
})
self.assertEqual(result["status"], "ready")
self.assertTrue(any(call.args[0][:2] == ["terminal", "send"] for call in orca.call_args_list))
def test_unknown_operation_and_command_failure_are_structured(self):
unknown = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "exec", "platform": "android",
})
self.assertEqual(unknown["status"], "blocked")
with mock.patch.object(agent, "run_fixed_command", return_value=(127, "adb missing")):
failed = agent.execute_runner_request(self.root, {
"binding": "tester.android", "operation": "platform_probe", "platform": "android",
})
self.assertEqual(failed["status"], "environment-fail")
def test_json_stdout_is_single_protocol_object(self):
process = subprocess.run(
[sys.executable, str(SCRIPT), "run", "exec", "--root", str(self.root)],
input=json.dumps({"binding": "tester.android", "operation": "unknown", "platform": "android"}),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(process.returncode, 0)
self.assertEqual(json.loads(process.stdout)["status"], "blocked")
self.assertEqual(process.stderr, "")
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
# bugfix-ship(已弃用)
`bugfix-ship` 已并入 [ship](../ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`ship/`。命中后按 **short 模式** 执行。
## 使用示例
```text
使用 bugfix-ship,修复这个线上回归并部署到测试环境。
```
Agent 命中后会立刻转到 `ship`,按 short 模式执行。新请求建议直接用 `ship`。
## 迁移
```bash
pouch add ship # 新名,当前项目
pouch add ship -g # 新名,全局
```
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `bugfix-ship` 可以分批改成 `ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+19
View File
@@ -0,0 +1,19 @@
---
name: bugfix-ship
description: >-
【已弃用,兼容占位】bugfix-ship 已并入 ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../ship/SKILL.md 并按其执行;本次按 short 模式。小需求、小改动、
修 bug、改文案、加按钮、修复后发 PR 或部署都属于 ship。
---
# bugfix-ship(已弃用)
本 skill 已并入 [ship](../ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
- 用户提到 `bugfix-ship`、`/bugfix-ship` 或 `$bugfix-ship` 时:读取
[../ship/SKILL.md](../ship/SKILL.md),按其内容与授权边界执行,并把本次 `kind`
定为 `short`。不要在本目录里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `bugfix-ship` 也能正常安装,软链不会悬空;方便时改成
`ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
+16 -3
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"
只构建不上传时明确说明即可;上传永远需要你显式授权。
@@ -43,7 +47,7 @@ pouch add builder -g # 全局
并负责上层编排。
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。
@@ -62,11 +66,16 @@ 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` 里,不要写进
@@ -186,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. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
@@ -213,8 +222,12 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` |
| `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect |
| `scripts/wait_ci.sh` | 轮询项目 `CI_STATUS_SCRIPT`,只等该 SHA,不触发 |
| `scripts/init_ci_status.sh` | 初始化时把 tea/glab/gh/curl 模板拷到 `.pouch/builder/ci-status.sh` |
| `templates/ci-status/` | tea / glab / gh / curl 查询脚本模板;契约见 [ci-status.md](references/ci-status.md) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch` → `~/.agents`)、
脏工作树策略等完整规则见 contract.md。
+25 -5
View File
@@ -2,8 +2,9 @@
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 仅用户点名镜像时才走。
---
@@ -20,6 +21,12 @@ submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.e
发布目标和本轮别名放在被忽略的 `.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` 解析项目根。
@@ -28,6 +35,8 @@ submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.e
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。
- 看构建、监听 CI、ACK/ship 要产物:执行「拿到产物」。不要当成第五条轨道。
- 指定 `builder.<name>`、远程构建机或跨 session:先读共享 `agent-runtime`。
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
@@ -38,7 +47,8 @@ submodule,submodule 是独立 builder 项目:它的 `makefile.builder`、`.e
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 初始化:完成 | 部分完成 | 阻塞
@@ -70,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_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`。
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh` / `obtain.sh`。`BUILD_VENUE=ci` 时 `push-docker` 就是 obtain。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道至少配置 `COPY_DEST`(本地目录或 `container:/dir`)或 `COPY_SCRIPT`(项目内脚本优先在 `makefile.builder` 设置;同时配置时命令行/环境值优先)。自定义脚本按 `FILE [FILE ...]` 接收产物路径,自行完成重命名、上传、远端校验和通知;相对路径按项目根解析,`BUILDER_PROJECT_ROOT` 提供项目根。`COPY_AS` 只由内置 `COPY_DEST` 路径处理。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不要把 token 当命令行参数;脚本可随项目放在 `.pouch/builder/` 下。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
## 拿到产物
调用方是 ship、ACK(delivery-loop 的 `artifact_built`),或用户要看 CI 构建。
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
- `BUILD_VENUE=ci`:`wait_ci.sh` 轮询项目的 `CI_STATUS_SCRIPT`,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。缺脚本则停下,回到初始化。
- 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。
- 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。
+55
View File
@@ -0,0 +1,55 @@
# CI 状态查询脚本
`BUILD_VENUE=ci` 时,流水线状态由**项目脚本**查询。builder 只轮询这份脚本,
不在 skill 里猜 gh / glab / tea / curl。
## 初始化
选 `ci` 后确认查询方式,再生成脚本:
```bash
<skill-dir>/scripts/init_ci_status.sh --root <project> --kind tea|glab|gh|curl
```
写出 `.pouch/builder/ci-status.sh`(可执行、进 git)。`makefile.builder` 写:
```makefile
BUILD_VENUE ?= ci
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
```
相对路径只能在 `.pouch/builder/` 下。凭据仍放 `.env.builder`(如 `GITEA_TOKEN`),
不要写进脚本参数或日志。
用户给了自定义命令时,把该命令写进同一份脚本,输出必须符合下方契约。
## 调用
```bash
.pouch/builder/ci-status.sh --sha <40-character-sha>
```
只查询、不触发流水线。`BUILDER_PROJECT_ROOT` 是项目根。
## 输出
退出 0 且 stdout 含 `STATUS=` 键值(可多行或同一行):
| STATUS | 含义 |
| --- | --- |
| `empty` | 该 SHA 还没有流水线 |
| `running` | 已出现、尚未结束 |
| `success` | 该 SHA 的流水线成功 |
| `failed` | 失败、取消或超时 |
| `error` | 查询本身失败(鉴权、CLI 缺失) |
可选:`ID=`、`PIPELINE=`(URL)、`JOBS=`。
| 退出码 | 含义 |
| --- | --- |
| 0 | 已查出状态(含 empty/running/failed) |
| 2 | 用法错误 |
| 3 | 无法查询(缺 CLI、未登录、被拒绝) |
`wait_ci.sh` 按这份输出轮询:`empty` 超过出现窗口记 `no_pipeline`;
`success` 才继续 obtain;`failed` 原样失败。
+16 -3
View File
@@ -9,7 +9,12 @@ 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`,调用方式:
@@ -44,13 +49,16 @@ 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-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` 是稳定入口,具体分发可由
@@ -74,6 +82,8 @@ make -f makefile.builder <target>
| 变量 | 默认 | 说明 |
|------|------|------|
| `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` 取全局最新 |
@@ -180,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`。
@@ -195,6 +207,7 @@ push 目标与 `version.sh` 定位 builder 脚本时按以下顺序,命中即
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
2. `$HOME/.pouch/skills/builder/scripts/`(标准 clone 位)
3. `$HOME/.agents/skills/builder/scripts/`(本机 agent 安装位)
两个位置都不可用时必须失败并提示:设置 `BUILDER_SKILL_DIR`,或把 pouch 仓库
clone 到 `~/.pouch`。
+55
View File
@@ -0,0 +1,55 @@
# 拿到产物(obtain)
Builder 的交付入口是 **按精确 Git commit 拿到产物**,不是「在哪台机器编译」。
docker / deb / android / ios / copy 仍是轨道;`BUILD_VENUE` 只说明构建发生在本机还是 CI。
## 谁来调用
- `ship` 和 ACK 走到 delivery-loop 的 `artifact_built` 时。
- 用户说「看构建」「监听 CI」「拿到镜像」时。
- `make -f makefile.builder obtain` 或 `push-docker`(`BUILD_VENUE=ci`)。
代码必须已经推到远端(`ci` 时流水线由 git ref 触发)。Builder 不代替 releaser 打 tag,也不创建分支。
## 命令
```bash
<builder>/scripts/obtain.sh --sha <full-sha> [--branch <name>] [--root <project>] [--timeout 1200]
```
成功打印:
```text
ARTIFACT=harbor.example.com/ns/name:1.4.2
DIGEST=sha256:…
SHA=<full-sha>
VENUE=ci
TAG=1.4.2
```
镜像 tag 对该 SHA 跑 `version.sh --docker`(可 `--branch` 注入 `CI_COMMIT_BRANCH`),禁止 `git describe`。
## 退出码
| 码 | 含义 | 调用方 |
|---|---|---|
| 0 | 产物已核验 | `artifact_built` 完成 |
| 1 | 该 SHA 无流水线、job 失败、超时、inspect 失败 | 标 `failed`,不得 publish/deploy |
| 2 | 用法或配置错误 | 标 `blocked` |
| 3 | 项目 CI 状态脚本无法查询 | 停;回到初始化补 `CI_STATUS_SCRIPT`,或用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 |
## `ci` 规则
- 只认该 SHA 的 pipeline,不认默认分支「最新一次绿」。
- 不触发流水线。
- 查询命令是项目 `.pouch/builder/ci-status.sh`(`CI_STATUS_SCRIPT`),初始化时按 tea / glab / gh / curl 写好。凭据仍放 `.env.builder`。
- job 变绿不够,docker 轨道必须 inspect 到 digest。
- 合进 `main` 若项目不构建,`no_pipeline` 是正确失败。预览走项目会构建的 ref(如 `dev/*`),正式走 tag。
## Agent
1. 固化 source revision 为完整 SHA。
2. 跑 `obtain.sh --sha <sha>`。
3. 退出 0:记录 ARTIFACT/DIGEST/SHA。`venue=ci` 且 job 已 push 时,`published` 视为已满足,不要本机再 `publish_docker.sh`。
4. 退出 1/2:停止 delivery-loop,报告 pipeline URL 或 `no_pipeline`。
5. 退出 3:先说明 CLI 身份不对;只有已有同 host 登录 tab 时才用来读 pipeline。
+119 -12
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):
@@ -532,8 +551,8 @@ def configured_value(
return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "")
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve COPY_SCRIPT without allowing arbitrary project-relative paths."""
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
@@ -542,10 +561,17 @@ def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str |
allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root)
except ValueError:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
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,
@@ -571,12 +597,77 @@ def check_copy_script(
)
def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
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:
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
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 docker_project:
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,
project: Path | None = None,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool = False,
) -> None:
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 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:
@@ -677,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))
@@ -756,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
@@ -781,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:
@@ -842,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,
@@ -853,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()
+2
View File
@@ -75,6 +75,8 @@ if [[ -z "$dest" && -z "${COPY_SCRIPT:-}" || $# -eq 0 ]]; then
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
+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"
+2
View File
@@ -11,6 +11,8 @@
# DOCKER_REPOSITORY=
# IMAGE_TAG=
# PLATFORMS= # optional override; project ARCHES lives in makefile.builder
# GITEA_API_URL= # optional for the curl CI status script
# GITEA_TOKEN= # optional token for private Gitea repositories
# COPY_DEST= # fallback: local dir, or docker dest container:/absolute/dir
# COPY_SCRIPT= # external/temporary executable override; project-local paths belong in makefile.builder
# COPY_AS= # optional extra basenames for the first file
+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
+27
View File
@@ -0,0 +1,27 @@
# delivery-loop
供其他交付类 skill 复用的状态机,统一处理验证、PR、合并、回归、构建和部署的顺序与安全门。
## 什么时候使用
通常由 `$ship` 或 `$pr-ship` 派发的 worker 读取,不需要单独调用。
入口主会话不要把本 skill 读进自己的上下文。
```text
按 delivery-loop 把当前 ship 推进到 review_ready。
```
## 使用前准备
- 入口已绑定 ship id 时,同时安装 `ship`,证据指针写进该记录。
- 项目已有有效的 ACK delivery profile 或等价交付配置。
- 已明确任务的目标分支、测试范围和允许的停止点。
- 需要外部写入时,当前请求已经授权对应动作。
## Agent 会做什么
按配置推进任务,逐阶段记录验证证据;遇到缺配置、失败或高风险变更时停止,不自动猜测。
## 如何判断完成
最终报告会明确停在 PR、合并、回归、产物或部署中的哪一状态,并给出可复核证据。
+36
View File
@@ -0,0 +1,36 @@
---
name: delivery-loop
description: >-
为修复、PR 和需求任务提供可配置的验证、发 PR、合并、回归、构建、发布与部署状态机。
仅作为 ship、pr-ship 的共享执行规则使用。
---
# Delivery Loop:共享交付状态机
这是 ship 与 pr-ship 共用的内部规则,不直接替用户选择生产发布,也不替项目保存环境事实。
## 使用方式
由 `ship` / `pr-ship` **派出来的 worker** 读取本 skill 和
`references/state-machine.md`。入口主会话不要读取或执行本 skill。
入口决定任务类型、停止点和派哪一步;本 skill 负责该步的阶段要求、证据和安全门。
## 不可违反的规则
- 复用现有 `hunt`、`tester`、`check`、`builder`、`deployer`、`releaser` 和
`orchestration` 能力;不要在本 skill 里复制它们的实现。`artifact_built` 只调用
builder `obtain.sh`,不要在本 skill 里 poll GitLab/GitHub。
- 先读取项目的 delivery profile、分支策略和当前工作树,再决定可执行的停止点。
- `stopAt` 未配置或与用户目标冲突时停止并报告缺口,不猜测是否应合并或部署。
- 提交、推送、合并、打 tag、发布产物和生产部署都必须同时满足项目配置与当前请求的授权。
- 不绕过审核、CI、测试失败、脏工作树、冲突、未确认的迁移或不可回滚的外部变更。
- 每个阶段都保存可复核证据;没有证据不得把阶段标为完成。入口已绑定 `ship-id` 时,把
指针写入 `.pouch/ship/<id>/current.yaml` 的 `evidence`(规则见 ship `record.md`);
缺指针则该阶段未完成。
- 失败时保留现场和回滚信息,不用重试掩盖失败,不伪造发布或部署成功。
## 结果
完成时返回状态机最终状态、停止点、变更 commit、PR/tag、测试与回归证据、产物摘要、部署
目标和回滚指针。若停在中间状态,明确下一步和阻塞原因。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "Delivery Loop"
short_description: "Shared delivery state machine for workflow skills"
default_prompt: "Use $delivery-loop to inspect a delivery workflow state and its safe stopping point."
policy:
allow_implicit_invocation: false
@@ -0,0 +1,62 @@
# Delivery Loop 状态机
## 状态
```text
change_ready -> verified -> pr_opened -> merged -> regression_passed -> artifact_built -> published -> deployed
```
入口可以从已有状态开始,但不能跳过本次变更所需的验证。内部里程碑可以更细,但项目
Ship 配置使用以下 canonical `stopAt`:
- `develop_verified`:开发阶段验证完成,包含单元测试、新增功能集成测试和静态检查;不等于完整回归。
- `review_ready`:PR/MR 已创建,等待审核。
- `release_ready`:PR 已合并且完整回归通过,映射内部 `regression_passed`;不表示已发布或部署。
- `released`:产物已构建并发布,映射内部 `published`。
- `deployed`:目标环境实际部署完成,映射内部 `deployed`。
旧记录可继续使用 `verified`(`develop_verified` 的别名)和 `validation_ready`
(`release_ready` 的别名);新记录应使用 canonical 名称。
入口 skill 的自然语言停止点映射如下:
| 用户目标 | Ship stopAt | 必要内部里程碑 |
| --- | --- | --- |
| 只做开发验证 | `develop_verified` | `verified` |
| 发 PR | `review_ready` | `pr_opened`,必要时预览构建/测试 |
| 合并后完整回归 | `release_ready` | `merged`、`regression_passed` |
| 构建并发布产物 | `released` | `merged`、`regression_passed`、`artifact_built`、`published` |
| 实际部署 | `deployed` | `merged`、`regression_passed`、`artifact_built`、`published`、`deployed` |
## 每个阶段的要求
- `change_ready`:记录基线 commit、工作树、目标分支、任务范围和风险。
- `verified`:开发阶段根因或需求验收明确;任务相关单元测试、新增功能集成测试和静态检查通过。
- `pr_opened`:提交说明、测试证据、风险和回滚信息已写入 PR;不能把草稿 PR 当 `review_ready`。
- `merged`:目标分支的合并 commit 已确认,不能只确认源分支 commit。
- `regression_passed`:以合并 commit 执行项目规定的完整回归;失败则回到修复或停止。
回归 worker 必须先发现项目及其子项目的 tester catalog(使用
`catalog.py roots --root <project-root>`),逐个测试根执行并记录独立快照;父仓库
没有 catalog 不能遮蔽有 catalog 的 `app` 子项目。任一发现的测试根未执行或无证据,
不得把阶段标为 `regression_passed`。还必须执行项目 profile 或
`.pouch/ack/project.md` 声明的项目原生测试套件,并为每个套件记录命令和结果;任一
测试面缺少结果时都不能通过回归。
- `artifact_built`:对**合并后的精确 commit SHA** 调用 builder `obtain.sh`。`BUILD_VENUE=ci` 时监听该 SHA 的流水线并 inspect 产物,不在本机重新编译。无流水线、job 失败或 digest 对不上则本阶段失败,不得进入 `published`。项目若声明默认分支不构建,合进 `main` 会 `no_pipeline`:预览推 `dev/*`,正式打 tag。
- `published`:镜像/APK 等外部分发位置可复核,记录 digest 或文件校验和。`venue=ci` 且 CI 已经 push 到 registry 时,obtain 成功即同时满足本阶段,不要再本机 `publish_docker.sh`。
- `deployed`:目标服务运行在预期版本,健康检查和关键路径验证通过。
## 配置和授权
读取项目自己的 delivery 配置,不把命令、主机、秘密复制到通用 skill。优先使用 ACK
的 `delivery.yaml`、builder/deployer/tester 契约和项目版本策略;缺失时停止在最近的安全状态。
生产部署必须同时满足:项目 profile 允许、目标明确、构建来源已固定、回归证据完整、当前
请求覆盖生产部署。没有这些条件时只能报告,而不能用“用户之前说过发布”替代当前授权。
## 证据账本
至少记录:基线和最终 commit、工作树状态、测试命令及结果、黑盒/真机证据、PR/MR URL、
合并 commit、tag、产物路径与摘要、部署节点/服务、健康检查、失败项和回滚指针。
已绑定 ship 记录时,上述证据以指针写入该 ship 的 `current.yaml`,不要另建一份账本。
阶段与字段对应见 ship `references/record.md`。
+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 通
+24 -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,26 @@ 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` 验证。
test-preflight 的一次性部署入口为 `scripts/deploy/prepare.py`。它从 stdin 接收
`{"operation":"prepare","environment":"test","project_root":"/abs/project","host":"...","public_host":"..."}`,
只解析项目 `.pouch/deployer/test`,复用 `lib.service_info` 的 `_config.yaml` 继承和 node
规则,确认 node 是本机后才按 `docker compose config`、`up -d --force-recreate`、`ps`
顺序准备单个 test 服务。输出始终是 `{status, observed, deployment_id?, checks?}` JSON;
prod、缺配置、非本机 node 为 `blocked`,Compose/命令失败为 `environment-fail`。诊断只写
stderr,不输出 `.env` 或命令输出中的凭据。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
+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 路径。
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""One-shot JSON adapter for preparing the project's test Compose service.
The adapter owns no deployment policy of its own: service discovery and
``_config.yaml`` inheritance stay in ``lib.service_info``. The command runner
is deliberately injectable so unit tests never need Docker, SSH, or a network.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
import lib # noqa: E402
STATUSES = {"ready", "blocked", "environment-fail"}
LOCAL_NODE_NAMES = {"local", "localhost", "127.0.0.1", "::1"}
CommandRunner = Callable[[list[str], Path], int]
NodeChecker = Callable[[str], bool]
def response(status: str, observed: str, *, deployment_id: str = "", checks: dict[str, Any] | None = None, remediation: str = "") -> dict[str, Any]:
if status not in STATUSES:
raise ValueError(f"invalid status: {status}")
value: dict[str, Any] = {"status": status, "observed": observed}
if deployment_id:
value["deployment_id"] = deployment_id
if checks:
value["checks"] = checks
if remediation:
value["remediation"] = remediation
return value
def _check(status: str, observed: str, expected: str, remediation: str = "") -> dict[str, str]:
value = {"status": status, "observed": observed, "expected": expected}
if remediation:
value["remediation"] = remediation
return value
def run_compose(command: list[str], cwd: Path) -> int:
"""Run adapter-owned Compose argv without a shell or protocol pollution."""
try:
process = subprocess.run(
command,
cwd=str(cwd),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError:
return 127
return process.returncode
def execute_compose(executor: CommandRunner, command: list[str], cwd: Path) -> int:
"""Normalize mock/OS command failures into an environment-fail exit code."""
try:
return int(executor(command, cwd))
except (OSError, TypeError, ValueError):
return 127
def node_is_local(node: str) -> bool:
"""Accept only a local node; ``ssh -G`` is config inspection, never SSH."""
if node in LOCAL_NODE_NAMES:
return True
local_names = {socket.gethostname(), socket.getfqdn(), os.environ.get("HOSTNAME", "")}
if node in {name for name in local_names if name}:
return True
try:
process = subprocess.run(
["ssh", "-G", node],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError:
return False
if process.returncode != 0:
return False
configured_host = ""
for line in (process.stdout or "").splitlines():
key, _, value = line.partition(" ")
if key == "hostname":
configured_host = value.strip()
break
return configured_host in LOCAL_NODE_NAMES or configured_host in {name for name in local_names if name}
def _project_root(value: Any) -> Path | None:
if not isinstance(value, str) or not value.strip():
return None
root = Path(value)
if not root.is_absolute() or ".." in root.parts or os.path.normpath(value) != value:
return None
return root
def prepare_test(request: Any, *, executor: CommandRunner = run_compose, local_node: NodeChecker = node_is_local) -> dict[str, Any]:
if not isinstance(request, dict):
return response("blocked", "prepare request must be a JSON object", remediation="Send one JSON object on stdin")
if request.get("operation") != "prepare":
return response("blocked", "only operation=prepare is supported", remediation="Use the test deployer prepare contract")
environment = request.get("environment")
if environment != "test":
return response("blocked", "only the test environment is permitted", remediation="Use environment: test; production is never accepted by this adapter")
root = _project_root(request.get("project_root"))
if root is None or not root.is_dir():
return response("blocked", "project_root must be an existing absolute project directory", remediation="Provide the project root containing .pouch/deployer/test")
host = request.get("host")
if not isinstance(host, str) or not host.strip():
return response("blocked", "host is required", remediation="Provide the test service host used by preflight")
public_host = request.get("public_host")
if public_host is not None and (not isinstance(public_host, str) or not public_host.strip()):
return response("blocked", "public_host must be non-empty text when provided")
deploy_root = root / ".pouch" / "deployer"
test_dir = deploy_root / "test"
if not deploy_root.is_dir() or not test_dir.is_dir():
return response("blocked", "test deployer directory is missing", remediation="Create .pouch/deployer/test with its Compose files and _config.yaml")
lib.PROJECT_ROOT = deploy_root
try:
info = lib.service_info("test", strict=False)
except (OSError, ValueError, SystemExit):
info = None
if not info:
return response("blocked", "test Compose service is not discoverable", remediation="Ensure test/compose.yaml and inherited _config.yaml resolve a node")
node = str(info.get("node") or "")
if not node or not local_node(node):
return response("blocked", "test Compose node is not confirmed local", remediation=f"Resolve node {node or '<missing>'!r} to this machine; this adapter never falls back to SSH")
compose_file = test_dir / "compose.yaml"
if not compose_file.is_file():
return response("blocked", "test compose.yaml is missing", remediation="Provide .pouch/deployer/test/compose.yaml")
deployment_id = f"test:{info['name']}"
base = ["docker", "compose", "-f", "compose.yaml"]
checks: dict[str, Any] = {}
code = execute_compose(executor, [*base, "config", "--quiet"], test_dir)
if code != 0:
checks["compose_config"] = _check("environment-fail", f"docker compose config exited {code}", "valid test Compose configuration", "Fix the test Compose file or its local environment")
return response("environment-fail", "test Compose configuration failed", deployment_id=deployment_id, checks=checks)
checks["compose_config"] = _check("ready", "validated", "valid test Compose configuration")
code = execute_compose(executor, [*base, "up", "-d", "--force-recreate"], test_dir)
if code != 0:
checks["compose_up"] = _check("environment-fail", f"docker compose up exited {code}", "test services started", "Fix the local Docker/image/network failure and retry")
return response("environment-fail", "test Compose prepare failed", deployment_id=deployment_id, checks=checks)
checks["compose_up"] = _check("ready", "completed", "test services started")
code = execute_compose(executor, [*base, "ps"], test_dir)
if code != 0:
checks["compose_ps"] = _check("environment-fail", f"docker compose ps exited {code}", "test services report status", "Inspect local test service health")
return response("environment-fail", "test Compose status check failed", deployment_id=deployment_id, checks=checks)
checks["compose_ps"] = _check("ready", "completed", "test services report status")
return response("ready", "test Compose service prepared", deployment_id=deployment_id, checks=checks)
def main() -> int:
try:
request = json.load(sys.stdin)
except (json.JSONDecodeError, OSError):
result = response("blocked", "prepare stdin is not one valid JSON object", remediation="Send exactly one JSON object on stdin")
else:
try:
result = prepare_test(request)
except Exception:
print("deployer prepare: unexpected adapter failure", file=sys.stderr)
result = response("environment-fail", "prepare adapter failed before producing evidence", remediation="Inspect the test deployer configuration")
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "prepare.py"
SPEC = importlib.util.spec_from_file_location("deployer_prepare", SCRIPT)
assert SPEC and SPEC.loader
prepare = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(prepare)
class PrepareTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
deployer = self.root / ".pouch" / "deployer" / "test"
deployer.mkdir(parents=True)
(deployer / "compose.yaml").write_text("services:\n app:\n image: example:test\n", encoding="utf-8")
(deployer / "_config.yaml").write_text("node: devbox\nname: writes-test\n", encoding="utf-8")
self.request = {"operation": "prepare", "environment": "test", "project_root": str(self.root), "host": "http://test.local"}
def tearDown(self) -> None:
self.temp.cleanup()
def test_prod_is_rejected_without_inspecting_or_executing_compose(self):
calls = []
result = prepare.prepare_test({**self.request, "environment": "prod"}, executor=lambda command, cwd: calls.append(command) or 0)
self.assertEqual(result["status"], "blocked")
self.assertEqual(calls, [])
def test_missing_config_is_blocked(self):
(self.root / ".pouch" / "deployer" / "test" / "_config.yaml").unlink()
result = prepare.prepare_test(self.request, local_node=lambda node: True)
self.assertEqual(result["status"], "blocked")
self.assertIn("discoverable", result["observed"])
def test_mock_compose_prepare_is_ready(self):
calls = []
def executor(command, cwd):
calls.append((command, cwd))
return 0
result = prepare.prepare_test(self.request, executor=executor, local_node=lambda node: node == "devbox")
self.assertEqual(result["status"], "ready")
self.assertEqual(calls[0][0][-2:], ["config", "--quiet"])
self.assertEqual(calls[1][0][-3:], ["up", "-d", "--force-recreate"])
self.assertEqual(calls[2][0][-1], "ps")
self.assertEqual(calls[0][1], self.root / ".pouch" / "deployer" / "test")
def test_compose_failures_are_environment_fail(self):
for failed_step in range(3):
calls = []
def executor(command, cwd):
del cwd
calls.append(command)
return 1 if len(calls) == failed_step + 1 else 0
result = prepare.prepare_test(self.request, executor=executor, local_node=lambda node: True)
self.assertEqual(result["status"], "environment-fail")
self.assertNotEqual(result["status"], "ready")
def test_json_stdout_has_no_diagnostics(self):
process = subprocess.run(
[sys.executable, str(SCRIPT)],
input=json.dumps({**self.request, "environment": "prod"}),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual(process.returncode, 0)
self.assertEqual(json.loads(process.stdout)["status"], "blocked")
self.assertEqual(process.stderr, "")
if __name__ == "__main__":
unittest.main()
+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。
## 目标判定
先明确以下之一:
+55
View File
@@ -0,0 +1,55 @@
# knowledge-explainer
把复杂知识、原理和资料讲给没有背景的读者听,先建立直觉,再解释机制、例子和边界。
## 什么时候使用
- 想科普一个概念、技术、现象或制度。
- 想弄清“是什么、为什么、怎么运作、有什么区别”。
- 想把一篇文章、论文或说明材料讲得更容易理解。
- 想让 Agent 用类比、例子或步骤帮助自己建立理解。
## 使用示例
```text
科普一下贝叶斯定理,先用直觉解释,再用一个生活中的例子说明。
```
```text
我没有计算机背景,解释一下数据库索引为什么能加快查询,并说明它的代价。
```
```text
请把这篇文章讲给没有相关背景的人听,先给结论,再解释核心机制,最后列出容易误解的地方。
```
```text
解释量子纠缠。不要只讲比喻,要说明比喻和真实物理含义分别是什么。
```
## Agent 会做什么
1. 判断你真正想了解的是定义、机制、因果、比较、应用还是风险。
2. 先给短而直接的结论,再逐层补充直觉、机制和例子。
3. 解释必要术语,区分事实、推断、类比和不确定性。
4. 根据主题补充适用条件、限制、常见误解和风险。
5. 处理你提供的资料时,保留原文的事实、条件、引用和待核实项,不凭空补充结论。
## 使用前准备
通常不需要准备。若有要求,可以一并说明:
- 读者是谁,例如小学生、产品经理、工程师或普通成年人。
- 希望快速了解,还是需要深入学习。
- 是否需要类比、公式、例子、参考资料或练习题。
## 如何判断完成
读完后,读者应该能够用自己的话回答:
- 这是什么。
- 它为什么会这样。
- 它大致如何运作。
- 这个解释在哪些情况下不适用。
本 skill 默认只输出解释,不修改文件。只有用户明确要求时,才把结果写入或改写为笔记、文章或其他文件。
+81
View File
@@ -0,0 +1,81 @@
---
name: knowledge-explainer
description: >-
把复杂概念、原理、术语和资料讲给没有背景的读者听,先建立直觉,再解释机制、例子和边界。适用于“科普一下”“这是什么”“为什么会这样”“怎么运作”“讲得通俗一点”“帮我理解这篇资料”等请求;不负责单纯校对、翻译或只做格式整理。
---
# 知识科普
把知识讲清楚,而不是把术语换成更简单的术语。默认面向有基本阅读能力、但没有该领域背景的成年人,使用自然、准确、不过度幼稚的中文。
## 何时使用
- 用户要求科普、解释概念、理解原理或弄清因果关系。
- 用户问“是什么、为什么、怎么工作、有什么用、有什么区别”。
- 用户提供文章、笔记、论文或说明材料,要求讲得更容易理解。
- 用户要求用比喻、例子、类比或分步骤解释一个主题。
## 不适用
- 只要求校对、润色、翻译或压缩原文,且不要求解释知识。
- 主要目标是维护一份长期知识笔记、统一 Markdown 结构或批量整理文档。
- 用户明确要求制作图解 HTML 或单页视觉材料。
## 核心工作方式
1. **先判断问题**:识别用户真正想知道的是定义、机制、因果、比较、应用还是风险。用户没有指定读者时,按普通成年人、零基础处理;只有读者差异会明显影响答案时才追问。
2. **先给核心结论**:开头用一两句话回答“它是什么”或“为什么会这样”,不要先铺背景。
3. **建立直觉模型**:用一个简短的整体画面说明对象、输入、过程和结果。比喻只能帮助理解,不能代替准确解释。
4. **再讲关键机制**:按因果顺序拆成少量步骤。每个术语首次出现时给出白话定义,后面保持叫法一致。
5. **补一个具体例子**:优先使用能体现机制的例子,不堆多个相似例子。必要时同时给出一个反例,说明边界。
6. **说明适用范围**:明确哪些情况下成立、哪些情况下不成立,以及常见误解。
7. **控制信息密度**:先给短版答案;只有用户要求深入,或主题本身需要,才补历史、细节、公式和争议。
## 默认输出结构
根据主题删减,不强行套用全部标题:
1. 一句话结论
2. 先用直觉理解
3. 它是怎么运作的
4. 一个具体例子
5. 容易混淆或误解的地方
6. 适用条件、限制和风险
7. 一句话回顾
如果用户只想快速了解,优先输出“结论、直觉、例子、限制”四部分。用户要求深入时,再增加术语、步骤、证据、历史或不同观点。
## 处理用户提供的资料
- 先区分资料中的事实、定义、推断、建议和待核实内容。
- 用自己的话解释,但不改变原文的事实、条件、数字、限制、引用和参考链接。
- 发现资料内部矛盾或可能过时时,明确指出,不要静默修正,也不要把推断写成事实。
- 资料很长时,先提炼主线,再解释细节,不逐段复述。
- 用户未要求改文件时,只在对话中回答,不直接修改原文。
## 准确性与安全边界
- 不为了通俗而编造事实、因果关系或例子。没有把握时标明不确定性。
- 对医疗、法律、金融、安全、用药、公共政策等高风险主题,区分一般信息和具体建议;涉及可能变化的规则、数据或产品信息时,先核验可靠来源,并给出来源或核验提示。
- 对存在争议的主题,分别说明共识、争议点和证据不足之处,不用“科学已经证明”这类笼统说法。
- 不把“可能、通常、在一定条件下”擅自改成“必然、一定、完全”。
- 不用一个比喻掩盖多个不同机制。比喻之后要用准确表述收束。
## 语言要求
- 先说人话,再保留必要术语。
- 每段只承担一个主要任务,长句拆短。
- 多用动词和因果连接,少用空泛的“本质上、可以理解为、值得注意的是”。
- 不用论文式铺垫,不用居高临下的口吻,也不刻意把成年人当小孩。
- 用户使用中文时默认用简体中文;用户指定语言、读者或语气时服从指定要求。
## 完成检查
返回前检查:
- 开头是否直接回答了用户的问题。
- 关键术语是否解释过,因果链是否完整。
- 例子是否真的帮助理解,而不是增加信息量。
- 事实、推断、类比和不确定性是否被区分。
- 是否保留了适用条件、限制和风险。
- 是否有可以删掉而不影响理解的背景和重复。
+25
View File
@@ -0,0 +1,25 @@
# long-ship(已弃用)
`long-ship` 已并入 [ship](../ship/README.md)。本目录只是兼容占位,
让旧项目里的 `.pouch.yaml`、已经装好的旧软链和旧说法不至于失效;真正的说明和流程都在
`ship/`。命中后按 **long 模式** 执行。
## 使用示例
```text
使用 long-ship,把动态编辑需求做成 PRD,批准后拆任务开发。
```
Agent 命中后会立刻转到 `ship`,按 long 模式执行。新请求建议直接用 `ship`,需要时可写
`long 模式`。
## 迁移
```bash
pouch add ship # 新名,当前项目
pouch add ship -g # 新名,全局
```
- 旧软链保持有效,不需要先删除。
- 各项目 `.pouch.yaml` 里的 `long-ship` 可以分批改成 `ship`。
- 确认没有项目再按旧名启用后,删除本目录即可;它是纯占位,删除不丢内容。
+19
View File
@@ -0,0 +1,19 @@
---
name: long-ship
description: >-
【已弃用,兼容占位】long-ship 已并入 ship。命中本 skill 时不要在此执行任何
流程,直接读取 ../ship/SKILL.md 并按其执行;本次按 long 模式。复杂需求、跨模块、
PRD 批准后开发、拆任务交付都属于 ship。
---
# long-ship(已弃用)
本 skill 已并入 [ship](../ship/SKILL.md)。这里只是兼容占位,**没有流程内容**。
- 用户提到 `long-ship`、`/long-ship`、`$long-ship` 或 long 模式时:读取
[../ship/SKILL.md](../ship/SKILL.md),按其内容与授权边界执行,并把本次 `kind`
定为 `long`。不要在本目录里寻找步骤、references 或 templates。
- 本目录不保留 `references/` 和 `templates/`,正文只有一份,在 `ship/`。
- 旧项目 `.pouch.yaml` 里仍写 `long-ship` 也能正常安装,软链不会悬空;方便时改成
`ship`。
- 确认没有项目再按旧名启用后,可以直接删除本目录。
+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:使用 `ship`。
- 复杂需求尚未形成批准 PRD:使用 `specifier` 和 `ship` long 模式。
- 只做 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
+24 -26
View File
@@ -1,36 +1,34 @@
# previewer
用 Node 启动默认存活 1 小时的轻量静态服务,再通过 Tailscale Serve 把本机的 HTML、PRD、报告
或其它静态文件暴露给 tailnet,并返回可访问的 HTTP URL。
用 Node 直接预览已有 HTML 和静态文件,返回可打开的 HTTP URL。只需要 Node.js,无需安装
npm 依赖,也不配置隧道或代理。
## 什么时候使用
## 用法
- 想在手机、平板或另一台电脑查看本机生成的 HTML。
- 其它 skill 需要把某个文件交给通用预览服务。
只面向 Tailscale tailnet,不提供公网访问,也不启动动态 Web 服务。
## 使用前准备
- 本机已安装 Node.js、已登录 Tailscale,且访问者也在允许访问的 tailnet 中。
- 提供要展示的文件路径;文件所在目录会被作为静态目录服务。
## 使用示例
```text
用 previewer 展示这个文件:/workspace/docs/prd-v002.html,挂载到 /preview/billing。
```bash
node ~/.pouch/skills/previewer/scripts/serve_file.js /workspace/preview/index.html
```
## Agent 会做什么
默认只允许同机访问,自动选择空闲端口,一小时后关闭。需要局域网访问时:
1. 检查 Tailscale 是否在线,以及 Serve 状态是否可读取。
2. 用 Node 在 localhost 启动文件所在目录的静态服务。
3. 将指定 Tailscale 路径代理到本地端口。
4. 返回包含 Tailscale IPv4 的 HTTP URL、Node PID、过期时间和停止命令。
```bash
node ~/.pouch/skills/previewer/scripts/serve_file.js /workspace/preview/index.html --host 0.0.0.0 --port 8088
```
默认 1 小时后自动关闭 Node 服务和对应的 Tailscale 路由;需要其它时长时可传 `--ttl` 秒数。
输出包含 URL、监听地址、端口、PID、过期时间和停止命令。绑定所有接口时还会列出本机网卡
的候选 URL,实际可达性取决于访问者所在网络。
## 如何判断完成
## 复用方式
- 得到一个 HTTP URL,并能从 tailnet 内的另一台设备打开目标文件。
- 失败时明确说明是 Tailscale 未安装、未登录、路由冲突还是 Serve 命令失败。
其它 skill 生成文件后,直接执行上面的同一份脚本,读取 JSON,不需要再次委派 Agent。
相同文件和参数的连续或并发调用会复用仍存活的服务,端口与过期时间保持不变。
HTML、CSS 或 JavaScript 更新后刷新页面即可;React 等项目需先由调用方构建成静态文件。
文件父目录作为服务目录,相对资源可以正常加载;不提供目录列表,也不允许访问目录外的文件。
本工具提供文件,页面由浏览器渲染;不执行模板生成或截图。
## 验证
```bash
node --test ~/.pouch/skills/previewer/tests/serve_file.test.js
```
+28 -43
View File
@@ -1,63 +1,48 @@
---
name: previewer
description: >-
提供通用的 Tailscale tailnet 内静态文件预览能力。用户要通过 HTTP 展示 HTML、PRD、报告、
截图或其它静态文件,要求检查 Tailscale Serve、挂载文件所在目录并返回可访问 URL 时使用;
不用于公网发布、动态应用部署或业务服务反向代理。
用 Node 将已有 HTML、PRD、报告、截图或其它静态文件提供为 HTTP 预览。
用户说“把这个 HTML 渲染出来”“预览这个文件”“给我预览 URL”时使用;
不生成页面内容,不执行构建,不配置代理、隧道或动态业务服务。
---
# Previewer:Tailnet 静态文件预览
# Previewer:HTML/静态文件预览
调用方只提供一个要展示的文件。Previewer 检查 Tailscale 是否在线和 Serve 状态,启动一个
Node 本地静态服务,再让 `tailscale serve` 代理这个 localhost 端口,最后返回 tailnet 内
HTTP URL。
## 何时使用
- 展示本机生成的 HTML、PRD、报告、截图或其它静态文件。
- 其它 skill 需要一个通用的“给我文件,返回可访问链接”能力。
- 用户明确要求通过 Tailscale 访问预览内容。
## 不适用
- 公网访问:不使用 Funnel,也不替用户开放公网入口。
- 动态服务、API、WebSocket 或需要构建/运行的应用:交给部署或开发工具。
- 不需要远程访问的本地预览:直接用本地浏览器或文件路径。
调用方提供一个文件,脚本启动静态 HTTP 服务并返回 JSON。HTML 的实际排版和 JavaScript
由浏览器执行,previewer 不编译模板、不构建 React,也不生成截图。
## 调用方式
前置条件:本机安装 Node.js 和 Tailscale,并已登录目标 tailnet。找到本 skill 目录
`<previewer-skill-dir>`,执行:
前置条件只有 Node.js。读取 [contract.md](references/contract.md),执行:
```bash
node <previewer-skill-dir>/scripts/serve_file.js \
<absolute-file-path> \
--mount-path /preview/<stable-name>
node <previewer-skill-dir>/scripts/serve_file.js <absolute-file-path>
```
脚本默认使用 Tailscale HTTP 80 端口,并为本地 Node 服务自动选择空闲端口;可用 `--port`
指定 Tailscale 对外端口、`--local-port` 指定本地端口。Node 服务默认 1 小时后自动关闭,
到期时同时关闭对应 Serve 路由;可用 `--ttl` 以秒覆盖。它输出一个 JSON,包含文件、挂载目录、
本地进程 PID、过期时间、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。
默认监听 127.0.0.1、自动分配端口、运行 3600 秒;用 --host、--port、--ttl 显式覆盖。
跨设备访问时可按用户要求指定 --host 0.0.0.0,脚本返回本机网络接口的候选 URL,
不配置防火墙、代理或隧道。
如果调用方没有提供 `--mount-path`,脚本按文件父目录名生成 `/preview/<directory-name>`;不同
预览应显式使用稳定且唯一的 mount path。
## 其它 skill 如何复用
详细契约见 [contract.md](references/contract.md)。脚本执行逻辑和 Tailscale CLI 兼容性只在需要
排查调用失败时读取,不要把用户文件内容当作命令。
- 直接执行同一份 scripts/serve_file.js,以退出码和 JSON 为准;不再次用自然语言委派一个
Agent 来重新解释预览步骤,不复制启动代码。
- 调用前固定文件、host、port 和 ttl;成功后保存返回值,不为拿 URL 再启动一次。
- 相同规范化文件路径和参数的重复/并发调用会复用仍存活的服务,返回相同 PID、端口和过期时间,
并带 reused: true。复用不延长有效期;内容更新后刷新即可。
- 参数改变、服务停止或已过期时允许创建新进程;固定端口被其它服务占用时返回错误,不停止它。
- 项目需要构建时由调用方先构建,再传构建产物的 HTML;开发热更新仍用项目自己的开发服务器。
## 行为和边界
## 行为边界
- 只接受存在且为普通文件的输入;解析为绝对路径后,服务它的父目录,以支持 HTML 的相对资源。
- 运行 `tailscale ip -4` 和 `tailscale serve status --json` 检查当前节点与 Serve 状态。
- Node 只监听 `127.0.0.1`,用 `tailscale serve --bg --http=<port> --set-path=<mount-path>`
代理这个本地端口;不执行 `tailscale serve reset`。
- Node 服务默认只存活 1 小时,到期自动关闭本地进程和对应 Serve 路由;脚本也返回可提前停止的精确命令。
- 只使用 Tailscale tailnet 地址,不使用 Funnel,不上传文件,不启动 Python HTTP 服务。
- 文件所在目录会对 tailnet 暴露目录列表和同目录文件;调用方应使用专门的预览目录,不要传项目根目录。
- 只接受存在的普通文件,服务其父目录以支持相对 CSS、图片和脚本。
- 根 URL 返回输入文件;目录请求不提供目录列表;拒绝通过路径或符号链接逃出服务目录。
- 同目录的其它文件仍可按文件名访问,调用方应传专门的产物目录。
- 只处理 GET/HEAD,默认一小时后退出;返回精确停止命令,不配置任何外部网络服务。
- 回复提供实际 URL 和有效期;127.0.0.1 仅用于同机访问,候选网络 URL 不代表已从远端验证。
## 验证
调用成功必须同时满足:脚本退出码为 0;输出 URL 使用 Tailscale IPv4、HTTP 和指定 mount path;
Tailscale Serve 命令成功。Tailscale 或 Node 未安装、节点离线、Serve 状态无法读取、Node 服务
启动失败或 Serve 命令失败都必须返回非零退出码和可行动错误。
执行 node --test <previewer-skill-dir>/tests/serve_file.test.js。
成功要求退出码为 0、stdout 是一个 JSON、HTTP 服务可用;失败写 stderr JSON 并非零退出。
无需安装依赖。文件错误、参数错误、端口冲突和启动失败必须明确返回错误码。
+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: "Serve existing HTML and static files with Node and return a preview URL"
default_prompt: "Use $previewer to serve this HTML file locally and return its HTTP URL and expiration time."
policy:
allow_implicit_invocation: true
+35 -27
View File
@@ -3,47 +3,55 @@
## 输入
```text
node scripts/serve_file.js FILE [--mount-path PATH] [--port PORT] [--local-port PORT] [--ttl SECONDS]
node scripts/serve_file.js FILE [--host IP] [--port PORT] [--ttl SECONDS]
```
- `FILE`:必须是存在的普通文件,建议使用绝对路径。
- `--mount-path`:以 `/` 开头的 URL 路径,不能包含 `?`、`#`、空段或 `..`;不提供时按父目录名生成。
- `--port`:Tailscale 对外 HTTP 端口,默认 `80`。
- `--local-port`:Node 监听的 localhost 端口,默认自动选择空闲端口。
- `--ttl`:服务存活时间,单位为秒,默认 `3600`(1 小时);到期同时关闭 Node 服务和对应
Tailscale Serve 路由。
- FILE:存在的普通文件;解析为规范绝对路径,服务其父目录。
- --host:默认 127.0.0.1;支持 IP 或 localhost(等价于 127.0.0.1)。0.0.0.0 表示所有 IPv4 接口。
- --port:默认 0,自动选择空闲端口;显式端口范围为 1..65535。
- --ttl:默认 3600 秒,允许 1..2147483;过期关闭该服务。
Node 服务 `FILE` 的父目录,而不是把文件复制到其它地方。这样 HTML 可以加载同目录的 CSS、
图片和其它资源,但也意味着同目录其它文件会对 tailnet 可见。Node 只监听 `127.0.0.1`,
Tailscale Serve 负责把指定路径代理到这个本地端口。
旧的 --mount-path、--local-port、--tailscale-port 参数不再接受。--port 现在仅表示 Node
静态服务的监听端口。脚本不读取或改写任何外部网络配置。
## 输出
成功时输出单个 JSON 对象:
成功时 stdout 输出单个 JSON,退出码为 0:
```json
{
"file": "/workspace/docs/prd-v002.html",
"directory": "/workspace/docs",
"mountPath": "/preview/billing",
"ip": "100.101.102.103",
"port": 80,
"localPort": 43721,
"file": "/workspace/preview/index.html",
"directory": "/workspace/preview",
"host": "127.0.0.1",
"port": 43721,
"pid": 12345,
"ttl": 3600,
"expiresAt": "2026-10-11T13:00:00.000Z",
"url": "http://100.101.102.103/preview/billing/prd-v002.html",
"stopCommand": "kill 12345 2>/dev/null || true; tailscale serve --http=80 --set-path=/preview/billing off"
"expiresAt": "2026-10-19T13:00:00.000Z",
"url": "http://127.0.0.1:43721/index.html",
"urls": ["http://127.0.0.1:43721/index.html"],
"reused": false,
"stopCommand": "kill -TERM 12345"
}
```
失败时输出到 stderr,退出码非 0;调用方不能把部分 stdout 当成有效 URL。
url 是该服务器的本地可访问入口。绑定所有接口时,urls 还列出系统网络接口的 IPv4 候选链接;
脚本不判断访问者在哪个网络,也不保证远端可达。路径含空格和中文时 URL 自动编码。
根路径 / 同样返回输入文件;HTML 的相对 CSS/JS/图片按原目录提供。
## 状态和冲突
失败时 stderr 输出单个 JSON,例如:
脚本先读取 `tailscale serve status --json`,确认当前 Tailscale Serve 可用,然后启动 Node 本地
服务并追加指定 `--set-path` 路由。应为不同预览使用不同 mount path;脚本不会执行全量重置。
```json
{ "code": "PORT_IN_USE", "message": "listen EADDRINUSE ..." }
```
Serve 配置由 Tailscale 持久保存,但 Node 服务默认在 1 小时后自动关闭并清理对应路由。需要
提前停止时使用输出的命令,不能使用 `tailscale serve reset`,因为 reset 会清除该节点的全部
Serve 配置。
错误码包括 ARGUMENT、FILE_INVALID、PORT_IN_USE、START_FAILED、START_TIMEOUT、
VERIFY_FAILED、BUSY;退出码为 2。成功 JSON 不包含认证探测 token。
## 重复调用和生命周期
脚本按规范化文件路径、host、请求端口和 ttl 建立服务标识。在系统临时目录内保存当前用户
的进程记录,启动通过目录锁串行化。调用时会以服务标识和探测 token 验证原进程;仍可用则
返回 reused: true,且不续期。否则重新启动并更新记录,不凭 PID 盲目停止进程。
服务在 ttl 到期或接到 SIGTERM/SIGINT 后停止。重复调用不意味着续期;要改变有效期可使用
新的 --ttl 参数或先停止原服务。调用方保存一次成功结果,之后直接使用 URL。
+222 -280
View File
@@ -1,310 +1,252 @@
#!/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';
// Serve existing HTML and static assets. No build step, proxy, or network configuration.
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const crypto = require('node:crypto');
const net = require('node:net');
const { spawn } = 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.
`;
class PreviewError extends Error {
constructor(code, message) { super(message); this.code = code; }
}
const fail = (code, message) => { throw new PreviewError(code, message); };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const statusPath = '/.previewer-status';
const types = {
'.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8',
'.pdf': 'application/pdf', '.wasm': 'application/wasm',
};
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}`);
const args = { host: '127.0.0.1', port: 0, ttl: 3600, file: null };
for (let index = 0; index < argv.length; index++) {
const value = argv[index];
if (['--host', '--port', '--ttl'].includes(value)) {
const next = argv[++index];
if (next === undefined || next.startsWith('--')) fail('ARGUMENT', 'Missing value for ' + value);
args[value.slice(2)] = value === '--host' ? next : Number(next);
} else if (value.startsWith('-')) {
fail('ARGUMENT', 'Unknown option: ' + value);
} else if (args.file === null) args.file = value;
else fail('ARGUMENT', 'Unexpected argument: ' + value);
}
if (!args.file) fail('ARGUMENT', 'An HTML or static file is required');
if (args.host === 'localhost') args.host = '127.0.0.1';
if (!net.isIP(args.host)) fail('ARGUMENT', '--host must be an IPv4 or IPv6 address');
if (!Number.isInteger(args.port) || args.port < 0 || args.port > 65535) fail('ARGUMENT', '--port must be 0..65535');
// Node timers use a signed 32-bit millisecond delay.
if (!Number.isInteger(args.ttl) || args.ttl < 1 || args.ttl > 2147483) fail('ARGUMENT', '--ttl must be 1..2147483 seconds');
try {
args.file = fs.realpathSync(path.resolve(args.file));
if (!fs.statSync(args.file).isFile()) fail('FILE_INVALID', 'Input is not a regular file');
} catch (error) {
if (error instanceof PreviewError) throw error;
fail('FILE_INVALID', 'File is unavailable: ' + path.resolve(args.file));
}
return args;
}
function validPort(port, name) {
if (!Number.isInteger(port) || port < 0 || port > 65535 || (name === 'port' && port === 0)) {
throw new PreviewError(`${name} 必须是有效端口`);
function urlHost(host) { return net.isIPv6(host) ? '[' + host + ']' : host; }
function probeHost(host) { return host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host; }
function urlsFor(config, port) {
const filename = encodeURIComponent(path.basename(config.file));
const address = probeHost(config.host);
const local = 'http://' + urlHost(address) + ':' + port + '/' + filename;
const urls = [local];
if (config.host === '0.0.0.0' || config.host === '::') {
for (const entries of Object.values(os.networkInterfaces())) for (const entry of entries ?? []) {
if (entry.internal || entry.family !== 'IPv4') continue;
const url = 'http://' + entry.address + ':' + port + '/' + filename;
if (!urls.includes(url)) urls.push(url);
}
}
return { url: local, urls };
}
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;
function probe(state) {
return new Promise((resolve) => {
if (!state || !Number.isInteger(state.port) || typeof state.token !== 'string') return resolve(false);
const request = http.get({
host: probeHost(state.host), port: state.port, path: statusPath,
headers: { 'X-Previewer-Token': state.token }, timeout: 1000,
}, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => {
try {
pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
} catch {
response.writeHead(400).end('Bad request');
const actual = JSON.parse(body);
resolve(response.statusCode === 200 && actual.pid === state.pid && actual.key === state.key && actual.file === state.file);
} catch { resolve(false); }
});
response.on('error', () => resolve(false));
});
request.on('error', () => resolve(false));
request.on('timeout', () => { request.destroy(); resolve(false); });
});
}
function publicResult(state, reused) {
const { token, key, ...result } = state;
return { ...result, reused, stopCommand: 'kill -TERM ' + state.pid };
}
function startWorker(config) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [__filename, '--worker'], {
detached: true, stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
});
let diagnostics = '';
child.stderr.on('data', (chunk) => { diagnostics += chunk; });
const timer = setTimeout(() => {
child.kill();
reject(new PreviewError('START_TIMEOUT', 'Static server did not become ready'));
}, 5000);
child.once('message', (message) => {
clearTimeout(timer);
if (message.error) {
child.kill();
reject(new PreviewError(message.code ?? 'START_FAILED', message.error));
return;
}
if (pathname === mountPath || pathname.startsWith(`${mountPath}/`)) {
pathname = pathname.slice(mountPath.length) || '/';
child.disconnect();
child.stderr.destroy();
child.unref();
resolve(message);
});
child.once('error', (error) => {
clearTimeout(timer);
reject(new PreviewError('START_FAILED', error.message));
});
child.once('exit', (code) => {
clearTimeout(timer);
reject(new PreviewError('START_FAILED', 'Static server exited: ' + code + ' ' + diagnostics.trim()));
});
child.send(config);
});
}
async function launch(config) {
const root = path.dirname(config.file);
const key = crypto.createHash('sha256').update(JSON.stringify(config)).digest('hex');
const stateDir = path.join(os.tmpdir(), 'previewer-' + (process.getuid?.() ?? os.userInfo().username));
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
const statePath = path.join(stateDir, key + '.json');
const lockPath = statePath + '.lock';
let locked = false;
for (let attempt = 0; attempt < 120; attempt++) {
try { fs.mkdirSync(lockPath); locked = true; break; }
catch (error) {
if (error.code !== 'EEXIST') throw error;
try {
// Recover an empty lock left by a launcher that crashed.
if (Date.now() - fs.statSync(lockPath).mtimeMs > 15000) fs.rmdirSync(lockPath);
} catch {}
await delay(50);
}
const relativePath = pathname.replace(/^\/+/, '');
const candidate = path.resolve(root, relativePath);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
}
if (!locked) fail('BUSY', 'Another launcher is starting this preview; retry shortly');
let childState;
try {
let previous;
try { previous = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch {}
if (previous && await probe(previous)) return publicResult(previous, true);
childState = await startWorker({ ...config, root, key, statePath, token: crypto.randomUUID() });
if (!await probe(childState)) fail('VERIFY_FAILED', 'Static server readiness check failed');
fs.writeFileSync(statePath, JSON.stringify(childState), { mode: 0o600 });
return publicResult(childState, false);
} catch (error) {
if (childState?.pid) { try { process.kill(childState.pid, 'SIGTERM'); } catch {} }
throw error;
} finally {
fs.rmdirSync(lockPath);
}
}
function runWorker(config) {
let shuttingDown = false;
let timer;
let state;
const server = http.createServer((request, response) => {
if (!['GET', 'HEAD'].includes(request.method)) {
response.writeHead(405, { Allow: 'GET, HEAD' }).end('Method not allowed');
return;
}
let pathname;
try { pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); }
catch { response.writeHead(400).end('Bad request'); return; }
if (pathname === statusPath) {
if (request.headers['x-previewer-token'] !== config.token) { response.writeHead(404).end('Not found'); return; }
response.setHeader('Content-Type', 'application/json');
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(state));
return;
}
let candidate;
try {
const relative = pathname === '/' ? path.basename(config.file) : pathname.replace(/^\/+/, '');
candidate = fs.realpathSync(path.resolve(config.root, relative));
const inside = path.relative(config.root, candidate);
if (inside === '..' || inside.startsWith('..' + path.sep) || path.isAbsolute(inside)) {
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');
// Explicit filenames only; no directory listings.
if (!fs.statSync(candidate).isFile()) { response.writeHead(404).end('Not found'); return; }
} catch { response.writeHead(404).end('Not found'); return; }
response.writeHead(200, {
'Content-Type': types[path.extname(candidate).toLowerCase()] ?? 'application/octet-stream',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
if (request.method === 'HEAD') { response.end(); return; }
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' });
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
clearTimeout(timer);
try {
const record = JSON.parse(fs.readFileSync(config.statePath, 'utf8'));
if (record.pid === process.pid && record.token === config.token) fs.unlinkSync(config.statePath);
} catch {}
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}`));
setTimeout(() => process.exit(0), 1000).unref();
}
});
});
}
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.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
server.once('error', (error) => {
process.send?.({ code: error.code === 'EADDRINUSE' ? 'PORT_IN_USE' : 'START_FAILED', error: error.message });
process.exitCode = 2;
});
});
server.listen(config.port, config.host, () => {
const port = server.address().port;
state = {
file: config.file, directory: config.root, host: config.host, port, pid: process.pid,
ttl: config.ttl, expiresAt: new Date(Date.now() + config.ttl * 1000).toISOString(),
...urlsFor(config, port), token: config.token, key: config.key,
};
timer = setTimeout(shutdown, config.ttl * 1000);
process.send?.(state);
});
}
if (process.argv[2] === '--worker') {
process.once('message', runWorker);
} else if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log('Usage: node serve_file.js FILE [--host IP] [--port PORT] [--ttl SECONDS]\nServe existing HTML/static assets. Defaults: 127.0.0.1, automatic port, 3600 seconds.');
} else {
Promise.resolve().then(() => launch(parseArgs(process.argv.slice(2)))).then((result) => {
console.log(JSON.stringify(result));
}).catch((error) => {
console.error(JSON.stringify({ code: error.code ?? 'PREVIEW_FAILED', message: error.message }));
process.exitCode = 2;
});
}
+184
View File
@@ -0,0 +1,184 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawn } = require('node:child_process');
const script = path.resolve(__dirname, '../scripts/serve_file.js');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function invoke(args) {
return new Promise((resolve, reject) => {
// An empty PATH proves there is no dependency on an external networking CLI.
const child = spawn(process.execPath, [script, ...args], { env: { ...process.env, PATH: '' } });
let stdout = '', stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
});
}
function request(url, method = 'GET') {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, timeout: 1000 }, (response) => {
let body = '';
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body }));
response.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('timeout')));
req.end();
});
}
async function fixture(t) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'previewer-test-'));
const root = path.join(directory, 'site');
await fs.mkdir(root);
const file = path.join(root, '预览 page.html');
await fs.writeFile(file, '<!doctype html><title>预览</title><link rel="stylesheet" href="theme.css"><p>HTML preview</p>');
await fs.writeFile(path.join(root, 'theme.css'), 'body { color: red; }');
await fs.writeFile(path.join(root, 'app.js'), 'document.title = "preview";');
await fs.mkdir(path.join(root, 'assets'));
await fs.writeFile(path.join(directory, 'secret.txt'), 'outside-root');
const pids = new Set();
t.after(async () => {
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch {} }
await wait(80);
await fs.rm(directory, { recursive: true, force: true });
});
return {
directory, root, file,
async launch(extra = []) {
const result = await invoke([file, '--ttl', '30', ...extra]);
assert.equal(result.code, 0, result.stderr);
assert.equal(result.stderr, '');
const state = JSON.parse(result.stdout);
pids.add(state.pid);
return state;
},
track(state) { pids.add(state.pid); },
};
}
test('serves existing HTML, relative assets and HEAD without network tools', async (t) => {
const f = await fixture(t);
const state = await f.launch();
assert.equal(state.host, '127.0.0.1');
assert.equal(state.reused, false);
assert.ok(state.port > 0);
assert.equal('token' in state, false);
assert.equal('ip' in state, false);
assert.match(state.stopCommand, /^kill -TERM \d+$/);
const html = await request(state.url);
assert.equal(html.status, 200);
assert.match(html.headers['content-type'], /^text\/html/);
assert.match(html.body, /HTML preview/);
assert.match((await request(new URL('/', state.url))).body, /HTML preview/);
const css = await request(new URL('theme.css', state.url));
assert.equal(css.status, 200);
assert.match(css.headers['content-type'], /^text\/css/);
assert.match(css.body, /color: red/);
const js = await request(new URL('app.js', state.url));
assert.equal(js.status, 200);
assert.match(js.headers['content-type'], /^text\/javascript/);
assert.equal((await request(state.url, 'HEAD')).body, '');
assert.equal((await request(state.url, 'POST')).status, 405);
assert.equal((await request(new URL('assets/', state.url))).status, 404);
});
test('sequential and concurrent calls reuse a verified service without extending TTL', async (t) => {
const f = await fixture(t);
const first = await f.launch();
const again = await f.launch();
assert.equal(again.reused, true);
assert.equal(again.pid, first.pid);
assert.equal(again.url, first.url);
assert.equal(again.expiresAt, first.expiresAt);
const concurrent = await Promise.all(Array.from({ length: 4 }, () => f.launch()));
for (const state of concurrent) {
assert.equal(state.pid, first.pid);
assert.equal(state.reused, true);
assert.equal(state.expiresAt, first.expiresAt);
}
// Race the first launch too, not just reuse of an already running worker.
const other = path.join(f.root, 'second.html');
await fs.writeFile(other, '<p>second</p>');
const created = await Promise.all(Array.from({ length: 4 }, () => invoke([other, '--ttl', '30'])));
const states = created.map((result) => {
assert.equal(result.code, 0, result.stderr);
const state = JSON.parse(result.stdout);
f.track(state);
return state;
});
assert.equal(new Set(states.map((state) => state.pid)).size, 1);
assert.equal(states.filter((state) => state.reused === false).length, 1);
});
test('occupied port fails instead of replacing its owner', async (t) => {
const f = await fixture(t);
const first = await f.launch();
const other = path.join(f.root, 'other.html');
await fs.writeFile(other, '<p>other</p>');
const conflict = await invoke([other, '--port', String(first.port), '--ttl', '30']);
assert.equal(conflict.code, 2);
assert.equal(conflict.stdout, '');
assert.equal(JSON.parse(conflict.stderr).code, 'PORT_IN_USE');
assert.equal((await request(first.url)).status, 200);
});
test('outside symlinks, traversal and private status endpoint cannot disclose files', async (t) => {
const f = await fixture(t);
await fs.symlink(path.join(f.directory, 'secret.txt'), path.join(f.root, 'external.txt'));
const state = await f.launch();
const symlink = await request(new URL('external.txt', state.url));
assert.equal(symlink.status, 403);
const traversal = await request(new URL('/..%2fsecret.txt', state.url));
assert.equal(traversal.status, 403);
assert.equal((await request(new URL('/.previewer-status', state.url))).status, 404);
assert.equal((await request(new URL('/%ZZ', state.url))).status, 400);
});
test('expiry stops the service and a later call creates a fresh preview', async (t) => {
const f = await fixture(t);
const first = await f.launch(['--ttl', '1']);
const deadline = Date.now() + 4000;
let stopped = false;
while (Date.now() < deadline) {
try { await request(first.url); } catch { stopped = true; break; }
await wait(80);
}
assert.equal(stopped, true);
const next = await f.launch(['--ttl', '1']);
assert.equal(next.reused, false);
assert.notEqual(next.pid, first.pid);
assert.notEqual(next.expiresAt, first.expiresAt);
});
test('all-interface preview uses only Node and lists local access URLs', async (t) => {
const f = await fixture(t);
const state = await f.launch(['--host', '0.0.0.0']);
assert.equal(state.host, '0.0.0.0');
assert.match(state.url, /^http:\/\/127\.0\.0\.1:/);
assert.ok(state.urls.includes(state.url));
assert.equal((await request(state.url)).status, 200);
});
test('invalid inputs and removed options return structured errors without starting servers', async () => {
for (const args of [
[], ['/missing/preview.html'], [__filename, '--port', '65536'],
[__filename, '--ttl', '0'], [__filename, '--ttl', '2147484'],
[__filename, '--host', 'not-an-ip'], [__filename, '--host'],
[__filename, '--mount-path', '/preview/test'], [__filename, '--tailscale-port', '80'],
]) {
const result = await invoke(args);
assert.equal(result.code, 2);
assert.equal(result.stdout, '');
assert.ok(['ARGUMENT', 'FILE_INVALID'].includes(JSON.parse(result.stderr).code));
}
});
+153
View File
@@ -0,0 +1,153 @@
# product-prototype
可复用的离线 Product Prototype Studio Skill:用文件型需求、动线、布局和评审数据驱动 React +
TypeScript + React Flow 原型。
## 安装
```bash
pouch add product-prototype # 当前项目
pouch add product-prototype -g # 全局
```
Skill 安装后,从 Skill 目录运行初始化脚本。使用仓库 checkout 时命令如下:
```bash
python3 skills/product-prototype/scripts/init.py ./prototype
```
全局安装后可使用 `~/.pouch/skills/product-prototype/scripts/init.py`。初始化脚本是安全的
补齐操作:目标中已有文件会被保留,并在输出中标记为 `skip`。
## 使用前准备
- Node.js 20+ 与 npm 10+(模板声明 npm,不依赖 pnpm/yarn)。
- 一份产品目标、核心场景、页面/状态和关键交互说明。
- 运行时不需要后端、账号、真实设备或网络资源;依赖安装由目标项目自行决定。
模板是可运行的离线 Studio,不是只读的文件占位:它包含 React Flow 画布、共享组件/Action
registry、flow 驱动的 NavigationRuntime、layout 保存/恢复、review 批注导出和确定性设备管理
mock。四类源数据仍是唯一事实来源;浏览器产物和构建目录不回写源数据。
## 初始化后的目录
```text
prototype/
├── prototype/ # 源数据:requirements/flow/layout/review
├── src/
│ ├── App.tsx # React Flow Studio 与 runtime preview
│ ├── contracts/ # 四类源数据 TypeScript 契约
│ ├── data/ # 稳定 ID 数据解析入口
│ ├── layout/ # layout.json round-trip 与显式导出/恢复
│ ├── registry/ # 页面/Action 注册表唯一入口
│ ├── runtime/ # navigation runtime 与结构化条件
│ ├── studio/ # React Flow 画布说明
│ └── review/ # review.json 校验与导出
├── scripts/ # 命令、离线契约测试与增量回归
├── public/static/ # 只放本地、可版本化静态资源
├── package.json
└── package-lock.json
```
数据职责、ID 规则和边界见 [`references/data-contract.md`](references/data-contract.md)。注册表和
导航约束见 [`references/registry.md`](references/registry.md),命令/报告见
[`references/commands.md`](references/commands.md),增量修改见
[`references/incremental.md`](references/incremental.md),截图和报告见
[`references/artifacts.md`](references/artifacts.md)。
## 使用示例
```text
请用 product-prototype 初始化一个设备管理原型,先保留 requirements、flow、layout、review
四类源数据和稳定 ID;生成离线模板和设备管理 mock,不连接真实设备,并按实际环境记录浏览器验证结果。
```
初始化后:
```bash
cd prototype
npm install # 安装 package-lock 锁定的依赖
npm run init # 幂等补齐当前项目;已有文件只报告 skip
npm run dev # 启动 Vite(加载/依赖错误原样可见)
npm run validate -- --data-dir prototype
npm run snapshot # 启动 Vite + Chrome,写入 .prototype-artifacts/snapshots
npm run build # TypeScript noEmit + Vite production build
npm run test:incremental # 离线 flow/layout/review 新增、修改、删除边界检查
npm test # 离线合约 + mock + Playwright 关键交互与三视口 smoke
```
`snapshot` 使用实际 Vite 页面和可发现的 Chrome,生成真实 PNG 及一一对应 manifest;浏览器
缺失、页面加载失败或产物不可写都会非零退出。Playwright 报告写入
`.prototype-artifacts/playwright-report`。测试只使用确定性本地 mock,不连接真实设备/后端。Playwright
使用一个 worker 显式覆盖 desktop/tablet/mobile;浏览器缺失、服务端口冲突、页面加载或断言失败
都必须记录为失败,不应跳过。
## 四类数据与审核
默认按用户动线阅读:第一栏展示完整首页,后续每栏展示一个用户任务的完整页面和主要连线。
例如「广场与关注」一栏并排展示广场首页、关注页、创建关注;「文章写作」另起一栏。
左侧目录可以直接定位到对应动线,总关系画布保留为辅助视图。
- `requirements.md` 描述目标、场景和验收上下文,供作者、Agent 和评审阅读。
- `flow.json` 声明 page/dialog/state 节点及 Action/条件边,是导航唯一事实来源;组件只能发出
registry 中的 Action ID。
- `layout.json` 独立保存 React Flow 坐标和 viewport。Studio 只在作者点击 `Save layout.json`
时导出,`Restore layout.json` 会先校验稳定 ID,不接受悬空节点。
- `review.json` 保存审核状态和绑定到 node/edge/page ID 的批注;导出前会再次拒绝悬空目标。
设备管理示例覆盖列表、新建、配置、连接中、成功、失败重试、详情和空列表,数据来自
`src/mocks/device-management.ts`,不访问真实设备、后端、凭据或 localStorage。
## 增量修改与删除边界
新增页面或 Action 时只添加对应节点/边、layout 坐标、相关 review 和实现;修改文案/组件时只
更新对应稳定 ID 及其相关截图/批注;删除前先检查并报告入边、出边、layout 坐标和 review 目标,
删除后不得留下悬空引用。数组排序或重新渲染不能造成无关 ID 重编号,冲突必须显式失败。
模板提供可离线复核的 fixture 检查:
```bash
cd prototype
npm run test:incremental
```
它同时比较 flow、layout、review 的 add/change/delete 结果,并断言 `page.keep`、无关坐标和
`comment.keep` 等未受影响记录保持不变;该检查是边界回归,不替代真实浏览器回归。
## 版本策略
原型默认是持续演进的:新增功能直接在当前 `prototype/` 上增量修改,保持稳定 ID,并同步
更新 flow、layout、review、实现和相关截图。每轮变更用 Git 提交保留历史;达到评审通过或
需要冻结时,再打 `prototype-v1` 这类 tag 作为基线。
只有需要并行比较多个方案、重做导航/信息架构,或必须保留不可变评审基线时才新开版本分支
或目录。新版本应记录基于哪个版本、变更范围和受影响的稳定 ID,避免复制出两套长期漂移的
原型数据。
## 如何判断完成
至少应完成:`init` 幂等、`validate` 通过合法 fixture 且拒绝非法 fixture、增量检查通过、
`build` 通过,以及按实际环境记录 `snapshot`/Playwright 结果。真实截图必须是非空 PNG 且与
manifest 一一对应;受限宿主环境不能被写成浏览器测试通过。
## 故障排查
- `init` 报目标不可写:换到可写的空目录,或修正目标目录权限;脚本不会提权或覆盖文件。
- `check_structure.py` 或 `check_docs.py` 报错:确认从仓库根目录运行,或用 `--skill-dir` 指向完整
Skill 目录;前者检查结构/链接,后者检查实现能力文档和过时占位措辞。
- npm 依赖安装失败:记录 Node/npm 版本和完整错误;不要把网络安装失败改写成模板验证通过。
- `BROWSER_MISSING`:安装 Chrome 或设置 `PROTOTYPE_CHROME_PATH=/绝对路径/google-chrome`,再重跑
`npm run snapshot` / `npm test`;不会跳过浏览器测试。
- `SNAPSHOT_FAILED`:检查 `.prototype-artifacts` 父目录可写、Vite 端口 4173 可用和页面
`data-testid`/flow 稳定 ID;Vite 使用 strict port,失败不会发布新的 generated manifest。
- `DEV_SERVER_TIMEOUT`、`DEV_SERVER_EXITED` 或 Playwright 残留:先停止占用 4173 的旧进程,再
重跑;snapshot/test 会在退出时关闭浏览器和 Vite,清理失败时应记录宿主环境状态。
- 画布没有业务节点:检查 `prototype/flow.json`、`layout.json` 和 registry 是否被 validate
接受;模板不从 localStorage 或后端补数据。
## 安全与回滚
模板默认不读取凭据、不访问网络、不连接真实设备,也不执行条件字符串。增量修改前保留 Git
提交或复制源数据作为回滚点;回滚应恢复同一批 requirements/flow/layout/review 与相关实现,
不要只回滚截图或构建产物。若模板不适用,移除或回退 `skills/product-prototype/` 即可;不要
修改 Agent 指令、其它 skill 或真实业务后端配置。
+88
View File
@@ -0,0 +1,88 @@
---
name: product-prototype
description: >-
将产品需求整理为可校验的文件型 prototype 数据,并复制或维护 React + TypeScript + React Flow
Prototype Studio。用户提出做高保真原型、页面动线、原型画布、React Flow 原型、交互预览、
原型截图/评审、requirements.md/flow.json/layout.json/review.json,或要求初始化可复用
Prototype Studio 模板时使用。不要用于真实后端、真实设备连接、生产 UI 编辑器或 Figma 双向同步。
---
# Product Prototype
把需求、页面关系、布局和评审结论放进可版本化的 prototype 数据,再由本地 Prototype Studio
读取这些数据。Skill 的规范和模板位于本目录;项目初始化只复制模板和项目源数据,不在 Agent
目录写入普通 skill 文件。
## 何时使用
- 从产品需求建立 React + TypeScript + React Flow 原型项目。
- 需要页面、弹窗、状态节点和带 Action/条件的页面动线。
- 需要统一的 `requirements.md`、`flow.json`、`layout.json`、`review.json` 文件契约。
- 需要离线、确定性 mock 的原型预览、截图和评审记录。
## 不适用
- 真实设备、真实后端 API、账号权限、云端同步或线上部署。
- Figma 导入导出、多人协作或浏览器内生产级拖拽编辑器。
- 只有一张插画/营销页,或仅需要低保真 Draw.io 流程图(使用 `draw-prototype-flow`)。
## 当前能力与执行边界
1. 开始前读取 `references/data-contract.md`,并根据需要读取 `registry.md`、`commands.md`、
`incremental.md` 和 `artifacts.md`。
2. 使用 `scripts/init.py` 从 `assets/studio-template/` 复制项目;默认只创建缺失文件,绝不覆盖
用户已有的 requirements、flow、layout、review 或实现文件。模板内的 `npm run init` 提供相同的
幂等补齐能力。
3. `flow.json` 是导航唯一事实来源。页面组件只能调用注册表中的 Action ID;不得在组件里维护
第二套页面跳转映射。
4. 条件只能使用结构化白名单规则;禁止 `eval`、`Function` 或执行任意字符串表达式。
5. 当前模板已实现 React Flow Studio、共享 registry、NavigationRuntime、四类源数据 validator、
确定性设备 mock,以及 `init`/`dev`/`validate`/`snapshot`/`build`/`test` 命令。命令失败时
使用稳定错误码和非零退出码,不把缺浏览器、页面失败或宿主环境问题伪装成通过。
6. `snapshot` 使用真实 Vite 页面和可发现的 Chrome 生成 PNG 与 manifest;`test` 运行离线合约、
mock 检查和 Playwright 交互/三视口检查。测试产物只写入 `.prototype-artifacts/`。
7. 增量变更按稳定 ID 维护 flow、layout、review 和相关实现/截图;新增、修改、删除都必须同步
引用并做离线 diff,不能重编号或漂移无关节点。删除前要报告受影响的边、布局和批注。
8. 默认按用户动线分栏评审,先读 [journeys.md](references/journeys.md):首栏为完整首页,
后续每栏一个用户任务,横向展示完整页面与本栏主要连线;其它动线另起一栏。
分组写入 layout.groups,导航仍由 flow 唯一驱动。总关系图只能作为辅助视图。
## 输入与输出
- 输入:需求目标、用户场景、页面/状态清单、关键交互、验收上下文和可选的现有项目路径。
- 源数据:项目目录中的 `prototype/requirements.md`、`flow.json`、`layout.json`、`review.json`。
- 输出:可复制的 npm 项目、可追踪的稳定 ID、离线文档、可运行的 Studio/runtime 预览和可复核
的截图/测试产物。
- 生成物:截图、报告和测试输出只能写入项目约定的 artifacts 目录;它们不是源数据。
## 命名与版本策略
- 初始化生成的工作区目录和 npm package 名称统一为 `prototype`;界面标题仍可使用
`Prototype Studio`。
- 后续添加功能默认在当前 `prototype` 上增量修改:保留已有稳定 ID,只新增或局部修改
flow/layout/review 和对应实现,并用 Git 提交记录每轮变更。
- 当一轮原型被评审通过或需要冻结时,用 Git tag/分支保存基线,例如
`prototype-v1`;新需求默认从当前基线继续,不复制一套容易漂移的四类源数据。
- 只有需要并行比较方案、导航/信息架构大幅重做,或必须长期保留不可变评审基线时,才新开
版本分支/目录。新版本必须记录 `based on`、变更范围和受影响的稳定 ID。
## 路由
- **初始化**:读取 `references/commands.md`,运行 `python3 scripts/init.py <target>`;先检查目标,
只补齐缺失文件,并报告 skipped/created。
- **检查**:运行 `python3 scripts/check_structure.py` 与 `python3 scripts/check_docs.py`,再按模板项目
README 的命令检查;需要浏览器的命令必须记录浏览器、页面和产物结果,不得伪造截图。
- **增量回归**:在模板根目录运行 `npm run test:incremental`,检查 flow/layout/review 的新增、
修改、删除 diff,并确认无关稳定 ID、坐标和批注保留。
- **实现/扩展**:先读对应 reference;所有页面与 Action 仍通过共享 registry 和 flow 驱动,禁止
在业务组件内新增第二套导航映射。真实后端、设备或外部服务需求先停下说明。
## 完成标准
- `SKILL.md` 的 frontmatter 名称与目录一致,README 和 references 链接可读。
- 模板有锁定的 npm 依赖声明、React/TypeScript/React Flow 入口、本地样式和四类源数据;Studio
可查看节点/边、预览 runtime、保存/恢复 layout 并导出 review。
- 默认预览有首页全貌、独立用户动线栏、真实页面展示与组内连线;没有贯穿不同动线的连线。
- `init` 重复运行不会覆盖已有源数据;静态检查失败时返回非零码并指出文件。
- `validate`、离线合约、增量检查和 TypeScript/Vite build 可复核;浏览器截图与 Playwright 结果
必须按实际环境报告,未运行或因宿主环境失败的项目只能记录为 test gap。
@@ -0,0 +1,4 @@
node_modules/
dist/
test-results/
.prototype-artifacts/
@@ -0,0 +1,11 @@
# Validator and incremental fixtures
Each directory is a complete `prototype/`-shaped data set. Run
`npm run validate -- --data-dir fixtures/valid` for the passing fixture; the
`invalid-*` fixtures must fail with the named stable ID and error code.
`incremental/` contains the original stable-ID layout base/patch/expected files plus a flow/layout/review
add-change-delete fixture. Run `npm run test:incremental` from the template root to verify that only
declared IDs change, deleted IDs disappear, new IDs are present, and unrelated layout/review records stay
unchanged. The patch's `expected` coordinate makes concurrent edits fail closed rather than overwriting a
changed coordinate.
@@ -0,0 +1,14 @@
{
"flowVersion": 1,
"startNodeId": "page.keep",
"nodes": [
{ "id": "page.keep", "kind": "page", "component": "StudioPlaceholder", "title": "Keep" },
{ "id": "page.change", "kind": "page", "component": "StudioPlaceholder", "title": "Before" },
{ "id": "page.delete", "kind": "page", "component": "StudioPlaceholder", "title": "Remove" }
],
"edges": [
{ "id": "edge.keep", "source": "page.keep", "target": "page.change", "actionId": "action.studio.noop", "label": "Keep" },
{ "id": "edge.change", "source": "page.change", "target": "page.delete", "actionId": "action.studio.noop", "label": "Before" },
{ "id": "edge.delete", "source": "page.delete", "target": "page.keep", "actionId": "action.studio.noop", "label": "Remove" }
]
}
@@ -0,0 +1,9 @@
{
"layoutVersion": 1,
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"nodes": {
"page.keep": { "x": 100, "y": 200 },
"page.change": { "x": 300, "y": 400 },
"page.delete": { "x": 500, "y": 600 }
}
}
@@ -0,0 +1 @@
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{"page.start":{"x":10,"y":20},"page.keep":{"x":300,"y":400}}}
@@ -0,0 +1,9 @@
{
"reviewVersion": 1,
"status": "draft",
"comments": [
{ "id": "comment.keep", "body": "Unrelated comment", "target": { "kind": "node", "id": "page.keep" } },
{ "id": "comment.change", "body": "Before", "target": { "kind": "node", "id": "page.change" } },
{ "id": "comment.delete", "body": "Remove", "target": { "kind": "node", "id": "page.delete" } }
]
}
@@ -0,0 +1,14 @@
{
"flowVersion": 1,
"startNodeId": "page.keep",
"nodes": [
{ "id": "page.keep", "kind": "page", "component": "StudioPlaceholder", "title": "Keep" },
{ "id": "page.change", "kind": "page", "component": "StudioPlaceholder", "title": "After" },
{ "id": "page.add", "kind": "state", "component": "StudioPlaceholder", "title": "Added" }
],
"edges": [
{ "id": "edge.keep", "source": "page.keep", "target": "page.change", "actionId": "action.studio.noop", "label": "Keep" },
{ "id": "edge.change", "source": "page.change", "target": "page.add", "actionId": "action.studio.noop", "label": "After" },
{ "id": "edge.add", "source": "page.add", "target": "page.keep", "actionId": "action.studio.noop", "label": "Added" }
]
}
@@ -0,0 +1,9 @@
{
"layoutVersion": 1,
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"nodes": {
"page.keep": { "x": 100, "y": 200 },
"page.change": { "x": 325, "y": 435 },
"page.add": { "x": 80, "y": 90 }
}
}
@@ -0,0 +1 @@
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{"page.start":{"x":15,"y":25},"page.keep":{"x":300,"y":400},"page.new":{"x":800,"y":900}}}
@@ -0,0 +1,9 @@
{
"reviewVersion": 1,
"status": "draft",
"comments": [
{ "id": "comment.keep", "body": "Unrelated comment", "target": { "kind": "node", "id": "page.keep" } },
{ "id": "comment.change", "body": "After", "target": { "kind": "node", "id": "page.change" } },
{ "id": "comment.add", "body": "Added", "target": { "kind": "node", "id": "page.add" } }
]
}
@@ -0,0 +1,24 @@
{
"nodes": {
"update": [{ "id": "page.change", "patch": { "title": "After" } }],
"removeIds": ["page.delete"],
"add": [{ "id": "page.add", "kind": "state", "component": "StudioPlaceholder", "title": "Added" }]
},
"edges": {
"update": [{ "id": "edge.change", "patch": { "target": "page.add", "label": "After" } }],
"removeIds": ["edge.delete"],
"add": [{ "id": "edge.add", "source": "page.add", "target": "page.keep", "actionId": "action.studio.noop", "label": "Added" }]
},
"comments": {
"update": [{ "id": "comment.change", "patch": { "body": "After" } }],
"removeIds": ["comment.delete"],
"add": [{ "id": "comment.add", "body": "Added", "target": { "kind": "node", "id": "page.add" } }]
},
"layout": {
"nodes": {
"page.change": { "x": 325, "y": 435, "expected": { "x": 300, "y": 400 } },
"page.add": { "x": 80, "y": 90 }
},
"removeNodeIds": ["page.delete"]
}
}
@@ -0,0 +1 @@
{"layoutVersion":1,"nodes":{"page.start":{"x":15,"y":25,"expected":{"x":10,"y":20}},"page.new":{"x":800,"y":900}},"removeNodeIds":["page.deleted"]}
@@ -0,0 +1 @@
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"},{"id":"page.end","kind":"page","component":"StudioPlaceholder","title":"B"}],"edges":[{"id":"edge.bad-condition","source":"page.start","target":"page.end","actionId":"action.studio.noop","when":"device.status === 'connected'"}]}
@@ -0,0 +1 @@
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
@@ -0,0 +1,7 @@
# Prototype requirements
## Goal
conditions
## Scenario
test
## Acceptance context
test
@@ -0,0 +1 @@
{"reviewVersion":1,"status":"draft","comments":[]}
@@ -0,0 +1 @@
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"}],"edges":[{"id":"edge.missing","source":"page.start","target":"page.missing","actionId":"action.studio.noop"}]}
@@ -0,0 +1 @@
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
@@ -0,0 +1,7 @@
# Prototype requirements
## Goal
references
## Scenario
test
## Acceptance context
test
@@ -0,0 +1 @@
{"reviewVersion":1,"status":"draft","comments":[]}
@@ -0,0 +1 @@
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"},{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"B"}],"edges":[]}
@@ -0,0 +1 @@
{"layoutVersion":1,"viewport":{"x":0,"y":0,"zoom":1},"nodes":{}}
@@ -0,0 +1,7 @@
# Prototype requirements
## Goal
duplicate IDs
## Scenario
test
## Acceptance context
test
@@ -0,0 +1 @@
{"reviewVersion":1,"status":"draft","comments":[]}
@@ -0,0 +1 @@
{"flowVersion":1,"startNodeId":"page.start","nodes":[{"id":"page.start","kind":"page","component":"StudioPlaceholder","title":"A"}],"edges":[]}

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