feat: add previewer static serving
This commit is contained in:
@@ -60,6 +60,7 @@ AGENTS.md # 本文档
|
|||||||
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
|
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
|
||||||
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
|
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
|
||||||
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
|
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
|
||||||
|
| [previewer](skills/previewer/README.md) | 通过 Tailscale Serve 将静态文件暴露为 tailnet 内 HTTP 预览 |
|
||||||
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
|
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
|
||||||
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
|
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
|
||||||
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
|
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ AGENTS.md # 详细规范与架构说明
|
|||||||
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
|
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
|
||||||
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
|
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
|
||||||
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
|
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
|
||||||
|
| [previewer](skills/previewer/README.md) | 通过 Tailscale Serve 将静态文件暴露为 tailnet 内 HTTP 预览 |
|
||||||
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
|
| [releaser](skills/releaser/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
|
||||||
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
|
| [specifier](skills/specifier/README.md) | 把复杂需求整理成多轮评审的 HTML PRD,并在批准后生成 ACK handoff |
|
||||||
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
|
| [manage-release](skills/manage-release/README.md) | 已弃用:`releaser` 的旧名兼容占位,不含流程 |
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# previewer
|
||||||
|
|
||||||
|
把本机的 HTML、PRD、报告或其它静态文件通过 Tailscale Serve 暴露给 tailnet,并返回可访问的 HTTP URL。
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- 想在手机、平板或另一台电脑查看本机生成的 HTML。
|
||||||
|
- 其它 skill 需要把某个文件交给通用预览服务。
|
||||||
|
|
||||||
|
只面向 Tailscale tailnet,不提供公网访问,也不启动动态 Web 服务。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- 本机已安装并登录 Tailscale,且访问者也在允许访问的 tailnet 中。
|
||||||
|
- 提供要展示的文件路径;文件所在目录会被作为静态目录服务。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
用 previewer 展示这个文件:/workspace/docs/prd-v002.html,挂载到 /preview/billing。
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 检查 Tailscale 是否在线,以及是否已有 Serve 路由。
|
||||||
|
2. 将文件所在目录挂到指定的独立路径,不覆盖冲突路由。
|
||||||
|
3. 返回包含 Tailscale IPv4 的 HTTP URL 和停止该路由的命令。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- 得到一个 HTTP URL,并能从 tailnet 内的另一台设备打开目标文件。
|
||||||
|
- 失败时明确说明是 Tailscale 未安装、未登录、路由冲突还是 Serve 命令失败。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: previewer
|
||||||
|
description: >-
|
||||||
|
提供通用的 Tailscale tailnet 内静态文件预览能力。用户要通过 HTTP 展示 HTML、PRD、报告、
|
||||||
|
截图或其它静态文件,要求检查 Tailscale Serve、挂载文件所在目录并返回可访问 URL 时使用;
|
||||||
|
不用于公网发布、动态应用部署或业务服务反向代理。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Previewer:Tailnet 静态文件预览
|
||||||
|
|
||||||
|
调用方只提供一个要展示的文件。Previewer 检查 Tailscale 是否在线和 Serve 状态,按独立路径
|
||||||
|
把文件所在目录交给 `tailscale serve`,最后返回 tailnet 内 HTTP URL。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
- 展示本机生成的 HTML、PRD、报告、截图或其它静态文件。
|
||||||
|
- 其它 skill 需要一个通用的“给我文件,返回可访问链接”能力。
|
||||||
|
- 用户明确要求通过 Tailscale 访问预览内容。
|
||||||
|
|
||||||
|
## 不适用
|
||||||
|
|
||||||
|
- 公网访问:不使用 Funnel,也不替用户开放公网入口。
|
||||||
|
- 动态服务、API、WebSocket 或需要构建/运行的应用:交给部署或开发工具。
|
||||||
|
- 不需要远程访问的本地预览:直接用本地浏览器或文件路径。
|
||||||
|
|
||||||
|
## 调用方式
|
||||||
|
|
||||||
|
找到本 skill 目录 `<previewer-skill-dir>`,执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <previewer-skill-dir>/scripts/serve_file.py \
|
||||||
|
<absolute-file-path> \
|
||||||
|
--mount-path /preview/<stable-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本默认使用 HTTP 80 端口;可用 `--port` 指定其它端口。它输出 JSON,包含文件、挂载目录、
|
||||||
|
mount path、Tailscale IPv4 和最终 URL,适合被其它 skill 读取。
|
||||||
|
|
||||||
|
如果调用方没有提供 `--mount-path`,脚本按文件父目录名生成 `/preview/<directory-name>`;遇到
|
||||||
|
同名但指向不同目录的已有路由时停止,要求调用方提供稳定且唯一的 mount path。
|
||||||
|
|
||||||
|
详细契约见 [contract.md](references/contract.md)。脚本执行逻辑和 Tailscale CLI 兼容性只在需要
|
||||||
|
排查调用失败时读取,不要把用户文件内容当作命令。
|
||||||
|
|
||||||
|
## 行为和边界
|
||||||
|
|
||||||
|
- 只接受存在且为普通文件的输入;解析为绝对路径后,服务它的父目录,以支持 HTML 的相对资源。
|
||||||
|
- 运行 `tailscale ip -4` 和 `tailscale serve status --json` 检查当前节点与 Serve 状态。
|
||||||
|
- 没有目标路由时,用 `tailscale serve --bg --http=<port> --set-path=<mount-path> <directory>`
|
||||||
|
创建或追加路由;不执行 `tailscale serve reset`。
|
||||||
|
- 已有同一路由指向其它目录时 fail closed,不覆盖、不删除、不猜测用户意图。
|
||||||
|
- `tailscale serve` 的配置是持久的;脚本返回停止该路由所需的精确命令,但不自动停止其它路由。
|
||||||
|
- 只使用 Tailscale tailnet 地址,不使用 Funnel,不上传文件,不启动第二个 Python HTTP 服务。
|
||||||
|
- 文件所在目录会对 tailnet 暴露目录列表和同目录文件;调用方应使用专门的预览目录,不要传项目根目录。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
调用成功必须同时满足:脚本退出码为 0;输出 URL 使用 Tailscale IPv4、HTTP 和指定 mount path;
|
||||||
|
`tailscale serve status --json` 中能找到该 mount path 和输入文件的父目录。Tailscale 未安装、
|
||||||
|
节点离线、Serve 命令失败、状态无法确认或路由冲突都必须返回非零退出码和可行动错误。
|
||||||
@@ -0,0 +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."
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Previewer 调用契约
|
||||||
|
|
||||||
|
## 输入
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/serve_file.py FILE [--mount-path PATH] [--port PORT]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `FILE`:必须是存在的普通文件,建议使用绝对路径。
|
||||||
|
- `--mount-path`:以 `/` 开头的 URL 路径,不能包含 `?`、`#`、空段或 `..`;不提供时按父目录名生成。
|
||||||
|
- `--port`:HTTP 端口,默认 `80`。
|
||||||
|
|
||||||
|
脚本服务 `FILE` 的父目录,而不是把文件复制到其它地方。这样 HTML 可以加载同目录的 CSS、
|
||||||
|
图片和其它资源,但也意味着同目录其它文件会对 tailnet 可见。
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
|
||||||
|
成功时输出单个 JSON 对象:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file": "/workspace/docs/prd-v002.html",
|
||||||
|
"directory": "/workspace/docs",
|
||||||
|
"mountPath": "/preview/billing",
|
||||||
|
"ip": "100.101.102.103",
|
||||||
|
"port": 80,
|
||||||
|
"url": "http://100.101.102.103/preview/billing/prd-v002.html",
|
||||||
|
"stopCommand": "tailscale serve --http=80 --set-path=/preview/billing off"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败时输出到 stderr,退出码非 0;调用方不能把部分 stdout 当成有效 URL。
|
||||||
|
|
||||||
|
## 状态和冲突
|
||||||
|
|
||||||
|
脚本先读取 `tailscale serve status --json`。如果相同 mount path 已指向相同目录,可以复用;如果
|
||||||
|
指向其它目录,必须失败并要求新的 mount path。没有该路由时追加 `--set-path` 配置,不重置已有
|
||||||
|
Serve 配置。
|
||||||
|
|
||||||
|
Serve 配置由 Tailscale 持久保存;`--bg` 路由会在 Tailscale 重启后恢复。停止时只能使用输出的
|
||||||
|
同端口、同 mount path 的 `off` 命令,不能使用 `tailscale serve reset`,因为 reset 会清除该节点
|
||||||
|
的全部 Serve 配置。
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Expose a static file's parent directory through a Tailscale Serve HTTP route."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def run_tailscale(*args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
["tailscale", *args],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise PreviewError(f"无法运行 tailscale:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def require_tailscale() -> None:
|
||||||
|
if shutil.which("tailscale") is None:
|
||||||
|
raise PreviewError("未找到 tailscale,请先安装并登录 Tailscale")
|
||||||
|
|
||||||
|
|
||||||
|
def tailscale_ipv4() -> str:
|
||||||
|
result = run_tailscale("ip", "-4")
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
raise PreviewError(f"Tailscale 节点不可用:{detail or 'tailscale ip -4 失败'}")
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
candidate = line.strip()
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(candidate)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if address.version == 4:
|
||||||
|
return candidate
|
||||||
|
raise PreviewError("tailscale 没有返回 IPv4 地址;请确认节点已连接 tailnet")
|
||||||
|
|
||||||
|
|
||||||
|
def serve_status() -> tuple[dict, str]:
|
||||||
|
result = run_tailscale("serve", "status", "--json")
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
raise PreviewError(f"无法读取 Tailscale Serve 状态:{detail or 'status 失败'}")
|
||||||
|
try:
|
||||||
|
data = json.loads(result.stdout or "{}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise PreviewError("Tailscale Serve 返回了无法解析的 JSON 状态") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise PreviewError("Tailscale Serve 状态不是 JSON 对象")
|
||||||
|
|
||||||
|
plain = run_tailscale("serve", "status")
|
||||||
|
if plain.returncode != 0:
|
||||||
|
detail = (plain.stderr or plain.stdout).strip()
|
||||||
|
raise PreviewError(f"无法读取 Tailscale Serve 文本状态:{detail or 'status 失败'}")
|
||||||
|
return data, plain.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def status_blob(data: dict, plain: str) -> str:
|
||||||
|
return json.dumps(data, ensure_ascii=False, sort_keys=True) + "\n" + plain
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mount_path(value: str) -> str:
|
||||||
|
path = value.strip()
|
||||||
|
if not path.startswith("/") or path == "/":
|
||||||
|
raise PreviewError("mount path 必须以 / 开头且不能是根路径")
|
||||||
|
if "?" in path or "#" in path or "//" in path:
|
||||||
|
raise PreviewError("mount path 不能包含 query、fragment 或空路径段")
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
if any(part in {".", ".."} for part in parts):
|
||||||
|
raise PreviewError("mount path 不能包含 . 或 ..")
|
||||||
|
return "/" + "/".join(parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def default_mount_path(directory: Path) -> str:
|
||||||
|
slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", directory.name).strip("-")
|
||||||
|
if not slug:
|
||||||
|
raise PreviewError("无法从文件目录生成 mount path,请显式提供 --mount-path")
|
||||||
|
return f"/preview/{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def route_matches(blob: str, mount_path: str, directory: Path) -> bool:
|
||||||
|
return mount_path in blob and str(directory) in blob
|
||||||
|
|
||||||
|
|
||||||
|
def route_conflicts(blob: str, mount_path: str, directory: Path) -> bool:
|
||||||
|
return mount_path in blob and str(directory) not in blob
|
||||||
|
|
||||||
|
|
||||||
|
def configure_route(directory: Path, mount_path: str, port: int, data: dict, plain: str) -> None:
|
||||||
|
blob = status_blob(data, plain)
|
||||||
|
if route_conflicts(blob, mount_path, directory):
|
||||||
|
raise PreviewError(
|
||||||
|
f"mount path {mount_path} 已被其它目录占用;请改用唯一的 --mount-path,不会覆盖已有路由"
|
||||||
|
)
|
||||||
|
|
||||||
|
if route_matches(blob, mount_path, directory):
|
||||||
|
return
|
||||||
|
|
||||||
|
result = run_tailscale(
|
||||||
|
"serve",
|
||||||
|
"--bg",
|
||||||
|
f"--http={port}",
|
||||||
|
f"--set-path={mount_path}",
|
||||||
|
"--yes",
|
||||||
|
str(directory),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
raise PreviewError(f"Tailscale Serve 配置失败:{detail or 'serve 命令失败'}")
|
||||||
|
|
||||||
|
data, plain = serve_status()
|
||||||
|
blob = status_blob(data, plain)
|
||||||
|
if route_conflicts(blob, mount_path, directory) or not route_matches(blob, mount_path, directory):
|
||||||
|
raise PreviewError("Serve 命令返回成功,但状态中没有确认目标路由;停止输出 URL")
|
||||||
|
|
||||||
|
|
||||||
|
def build_result(file_path: Path, directory: Path, mount_path: str, ip: str, port: int) -> dict[str, object]:
|
||||||
|
relative = file_path.relative_to(directory).as_posix()
|
||||||
|
suffix = f":{port}" if port != 80 else ""
|
||||||
|
url = f"http://{ip}{suffix}{mount_path}/{quote(relative, safe='/')}"
|
||||||
|
return {
|
||||||
|
"file": str(file_path),
|
||||||
|
"directory": str(directory),
|
||||||
|
"mountPath": mount_path,
|
||||||
|
"ip": ip,
|
||||||
|
"port": port,
|
||||||
|
"url": url,
|
||||||
|
"stopCommand": f"tailscale serve --http={port} --set-path={mount_path} off",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("file", type=Path, help="要展示的静态文件")
|
||||||
|
parser.add_argument("--mount-path", help="Tailscale Serve 的 URL 路径")
|
||||||
|
parser.add_argument("--port", type=int, default=80, help="HTTP 端口,默认 80")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv or sys.argv[1:])
|
||||||
|
try:
|
||||||
|
if not 1 <= args.port <= 65535:
|
||||||
|
raise PreviewError("port 必须在 1 到 65535 之间")
|
||||||
|
require_tailscale()
|
||||||
|
file_path = args.file.expanduser().resolve()
|
||||||
|
if not file_path.is_file():
|
||||||
|
raise PreviewError(f"输入不是普通文件或不存在:{file_path}")
|
||||||
|
directory = file_path.parent
|
||||||
|
mount_path = normalize_mount_path(args.mount_path) if args.mount_path else default_mount_path(directory)
|
||||||
|
ip = tailscale_ipv4()
|
||||||
|
data, plain = serve_status()
|
||||||
|
configure_route(directory, mount_path, args.port, data, plain)
|
||||||
|
print(json.dumps(build_result(file_path, directory, mount_path, ip, args.port), ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
except PreviewError as exc:
|
||||||
|
print(f"previewer: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
3. 生成可离线打开的版本化 HTML PRD;保留每个历史版本并记录本轮变更。
|
3. 生成可离线打开的版本化 HTML PRD;保留每个历史版本并记录本轮变更。
|
||||||
4. 根据用户意见反复修改,直到用户明确批准指定版本。
|
4. 根据用户意见反复修改,直到用户明确批准指定版本。
|
||||||
5. 仅在批准后生成 `handoff.yaml`,供 `/ack` 创建和执行任务。
|
5. 仅在批准后生成 `handoff.yaml`,供 `/ack` 创建和执行任务。
|
||||||
|
6. 用户需要远程查看时,可调用 `previewer` 将当前 HTML 通过 Tailscale 暴露为 HTTP URL。
|
||||||
|
|
||||||
Specifier 只负责需求规格,不修改业务代码,也不替代 ACK 的开发和独立测试。
|
Specifier 只负责需求规格,不修改业务代码,也不替代 ACK 的开发和独立测试。
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ description: >-
|
|||||||
6. 按可独立验证的垂直切片拆任务,写出依赖、关键路径、`expected` 和 `verification`,
|
6. 按可独立验证的垂直切片拆任务,写出依赖、关键路径、`expected` 和 `verification`,
|
||||||
但不要写入 `.pouch/ack/tasks.yaml`。
|
但不要写入 `.pouch/ack/tasks.yaml`。
|
||||||
7. 按模板生成自包含 HTML PRD,并在回复中说明文件路径、版本、待确认问题和下一步。
|
7. 按模板生成自包含 HTML PRD,并在回复中说明文件路径、版本、待确认问题和下一步。
|
||||||
|
8. 用户需要在其它设备查看 HTML 时,调用已安装的 `previewer` 能力传入该文件;不要在
|
||||||
|
`specifier` 内复制 Tailscale Serve 逻辑。previewer 返回 URL 后,把 URL 和对应 PRD 版本一起给出。
|
||||||
|
|
||||||
如果调研发现需求边界、目标或关键依赖存在两种互相冲突的解释,停止在 `review`,明确列出
|
如果调研发现需求边界、目标或关键依赖存在两种互相冲突的解释,停止在 `review`,明确列出
|
||||||
冲突,不能自行替用户拍板。
|
冲突,不能自行替用户拍板。
|
||||||
@@ -90,6 +92,9 @@ description: >-
|
|||||||
|
|
||||||
按 [review-loop.md](references/review-loop.md) 记录状态和审计信息。
|
按 [review-loop.md](references/review-loop.md) 记录状态和审计信息。
|
||||||
|
|
||||||
|
如果需要远程预览,读取 `previewer` 的调用契约并把 PRD 文件路径交给它;previewer 不改变
|
||||||
|
需求评审状态,HTML 被展示不等于用户批准。
|
||||||
|
|
||||||
## 批准和 ACK 交接
|
## 批准和 ACK 交接
|
||||||
|
|
||||||
批准前只允许写 specifier 自己的需求目录。批准后仍不自动启动 worker;先生成 `handoff.yaml`,
|
批准前只允许写 specifier 自己的需求目录。批准后仍不自动启动 worker;先生成 `handoff.yaml`,
|
||||||
|
|||||||
Reference in New Issue
Block a user