Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7a201962a | |||
| ad6695245b | |||
| ee31278947 | |||
| 282a6809ac | |||
| a8efa5f359 |
@@ -60,7 +60,7 @@ AGENTS.md # 本文档
|
|||||||
| Skill | 说明 |
|
| Skill | 说明 |
|
||||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
|
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
|
||||||
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
|
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行三角色闭环、测试环境、发版与回归 |
|
||||||
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md |
|
||||||
@@ -148,8 +148,8 @@ catalog.yaml ←── skiff add / fetch
|
|||||||
▼ ▼
|
▼ ▼
|
||||||
~/.cursor/skills/ project/.agents/skills/
|
~/.cursor/skills/ project/.agents/skills/
|
||||||
~/.claude/skills/ project/.claude/skills/
|
~/.claude/skills/ project/.claude/skills/
|
||||||
~/.codex/skills/ project/.opencode/skills/
|
~/.codex/skills/ project/.skills.yaml
|
||||||
~/.config/opencode/skills/ project/.skills.yaml
|
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Skill 三层分类
|
### Skill 三层分类
|
||||||
@@ -177,7 +177,7 @@ catalog.yaml ←── skiff add / fetch
|
|||||||
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
|
| Cursor | `~/.cursor/skills/` | `.agents/skills/` |
|
||||||
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
|
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
|
||||||
| Codex | `~/.codex/skills/` | `.agents/skills/` |
|
| Codex | `~/.codex/skills/` | `.agents/skills/` |
|
||||||
| OpenCode | `~/.config/opencode/skills/` | `.opencode/skills/` |
|
| Agents 标准 | `~/.agents/skills/` | `.agents/skills/`(与 cursor/codex 共用;覆盖 OMP) |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ skiff init ack
|
|||||||
skiff init ack --project ~/app
|
skiff init ack --project ~/app
|
||||||
```
|
```
|
||||||
|
|
||||||
初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护
|
初始化会生成默认关闭的 `docs/ack/delivery.yaml` 和空的 `docs/ack/regression.yaml`。
|
||||||
DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。
|
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
|
||||||
|
验证通过后收获回归用例。
|
||||||
|
|
||||||
新建 skill:
|
新建 skill:
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ npx skills find typescript
|
|||||||
~/.cursor/skills/ project/.agents/skills/
|
~/.cursor/skills/ project/.agents/skills/
|
||||||
~/.claude/skills/ project/.claude/skills/
|
~/.claude/skills/ project/.claude/skills/
|
||||||
~/.codex/skills/ project/.skills.yaml
|
~/.codex/skills/ project/.skills.yaml
|
||||||
|
~/.agents/skills/ (agents 标准目录,覆盖 OMP)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 设计原则
|
## 设计原则
|
||||||
|
|||||||
+3
-1
@@ -58,7 +58,7 @@ skiff bootstrap
|
|||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
| `skiff list [--source NAME]` | 列出所有来源或指定 source 中的 skill |
|
||||||
| `skiff status [--target all\|cursor\|claude\|codex]` | 安装状态总览 |
|
| `skiff status [--target all\|cursor\|claude\|codex\|agents]` | 安装状态总览 |
|
||||||
|
|
||||||
### 项目初始化
|
### 项目初始化
|
||||||
|
|
||||||
@@ -87,6 +87,7 @@ skiff bootstrap
|
|||||||
| cursor | `~/.cursor/skills/` |
|
| cursor | `~/.cursor/skills/` |
|
||||||
| claude | `~/.claude/skills/` |
|
| claude | `~/.claude/skills/` |
|
||||||
| codex | `~/.codex/skills/` |
|
| codex | `~/.codex/skills/` |
|
||||||
|
| agents | `~/.agents/skills/`(agents 标准目录,覆盖 OMP) |
|
||||||
|
|
||||||
### 预置 Catalog Source
|
### 预置 Catalog Source
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ skiff 不保存 token。可以使用 `company/code-review`,也可以使用
|
|||||||
| cursor | `<project>/.agents/skills/` |
|
| cursor | `<project>/.agents/skills/` |
|
||||||
| claude | `<project>/.claude/skills/` |
|
| claude | `<project>/.claude/skills/` |
|
||||||
| codex | `<project>/.agents/skills/` |
|
| codex | `<project>/.agents/skills/` |
|
||||||
|
| agents | `<project>/.agents/skills/`(与 cursor/codex 共用路径,软链幂等) |
|
||||||
|
|
||||||
### 脚手架与健康检查
|
### 脚手架与健康检查
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ AGENT_ALIASES: dict[str, str] = {
|
|||||||
"claude": "claude",
|
"claude": "claude",
|
||||||
"claude-code": "claude",
|
"claude-code": "claude",
|
||||||
"codex": "codex",
|
"codex": "codex",
|
||||||
"opencode": "opencode",
|
"agents": "agents",
|
||||||
"*": "*",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
AGENT_CHOICES = sorted({*ALL_TARGETS, *AGENT_ALIASES.keys()})
|
||||||
|
|||||||
+30
-3
@@ -1312,9 +1312,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
tasks_file = destination / "tasks.yaml"
|
tasks_file = destination / "tasks.yaml"
|
||||||
knowledge_file = destination / "knowledge.yaml"
|
knowledge_file = destination / "knowledge.yaml"
|
||||||
delivery_file = destination / "delivery.yaml"
|
delivery_file = destination / "delivery.yaml"
|
||||||
|
regression_file = destination / "regression.yaml"
|
||||||
managed_targets = [project_file, tasks_file]
|
managed_targets = [project_file, tasks_file]
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
managed_targets.extend((knowledge_file, delivery_file))
|
managed_targets.extend((knowledge_file, delivery_file, regression_file))
|
||||||
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||||
if existing:
|
if existing:
|
||||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||||
@@ -1331,6 +1332,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
(
|
(
|
||||||
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
|
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
|
||||||
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
|
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
|
||||||
|
(
|
||||||
|
skill_source / "templates" / "regression.template.yaml",
|
||||||
|
regression_file,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
missing = [path for path, _ in template_targets if not path.is_file()]
|
missing = [path for path, _ in template_targets if not path.is_file()]
|
||||||
@@ -1340,10 +1345,16 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||||
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
||||||
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
||||||
|
regression_validator = skill_source / "scripts" / "validate_regression.py"
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
missing_validators = [
|
missing_validators = [
|
||||||
path
|
path
|
||||||
for path in (validator, knowledge_validator, delivery_validator)
|
for path in (
|
||||||
|
validator,
|
||||||
|
knowledge_validator,
|
||||||
|
delivery_validator,
|
||||||
|
regression_validator,
|
||||||
|
)
|
||||||
if not path.is_file()
|
if not path.is_file()
|
||||||
]
|
]
|
||||||
if missing_validators:
|
if missing_validators:
|
||||||
@@ -1416,6 +1427,21 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"初始化交付契约校验失败(exit {completed.returncode})"
|
f"初始化交付契约校验失败(exit {completed.returncode})"
|
||||||
)
|
)
|
||||||
|
if args.name == "ack" and regression_validator.is_file():
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(regression_validator),
|
||||||
|
str(staged_files[regression_file]),
|
||||||
|
"--tasks",
|
||||||
|
str(staged_files[tasks_file]),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise SystemExit(
|
||||||
|
f"初始化回归目录校验失败(exit {completed.returncode})"
|
||||||
|
)
|
||||||
|
|
||||||
for target, staged in staged_files.items():
|
for target, staged in staged_files.items():
|
||||||
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
||||||
@@ -1644,6 +1670,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
_print(f" 知识库: {knowledge_file}")
|
_print(f" 知识库: {knowledge_file}")
|
||||||
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
||||||
|
_print(f" 回归目录: {regression_file}")
|
||||||
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
||||||
|
|
||||||
|
|
||||||
@@ -1655,7 +1682,7 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
|||||||
nargs="+",
|
nargs="+",
|
||||||
action="append",
|
action="append",
|
||||||
metavar="AGENT",
|
metavar="AGENT",
|
||||||
help="目标 agent(cursor、claude、claude-code、codex、opencode、*)",
|
help="目标 agent(cursor、claude、claude-code、codex、agents、*)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-g",
|
"-g",
|
||||||
|
|||||||
+5
-3
@@ -20,17 +20,19 @@ AGENT_GLOBAL: dict[str, Path] = {
|
|||||||
"cursor": HOME / ".cursor" / "skills",
|
"cursor": HOME / ".cursor" / "skills",
|
||||||
"claude": HOME / ".claude" / "skills",
|
"claude": HOME / ".claude" / "skills",
|
||||||
"codex": HOME / ".codex" / "skills",
|
"codex": HOME / ".codex" / "skills",
|
||||||
"opencode": HOME / ".config" / "opencode" / "skills",
|
# agents 标准目录:OMP 原生 canonical(agents provider),cursor/codex 项目级同路径
|
||||||
|
"agents": HOME / ".agents" / "skills",
|
||||||
}
|
}
|
||||||
|
|
||||||
AGENT_PROJECT: dict[str, str] = {
|
AGENT_PROJECT: dict[str, str] = {
|
||||||
"cursor": ".agents/skills",
|
"cursor": ".agents/skills",
|
||||||
"claude": ".claude/skills",
|
"claude": ".claude/skills",
|
||||||
"codex": ".agents/skills",
|
"codex": ".agents/skills",
|
||||||
"opencode": ".opencode/skills",
|
# 与 cursor/codex 共用 .agents/skills;软链幂等,同路径只写一次
|
||||||
|
"agents": ".agents/skills",
|
||||||
}
|
}
|
||||||
|
|
||||||
ALL_TARGETS = ("cursor", "claude", "codex", "opencode")
|
ALL_TARGETS = ("cursor", "claude", "codex", "agents")
|
||||||
|
|
||||||
|
|
||||||
def resolve_targets(target: str | None) -> list[str]:
|
def resolve_targets(target: str | None) -> list[str]:
|
||||||
|
|||||||
+87
-25
@@ -9,10 +9,26 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
|
|||||||
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
||||||
`leftover`,然后继续处理其它任务。
|
`leftover`,然后继续处理其它任务。
|
||||||
|
|
||||||
项目还可以在同一份 `docs/ack/delivery.yaml` 里声明测试环境部署和版本发布。
|
测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份
|
||||||
用户告诉 ACK 这两件事怎么做之后,再说「重新布测试环境」或「发布一个版本」,
|
`docs/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进
|
||||||
ACK 按对应 intent 执行。任务全部验证后仍可按 profile 做常规交付。配置默认关闭,
|
`docs/ack/regression.yaml`;之后可以单独跑回归。
|
||||||
稳定发布与生产部署始终保留人工批准点。
|
|
||||||
|
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
| 场景 | 怎么说 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 初始化 | `/ack 初始化` | 生成并补全 `docs/ack/` |
|
||||||
|
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件 |
|
||||||
|
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 |
|
||||||
|
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 |
|
||||||
|
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
|
||||||
|
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.skiff/deployer/<env>` |
|
||||||
|
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
|
||||||
|
| 回归 | `/ack 回归` | 先布测试环境,再按 `regression.yaml` 用浏览器或 API 跑 |
|
||||||
|
|
||||||
|
做需求和修 bug 在 `verified` 之后,都要更新回归目录。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -28,8 +44,6 @@ skiff add ack -g
|
|||||||
skiff add ack
|
skiff add ack
|
||||||
```
|
```
|
||||||
|
|
||||||
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
|
||||||
|
|
||||||
## 初始化项目
|
## 初始化项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,7 +58,8 @@ docs/ack/
|
|||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
├── knowledge.yaml
|
├── knowledge.yaml
|
||||||
└── delivery.yaml # 默认 enabled: false
|
├── delivery.yaml # 默认 enabled: false
|
||||||
|
└── regression.yaml # 默认 cases: []
|
||||||
```
|
```
|
||||||
|
|
||||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
||||||
@@ -62,17 +77,19 @@ skills/ack/
|
|||||||
├── README.md
|
├── README.md
|
||||||
├── VERSION
|
├── VERSION
|
||||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
├── references/ # 三角色规范、闭环流程和初始化说明
|
||||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
|
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml、regression.yaml 模板和 schema
|
||||||
├── examples/ # 完整示例
|
├── examples/ # 完整示例
|
||||||
└── scripts/ # 状态校验、任务/知识选择、安全验证执行与结构化 worker launcher
|
└── scripts/ # 状态校验、任务/知识/回归选择、安全验证执行与结构化 worker launcher
|
||||||
```
|
```
|
||||||
|
|
||||||
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
|
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
|
||||||
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
||||||
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
||||||
已经独立验证的项目知识护栏。
|
已经独立验证的项目知识护栏。
|
||||||
`docs/ack/delivery.yaml` 是测试环境部署和版本发布的唯一契约,也声明常规构建、
|
`docs/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一契约;测试环境由 ACK
|
||||||
发布和部署能力;每次执行结果另记在 `tasks.yaml.deliveryRuns`。
|
内部调用 deployer,运行证据记在 `tasks.yaml.deliveryRuns`。
|
||||||
|
`docs/ack/regression.yaml` 是黑盒回归用例目录,运行证据记在
|
||||||
|
`tasks.yaml.regressionRuns`。
|
||||||
|
|
||||||
## 检查项目状态
|
## 检查项目状态
|
||||||
|
|
||||||
@@ -84,6 +101,8 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
|||||||
--tasks docs/ack/tasks.yaml
|
--tasks docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_regression.py docs/ack/regression.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
Coordinator 可以按当前任务上下文做确定性推荐:
|
||||||
@@ -95,6 +114,10 @@ python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
|
|||||||
|
|
||||||
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
|
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
|
||||||
--component web --path web/app.py --tag long-running-service --limit 10
|
--component web --path web/app.py --tag long-running-service --limit 10
|
||||||
|
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py docs/ack/regression.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py docs/ack/regression.yaml \
|
||||||
|
--suite full --case-id REG-login-001
|
||||||
```
|
```
|
||||||
|
|
||||||
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
|
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
|
||||||
@@ -134,30 +157,31 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
|
|||||||
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
|
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
|
||||||
同时只补 `project.knowledgeFile: docs/ack/knowledge.yaml`,再运行跨文件校验。
|
同时只补 `project.knowledgeFile: docs/ack/knowledge.yaml`,再运行跨文件校验。
|
||||||
|
|
||||||
只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`。知识正文不能作为自由 shell
|
只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`。知识正文
|
||||||
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
不能作为自由 shell 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK
|
||||||
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
|
|
||||||
## 配置与运行交付
|
## 配置与运行交付
|
||||||
|
|
||||||
用户可以直接向 `/ack` 说明两件独立操作,并写进同一份契约:
|
测试环境走 deployer 的项目内布局(通常是 `.skiff/deployer/test`),发版仍用
|
||||||
|
delivery profile。可以直接说:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ack 测试时先 go build -o garden ./cmd/garden,再启动这个二进制;
|
/ack 测试环境用项目里的 .skiff/deployer/test;发版方式以后再告诉你。
|
||||||
发版方式以后再告诉你。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
ACK 把它维护成 `docs/ack/delivery.yaml` 的 `intents.testEnvironment` /
|
ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成
|
||||||
`intents.release`、entrypoint、artifact、environment 和 profile。首次配置保持
|
`intents.release` 指向的 profile。首次配置保持关闭,确认后才启用。之后可以说:
|
||||||
关闭,确认后才启用。之后用户可以说:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ack 重新布一下测试环境,我要测试
|
/ack 重新布一下测试环境,我要测试
|
||||||
/ack 发布一个版本
|
/ack 发布一个版本
|
||||||
```
|
```
|
||||||
|
|
||||||
对应 intent 未配置时先问清楚并写回同一文件,不猜测。intent 运行不要求当前有
|
对应 intent 未配置时先问清楚并写回同一文件,不猜测。运行测试环境时 ACK 加载
|
||||||
`verified` 任务;`deliveryRuns.intent` 记录是测试环境还是发版。
|
deployer skill,不复制 compose 命令。本地进程启动写在 `project.md`,不算这个
|
||||||
|
模式。intent 运行不要求当前有 `verified` 任务;`deliveryRuns.intent` 记录是
|
||||||
|
测试环境还是发版。
|
||||||
|
|
||||||
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
|
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
|
||||||
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
|
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
|
||||||
@@ -235,7 +259,42 @@ Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verifi
|
|||||||
```
|
```
|
||||||
|
|
||||||
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
||||||
可观测验收信号;用户确认后才派发实现和复测。
|
可观测验收信号;用户确认后才派发实现和复测。任务 `verified` 后会把本轮黑盒路径
|
||||||
|
收进 `docs/ack/regression.yaml`,确认后才写入。
|
||||||
|
|
||||||
|
## 修复 bug
|
||||||
|
|
||||||
|
可以直接说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ack 修这个 bug:<现象、复现、期望>
|
||||||
|
```
|
||||||
|
|
||||||
|
Coordinator 写短问题说明、复现步骤和可观测验收,不写大 PRD;你确认后再派发。
|
||||||
|
Developer 先补会失败的用例再修,后续闭环与做需求相同。
|
||||||
|
|
||||||
|
配了飞书 `bugIntake` 时,飞书是审核前的唯一协作区。你只需维护标题、详细描述和
|
||||||
|
附件;Coordinator 整理问题说明、期望效果和验收标准并写回飞书。你针对当前
|
||||||
|
revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ack 处理飞书里待整理的 bug
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行回归
|
||||||
|
|
||||||
|
平时做需求和修 bug 结束后,ACK 按本轮内容更新 `docs/ack/regression.yaml`。
|
||||||
|
之后可以单独跑:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ack 回归
|
||||||
|
/ack 跑 full 回归
|
||||||
|
/ack 回归 REG-login-001
|
||||||
|
```
|
||||||
|
|
||||||
|
ACK 先按 deployer 布测试环境,再派独立 Test 按用例用浏览器或 API 执行。失败只
|
||||||
|
报告,不会自动开修;要修再说 `/ack 修这些回归失败`。默认跑 smoke。细则见
|
||||||
|
`references/regression.md`。
|
||||||
|
|
||||||
首次配置交付可以说:
|
首次配置交付可以说:
|
||||||
|
|
||||||
@@ -264,5 +323,8 @@ ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、
|
|||||||
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
|
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
|
||||||
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
|
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
|
||||||
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
|
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
|
||||||
`--always-approve`,sandbox 仍必开。旧项目可以不迁移而继续使用原闭环。旧项目的
|
`--always-approve`,sandbox 仍必开;从 `0.19.0` 起 `intents.testEnvironment` 改为
|
||||||
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
deployer 绑定,ACK 内部调用 deployer skill 布测试环境,并增加
|
||||||
|
`docs/ack/regression.yaml` 与「运行回归」模式。旧的测试环境 profile ID 字符串不再
|
||||||
|
执行,需要迁到 `{via: deployer, env: <env>}`。旧项目可以不迁移回归目录而继续使用
|
||||||
|
原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||||
|
|||||||
+84
-35
@@ -2,16 +2,17 @@
|
|||||||
name: ack
|
name: ack
|
||||||
description: >-
|
description: >-
|
||||||
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||||
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或修复 bug、指挥
|
||||||
工作,配置测试环境与发版方式,重新部署测试环境,或发布版本时使用。
|
Coordinator/Developer/Test 工作,配置测试环境与发版方式,重新部署测试环境
|
||||||
|
(内部调用 deployer),发布版本,或运行回归测试时使用。
|
||||||
---
|
---
|
||||||
|
|
||||||
# ACK 项目协作入口
|
# ACK 项目协作入口
|
||||||
|
|
||||||
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
||||||
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
||||||
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
|
`tasks.yaml`、`knowledge.yaml`、默认关闭的 `delivery.yaml` 和空的
|
||||||
内容。
|
`regression.yaml`,不要复制或链接 Skill 内容。
|
||||||
|
|
||||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
||||||
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
||||||
@@ -20,12 +21,16 @@ description: >-
|
|||||||
|
|
||||||
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
||||||
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
||||||
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
- 用户要求用 ACK 做需求、修复 bug 或继续任务:执行“工作”。修 bug 不写大 PRD;
|
||||||
|
飞书收件仍走本模式。
|
||||||
- 用户用自然语言说明怎么部署测试环境、怎么发布版本,或要求增加、修改、关闭交付
|
- 用户用自然语言说明怎么部署测试环境、怎么发布版本,或要求增加、修改、关闭交付
|
||||||
流程:执行“交付配置维护”。测试环境和发版必须写进同一份
|
流程:执行“交付配置维护”。测试环境绑定 deployer,发版写在同一份
|
||||||
`docs/ack/delivery.yaml`。
|
`docs/ack/delivery.yaml`。
|
||||||
- 用户要求部署、重新部署测试环境,或按已配置方式开始测试:执行“运行测试环境”。
|
- 用户要求部署、重新部署测试环境,或按已配置方式开始测试:执行“运行测试环境”。
|
||||||
|
内部加载 deployer skill,不在 ACK 里复制 compose/rsync 命令。
|
||||||
- 用户要求发布版本:执行“运行版本发布”。
|
- 用户要求发布版本:执行“运行版本发布”。
|
||||||
|
- 用户要求回归、跑回归测试:执行“运行回归”。先布测试环境,再派 Test 按
|
||||||
|
`docs/ack/regression.yaml` 用浏览器或 API 执行。
|
||||||
|
|
||||||
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
||||||
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
||||||
@@ -67,7 +72,10 @@ description: >-
|
|||||||
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
||||||
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
|
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
|
||||||
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
|
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
|
||||||
9. 更新 `updatedAt`,并运行:
|
9. 检查 `docs/ack/regression.yaml`。新项目保留 `cases: []`。旧项目没有该文件时仍
|
||||||
|
可继续原闭环;用户授权后从 `templates/regression.template.yaml` 生成,并只补
|
||||||
|
`project.regressionFile` 与顶层 `regressionRuns: []`。不要从聊天虚构用例。
|
||||||
|
10. 更新 `updatedAt`,并运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
@@ -75,14 +83,16 @@ description: >-
|
|||||||
--tasks docs/ack/tasks.yaml
|
--tasks docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_regression.py docs/ack/regression.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有
|
11. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 与
|
||||||
`<...>` 占位符。
|
`regression.yaml` 是否仍有 `<...>` 占位符。
|
||||||
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
||||||
缺失值。
|
缺失值。
|
||||||
11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
12. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
||||||
不推送。
|
不推送。
|
||||||
|
|
||||||
## 检查
|
## 检查
|
||||||
|
|
||||||
@@ -91,6 +101,7 @@ description: >-
|
|||||||
- `docs/ack/tasks.yaml`
|
- `docs/ack/tasks.yaml`
|
||||||
- `docs/ack/knowledge.yaml`
|
- `docs/ack/knowledge.yaml`
|
||||||
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||||
|
- `docs/ack/regression.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||||
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
|
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
|
||||||
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
|
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
|
||||||
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
|
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
|
||||||
@@ -105,8 +116,10 @@ description: >-
|
|||||||
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
|
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
|
||||||
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
|
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
|
||||||
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <project-root>`
|
||||||
校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目
|
校验交付能力、顺序、安全边界和跨文件引用。如果存在回归目录或任务板声明了
|
||||||
缺少可选交付配置而宣称失败。
|
`project.regressionFile`,再使用 `<ack-skill-dir>/scripts/validate_regression.py
|
||||||
|
docs/ack/regression.yaml --tasks docs/ack/tasks.yaml` 校验用例与跨文件引用。
|
||||||
|
只报告证据明确的问题,不因旧项目缺少可选交付或回归配置而宣称失败。
|
||||||
5. 若存在 `project.bugIntake`,运行
|
5. 若存在 `project.bugIntake`,运行
|
||||||
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。
|
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。
|
||||||
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
|
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
|
||||||
@@ -136,6 +149,8 @@ description: >-
|
|||||||
- kickoff 指定且与当前任务相关的 references 文件
|
- kickoff 指定且与当前任务相关的 references 文件
|
||||||
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
|
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
|
||||||
`<ack-skill-dir>/references/delivery.md`
|
`<ack-skill-dir>/references/delivery.md`
|
||||||
|
- 若 `tasks.yaml.project.regressionFile` 存在,再读取该 `regression.yaml` 和
|
||||||
|
`<ack-skill-dir>/references/regression.md`
|
||||||
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
||||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||||
@@ -160,9 +175,10 @@ description: >-
|
|||||||
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||||
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||||
然后交给用户确认;若启用了交付,必须默认把 `defaultProfile`、目标、停止点和需要
|
然后交给用户确认。修 bug 写短问题说明、复现步骤和可观测验收,不写大 PRD;
|
||||||
审批的步骤放入同一份计划,不能静默省略。用户可明确取消本轮交付;确认前不派发
|
Developer 先补会失败的用例再修。若启用了交付,必须默认把 `defaultProfile`、
|
||||||
实现,也不执行交付。
|
目标、停止点和需要审批的步骤放入同一份计划,不能静默省略。用户可明确取消
|
||||||
|
本轮交付;确认前不派发实现,也不执行交付。
|
||||||
5. 创建或更换 worker 时,只使用
|
5. 创建或更换 worker 时,只使用
|
||||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||||
@@ -180,8 +196,11 @@ description: >-
|
|||||||
注入;卡在审批提示、未回车或额度限制时按环境失败处理并报告),等待期间用
|
注入;卡在审批提示、未回车或额度限制时按环境失败处理并报告),等待期间用
|
||||||
`scripts/worker_probe.py` 滚动检查活性,不盲等 `worker_done`。Coordinator 读取
|
`scripts/worker_probe.py` 滚动检查活性,不盲等 `worker_done`。Coordinator 读取
|
||||||
证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和
|
证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和
|
||||||
`knowledgeCandidates`,Test 回报 `knowledgeChecks`;`candidate` 只有在独立验证和
|
`knowledgeCandidates`,Test 回报 `knowledgeChecks` 和 `regressionCandidates`;
|
||||||
gate 后才能由 Coordinator 写入或激活。
|
`candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。
|
||||||
|
任务进入 `verified` 且改了用户可见行为或 API 后,按 `references/regression.md`
|
||||||
|
给出新增/更新/退役/无回归四选一,用户确认后写入 `docs/ack/regression.yaml`,
|
||||||
|
并把 case id 记入 `regressionRefs`。Test 只提名,不写该文件。
|
||||||
7. 执行知识项的 `verification.ref` 时,只调用
|
7. 执行知识项的 `verification.ref` 时,只调用
|
||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||||
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
<verification-ref> --project-root <project-root>`。不要直接执行选择器返回的 path/args,
|
||||||
@@ -211,11 +230,13 @@ description: >-
|
|||||||
|
|
||||||
## 交付配置维护
|
## 交付配置维护
|
||||||
|
|
||||||
1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布
|
1. 读取 `references/delivery.md`、deployer skill、模板、schema、现有
|
||||||
入口和 CI;把用户自然语言描述转换为结构化 `intents`、entrypoint、artifact、
|
`delivery.yaml`、项目构建/发布入口和 CI。测试环境写成
|
||||||
destination、environment 与 profile。测试环境部署和版本发布都写进这一份
|
`intents.testEnvironment: {via: deployer, env: <env>}`,并按 deployer skill
|
||||||
`delivery.yaml`,不要拆成第二份文档。配置只引用仓库内脚本或声明式工具 target,
|
准备 `.skiff/deployer/<env>`;不要把 compose/rsync 命令写进 ACK。发版仍指向
|
||||||
不保存 shell。
|
本文件的 profile。不要拆成第二份文档。配置只引用仓库内脚本或声明式工具
|
||||||
|
target,不保存 shell。本地 `npm run dev` / `go run` 写在 `project.md` 的
|
||||||
|
Developer 白盒命令里,不算测试环境部署。
|
||||||
2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
|
2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
|
||||||
`deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
|
`deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
|
||||||
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
|
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
|
||||||
@@ -227,13 +248,39 @@ description: >-
|
|||||||
|
|
||||||
## 运行测试环境
|
## 运行测试环境
|
||||||
|
|
||||||
1. 读取 `docs/ack/delivery.yaml` 与 `references/delivery.md`。
|
1. 读取 `docs/ack/delivery.yaml`、`references/delivery.md` 和 deployer skill 的
|
||||||
|
`SKILL.md`。
|
||||||
2. `enabled` 不为 true,或 `intents.testEnvironment` 为 null:停止,请用户说明如何
|
2. `enabled` 不为 true,或 `intents.testEnvironment` 为 null:停止,请用户说明如何
|
||||||
部署测试环境,转入交付配置维护。不猜测编译或启动命令。
|
部署测试环境,转入交付配置维护。不猜测编译或启动命令。
|
||||||
3. 不要求任务已 `verified`。按该 profile 执行 build → deploy → health-check。
|
3. `intents.testEnvironment` 必须是 `{via: deployer, env: <env>}`。若仍是旧的
|
||||||
4. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`,
|
profile ID 字符串:停止,展示迁移说明,转入交付配置维护。不要执行 ACK
|
||||||
`intent: testEnvironment`,`taskIds` 可为空。
|
delivery profile 来布测试环境。
|
||||||
5. 派发 Test 前若该 intent 已启用,必须先完成本步骤。
|
4. 不要求任务已 `verified`。按 deployer 的项目内环境布局操作
|
||||||
|
`.skiff/deployer/<env>`:list 确认服务,再按服务 sync + up(或用户要求的
|
||||||
|
recreate),并用 ps/logs/健康检查验证。不要复制 deployer 脚本,不要发明
|
||||||
|
第二套 compose 命令。
|
||||||
|
5. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`,
|
||||||
|
`intent: testEnvironment`,`profile` 记 `deployer-<env>`,`taskIds` 可为空。
|
||||||
|
6. 派发 Test 前若该 intent 已启用,必须先完成本步骤。deployer 未安装、环境目录
|
||||||
|
不存在或健康检查失败:fail closed,报告 `userAction`,不把环境失败写成产品
|
||||||
|
失败。
|
||||||
|
|
||||||
|
## 运行回归
|
||||||
|
|
||||||
|
1. 若 `docs/ack` 不存在,停止并建议先初始化。
|
||||||
|
2. 若没有 `docs/ack/regression.yaml` 或 `project.regressionFile`:停止,用户授权后
|
||||||
|
从模板生成空文件并只补任务板指针与 `regressionRuns: []`。
|
||||||
|
3. 用 `scripts/select_regression.py` 读取 active 用例(默认 `--suite smoke`;用户
|
||||||
|
指定 full 或 case id 时缩小范围)。没有命中用例时停止并说明先收获用例。
|
||||||
|
不要把完整 `regression.yaml` 注入上下文。细则见 `references/regression.md`。
|
||||||
|
4. 先执行「运行测试环境」。
|
||||||
|
5. 当前会话担任 Coordinator:按 Test profile 启动独立 Test worker,派发回归清单、
|
||||||
|
Base URL 和每条 case 的 surface/steps/expected。Coordinator 不亲自点浏览器或
|
||||||
|
打 API。
|
||||||
|
6. Test 按 `surface` 执行:`browser` 必须走真实交互,不得改成只打 API。逐条对照
|
||||||
|
`expected` 回报。环境失败记环境事件,不记产品失败。
|
||||||
|
7. Coordinator 终检后写入 `regressionRuns`。失败只报告,不自动派 Developer,不占
|
||||||
|
任务三轮预算。用户明确要求修复时再按修 bug 为每条失败开任务。
|
||||||
|
|
||||||
## 运行版本发布
|
## 运行版本发布
|
||||||
|
|
||||||
@@ -264,13 +311,15 @@ description: >-
|
|||||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||||
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||||
提交、推送、创建终端、新 worktree、发布产物或部署。
|
提交、推送、创建终端、新 worktree、发布产物或部署。
|
||||||
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml`、`regression.yaml`、
|
||||||
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
|
`deliveryRuns` 和 `regressionRuns`;Developer 与 Test 只读,只能通过回报提名
|
||||||
维护中修改。
|
或验证。`delivery.yaml` 只在显式的交付配置维护中修改。
|
||||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
||||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务
|
- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务
|
||||||
视图,写回前仍运行完整任务板校验。
|
视图,写回前仍运行完整任务板校验。
|
||||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
|
`docs/ack/knowledge.yaml`、可选的 `docs/ack/delivery.yaml` 和可选的
|
||||||
ACK Skill 目录读取。
|
`docs/ack/regression.yaml`;通用资源始终从当前 ACK Skill 目录读取。
|
||||||
|
- 不把完整 `regression.yaml` 注入上下文;使用 `select_regression.py` 获取有预算的
|
||||||
|
用例视图,写回前仍运行完整校验。
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.18.0
|
0.19.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "ACK"
|
display_name: "ACK"
|
||||||
short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环"
|
short_description: "初始化、检查并运行 ACK 开发、验证、测试环境、发版与回归闭环"
|
||||||
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, record how to deploy the test environment and publish a release in delivery.yaml, redeploy the test environment, or publish a version."
|
default_prompt: "Use $ack to initialize or check ACK, coordinate feature or bug work, bind the test environment to deployer, redeploy it, publish a release, or run regression."
|
||||||
policy:
|
policy:
|
||||||
allow_implicit_invocation: false
|
allow_implicit_invocation: false
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ enabled: true
|
|||||||
defaultProfile: "review"
|
defaultProfile: "review"
|
||||||
|
|
||||||
intents:
|
intents:
|
||||||
testEnvironment: test-env
|
testEnvironment:
|
||||||
|
via: deployer
|
||||||
|
env: test
|
||||||
release: null
|
release: null
|
||||||
|
|
||||||
entrypoints:
|
entrypoints:
|
||||||
@@ -104,19 +106,6 @@ environments:
|
|||||||
mutex: "notes-test-deploy"
|
mutex: "notes-test-deploy"
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
test-env:
|
|
||||||
stopAt: validation_ready
|
|
||||||
steps:
|
|
||||||
- id: build-deb
|
|
||||||
action: build
|
|
||||||
artifact: service-deb
|
|
||||||
- id: deploy-test
|
|
||||||
action: deploy
|
|
||||||
artifact: service-deb
|
|
||||||
environment: test-server
|
|
||||||
- id: smoke-test
|
|
||||||
action: health-check
|
|
||||||
environment: test-server
|
|
||||||
review:
|
review:
|
||||||
stopAt: review_ready
|
stopAt: review_ready
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ack v0.18.0。
|
> 本项目基于 ack v0.19.0。
|
||||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。
|
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml`
|
||||||
|
> 与 `regression.yaml`。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`docs/ack/knowledge.yaml`
|
||||||
- 交付契约:`docs/ack/delivery.yaml`
|
- 交付契约:`docs/ack/delivery.yaml`
|
||||||
|
- 回归目录:`docs/ack/regression.yaml`
|
||||||
- 覆盖层文件:`docs/ack/project.md`
|
- 覆盖层文件:`docs/ack/project.md`
|
||||||
|
|
||||||
## 稳定规范(引用,不重复)
|
## 稳定规范(引用,不重复)
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令:`references/orca-adapter.md`
|
- Orca 编排命令:`references/orca-adapter.md`
|
||||||
- 验证后交付:`references/delivery.md`
|
- 验证后交付:`references/delivery.md`
|
||||||
|
- 回归目录与运行:`references/regression.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@
|
|||||||
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
|
||||||
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
@@ -80,8 +84,10 @@ ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动
|
|||||||
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
||||||
path/args。
|
path/args。
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||||
交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。
|
`scripts/validate_regression.py` 执行。
|
||||||
|
测试环境由 ACK 内部调用 deployer;发版机器入口以 `delivery.yaml` 为准,本覆盖层
|
||||||
|
不维护第二套发布或部署命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
version: 1
|
||||||
|
updatedAt: "2026-08-25T10:00:00+08:00"
|
||||||
|
project:
|
||||||
|
name: "notes-web"
|
||||||
|
cases:
|
||||||
|
- id: REG-login-001
|
||||||
|
title: 登录后进入工作台
|
||||||
|
status: active
|
||||||
|
source:
|
||||||
|
taskId: BUG-001
|
||||||
|
kind: bug
|
||||||
|
suite: smoke
|
||||||
|
surface: browser
|
||||||
|
setup: 使用测试账号 A,未登录
|
||||||
|
steps:
|
||||||
|
- 打开 /login
|
||||||
|
- 输入账号 A 并提交
|
||||||
|
expected:
|
||||||
|
- kind: visible-text
|
||||||
|
value: 工作台
|
||||||
|
- kind: url
|
||||||
|
value: /dashboard
|
||||||
|
- id: REG-notes-create-001
|
||||||
|
title: 创建笔记后列表出现标题
|
||||||
|
status: active
|
||||||
|
source:
|
||||||
|
taskId: FEAT-012
|
||||||
|
kind: feature
|
||||||
|
suite: full
|
||||||
|
surface: api
|
||||||
|
setup: 已登录账号 A,笔记列表为空
|
||||||
|
steps:
|
||||||
|
- POST /api/notes 创建标题为回归笔记的条目
|
||||||
|
- GET /api/notes
|
||||||
|
expected:
|
||||||
|
- kind: api-status
|
||||||
|
value: "200"
|
||||||
|
- kind: api-field
|
||||||
|
value: "items[].title=回归笔记"
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||||
source: "Coordinator (PM) Agent"
|
source: "Coordinator (PM) Agent"
|
||||||
ackVersion: "0.18.0"
|
ackVersion: "0.19.0"
|
||||||
project:
|
project:
|
||||||
name: "notes-web"
|
name: "notes-web"
|
||||||
baseUrl: "http://localhost:5173"
|
baseUrl: "http://localhost:5173"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: "docs/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||||
deliveryFile: "docs/ack/delivery.yaml"
|
deliveryFile: "docs/ack/delivery.yaml"
|
||||||
|
regressionFile: "docs/ack/regression.yaml"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "manual"
|
mode: "manual"
|
||||||
@@ -96,6 +97,7 @@ project:
|
|||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
deliveryRuns: []
|
deliveryRuns: []
|
||||||
|
regressionRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: ["BUG-002"]
|
verified: ["BUG-002"]
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
||||||
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
||||||
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与
|
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、
|
||||||
默认关闭的 `delivery.yaml`。
|
默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。
|
||||||
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
||||||
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||||
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
`docs/ack/knowledge.yaml`。
|
`docs/ack/knowledge.yaml`。
|
||||||
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
|
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
|
||||||
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
||||||
|
- [ ] 新项目的 `project.regressionFile` 固定为 `docs/ack/regression.yaml`,顶层有
|
||||||
|
`regressionRuns: []`;旧项目未采用回归能力时可无这两项。
|
||||||
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
||||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
- [ ] 私有配置只读且不提交。
|
- [ ] 私有配置只读且不提交。
|
||||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||||
|
- [ ] `regression.yaml` 只有 Coordinator 写;Test 只通过 `regressionCandidates` 提名。
|
||||||
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||||
|
|
||||||
## 任务板
|
## 任务板
|
||||||
@@ -48,8 +51,9 @@
|
|||||||
## 可选交付
|
## 可选交付
|
||||||
|
|
||||||
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
|
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
|
||||||
- [ ] 测试环境部署和版本发布都写在同一份 `delivery.yaml` 的 `intents` 中,没有第二份
|
- [ ] 测试环境绑定 deployer(`intents.testEnvironment.via: deployer`),发版写在
|
||||||
操作文档。未说明的 intent 保持 `null`。
|
同一份 `delivery.yaml` 的 `intents.release`。没有第二份操作文档。未说明的
|
||||||
|
intent 保持 `null`。旧 profile ID 字符串已迁移。
|
||||||
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
|
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
|
||||||
shell、自由 command、凭据值或环境变量值。
|
shell、自由 command、凭据值或环境变量值。
|
||||||
- [ ] artifact、destination、environment 和 profile 引用均通过
|
- [ ] artifact、destination、environment 和 profile 引用均通过
|
||||||
@@ -81,6 +85,13 @@
|
|||||||
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
|
- [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。
|
||||||
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
|
|
||||||
|
## 回归
|
||||||
|
|
||||||
|
- [ ] 新项目 `regression.yaml` 为 `cases: []`,没有虚构用例。
|
||||||
|
- [ ] 已运行 `validate_regression.py --tasks ...` 并通过。
|
||||||
|
- [ ] 任务 `verified` 后给出新增/更新/退役/无回归四选一,确认后才写入目录。
|
||||||
|
- [ ] 运行回归先走 deployer 测试环境,再派独立 Test;失败不自动开修。
|
||||||
|
|
||||||
## 编排
|
## 编排
|
||||||
|
|
||||||
- [ ] 已选择 Orca 或手动模式。
|
- [ ] 已选择 Orca 或手动模式。
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ Coordinator 发现或读取 open 任务
|
|||||||
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
-> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞)
|
||||||
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates)
|
||||||
-> writeback fixed_by_dev
|
-> writeback fixed_by_dev
|
||||||
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先执行该 profile
|
-> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先按 deployer
|
||||||
拉起待测服务,再派 Test;Test 不发明编译或启动命令
|
绑定拉起 `.skiff/deployer/<env>`,再派 Test;Test 不发明编译或启动命令
|
||||||
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
-> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker
|
||||||
-> dispatch 给 Test(retesting)
|
-> dispatch 给 Test(retesting)
|
||||||
-> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
-> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理)
|
||||||
@@ -174,9 +174,9 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
| 小改动、追求快 | 当前 worktree |
|
| 小改动、追求快 | 当前 worktree |
|
||||||
|
|
||||||
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
|
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
|
||||||
`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在
|
`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威
|
||||||
worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护
|
副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、
|
||||||
时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||||
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
||||||
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ frontendDir:
|
|||||||
worktreePath:
|
worktreePath:
|
||||||
```
|
```
|
||||||
|
|
||||||
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以该 profile 为准,不另写一套启动命令。
|
如果开发在 `<dev_worktree>` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以 deployer 绑定为准,不另写一套启动命令。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -54,28 +54,33 @@ channel、environment 或 source revision 漂移时重新确认。
|
|||||||
|
|
||||||
## 3.1 测试环境与发版写在同一份契约
|
## 3.1 测试环境与发版写在同一份契约
|
||||||
|
|
||||||
`docs/ack/delivery.yaml` 是测试环境部署和版本发布的唯一文档。不要另写操作手册,
|
`docs/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一文档。不要另写操作手册,
|
||||||
也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或
|
也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或
|
||||||
「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`、entrypoint、
|
「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`。
|
||||||
artifact、environment 和 profile。
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
intents:
|
intents:
|
||||||
testEnvironment: local-binary # profile ID,或 null
|
testEnvironment:
|
||||||
release: null # profile ID,或 null
|
via: deployer
|
||||||
|
env: test # 项目 .skiff/deployer/test;尚未说明时为 null
|
||||||
|
release: null # profile ID,或 null
|
||||||
```
|
```
|
||||||
|
|
||||||
- `testEnvironment` 指向 `stopAt: validation_ready` 的 profile:build 产物、部署到
|
- `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」
|
||||||
development/staging、健康检查。用户说「重新布测试环境」「我要测试」时执行它;
|
「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.skiff/deployer/<env>`
|
||||||
派发 Test 复测前,若该 intent 已配置且 `enabled: true`,Coordinator 也先执行它。
|
按服务执行 sync + up 和健康检查。派发 Test 复测或跑回归前,若该 intent 已配置
|
||||||
不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动命令。
|
且 `enabled: true`,Coordinator 也先执行它。不要求当前有 `verified` 任务。
|
||||||
|
Test 不对这个 intent 发明编译或启动命令。旧的 profile ID 字符串不再执行,必须
|
||||||
|
迁到 `{via: deployer, env: <env>}`。本地进程启动写在 `project.md`,不算这个
|
||||||
|
intent。
|
||||||
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
|
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
|
||||||
口头「发版」不能代替 stable/production 的 `approval` 步骤。
|
口头「发版」不能代替 stable/production 的 `approval` 步骤。
|
||||||
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
|
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
|
||||||
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
|
写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。
|
||||||
- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填
|
- 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填
|
||||||
`testEnvironment` 或 `release`,`taskIds` 可为空。绑定任务的常规交付 run 不填
|
`testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记
|
||||||
`intent`,仍只能引用 `verified` 任务。
|
`deployer-<env>`。绑定任务的常规交付 run 不填 `intent`,仍只能引用
|
||||||
|
`verified` 任务。
|
||||||
|
|
||||||
## 4. 运行前检查
|
## 4. 运行前检查
|
||||||
|
|
||||||
@@ -147,7 +152,9 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
|||||||
## 7. 与低层 Skill 的边界
|
## 7. 与低层 Skill 的边界
|
||||||
|
|
||||||
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
||||||
上传、镜像或 Git 发布实现。`builder` 和
|
上传、镜像、Git 发布或远程 Compose 实现。`builder`、`manage-release` 和
|
||||||
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载
|
||||||
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失
|
||||||
调用时,ACK 不能绕过它的触发与授权边界。
|
builder 或 manage-release 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用
|
||||||
|
时把对应步骤标为 `blocked`。低层 skill 自身要求显式调用时,ACK 不能绕过它的
|
||||||
|
触发与授权边界;ACK 内部调用 deployer 布测试环境是该 skill 的合法调用路径。
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
3. `skiff` 命令可用。
|
3. `skiff` 命令可用。
|
||||||
|
|
||||||
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent
|
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`docs/ack/regression.yaml`、
|
||||||
|
`AGENTS.md` 或其它 Agent
|
||||||
指令文件。ACK 不会自动
|
指令文件。ACK 不会自动
|
||||||
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
||||||
内容或其它私有配置写入 ACK 项目状态。
|
内容或其它私有配置写入 ACK 项目状态。
|
||||||
@@ -38,7 +39,8 @@ docs/ack/
|
|||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
├── knowledge.yaml
|
├── knowledge.yaml
|
||||||
└── delivery.yaml # 默认 enabled: false
|
├── delivery.yaml # 默认 enabled: false
|
||||||
|
└── regression.yaml # 默认 cases: []
|
||||||
```
|
```
|
||||||
|
|
||||||
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
||||||
@@ -62,6 +64,13 @@ docs/ack/
|
|||||||
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
||||||
解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。
|
解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。
|
||||||
|
|
||||||
|
### 旧项目补充回归目录
|
||||||
|
|
||||||
|
`regression.yaml` 对旧项目是可选能力。用户明确要求回归或授权补齐时,从
|
||||||
|
`templates/regression.template.yaml` 生成 `docs/ack/regression.yaml`,并只补
|
||||||
|
`project.regressionFile: docs/ack/regression.yaml` 与顶层 `regressionRuns: []`。
|
||||||
|
保持 `cases: []`,不要从聊天虚构用例。
|
||||||
|
|
||||||
## 完善项目覆盖层
|
## 完善项目覆盖层
|
||||||
|
|
||||||
编辑 `docs/ack/project.md`,填入:
|
编辑 `docs/ack/project.md`,填入:
|
||||||
@@ -89,6 +98,8 @@ docs/ack/
|
|||||||
不再参与路径绑定。
|
不再参与路径绑定。
|
||||||
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
||||||
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||||
|
- 新项目的 `project.regressionFile` 固定为 `docs/ack/regression.yaml`,并保留顶层
|
||||||
|
`regressionRuns: []`。旧项目只有在采用回归能力时才补这两个字段。
|
||||||
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
- `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在
|
||||||
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
`--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。
|
||||||
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||||
@@ -118,10 +129,16 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
|
|||||||
|
|
||||||
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
||||||
`intents.testEnvironment: null` 与 `intents.release: null`。
|
`intents.testEnvironment: null` 与 `intents.release: null`。
|
||||||
不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言说明测试环境或发版
|
不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator
|
||||||
方式后,Coordinator 按 `delivery.md` 把两者都写入这一份契约:`intents` 指向对应
|
按 deployer skill 准备 `.skiff/deployer/<env>`,并把
|
||||||
profile,工具 target 与仓库脚本分开引用。配置中不保存 shell、环境变量值或凭据
|
`intents.testEnvironment` 写成 `{via: deployer, env: <env>}`;发版仍指向 profile。
|
||||||
正文;稳定发布和生产部署必须有显式 approval 步骤。
|
配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式
|
||||||
|
approval 步骤。
|
||||||
|
|
||||||
|
## 初始化回归目录
|
||||||
|
|
||||||
|
新项目的 `docs/ack/regression.yaml` 保持 `cases: []`。不要从 README 或聊天猜测
|
||||||
|
用例。任务 `verified` 后按 `regression.md` 收获。
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
||||||
@@ -132,12 +149,14 @@ python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
|||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_regression.py docs/ack/regression.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
同时确认:
|
同时确认:
|
||||||
|
|
||||||
- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的
|
- `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 和
|
||||||
`<...>` 占位符。
|
`regression.yaml` 没有未替换的 `<...>` 占位符。
|
||||||
- `project.overlayFile` 指向真实文件。
|
- `project.overlayFile` 指向真实文件。
|
||||||
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
||||||
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
|
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
|
||||||
@@ -154,7 +173,7 @@ python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
|||||||
|
|
||||||
完成后报告:
|
完成后报告:
|
||||||
|
|
||||||
- 创建或确认的四个项目文件。
|
- 创建或确认的项目文件(含回归目录)。
|
||||||
- 检测到的技术栈和验证命令。
|
- 检测到的技术栈和验证命令。
|
||||||
- 任务板、项目知识和交付契约校验结果。
|
- 任务板、项目知识和交付契约校验结果。
|
||||||
- 仍需用户补充的值。
|
- 仍需用户补充的值。
|
||||||
|
|||||||
@@ -37,11 +37,12 @@
|
|||||||
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker,
|
||||||
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected
|
||||||
fingerprint 创建 fresh worker;
|
fingerprint 创建 fresh worker;
|
||||||
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先拉起测试环境 →
|
dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 →
|
||||||
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||||
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独
|
||||||
记录、恢复并告诉我下一步,不占产品复验轮次。
|
记录、恢复并告诉我下一步,不占产品复验轮次。
|
||||||
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
7. 所选任务都 verified 后,按 regression.md 给出新增/更新/退役/无回归,确认后写入
|
||||||
|
docs/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写
|
||||||
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready
|
||||||
或 review_ready,stable/production 步骤再次向我确认。
|
或 review_ready,stable/production 步骤再次向我确认。
|
||||||
```
|
```
|
||||||
@@ -155,7 +156,7 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调
|
|||||||
task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done
|
task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done
|
||||||
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
→ 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker
|
||||||
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
→ 每轮使用 Coordinator 分配的稳定 <task-id>-A<round>
|
||||||
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
|
→ 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result
|
||||||
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
→ Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks
|
||||||
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
→ 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次)
|
||||||
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
→ Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮)
|
||||||
@@ -174,8 +175,13 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
|||||||
|
|
||||||
## 第 5 步:可选交付
|
## 第 5 步:可选交付
|
||||||
|
|
||||||
用户说「重新布测试环境」或「发布一个版本」时,按 `delivery.md` §3.1 的
|
用户说「重新布测试环境」时加载 deployer skill 执行
|
||||||
`intents` 执行对应 profile,不另找文档。intent 为 null 时先做交付配置维护。
|
`intents.testEnvironment` 绑定;「发布一个版本」时按 `delivery.md` §3.1 的
|
||||||
|
release profile 执行。intent 为 null 时先做交付配置维护。
|
||||||
|
|
||||||
|
所选任务 `verified` 后,按 `regression.md` 把本轮黑盒路径收获进
|
||||||
|
`docs/ack/regression.yaml`(新增/更新/退役/无回归四选一,用户确认后写入)。
|
||||||
|
用户说「回归」时先布测试环境,再派 Test 按目录执行。
|
||||||
|
|
||||||
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
||||||
`delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`,
|
`delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`,
|
||||||
@@ -203,6 +209,6 @@ Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py`
|
|||||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||||
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile
|
||||||
跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收仅属于 verified
|
到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收
|
||||||
任务的 worker,保留 blocked/failed/leftover worker。
|
仅属于 verified 任务的 worker,保留 blocked/failed/leftover worker。
|
||||||
|
|||||||
@@ -176,14 +176,18 @@ one dispatch = one bug = one acceptance path
|
|||||||
|
|
||||||
## 8. 优先让测试可执行化
|
## 8. 优先让测试可执行化
|
||||||
|
|
||||||
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。这类可执行测试由 Test 拥有并维护(见 `roles-and-permissions.md` 权限表的 `<integration_test_paths>`)。优先级:
|
如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。黑盒回归目录是
|
||||||
|
`docs/ack/regression.yaml`(见 `regression.md`);可执行脚本仍由 Test 维护在
|
||||||
|
`<integration_test_paths>`。优先级:
|
||||||
|
|
||||||
1. API smoke。
|
1. 把本轮验收信号收获进 `regression.yaml`。
|
||||||
2. 浏览器脚本或 case 文档。
|
2. API smoke。
|
||||||
3. 单元测试。
|
3. 浏览器脚本或 `automationRef`。
|
||||||
4. 人工检查清单。
|
4. 单元测试。
|
||||||
|
5. 人工检查清单。
|
||||||
|
|
||||||
目标不是全部自动化,而是把最容易反复误判的路径自动化。
|
目标不是全部自动化,而是把最容易反复误判的路径变成可重复跑的回归用例。任务
|
||||||
|
`verified` 后必须给出新增/更新/退役/无回归四选一。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ Developer 本轮声称(仅供参考,不作数):
|
|||||||
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
- <K-014@2>: <directive + rationale + verification.ref + resolved path/args>
|
||||||
|
|
||||||
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
复测要求(见 roles-and-permissions.md §三角色能力清单 · Test):
|
||||||
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 `delivery.yaml` 的 `intents.testEnvironment` 拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
|
- 先对齐运行环境(pwd / 分支 / commit / 服务 worktree,见 closed-loop.md),避免测错实例或旧构建。测试环境由 Coordinator 按 deployer 绑定拉起;不要自行发明编译或启动命令。网站类确认 Base URL 已指向这次产物后再测。
|
||||||
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
- 网站类任务优先用浏览器复测真实交互,其次才是 API / 脚本。
|
||||||
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
- 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态:
|
||||||
1. <observable signal 1>
|
1. <observable signal 1>
|
||||||
@@ -137,7 +137,7 @@ Developer 本轮声称(仅供参考,不作数):
|
|||||||
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
- 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报
|
||||||
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
`environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败
|
||||||
证据,则分别列出产品信号与环境限制。
|
证据,则分别列出产品信号与环境限制。
|
||||||
- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。
|
- 需要时把本轮通过的黑盒路径写成 `regressionCandidates`(见 regression.md),不要直接改 `docs/ack/regression.yaml`。
|
||||||
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
- 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给
|
||||||
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
`<ack-skill-dir>/scripts/run_verification.py docs/ack/knowledge.yaml
|
||||||
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
|
<verification-ref> --project-root <project-root>`,并回报 `knowledgeChecks`。
|
||||||
@@ -215,6 +215,16 @@ knowledgeChecks:
|
|||||||
- ref: <K-001@1>
|
- ref: <K-001@1>
|
||||||
result: <passed|failed|not_applicable>
|
result: <passed|failed|not_applicable>
|
||||||
evidence: <independent evidence>
|
evidence: <independent evidence>
|
||||||
|
regressionCandidates:
|
||||||
|
- title: <reusable black-box case>
|
||||||
|
surface: browser/api
|
||||||
|
suite: smoke/full
|
||||||
|
setup: <preconditions>
|
||||||
|
steps: [<step>]
|
||||||
|
expected:
|
||||||
|
- kind: visible-text/api-status/api-field/url/interaction
|
||||||
|
value: <observable signal>
|
||||||
|
sourceKind: feature/bug
|
||||||
knowledgeCandidates:
|
knowledgeCandidates:
|
||||||
- kind: guardrail/pitfall/verification
|
- kind: guardrail/pitfall/verification
|
||||||
title: <new lesson found by Test>
|
title: <new lesson found by Test>
|
||||||
@@ -263,6 +273,10 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
||||||
- 待验证 candidate:<remaining candidates or none>
|
- 待验证 candidate:<remaining candidates or none>
|
||||||
|
|
||||||
|
回归:
|
||||||
|
- 收获:新增/更新/退役/无回归 <case ids or none>
|
||||||
|
- 最近一次回归运行:<RR-id / passed|failed|n/a>
|
||||||
|
|
||||||
交付(未启用时写 n/a):
|
交付(未启用时写 n/a):
|
||||||
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
- run/profile/status:<delivery run id / profile / validation_ready|review_ready|released|blocked|failed>
|
||||||
- PR/MR:<URL and head/base>
|
- PR/MR:<URL and head/base>
|
||||||
@@ -274,3 +288,28 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- <repo_path>: <git status summary>
|
- <repo_path>: <git status summary>
|
||||||
- <dev_worktree>: <git status summary>
|
- <dev_worktree>: <git status summary>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 派发给 Test 跑回归
|
||||||
|
|
||||||
|
```text
|
||||||
|
请按回归目录对当前测试环境做独立黑盒回归。
|
||||||
|
suite: <smoke|full|custom>
|
||||||
|
baseUrl: <deployer 给出的地址>
|
||||||
|
|
||||||
|
用例(来自 select_regression.py,不要发明步骤):
|
||||||
|
- <REG-id>: surface=<browser|api>
|
||||||
|
setup: <setup>
|
||||||
|
steps: <steps>
|
||||||
|
expected: <expected signals>
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 先确认 Base URL 可访问;不可访问时回报 environmentFailure,不要编造产品失败。
|
||||||
|
- surface=browser 必须走真实页面交互,不能改成只打 API。
|
||||||
|
- 逐条对照 expected 回报 pass/fail 与证据。
|
||||||
|
- 不要修改源码,不要写 tasks.yaml / knowledge.yaml / regression.yaml。
|
||||||
|
|
||||||
|
完成后按 §5 的复测报告格式回报,signals 使用 REG-id。
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# ACK 回归目录与运行
|
||||||
|
|
||||||
|
本文件是回归用例目录、收获时机和运行模式的 SSOT。三角色闭环仍见
|
||||||
|
`closed-loop.md`;测试环境部署见 `delivery.md` 与 deployer skill。
|
||||||
|
|
||||||
|
回归不是新 skill,也不写入 `knowledge.yaml`。知识是护栏;回归是可观测的黑盒
|
||||||
|
用例清单。
|
||||||
|
|
||||||
|
## 1. 目录
|
||||||
|
|
||||||
|
项目状态是 `docs/ack/regression.yaml`。任务板用
|
||||||
|
`project.regressionFile: docs/ack/regression.yaml` 声明,运行证据写在
|
||||||
|
`tasks.yaml.regressionRuns`。新项目初始化会生成空目录;旧项目没有该文件时,
|
||||||
|
「运行回归」先停下来,用户授权后再从模板补齐。
|
||||||
|
|
||||||
|
只有 Coordinator 写 `regression.yaml`。Test 在复测报告里提名
|
||||||
|
`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的
|
||||||
|
`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改
|
||||||
|
`docs/ack/`。
|
||||||
|
|
||||||
|
`tests/browser/**` 仍可由 Test 维护可执行脚本。用例可用可选 `automationRef`
|
||||||
|
指向那些脚本;没有脚本时 Test worker 按目录里的步骤和验收信号执行。
|
||||||
|
|
||||||
|
## 2. 用例
|
||||||
|
|
||||||
|
每条用例是给独立 Test worker 的说明书,复用
|
||||||
|
`optimization-method.md` §1 的可观测信号。ACK 不提供 YAML-DSL 解释器,也不强制
|
||||||
|
Playwright。
|
||||||
|
|
||||||
|
读取时用 `scripts/select_regression.py`,不要把完整目录注入上下文:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py docs/ack/regression.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py docs/ack/regression.yaml \
|
||||||
|
--suite full
|
||||||
|
python3 <ack-skill-dir>/scripts/select_regression.py docs/ack/regression.yaml \
|
||||||
|
--case-id REG-login-001
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 `--suite smoke` 只返回 `status: active` 且 `suite: smoke` 的用例。
|
||||||
|
`--suite full` 返回全部 active 用例。写回前仍运行
|
||||||
|
`scripts/validate_regression.py`。
|
||||||
|
|
||||||
|
`surface: browser` 的用例必须走真实页面交互,不能改成只打 API。
|
||||||
|
`surface: api` 以 HTTP 状态和字段为证据。
|
||||||
|
|
||||||
|
## 3. 收获
|
||||||
|
|
||||||
|
任务进入 `verified` 且改了用户可见行为或 API 后,闭环结束前 Coordinator 必须给出
|
||||||
|
四选一,不能跳过:
|
||||||
|
|
||||||
|
- 新增
|
||||||
|
- 更新已有用例
|
||||||
|
- 退役(功能已删除)
|
||||||
|
- 无回归(纯内部重构、leftover、无用户可见变化)
|
||||||
|
|
||||||
|
草案来自任务验收信号和 Test 的独立证据,按 `path + expected` 去重。用户确认后
|
||||||
|
才写入目录。P0 / 主路径默认 `suite: smoke`,其余 `full`。
|
||||||
|
|
||||||
|
## 4. 运行
|
||||||
|
|
||||||
|
用户说「回归」「跑回归测试」时执行本模式,不要求当前有 `verified` 任务。
|
||||||
|
|
||||||
|
1. 目录存在且有选中的 active 用例;否则停止。
|
||||||
|
2. 先按「运行测试环境」调用 deployer,拿到 Base URL。
|
||||||
|
3. Coordinator 派独立 Test worker,只带选中用例、Base URL 和 surface。
|
||||||
|
4. Test 逐条对照 `expected` 回报证据。Coordinator 终检后写入 `regressionRuns`。
|
||||||
|
5. 失败只报告,不自动派 Developer,不占任务三轮预算。用户明确要求修复时,按
|
||||||
|
「修 bug」为每条失败开一个任务。
|
||||||
|
|
||||||
|
环境失败(没有浏览器、打不开部署地址、deployer 失败)记环境事件,不记产品失败。
|
||||||
|
|
||||||
|
默认跑 smoke。用户点名 full 或具体 case id 时按点名范围执行。
|
||||||
@@ -12,7 +12,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
||||||
|------|----------|----------|------------|
|
|------|----------|----------|------------|
|
||||||
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml` / `regression.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付与回归 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付与回归证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
||||||
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
||||||
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
||||||
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||||
@@ -110,6 +110,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `regression.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Test 通过 `regressionCandidates` 提名 |
|
||||||
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -178,11 +179,12 @@ planned -> skipped
|
|||||||
|
|
||||||
## 项目状态写入约定(并发安全)
|
## 项目状态写入约定(并发安全)
|
||||||
|
|
||||||
`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源,
|
`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识
|
||||||
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
事实源,`regression.yaml` 是黑盒回归用例事实源,`delivery.yaml` 是项目交付能力
|
||||||
|
事实源。为避免多 Agent 并发写冲突:
|
||||||
|
|
||||||
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
- **只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`**。
|
||||||
对它们都是只读的。
|
Test 与 Developer 对它们都是只读的。
|
||||||
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
- Developer 的实现状态、Test 的复测证据都通过消息回传(`worker_done` / 复测报告),由 Coordinator 落盘。
|
||||||
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
|
- Developer 和 Test 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在
|
||||||
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
|
当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""选择 ACK 回归目录中的 active 用例。
|
||||||
|
|
||||||
|
默认返回 smoke 套件。本脚本只输出数据,不执行 steps 或 automationRef。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 select_regression.py docs/ack/regression.yaml
|
||||||
|
python3 select_regression.py docs/ack/regression.yaml --suite full
|
||||||
|
python3 select_regression.py docs/ack/regression.yaml --case-id REG-login-001
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from validate_regression import ID_RE, load_yaml, validate_builtin
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 50
|
||||||
|
MAX_LIMIT = 200
|
||||||
|
|
||||||
|
|
||||||
|
def select_cases(
|
||||||
|
data: dict[str, Any],
|
||||||
|
*,
|
||||||
|
suite: str,
|
||||||
|
case_ids: list[str],
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cases = data.get("cases")
|
||||||
|
if not isinstance(cases, list):
|
||||||
|
raise ValueError("cases 必须是列表")
|
||||||
|
|
||||||
|
wanted = set(case_ids)
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
for case in cases:
|
||||||
|
if not isinstance(case, dict):
|
||||||
|
continue
|
||||||
|
if case.get("status") != "active":
|
||||||
|
continue
|
||||||
|
case_id = case.get("id")
|
||||||
|
if wanted:
|
||||||
|
if case_id not in wanted:
|
||||||
|
continue
|
||||||
|
elif suite == "smoke" and case.get("suite") != "smoke":
|
||||||
|
continue
|
||||||
|
selected.append(case)
|
||||||
|
|
||||||
|
missing = wanted - {
|
||||||
|
case.get("id") for case in selected if isinstance(case.get("id"), str)
|
||||||
|
}
|
||||||
|
if missing:
|
||||||
|
raise ValueError("找不到 active 用例: " + ", ".join(sorted(missing)))
|
||||||
|
if len(selected) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"命中 {len(selected)} 条,超过 --limit {limit};"
|
||||||
|
"请用 --suite / --case-id 缩小范围"
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="选择 ACK 回归用例")
|
||||||
|
parser.add_argument(
|
||||||
|
"regression", nargs="?", default="docs/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--suite",
|
||||||
|
choices=("smoke", "full"),
|
||||||
|
default="smoke",
|
||||||
|
help="smoke 只返回 suite=smoke;full 返回全部 active 用例",
|
||||||
|
)
|
||||||
|
parser.add_argument("--case-id", action="append", default=[], dest="case_ids")
|
||||||
|
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
||||||
|
parser.add_argument(
|
||||||
|
"--format", choices=("json", "ids"), default="json", dest="output_format"
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
regression_path = Path(args.regression)
|
||||||
|
if not regression_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
|
||||||
|
return 2
|
||||||
|
if not 1 <= args.limit <= MAX_LIMIT:
|
||||||
|
sys.stderr.write(f"--limit 必须在 1..{MAX_LIMIT} 之间\n")
|
||||||
|
return 2
|
||||||
|
for case_id in args.case_ids:
|
||||||
|
if ID_RE.fullmatch(case_id) is None:
|
||||||
|
sys.stderr.write(f"--case-id 必须使用 REG-<id> 格式: {case_id}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data = load_yaml(regression_path, "回归目录")
|
||||||
|
errors = validate_builtin(data)
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write(f"回归目录无效,拒绝选择,共 {len(errors)} 项:\n")
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f" - {error}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = select_cases(
|
||||||
|
data,
|
||||||
|
suite=args.suite,
|
||||||
|
case_ids=args.case_ids,
|
||||||
|
limit=args.limit,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
sys.stderr.write(f"回归选择失败: {exc}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ids = [case.get("id") for case in selected]
|
||||||
|
if args.output_format == "ids":
|
||||||
|
if ids:
|
||||||
|
sys.stdout.write("\n".join(str(item) for item in ids) + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"count": len(selected),
|
||||||
|
"limit": args.limit,
|
||||||
|
"suite": args.suite,
|
||||||
|
"ids": ids,
|
||||||
|
"cases": selected,
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -102,9 +102,9 @@ CLASSIFICATIONS = {"development", "staging", "production"}
|
|||||||
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||||
INTENT_FIELDS = {"testEnvironment", "release"}
|
INTENT_FIELDS = {"testEnvironment", "release"}
|
||||||
INTENT_STOP_AT = {
|
INTENT_STOP_AT = {
|
||||||
"testEnvironment": "validation_ready",
|
|
||||||
"release": "released",
|
"release": "released",
|
||||||
}
|
}
|
||||||
|
TEST_ENVIRONMENT_FIELDS = {"via", "env"}
|
||||||
ACTIONS = {
|
ACTIONS = {
|
||||||
"verify",
|
"verify",
|
||||||
"pull-request",
|
"pull-request",
|
||||||
@@ -681,10 +681,45 @@ def _validate_profiles(
|
|||||||
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
|
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_test_environment_intent(
|
||||||
|
value: Any,
|
||||||
|
errors: list[str],
|
||||||
|
project_root: Path | None,
|
||||||
|
) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
if isinstance(value, str):
|
||||||
|
errors.append(
|
||||||
|
"intents.testEnvironment: 已改为 deployer 绑定 "
|
||||||
|
"{via: deployer, env: <env>},不能再使用 profile ID "
|
||||||
|
f"{value!r}。请迁移后由 ACK 内部调用 deployer skill"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append("intents.testEnvironment: 必须是 null 或 {via, env} 对象")
|
||||||
|
return
|
||||||
|
_reject_unknown(value, TEST_ENVIRONMENT_FIELDS, "intents.testEnvironment", errors)
|
||||||
|
if value.get("via") != "deployer":
|
||||||
|
errors.append("intents.testEnvironment.via 必须是 'deployer'")
|
||||||
|
env = value.get("env")
|
||||||
|
if not isinstance(env, str) or ID_RE.fullmatch(env) is None:
|
||||||
|
errors.append("intents.testEnvironment.env 必须是小写连字符环境名")
|
||||||
|
return
|
||||||
|
if project_root is None:
|
||||||
|
return
|
||||||
|
env_dir = project_root / ".skiff" / "deployer" / env
|
||||||
|
if not env_dir.is_dir():
|
||||||
|
errors.append(
|
||||||
|
"intents.testEnvironment.env: 找不到 "
|
||||||
|
f".skiff/deployer/{env};先按 deployer skill 配置项目测试环境"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_intents(
|
def _validate_intents(
|
||||||
values: Any,
|
values: Any,
|
||||||
profiles: dict[str, Any],
|
profiles: dict[str, Any],
|
||||||
errors: list[str],
|
errors: list[str],
|
||||||
|
project_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if values is None:
|
if values is None:
|
||||||
return
|
return
|
||||||
@@ -692,25 +727,29 @@ def _validate_intents(
|
|||||||
errors.append("intents: 必须是对象")
|
errors.append("intents: 必须是对象")
|
||||||
return
|
return
|
||||||
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
|
_reject_unknown(values, INTENT_FIELDS, "intents", errors)
|
||||||
for field in sorted(INTENT_FIELDS):
|
if "testEnvironment" not in values:
|
||||||
if field not in values:
|
errors.append("intents.testEnvironment: 必填")
|
||||||
errors.append(f"intents.{field}: 必填")
|
else:
|
||||||
continue
|
_validate_test_environment_intent(
|
||||||
profile_id = values[field]
|
values.get("testEnvironment"), errors, project_root
|
||||||
if profile_id is None:
|
)
|
||||||
continue
|
if "release" not in values:
|
||||||
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
errors.append("intents.release: 必填")
|
||||||
errors.append(f"intents.{field}: 必须是 null 或小写连字符 profile ID")
|
return
|
||||||
continue
|
profile_id = values["release"]
|
||||||
profile = profiles.get(profile_id)
|
if profile_id is None:
|
||||||
if profile is None:
|
return
|
||||||
errors.append(f"intents.{field}: 未定义 profile {profile_id!r}")
|
if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None:
|
||||||
continue
|
errors.append("intents.release: 必须是 null 或小写连字符 profile ID")
|
||||||
expected_stop = INTENT_STOP_AT[field]
|
return
|
||||||
if _mapping(profile) and profile.get("stopAt") != expected_stop:
|
profile = profiles.get(profile_id)
|
||||||
errors.append(
|
if profile is None:
|
||||||
f"intents.{field}: profile {profile_id!r} 必须 stopAt {expected_stop}"
|
errors.append(f"intents.release: 未定义 profile {profile_id!r}")
|
||||||
)
|
return
|
||||||
|
if _mapping(profile) and profile.get("stopAt") != INTENT_STOP_AT["release"]:
|
||||||
|
errors.append(
|
||||||
|
f"intents.release: profile {profile_id!r} 必须 stopAt released"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
|
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
|
||||||
@@ -758,7 +797,7 @@ def validate_builtin(data: dict[str, Any], project_root: Path | None = None) ->
|
|||||||
environments=environments,
|
environments=environments,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
_validate_intents(data.get("intents"), profiles, errors)
|
_validate_intents(data.get("intents"), profiles, errors, project_root)
|
||||||
|
|
||||||
if enabled:
|
if enabled:
|
||||||
if default_profile not in profiles:
|
if default_profile not in profiles:
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""校验 ACK 项目回归目录及其任务引用。
|
||||||
|
|
||||||
|
权威结构位于 templates/regression.schema.json。jsonschema 是可选依赖;内置规则
|
||||||
|
始终检查用例 ID、验收信号和跨文件引用。本脚本只解析数据,不执行 steps。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 validate_regression.py docs/ack/regression.yaml
|
||||||
|
python3 validate_regression.py docs/ack/regression.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml
|
||||||
|
|
||||||
|
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from yaml_subset import (
|
||||||
|
DuplicateKeyError,
|
||||||
|
YamlSubsetError,
|
||||||
|
load_json_unique,
|
||||||
|
load_yaml_subset,
|
||||||
|
make_unique_pyyaml_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||||
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||||
|
STATUSES = {"active", "retired"}
|
||||||
|
SUITES = {"smoke", "full"}
|
||||||
|
SURFACES = {"browser", "api"}
|
||||||
|
SOURCE_KINDS = {"feature", "bug"}
|
||||||
|
EXPECTED_KINDS = {"visible-text", "api-status", "api-field", "url", "interaction"}
|
||||||
|
CASE_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"source",
|
||||||
|
"suite",
|
||||||
|
"surface",
|
||||||
|
"setup",
|
||||||
|
"steps",
|
||||||
|
"expected",
|
||||||
|
"automationRef",
|
||||||
|
}
|
||||||
|
SOURCE_FIELDS = {"taskId", "kind"}
|
||||||
|
EXPECTED_FIELDS = {"kind", "value"}
|
||||||
|
TOP_LEVEL_FIELDS = {"version", "updatedAt", "project", "cases"}
|
||||||
|
|
||||||
|
|
||||||
|
def _nonempty(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> bool:
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unknown(
|
||||||
|
value: dict[str, Any],
|
||||||
|
allowed: set[str],
|
||||||
|
where: str,
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for field in sorted(set(value) - allowed):
|
||||||
|
errors.append(f"{where}: 未知字段 {field!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
try:
|
||||||
|
data = load_json_unique(content)
|
||||||
|
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
||||||
|
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
data = load_yaml_subset(content)
|
||||||
|
except YamlSubsetError as exc:
|
||||||
|
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
|
||||||
|
except yaml.YAMLError as exc: # type: ignore
|
||||||
|
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def infer_project_root(document_path: Path) -> Path | None:
|
||||||
|
lexical = document_path.expanduser().absolute()
|
||||||
|
parent = lexical.parent
|
||||||
|
if parent.name == "ack" and parent.parent.name == "docs":
|
||||||
|
return parent.parent.parent.resolve()
|
||||||
|
for candidate in (parent, *parent.parents):
|
||||||
|
if (candidate / ".git").exists():
|
||||||
|
return candidate.resolve()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_expected(value: Any, where: str, errors: list[str]) -> None:
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
errors.append(f"{where}: 必须是非空列表")
|
||||||
|
return
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
item_where = f"{where}[{index}]"
|
||||||
|
if not _mapping(item):
|
||||||
|
errors.append(f"{item_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(item, EXPECTED_FIELDS, item_where, errors)
|
||||||
|
if item.get("kind") not in EXPECTED_KINDS:
|
||||||
|
errors.append(f"{item_where}.kind: 必须是 {sorted(EXPECTED_KINDS)}")
|
||||||
|
if not _nonempty(item.get("value")):
|
||||||
|
errors.append(f"{item_where}.value: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_builtin(data: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
|
||||||
|
if data.get("version") != 1 or isinstance(data.get("version"), bool):
|
||||||
|
errors.append("version 必须是整数 1")
|
||||||
|
if not _nonempty(data.get("updatedAt")):
|
||||||
|
errors.append("updatedAt 必须是非空字符串")
|
||||||
|
|
||||||
|
project = data.get("project")
|
||||||
|
if not _mapping(project):
|
||||||
|
errors.append("project 必须是对象")
|
||||||
|
else:
|
||||||
|
_reject_unknown(project, {"name"}, "project", errors)
|
||||||
|
if not _nonempty(project.get("name")):
|
||||||
|
errors.append("project.name 必须是非空字符串")
|
||||||
|
|
||||||
|
cases = data.get("cases")
|
||||||
|
if not isinstance(cases, list):
|
||||||
|
errors.append("cases 必须是列表")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, case in enumerate(cases):
|
||||||
|
where = f"cases[{index}]"
|
||||||
|
if not _mapping(case):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(case, CASE_FIELDS, where, errors)
|
||||||
|
missing = sorted(CASE_FIELDS - {"automationRef"} - set(case))
|
||||||
|
for field in missing:
|
||||||
|
errors.append(f"{where}.{field}: 必填")
|
||||||
|
|
||||||
|
case_id = case.get("id")
|
||||||
|
if not isinstance(case_id, str) or ID_RE.fullmatch(case_id) is None:
|
||||||
|
errors.append(f"{where}.id: 必须使用 REG-<id> 格式")
|
||||||
|
elif case_id in seen:
|
||||||
|
errors.append(f"{where}.id: 不能重复 {case_id!r}")
|
||||||
|
else:
|
||||||
|
seen.add(case_id)
|
||||||
|
|
||||||
|
if not _nonempty(case.get("title")):
|
||||||
|
errors.append(f"{where}.title: 必须是非空字符串")
|
||||||
|
if case.get("status") not in STATUSES:
|
||||||
|
errors.append(f"{where}.status: 必须是 {sorted(STATUSES)}")
|
||||||
|
if case.get("suite") not in SUITES:
|
||||||
|
errors.append(f"{where}.suite: 必须是 {sorted(SUITES)}")
|
||||||
|
if case.get("surface") not in SURFACES:
|
||||||
|
errors.append(f"{where}.surface: 必须是 {sorted(SURFACES)}")
|
||||||
|
if not _nonempty(case.get("setup")):
|
||||||
|
errors.append(f"{where}.setup: 必须是非空字符串")
|
||||||
|
|
||||||
|
source = case.get("source")
|
||||||
|
if not _mapping(source):
|
||||||
|
errors.append(f"{where}.source: 必须是对象")
|
||||||
|
else:
|
||||||
|
_reject_unknown(source, SOURCE_FIELDS, f"{where}.source", errors)
|
||||||
|
task_id = source.get("taskId")
|
||||||
|
if not isinstance(task_id, str) or TASK_ID_RE.fullmatch(task_id) is None:
|
||||||
|
errors.append(f"{where}.source.taskId: 必须是任务 ID")
|
||||||
|
if source.get("kind") not in SOURCE_KINDS:
|
||||||
|
errors.append(f"{where}.source.kind: 必须是 {sorted(SOURCE_KINDS)}")
|
||||||
|
|
||||||
|
steps = case.get("steps")
|
||||||
|
if (
|
||||||
|
not isinstance(steps, list)
|
||||||
|
or not steps
|
||||||
|
or any(not _nonempty(step) for step in steps)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.steps: 必须是非空字符串列表")
|
||||||
|
|
||||||
|
_validate_expected(case.get("expected"), f"{where}.expected", errors)
|
||||||
|
|
||||||
|
automation_ref = case.get("automationRef")
|
||||||
|
if automation_ref is not None:
|
||||||
|
if (
|
||||||
|
not isinstance(automation_ref, str)
|
||||||
|
or automation_ref.startswith("/")
|
||||||
|
or "\\" in automation_ref
|
||||||
|
or ".." in automation_ref.split("/")
|
||||||
|
or RELATIVE_PATH_RE.fullmatch(automation_ref) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.automationRef: 必须是项目内相对路径")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
|
||||||
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
errors = []
|
||||||
|
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
|
||||||
|
location = "/".join(str(part) for part in error.path) or "<root>"
|
||||||
|
errors.append(f"[schema] {location}: {error.message}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tasks_link(
|
||||||
|
regression: dict[str, Any],
|
||||||
|
tasks: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
project = tasks.get("project")
|
||||||
|
if not isinstance(project, dict):
|
||||||
|
return ["[tasks] project 必须是对象"]
|
||||||
|
regression_file = project.get("regressionFile")
|
||||||
|
if regression_file != "docs/ack/regression.yaml":
|
||||||
|
errors.append(
|
||||||
|
"[tasks] project.regressionFile 必须固定为 docs/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
if not isinstance(tasks.get("regressionRuns"), list):
|
||||||
|
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
|
||||||
|
|
||||||
|
regression_project = regression.get("project")
|
||||||
|
if (
|
||||||
|
isinstance(regression_project, dict)
|
||||||
|
and _nonempty(regression_project.get("name"))
|
||||||
|
and _nonempty(project.get("name"))
|
||||||
|
and regression_project["name"] != project["name"]
|
||||||
|
):
|
||||||
|
errors.append("regression.project.name 必须与 tasks.project.name 一致")
|
||||||
|
|
||||||
|
case_ids = {
|
||||||
|
case.get("id")
|
||||||
|
for case in regression.get("cases") or []
|
||||||
|
if isinstance(case, dict) and isinstance(case.get("id"), str)
|
||||||
|
}
|
||||||
|
task_ids = {
|
||||||
|
task.get("id")
|
||||||
|
for task in tasks.get("tasks") or []
|
||||||
|
if isinstance(task, dict) and isinstance(task.get("id"), str)
|
||||||
|
}
|
||||||
|
for index, task in enumerate(tasks.get("tasks") or []):
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
continue
|
||||||
|
where = f"[tasks] tasks[{index}]"
|
||||||
|
refs = task.get("regressionRefs")
|
||||||
|
if refs is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(refs, list):
|
||||||
|
errors.append(f"{where}.regressionRefs: 必须是列表")
|
||||||
|
continue
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ref_index, ref in enumerate(refs):
|
||||||
|
ref_where = f"{where}.regressionRefs[{ref_index}]"
|
||||||
|
if not isinstance(ref, str) or ID_RE.fullmatch(ref) is None:
|
||||||
|
errors.append(f"{ref_where}: 必须使用 REG-<id> 格式")
|
||||||
|
continue
|
||||||
|
if ref in seen:
|
||||||
|
errors.append(f"{ref_where}: 不能重复 {ref!r}")
|
||||||
|
seen.add(ref)
|
||||||
|
if ref not in case_ids:
|
||||||
|
errors.append(f"{ref_where}: 未知用例 {ref!r}")
|
||||||
|
|
||||||
|
for index, run in enumerate(tasks.get("regressionRuns") or []):
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
continue
|
||||||
|
where = f"[tasks] regressionRuns[{index}]"
|
||||||
|
for case_id in run.get("caseIds") or []:
|
||||||
|
if isinstance(case_id, str) and case_id not in case_ids:
|
||||||
|
errors.append(f"{where}.caseIds: 未知用例 {case_id!r}")
|
||||||
|
source_task_ids = run.get("taskIds") or []
|
||||||
|
if isinstance(source_task_ids, list):
|
||||||
|
for task_id in source_task_ids:
|
||||||
|
if isinstance(task_id, str) and task_id not in task_ids:
|
||||||
|
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_all(
|
||||||
|
data: dict[str, Any],
|
||||||
|
schema_path: Path,
|
||||||
|
*,
|
||||||
|
use_schema: bool | None = None,
|
||||||
|
) -> tuple[list[str], str]:
|
||||||
|
if use_schema is None:
|
||||||
|
try:
|
||||||
|
import jsonschema # type: ignore # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
use_schema = False
|
||||||
|
else:
|
||||||
|
use_schema = schema_path.is_file()
|
||||||
|
|
||||||
|
errors = validate_builtin(data)
|
||||||
|
if use_schema and schema_path.is_file():
|
||||||
|
errors = validate_with_schema(data, schema_path) + errors
|
||||||
|
mode = f"schema ({schema_path.name}) + 内置结构"
|
||||||
|
else:
|
||||||
|
mode = "内置结构"
|
||||||
|
return list(dict.fromkeys(errors)), mode
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 ACK 项目回归目录")
|
||||||
|
parser.add_argument(
|
||||||
|
"regression", nargs="?", default="docs/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml")
|
||||||
|
parser.add_argument("--schema", help="regression.schema.json 路径(默认自动探测)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
regression_path = Path(args.regression)
|
||||||
|
if not regression_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到回归目录: {regression_path}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
schema_path = (
|
||||||
|
Path(args.schema)
|
||||||
|
if args.schema
|
||||||
|
else Path(__file__).resolve().parent.parent
|
||||||
|
/ "templates"
|
||||||
|
/ "regression.schema.json"
|
||||||
|
)
|
||||||
|
if args.schema and not schema_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到 schema: {schema_path}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
data = load_yaml(regression_path, "回归目录")
|
||||||
|
errors, mode = validate_all(data, schema_path)
|
||||||
|
|
||||||
|
if args.tasks:
|
||||||
|
tasks_path = Path(args.tasks)
|
||||||
|
if not tasks_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
|
||||||
|
return 2
|
||||||
|
tasks = load_yaml(tasks_path, "任务板")
|
||||||
|
errors.extend(validate_tasks_link(data, tasks))
|
||||||
|
mode += " + tasks 引用"
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write(f"回归目录校验失败({mode}),共 {len(errors)} 项:\n")
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f"- {error}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write(f"回归目录校验通过({mode})\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -51,6 +51,8 @@ KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
|||||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||||
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
REGRESSION_RUN_ID_RE = re.compile(r"^RR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
REGRESSION_CASE_ID_RE = re.compile(r"^REG-[A-Za-z0-9][A-Za-z0-9-]*$")
|
||||||
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||||
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
||||||
SEMVER_RE = re.compile(
|
SEMVER_RE = re.compile(
|
||||||
@@ -117,6 +119,51 @@ DELIVERY_STATUSES = {
|
|||||||
}
|
}
|
||||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||||
|
REGRESSION_RUN_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"suite",
|
||||||
|
"caseIds",
|
||||||
|
"status",
|
||||||
|
"sourceRevision",
|
||||||
|
"configRevision",
|
||||||
|
"baseUrl",
|
||||||
|
"results",
|
||||||
|
"evidence",
|
||||||
|
"updatedAt",
|
||||||
|
}
|
||||||
|
REGRESSION_RUN_OPTIONAL_FIELDS = {"deliveryRunId", "taskIds"}
|
||||||
|
REGRESSION_RUN_SUITES = {"smoke", "full", "custom"}
|
||||||
|
REGRESSION_RUN_STATUSES = {
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"passed",
|
||||||
|
"failed",
|
||||||
|
"blocked",
|
||||||
|
"skipped",
|
||||||
|
}
|
||||||
|
REGRESSION_RESULT_FIELDS = {"caseId", "result", "evidence"}
|
||||||
|
REGRESSION_RESULTS = {"pass", "fail", "skipped"}
|
||||||
|
REGRESSION_CANDIDATE_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"surface",
|
||||||
|
"suite",
|
||||||
|
"setup",
|
||||||
|
"steps",
|
||||||
|
"expected",
|
||||||
|
"sourceKind",
|
||||||
|
}
|
||||||
|
REGRESSION_CANDIDATE_REQUIRED_FIELDS = {"title", "surface", "steps", "expected"}
|
||||||
|
REGRESSION_SURFACES = {"browser", "api"}
|
||||||
|
REGRESSION_SUITES = {"smoke", "full"}
|
||||||
|
REGRESSION_SOURCE_KINDS = {"feature", "bug"}
|
||||||
|
REGRESSION_EXPECTED_KINDS = {
|
||||||
|
"visible-text",
|
||||||
|
"api-status",
|
||||||
|
"api-field",
|
||||||
|
"url",
|
||||||
|
"interaction",
|
||||||
|
}
|
||||||
FEISHU_REQUIRED_FIELDS = {
|
FEISHU_REQUIRED_FIELDS = {
|
||||||
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||||
"attachments", "updatedAt",
|
"attachments", "updatedAt",
|
||||||
@@ -636,6 +683,205 @@ def validate_delivery_runs(
|
|||||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_regression_fields(task: dict, where: str, errors: list[str]) -> None:
|
||||||
|
if "regressionRefs" in task:
|
||||||
|
refs = task["regressionRefs"]
|
||||||
|
if not isinstance(refs, list):
|
||||||
|
errors.append(f"{where}.regressionRefs: 必须是列表")
|
||||||
|
else:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, ref in enumerate(refs):
|
||||||
|
item_where = f"{where}.regressionRefs[{index}]"
|
||||||
|
if not isinstance(ref, str) or REGRESSION_CASE_ID_RE.fullmatch(ref) is None:
|
||||||
|
errors.append(f"{item_where}: 必须使用 REG-<id> 格式")
|
||||||
|
continue
|
||||||
|
if ref in seen:
|
||||||
|
errors.append(f"{item_where}: 不能重复 {ref!r}")
|
||||||
|
seen.add(ref)
|
||||||
|
|
||||||
|
if "regressionCandidates" not in task:
|
||||||
|
return
|
||||||
|
candidates = task["regressionCandidates"]
|
||||||
|
if not isinstance(candidates, list):
|
||||||
|
errors.append(f"{where}.regressionCandidates: 必须是列表")
|
||||||
|
return
|
||||||
|
for index, candidate in enumerate(candidates):
|
||||||
|
item_where = f"{where}.regressionCandidates[{index}]"
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
errors.append(f"{item_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(
|
||||||
|
candidate, REGRESSION_CANDIDATE_FIELDS, item_where, errors
|
||||||
|
)
|
||||||
|
missing = sorted(REGRESSION_CANDIDATE_REQUIRED_FIELDS - set(candidate))
|
||||||
|
for field in missing:
|
||||||
|
errors.append(f"{item_where}.{field}: 必填")
|
||||||
|
if "id" in candidate and (
|
||||||
|
not isinstance(candidate.get("id"), str)
|
||||||
|
or REGRESSION_CASE_ID_RE.fullmatch(candidate["id"]) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{item_where}.id: 必须使用 REG-<id> 格式")
|
||||||
|
if not _nonempty_string(candidate.get("title")):
|
||||||
|
errors.append(f"{item_where}.title: 必须是非空字符串")
|
||||||
|
if candidate.get("surface") not in REGRESSION_SURFACES:
|
||||||
|
errors.append(f"{item_where}.surface: 必须是 {sorted(REGRESSION_SURFACES)}")
|
||||||
|
if "suite" in candidate and candidate.get("suite") not in REGRESSION_SUITES:
|
||||||
|
errors.append(f"{item_where}.suite: 必须是 {sorted(REGRESSION_SUITES)}")
|
||||||
|
if "sourceKind" in candidate and (
|
||||||
|
candidate.get("sourceKind") not in REGRESSION_SOURCE_KINDS
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{item_where}.sourceKind: 必须是 {sorted(REGRESSION_SOURCE_KINDS)}"
|
||||||
|
)
|
||||||
|
steps = candidate.get("steps")
|
||||||
|
if (
|
||||||
|
not isinstance(steps, list)
|
||||||
|
or not steps
|
||||||
|
or any(not _nonempty_string(step) for step in steps)
|
||||||
|
):
|
||||||
|
errors.append(f"{item_where}.steps: 必须是非空字符串列表")
|
||||||
|
expected = candidate.get("expected")
|
||||||
|
if not isinstance(expected, list) or not expected:
|
||||||
|
errors.append(f"{item_where}.expected: 必须是非空列表")
|
||||||
|
continue
|
||||||
|
for expected_index, item in enumerate(expected):
|
||||||
|
expected_where = f"{item_where}.expected[{expected_index}]"
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors.append(f"{expected_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
if item.get("kind") not in REGRESSION_EXPECTED_KINDS:
|
||||||
|
errors.append(
|
||||||
|
f"{expected_where}.kind: 必须是 {sorted(REGRESSION_EXPECTED_KINDS)}"
|
||||||
|
)
|
||||||
|
if not _nonempty_string(item.get("value")):
|
||||||
|
errors.append(f"{expected_where}.value: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_regression_runs(value: object, errors: list[str]) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
errors.append("regressionRuns 必须是列表")
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_run_ids: set[str] = set()
|
||||||
|
for index, run in enumerate(value):
|
||||||
|
where = f"regressionRuns[{index}]"
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(
|
||||||
|
run,
|
||||||
|
REGRESSION_RUN_FIELDS | REGRESSION_RUN_OPTIONAL_FIELDS,
|
||||||
|
where,
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
missing = sorted(REGRESSION_RUN_FIELDS - set(run))
|
||||||
|
for field in missing:
|
||||||
|
errors.append(f"{where}.{field}: 必填")
|
||||||
|
|
||||||
|
run_id = run.get("id")
|
||||||
|
if not isinstance(run_id, str) or REGRESSION_RUN_ID_RE.fullmatch(run_id) is None:
|
||||||
|
errors.append(f"{where}.id: 必须使用 RR-<id> 格式")
|
||||||
|
elif run_id in seen_run_ids:
|
||||||
|
errors.append(f"{where}.id: 不能重复 {run_id!r}")
|
||||||
|
else:
|
||||||
|
seen_run_ids.add(run_id)
|
||||||
|
|
||||||
|
if run.get("suite") not in REGRESSION_RUN_SUITES:
|
||||||
|
errors.append(f"{where}.suite: 必须是 {sorted(REGRESSION_RUN_SUITES)}")
|
||||||
|
status = run.get("status")
|
||||||
|
if status not in REGRESSION_RUN_STATUSES:
|
||||||
|
errors.append(f"{where}.status: 必须是 {sorted(REGRESSION_RUN_STATUSES)}")
|
||||||
|
|
||||||
|
case_ids = run.get("caseIds")
|
||||||
|
if (
|
||||||
|
not isinstance(case_ids, list)
|
||||||
|
or not case_ids
|
||||||
|
or any(
|
||||||
|
not isinstance(case_id, str)
|
||||||
|
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||||
|
for case_id in case_ids
|
||||||
|
)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.caseIds: 必须是非空 REG-<id> 列表")
|
||||||
|
case_ids = []
|
||||||
|
elif len(case_ids) != len(set(case_ids)):
|
||||||
|
errors.append(f"{where}.caseIds: 不能包含重复值")
|
||||||
|
|
||||||
|
task_ids = run.get("taskIds")
|
||||||
|
if task_ids is not None and (
|
||||||
|
not isinstance(task_ids, list)
|
||||||
|
or any(not _nonempty_string(task_id) for task_id in task_ids)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.taskIds: 必须是任务 ID 列表")
|
||||||
|
delivery_run_id = run.get("deliveryRunId")
|
||||||
|
if delivery_run_id is not None and (
|
||||||
|
not isinstance(delivery_run_id, str)
|
||||||
|
or DELIVERY_RUN_ID_RE.fullmatch(delivery_run_id) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.deliveryRunId: 必须使用 DR-<id> 格式")
|
||||||
|
|
||||||
|
for field in ("sourceRevision", "configRevision"):
|
||||||
|
revision = run.get(field)
|
||||||
|
if revision is not None and (
|
||||||
|
not isinstance(revision, str) or GIT_REVISION_RE.fullmatch(revision) is None
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{where}.{field}: 必须是 null 或 7..64 位小写十六进制 revision"
|
||||||
|
)
|
||||||
|
if status != "skipped":
|
||||||
|
for field in ("sourceRevision", "configRevision", "baseUrl"):
|
||||||
|
if not _nonempty_string(run.get(field)):
|
||||||
|
errors.append(f"{where}.{field}: status={status!r} 时必须填写")
|
||||||
|
|
||||||
|
results = run.get("results")
|
||||||
|
if not isinstance(results, list):
|
||||||
|
errors.append(f"{where}.results: 必须是列表")
|
||||||
|
results = []
|
||||||
|
elif status in {"passed", "failed"} and not results:
|
||||||
|
errors.append(f"{where}.results: status={status!r} 时不能为空")
|
||||||
|
seen_results: set[str] = set()
|
||||||
|
for result_index, result in enumerate(results):
|
||||||
|
result_where = f"{where}.results[{result_index}]"
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
errors.append(f"{result_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
reject_unknown_fields(result, REGRESSION_RESULT_FIELDS, result_where, errors)
|
||||||
|
case_id = result.get("caseId")
|
||||||
|
if (
|
||||||
|
not isinstance(case_id, str)
|
||||||
|
or REGRESSION_CASE_ID_RE.fullmatch(case_id) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{result_where}.caseId: 必须使用 REG-<id> 格式")
|
||||||
|
elif case_id in seen_results:
|
||||||
|
errors.append(f"{result_where}.caseId: 不能重复 {case_id!r}")
|
||||||
|
else:
|
||||||
|
seen_results.add(case_id)
|
||||||
|
if case_id not in case_ids:
|
||||||
|
errors.append(f"{result_where}.caseId: 不在 caseIds 中")
|
||||||
|
if result.get("result") not in REGRESSION_RESULTS:
|
||||||
|
errors.append(
|
||||||
|
f"{result_where}.result: 必须是 {sorted(REGRESSION_RESULTS)}"
|
||||||
|
)
|
||||||
|
if not _nonempty_string(result.get("evidence")):
|
||||||
|
errors.append(f"{result_where}.evidence: 必须是非空字符串")
|
||||||
|
if status == "passed" and any(
|
||||||
|
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: passed 运行不能包含 fail 结果")
|
||||||
|
if status == "failed" and not any(
|
||||||
|
isinstance(item, dict) and item.get("result") == "fail" for item in results
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: failed 运行必须包含至少一条 fail 结果")
|
||||||
|
|
||||||
|
evidence = run.get("evidence")
|
||||||
|
if not isinstance(evidence, list) or any(
|
||||||
|
not _nonempty_string(item) for item in evidence
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||||
|
if not _nonempty_string(run.get("updatedAt")):
|
||||||
|
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||||
|
|
||||||
|
|
||||||
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||||
import jsonschema # type: ignore
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
@@ -779,6 +1025,19 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||||
if "deliveryRuns" in data and "deliveryFile" not in project:
|
if "deliveryRuns" in data and "deliveryFile" not in project:
|
||||||
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
||||||
|
if (
|
||||||
|
"regressionFile" in project
|
||||||
|
and project.get("regressionFile") != "docs/ack/regression.yaml"
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"project.regressionFile 必须固定为 docs/ack/regression.yaml"
|
||||||
|
)
|
||||||
|
if "regressionFile" in project and not isinstance(
|
||||||
|
data.get("regressionRuns"), list
|
||||||
|
):
|
||||||
|
errors.append("引用 regressionFile 的任务板必须包含 regressionRuns 列表")
|
||||||
|
if "regressionRuns" in data and "regressionFile" not in project:
|
||||||
|
errors.append("regressionRuns 存在时 project.regressionFile 必须存在")
|
||||||
|
|
||||||
ack_version = data.get("ackVersion")
|
ack_version = data.get("ackVersion")
|
||||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||||
@@ -941,6 +1200,7 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
|
||||||
|
|
||||||
validate_knowledge_fields(task, where, status, errors)
|
validate_knowledge_fields(task, where, status, errors)
|
||||||
|
validate_regression_fields(task, where, errors)
|
||||||
|
|
||||||
if "dispatch" not in task:
|
if "dispatch" not in task:
|
||||||
dispatch = {}
|
dispatch = {}
|
||||||
@@ -1129,6 +1389,8 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||||
}
|
}
|
||||||
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
||||||
|
if "regressionRuns" in data:
|
||||||
|
validate_regression_runs(data["regressionRuns"], errors)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,18 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"testEnvironment": {
|
"testEnvironment": {
|
||||||
"type": ["string", "null"],
|
"oneOf": [
|
||||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
{ "type": "null" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["via", "env"],
|
||||||
|
"properties": {
|
||||||
|
"via": { "const": "deployer" },
|
||||||
|
"env": { "$ref": "#/definitions/id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ project:
|
|||||||
enabled: false
|
enabled: false
|
||||||
defaultProfile: null
|
defaultProfile: null
|
||||||
|
|
||||||
# 测试环境部署和版本发布都写在本文件。null 表示用户尚未说明该操作。
|
# 测试环境由 ACK 内部调用 deployer;发版仍指向本文件的 profile。
|
||||||
|
# null 表示用户尚未说明该操作。
|
||||||
intents:
|
intents:
|
||||||
testEnvironment: null
|
testEnvironment: null
|
||||||
release: null
|
release: null
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
||||||
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||||
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的
|
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的
|
||||||
> `delivery.yaml`,
|
> `delivery.yaml` 与空的 `regression.yaml`,
|
||||||
> 不复制或链接 Skill。
|
> 不复制或链接 Skill。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`docs/ack/knowledge.yaml`
|
||||||
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
|
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
|
||||||
|
- 回归目录:`docs/ack/regression.yaml`
|
||||||
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
||||||
|
|
||||||
## 通用规范(由 ACK Skill 按需读取)
|
## 通用规范(由 ACK Skill 按需读取)
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||||
- 验证后交付与配置维护(可选):`references/delivery.md`
|
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||||
|
- 回归目录与运行(可选):`references/regression.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `regression.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Test 通过 regressionCandidates 提名 |
|
||||||
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令(项目覆盖层)
|
## 命令(项目覆盖层)
|
||||||
@@ -79,7 +82,7 @@ Developer 白盒验证:
|
|||||||
<local_run_command>
|
<local_run_command>
|
||||||
```
|
```
|
||||||
|
|
||||||
Test 黑盒复测(服务启动以 `delivery.yaml` 的 `intents.testEnvironment` 为准):
|
Test 黑盒复测(服务启动以 deployer 绑定的 `intents.testEnvironment` 为准):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
<preflight_command>
|
<preflight_command>
|
||||||
@@ -96,9 +99,10 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
`ACK_PROJECT_ROOT_DISPLAY`。
|
`ACK_PROJECT_ROOT_DISPLAY`。
|
||||||
|
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和
|
||||||
|
`scripts/validate_regression.py` 执行。
|
||||||
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
|
构建、测试环境部署和版本发布的机器入口以 `delivery.yaml` 的 `intents` 为准;
|
||||||
本文件不维护第二套交付命令。
|
测试环境由 ACK 内部调用 deployer。本文件不维护第二套交付命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
@@ -112,16 +116,17 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
- 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。
|
||||||
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
- 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。
|
||||||
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
- `worker_done` 与复测报告都不等于完成。必须 Test 独立复测 + Coordinator 终检后才能 `verified`。
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 都只读,
|
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`;
|
||||||
通过消息回报。
|
Developer 与 Test 都只读,通过消息回报。
|
||||||
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
- Coordinator 只派发按 scope 命中并显式写入 `knowledgeRefs` 的 `active` 知识;
|
||||||
`candidate` 不派发,知识库不全量注入。
|
`candidate` 不派发,知识库不全量注入。
|
||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
||||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境
|
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境
|
||||||
与发版都写在这份契约的 `intents` 里;用户明确要求重新部署测试环境或发布版本时
|
绑定 deployer,发版写在 `intents.release`;用户明确要求重新部署测试环境或发布
|
||||||
才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后运行。
|
版本时才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后
|
||||||
|
运行。回归目录在 `docs/ack/regression.yaml`,由用户明确要求「回归」时运行。
|
||||||
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
- 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有
|
||||||
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/regression.schema.json",
|
||||||
|
"title": "ACK project regression catalog",
|
||||||
|
"description": "docs/ack/regression.yaml 的权威结构。用例是给 Test worker 的可观测信号说明书,不是可执行 DSL。",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "updatedAt", "project", "cases"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "integer", "const": 1 },
|
||||||
|
"updatedAt": { "type": "string" },
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cases": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/case" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"caseId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"stringList": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
},
|
||||||
|
"relativePath": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$"
|
||||||
|
},
|
||||||
|
"expectedSignal": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "value"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["visible-text", "api-status", "api-field", "url", "interaction"]
|
||||||
|
},
|
||||||
|
"value": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"case": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"source",
|
||||||
|
"suite",
|
||||||
|
"surface",
|
||||||
|
"setup",
|
||||||
|
"steps",
|
||||||
|
"expected"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/definitions/caseId" },
|
||||||
|
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"status": { "type": "string", "enum": ["active", "retired"] },
|
||||||
|
"source": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["taskId", "kind"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"taskId": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"kind": { "type": "string", "enum": ["feature", "bug"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suite": { "type": "string", "enum": ["smoke", "full"] },
|
||||||
|
"surface": { "type": "string", "enum": ["browser", "api"] },
|
||||||
|
"setup": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"steps": { "$ref": "#/definitions/stringList" },
|
||||||
|
"expected": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/definitions/expectedSignal" }
|
||||||
|
},
|
||||||
|
"automationRef": { "$ref": "#/definitions/relativePath" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 复制为 docs/ack/regression.yaml。只有 Coordinator 写入;Test 通过回报提名。
|
||||||
|
# 结构见 templates/regression.schema.json。
|
||||||
|
version: 1
|
||||||
|
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||||
|
project:
|
||||||
|
name: "<project_name>"
|
||||||
|
cases: []
|
||||||
@@ -69,6 +69,11 @@
|
|||||||
"const": "docs/ack/delivery.yaml",
|
"const": "docs/ack/delivery.yaml",
|
||||||
"description": "可选项目交付契约的唯一权威路径"
|
"description": "可选项目交付契约的唯一权威路径"
|
||||||
},
|
},
|
||||||
|
"regressionFile": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "docs/ack/regression.yaml",
|
||||||
|
"description": "可选项目回归目录的唯一权威路径"
|
||||||
|
},
|
||||||
"bugIntake": {
|
"bugIntake": {
|
||||||
"$ref": "#/definitions/feishuBugIntake"
|
"$ref": "#/definitions/feishuBugIntake"
|
||||||
},
|
},
|
||||||
@@ -122,6 +127,12 @@
|
|||||||
"$ref": "#/definitions/deliveryRun"
|
"$ref": "#/definitions/deliveryRun"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"regressionRuns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/regressionRun"
|
||||||
|
}
|
||||||
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -243,6 +254,32 @@
|
|||||||
"deliveryRuns": {}
|
"deliveryRuns": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"regressionFile"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"regressionFile": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": [
|
||||||
|
"regressionRuns"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"regressionRuns": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -1750,6 +1787,20 @@
|
|||||||
"$ref": "#/definitions/knowledgeCheck"
|
"$ref": "#/definitions/knowledgeCheck"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"regressionRefs": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regressionCandidates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/regressionCandidate"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"if": {
|
"if": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1908,6 +1959,131 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"regressionCandidate": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "surface", "steps", "expected"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
|
||||||
|
"surface": { "type": "string", "enum": ["browser", "api"] },
|
||||||
|
"suite": { "type": "string", "enum": ["smoke", "full"] },
|
||||||
|
"setup": { "type": "string" },
|
||||||
|
"steps": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "value"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"visible-text",
|
||||||
|
"api-status",
|
||||||
|
"api-field",
|
||||||
|
"url",
|
||||||
|
"interaction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"value": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceKind": { "type": "string", "enum": ["feature", "bug"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regressionRun": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"suite",
|
||||||
|
"caseIds",
|
||||||
|
"status",
|
||||||
|
"sourceRevision",
|
||||||
|
"configRevision",
|
||||||
|
"baseUrl",
|
||||||
|
"results",
|
||||||
|
"evidence",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^RR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||||
|
},
|
||||||
|
"suite": { "type": "string", "enum": ["smoke", "full", "custom"] },
|
||||||
|
"caseIds": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"taskIds": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"deliveryRunId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"passed",
|
||||||
|
"failed",
|
||||||
|
"blocked",
|
||||||
|
"skipped"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceRevision": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{7,64}$"
|
||||||
|
},
|
||||||
|
"configRevision": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{7,64}$"
|
||||||
|
},
|
||||||
|
"baseUrl": { "type": ["string", "null"] },
|
||||||
|
"results": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["caseId", "result", "evidence"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"caseId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^REG-[A-Za-z0-9][A-Za-z0-9-]*$"
|
||||||
|
},
|
||||||
|
"result": { "type": "string", "enum": ["pass", "fail", "skipped"] },
|
||||||
|
"evidence": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"updatedAt": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ project:
|
|||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: "docs/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||||
deliveryFile: "docs/ack/delivery.yaml"
|
deliveryFile: "docs/ack/delivery.yaml"
|
||||||
|
regressionFile: "docs/ack/regression.yaml"
|
||||||
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||||
# bugIntake:
|
# bugIntake:
|
||||||
# provider: "feishu-base"
|
# provider: "feishu-base"
|
||||||
@@ -114,6 +115,7 @@ project:
|
|||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
deliveryRuns: []
|
deliveryRuns: []
|
||||||
|
regressionRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: []
|
verified: []
|
||||||
@@ -137,6 +139,8 @@ tasks:
|
|||||||
knowledgeApplied: []
|
knowledgeApplied: []
|
||||||
knowledgeCandidates: []
|
knowledgeCandidates: []
|
||||||
knowledgeChecks: []
|
knowledgeChecks: []
|
||||||
|
regressionRefs: []
|
||||||
|
regressionCandidates: []
|
||||||
|
|
||||||
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||||
# source:
|
# source:
|
||||||
|
|||||||
+62
-10
@@ -1,13 +1,14 @@
|
|||||||
# deployer
|
# deployer
|
||||||
|
|
||||||
把「一堆 VPS / NAS 上的 Docker 服务」变成一个 Git 仓库就能管的事:仓库里只放服务配置(数据),
|
把「服务怎么跑」写进 Git,部署方法由这个 skill 自带:换台电脑、换个项目都能直接用。
|
||||||
部署、同步、升级的方法和脚本全部由这个 skill 自带,换台电脑、换个项目都能直接用。
|
|
||||||
|
|
||||||
支持两种用法:
|
支持三种用法:
|
||||||
|
|
||||||
- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有服务(如 app00)
|
- **独立配置中心**:一个专门的 Git 仓库管所有机器的所有 Compose 服务(如 app00)
|
||||||
- **项目内环境**:在普通项目里放 `.skiff/deployer/{prod,test,dev}/`,
|
- **项目内 Compose 环境**:在普通项目里放 `.skiff/deployer/{prod,test,dev}/`,
|
||||||
把这个项目的生产/测试/开发环境也用同一套流程部署
|
把这个项目的生产/测试/开发环境用 rsync + docker compose 部署
|
||||||
|
- **Argo CD / GitOps**:项目里放 `.skiff/deployer/argocd.yaml`;Agent 改 GitOps 并开 MR,
|
||||||
|
你合并后由 Argo CD 同步。仓库既可以只写 Git 地址(部署时浅 clone),也可以指定本机已有目录。
|
||||||
|
|
||||||
## 什么时候使用
|
## 什么时候使用
|
||||||
|
|
||||||
@@ -16,14 +17,15 @@
|
|||||||
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
|
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
|
||||||
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
|
- 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务
|
||||||
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
|
- 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套
|
||||||
|
- ACK 说「重新布测试环境」或跑回归前要先拉起 `.skiff/deployer/test`
|
||||||
- 需要一张「哪台机器跑哪些服务」的清单
|
- 需要一张「哪台机器跑哪些服务」的清单
|
||||||
|
- 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署
|
||||||
|
|
||||||
## 使用前准备
|
## 使用前准备
|
||||||
|
|
||||||
- 本机装有 Python 3、`rsync`、`ssh`
|
- Compose:本机 Python 3、`rsync`、`ssh`;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
|
||||||
- 目标机器装好 Docker + Docker Compose v2
|
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
|
||||||
- `~/.ssh/config` 里为每台机器配好 Host 别名,且能免密(或 agent)登录
|
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
|
||||||
- 知道每个服务的运行时数据放在哪(这些目录不能被同步覆盖)
|
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
@@ -44,6 +46,11 @@ web1 能出网,直接让它从 https://... 把包拉下来装
|
|||||||
给这个项目建好 .skiff/deployer,prod 和 test 分别放到两台机器上
|
给这个项目建好 .skiff/deployer,prod 和 test 分别放到两台机器上
|
||||||
把 test 环境重新部署一下
|
把 test 环境重新部署一下
|
||||||
prod 的 compose 加个 redis,改完发上去
|
prod 的 compose 加个 redis,改完发上去
|
||||||
|
|
||||||
|
# Argo CD
|
||||||
|
把这个项目接到 argocd,仓库写成 git@git.example.com:org/infra-gitops.git
|
||||||
|
GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||||
|
升镜像 tag,改 GitOps 开 MR
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agent 会做什么
|
## Agent 会做什么
|
||||||
@@ -55,6 +62,8 @@ prod 的 compose 加个 redis,改完发上去
|
|||||||
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
|
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
|
||||||
5. 同步后查看容器状态和日志确认生效
|
5. 同步后查看容器状态和日志确认生效
|
||||||
6. 只针对你指定的那一个服务操作,不会批量动整台机器
|
6. 只针对你指定的那一个服务操作,不会批量动整台机器
|
||||||
|
7. Argo CD:读 `.skiff/deployer/argocd.yaml`,浅 clone 或使用 `repo_dir`,改清单,推分支开 MR,停下来等你合并;
|
||||||
|
不直接 kubectl 发布,不把 Harbor/TLS 密钥提交进 Git
|
||||||
|
|
||||||
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
|
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
|
||||||
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
|
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
|
||||||
@@ -68,3 +77,46 @@ prod 的 compose 加个 redis,改完发上去
|
|||||||
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
|
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
|
||||||
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
|
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
|
||||||
- 域名/端口类服务能 curl 通
|
- 域名/端口类服务能 curl 通
|
||||||
|
- Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问
|
||||||
|
|
||||||
|
## Argo CD 的两种接法
|
||||||
|
|
||||||
|
在业务项目里放 `.skiff/deployer/argocd.yaml`。Agent 只改 GitOps 并开 MR,**你合并之后** Argo CD 才部署。镜像、namespace、域名、Secret 名以 GitOps 清单为准,不必在这个文件里再抄一遍。
|
||||||
|
|
||||||
|
### 1. 只写仓库地址(默认)
|
||||||
|
|
||||||
|
本机不用长期放 GitOps 仓库。部署时 Agent 浅 clone 到临时目录,改完开 MR,用完删掉。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .skiff/deployer/argocd.yaml
|
||||||
|
repo: git@git.example.com:org/infra-gitops.git
|
||||||
|
```
|
||||||
|
|
||||||
|
`https://git.example.com/org/infra-gitops.git` 也可以。对本机 git 来说这个地址要能 clone 和 push。
|
||||||
|
|
||||||
|
对 Agent 说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
把这个项目接到 argocd,仓库是 git@git.example.com:org/infra-gitops.git
|
||||||
|
升到 v0.0.2,改 GitOps 开 MR
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 指定本地目录(不 clone)
|
||||||
|
|
||||||
|
GitOps 仓库已经 checkout 在旁边时,写 `repo_dir`,Agent 直接进这个目录改、推分支、开 MR。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .skiff/deployer/argocd.yaml
|
||||||
|
repo: git@git.example.com:org/infra-gitops.git
|
||||||
|
repo_dir: ../infra-gitops
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以只写 `repo_dir`(远程从该目录的 `origin` 读)。相对路径相对**业务项目根**。
|
||||||
|
|
||||||
|
对 Agent 说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GitOps 已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||||
|
```
|
||||||
|
|
||||||
|
两种接法都可以再加 `path:`,仅当 Application 目录不是默认的 `argocd/applications/<项目名>` 时才需要。
|
||||||
|
|||||||
+73
-25
@@ -1,38 +1,55 @@
|
|||||||
---
|
---
|
||||||
name: deployer
|
name: deployer
|
||||||
description: >-
|
description: >-
|
||||||
管理多 VPS / NAS 的 Docker Compose 配置中心:仓库只存服务数据(compose.yaml、静态配置),
|
管理两类部署:多机 Docker Compose(仓库存 compose.yaml 与静态配置,本 skill 脚本
|
||||||
部署方法与脚本由本 skill 提供。当用户要求部署、同步、升级、重启远程 Docker 服务,
|
同步到 SSH 节点后 docker compose 应用),以及 Argo CD GitOps(改 GitOps 仓库清单、
|
||||||
新增/迁移/下线服务,梳理节点与服务清单,向节点分发安装 deb 包(scp 上传 + dpkg/apt 安装,
|
开 PR/MR,用户合并后由 Argo CD 同步)。当用户要求部署、同步、升级、重启远程
|
||||||
或从 URL 远程拉取安装),或提到 make sync/deploy/upgrade/TGT、_config.yaml、rsync 同步、
|
Compose 服务,向节点装 deb,新增/迁移/下线服务,梳理节点清单,make deploy TGT、
|
||||||
tar over SSH、Synology NAS 部署失败时使用。
|
_config.yaml、rsync、tar over SSH、NAS 部署失败;或要求 ArgoCD / GitOps / K8s
|
||||||
|
部署、更新 Application、升镜像 tag、开 MR 让用户合并部署;或 ACK 要求拉起/
|
||||||
|
重布项目测试环境时使用。
|
||||||
---
|
---
|
||||||
|
|
||||||
# deployer:多机 Compose 配置中心
|
# deployer:Compose 节点与 Argo CD GitOps
|
||||||
|
|
||||||
仓库 = 数据(各机器的 `compose.yaml` 与静态配置);方法 = 本 skill 的脚本与规范。
|
两条轨道,配置都在仓库里,方法由本 skill 提供。
|
||||||
本地改配置 → skill 脚本同步到对应 SSH 节点 → 远程 `docker compose` 应用。
|
|
||||||
|
- **Compose**:本地改 `compose.yaml` → 脚本同步到 SSH 节点 → 远程 `docker compose`。
|
||||||
|
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用
|
||||||
|
Compose 的 `sync.py`/`remote.py` 去推集群。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 何时使用
|
## 何时使用
|
||||||
|
|
||||||
- 部署 / 同步 / 升级 / 重启某个远程 Docker Compose 服务
|
- 部署 / 同步 / 升级 / 重启某个远程 Docker Compose 服务
|
||||||
- 向节点安装 deb 包:scp 上传本地 .deb 后 dpkg/apt 安装,或远程从 URL 直接拉取安装
|
- 向节点安装 deb 包:scp 上传本地 .deb 后 dpkg/apt 安装,或从 URL 远程拉取安装
|
||||||
- 新增、迁移、下线一个服务;梳理「哪台机器跑什么」
|
- 新增、迁移、下线一个服务;梳理「哪台机器跑什么」
|
||||||
- sync 失败排查、证书丢失、改了配置不生效等运维问题
|
- sync 失败排查、证书丢失、改了配置不生效等运维问题
|
||||||
- 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步
|
- 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步
|
||||||
|
- Argo CD / GitOps / 集群部署:新增 Application、改清单、升镜像 tag、开 MR 等用户合并
|
||||||
|
- ACK Coordinator 拉起或重布项目测试环境(`.skiff/deployer/<env>`)
|
||||||
|
|
||||||
## 不适用
|
## 不适用
|
||||||
|
|
||||||
- 单机 docker 日常使用(无多机同步诉求)
|
- 单机 docker 日常使用(无多机同步诉求)
|
||||||
- K8s / Nomad 等编排系统
|
- Nomad,或绕过 GitOps 用 `kubectl apply` 当常规发布
|
||||||
- CI/CD 流水线构建发布(本流程是 push 式运维,不是流水线)
|
- 构建并推送镜像(走 builder);本 skill 只改 GitOps 里对该镜像的引用
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 核心模型(先读懂再动手)
|
## 核心模型(先读懂再动手)
|
||||||
|
|
||||||
|
### 轨道选择
|
||||||
|
|
||||||
|
| 信号 | 轨道 |
|
||||||
|
|------|------|
|
||||||
|
| ArgoCD / GitOps / 集群 / 开 MR 部署 / 项目有 `.skiff/deployer/argocd.yaml` | Argo CD,见 [argocd.md](references/argocd.md) |
|
||||||
|
| sync、`TGT=`、某台机器、`compose.yaml` | Compose(下文布局与步骤) |
|
||||||
|
| 两者都有且意图不清 | 先问 |
|
||||||
|
|
||||||
|
### Compose
|
||||||
|
|
||||||
- **仓库只放数据**:`compose.yaml`、Caddyfile、Traefik 动态配置等静态配置进 Git;
|
- **仓库只放数据**:`compose.yaml`、Caddyfile、Traefik 动态配置等静态配置进 Git;
|
||||||
运行时数据(证书、数据库、上传文件)永不进 Git,也永不参与同步范围。
|
运行时数据(证书、数据库、上传文件)永不进 Git,也永不参与同步范围。
|
||||||
- **每个可部署服务目录必须有 `compose.yaml`**,且能解析出目标节点 `node`
|
- **每个可部署服务目录必须有 `compose.yaml`**,且能解析出目标节点 `node`
|
||||||
@@ -40,7 +57,12 @@ description: >-
|
|||||||
- **`node` 即 SSH Host 别名**(`~/.ssh/config`),支持 `user@host` 形式。
|
- **`node` 即 SSH Host 别名**(`~/.ssh/config`),支持 `user@host` 形式。
|
||||||
- `unused/` 下不参与自动发现与部署。
|
- `unused/` 下不参与自动发现与部署。
|
||||||
|
|
||||||
### 两种布局
|
### Argo CD
|
||||||
|
|
||||||
|
源项目 `.skiff/deployer/argocd.yaml`:`repo` 写 Git 地址(部署时浅 clone),或加 `repo_dir` 用已有 checkout。
|
||||||
|
改 GitOps 清单,不要改 Compose 脚本。密钥不入库。两种接法见 skill README,步骤见 [argocd.md](references/argocd.md)。
|
||||||
|
|
||||||
|
### 两种 Compose 布局
|
||||||
|
|
||||||
**A. 独立配置中心仓库**(如 app00):仓库根即部署根,
|
**A. 独立配置中心仓库**(如 app00):仓库根即部署根,
|
||||||
`DEPLOYER_ROOT=/path/to/repo` 指定后按仓库内相对路径操作:
|
`DEPLOYER_ROOT=/path/to/repo` 指定后按仓库内相对路径操作:
|
||||||
@@ -64,6 +86,7 @@ my-project/
|
|||||||
├── src/ ... # 项目本体
|
├── src/ ... # 项目本体
|
||||||
└── .skiff/deployer/
|
└── .skiff/deployer/
|
||||||
├── _config.yaml # 三个环境共享默认(node/base_path 等)
|
├── _config.yaml # 三个环境共享默认(node/base_path 等)
|
||||||
|
├── argocd.yaml # 可选,Argo CD 指针(不是 compose 环境)
|
||||||
├── prod/
|
├── prod/
|
||||||
│ ├── compose.yaml # 生产 compose 与配置
|
│ ├── compose.yaml # 生产 compose 与配置
|
||||||
│ └── _config.yaml # 环境级覆盖
|
│ └── _config.yaml # 环境级覆盖
|
||||||
@@ -75,9 +98,18 @@ my-project/
|
|||||||
(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖;
|
(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖;
|
||||||
`_config.yaml` 写 `name:` 可显式指定。
|
`_config.yaml` 写 `name:` 可显式指定。
|
||||||
|
|
||||||
|
## 被 ACK 调用
|
||||||
|
|
||||||
|
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对项目
|
||||||
|
`.skiff/deployer/<env>`(通常是 `test`)按下面 Compose 轨道执行。ACK 只负责何时
|
||||||
|
布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、
|
||||||
|
Argo CD 合入和节点级批量操作仍须用户明确要求,不能因为 ACK 调用就扩大范围。
|
||||||
|
|
||||||
## 步骤
|
## 步骤
|
||||||
|
|
||||||
### 0. 定位部署根
|
### Compose 轨道
|
||||||
|
|
||||||
|
#### 0. 定位部署根
|
||||||
|
|
||||||
skill 目录下的 `scripts/deploy/` 是通用部署工具链(lib/sync/remote/list),
|
skill 目录下的 `scripts/deploy/` 是通用部署工具链(lib/sync/remote/list),
|
||||||
不依赖具体项目路径。部署根按以下顺序解析:
|
不依赖具体项目路径。部署根按以下顺序解析:
|
||||||
@@ -89,19 +121,19 @@ skill 目录下的 `scripts/deploy/` 是通用部署工具链(lib/sync/remote/
|
|||||||
```bash
|
```bash
|
||||||
# <skill-dir> = 本 SKILL.md 所在目录,先解析出来记下
|
# <skill-dir> = 本 SKILL.md 所在目录,先解析出来记下
|
||||||
# 布局 A:显式指定仓库根
|
# 布局 A:显式指定仓库根
|
||||||
export DEPLOYER_ROOT=/path/to/your/compose-repo
|
export DEPLOYER_ROOT=/path/to/your-compose-repo
|
||||||
python3 <skill-dir>/scripts/deploy/list.py
|
python3 <skill-dir>/scripts/deploy/list.py
|
||||||
|
|
||||||
# 布局 B:在项目内直接跑即可(cwd 在项目里)
|
# 布局 B:在项目内直接跑即可(cwd 在项目里)
|
||||||
python3 <skill-dir>/scripts/deploy/list.py
|
python3 <skill-dir>/scripts/deploy/list.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1. 摸底:列出服务与节点
|
#### 1. 摸底:列出服务与节点
|
||||||
|
|
||||||
上一步的 `list.py` 输出全部服务与节点分布;新增环境/服务后重跑确认被发现。
|
上一步的 `list.py` 输出全部服务与节点分布;新增环境/服务后重跑确认被发现。
|
||||||
项目布局下 `prod/test/dev` 各显示为 `{项目名}-{env}`。
|
项目布局下 `prod/test/dev` 各显示为 `{项目名}-{env}`。`argocd.yaml` 不会被 list.py 当成 compose 服务。
|
||||||
|
|
||||||
### 2. 解析单个服务
|
#### 2. 解析单个服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看 node、远程路径、排除规则(sync.py 干跑会打印这些信息)
|
# 查看 node、远程路径、排除规则(sync.py 干跑会打印这些信息)
|
||||||
@@ -110,7 +142,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
|
|||||||
|
|
||||||
或直接读服务目录及祖先的 `_config.yaml`。
|
或直接读服务目录及祖先的 `_config.yaml`。
|
||||||
|
|
||||||
### 3. 命令选择(语义严格区分)
|
#### 3. 命令选择(语义严格区分)
|
||||||
|
|
||||||
| 意图 | 命令 |
|
| 意图 | 命令 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -121,12 +153,12 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
|
|||||||
| 仅重启,不同步文件 | `remote.py <svc> restart` |
|
| 仅重启,不同步文件 | `remote.py <svc> restart` |
|
||||||
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
|
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
|
||||||
|
|
||||||
### 4. 项目侧 Makefile(可选薄封装)
|
#### 4. 项目侧 Makefile(可选薄封装)
|
||||||
|
|
||||||
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。
|
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。
|
||||||
没有 Makefile 时直接调 python 脚本即可,不要新建封装层。
|
没有 Makefile 时直接调 python 脚本即可,不要新建封装层。
|
||||||
|
|
||||||
### 5. 新增服务 / 环境 checklist
|
#### 5. 新增服务 / 环境 checklist
|
||||||
|
|
||||||
独立仓库布局:
|
独立仓库布局:
|
||||||
|
|
||||||
@@ -146,13 +178,13 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
|
|||||||
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
|
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
|
||||||
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉)
|
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉)
|
||||||
|
|
||||||
### 6. 下线服务
|
#### 6. 下线服务
|
||||||
|
|
||||||
独立仓库布局:配置移入 `unused/`(自动脱离发现体系),远程按需手动清理:
|
独立仓库布局:配置移入 `unused/`(自动脱离发现体系),远程按需手动清理:
|
||||||
`ssh <node> "cd <base_path>/<name> && docker compose down"`,数据卷按需保留或删除。
|
`ssh <node> "cd <base_path>/<name> && docker compose down"`,数据卷按需保留或删除。
|
||||||
项目环境布局:删除对应 `.skiff/deployer/{env}/` 目录即可脱离发现体系,远程清理同上。
|
项目环境布局:删除对应 `.skiff/deployer/{env}/` 目录即可脱离发现体系,远程清理同上。
|
||||||
|
|
||||||
### 7. 向节点安装 deb 包
|
#### 7. 向节点安装 deb 包
|
||||||
|
|
||||||
`deb.py` 把 deb 包发到节点并安装。目标两种写法:仓库内目录
|
`deb.py` 把 deb 包发到节点并安装。目标两种写法:仓库内目录
|
||||||
(复用 `_config.yaml` 继承链解析 node/port/identity_file,如 `hosts/web1`),
|
(复用 `_config.yaml` 继承链解析 node/port/identity_file,如 `hosts/web1`),
|
||||||
@@ -177,6 +209,19 @@ python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1
|
|||||||
- 升级同版本号前想先看包信息:`ssh <node> "dpkg -I <暂存路径>"`;
|
- 升级同版本号前想先看包信息:`ssh <node> "dpkg -I <暂存路径>"`;
|
||||||
装完验证:`ssh <node> "dpkg -l | grep <pkg>"`。
|
装完验证:`ssh <node> "dpkg -l | grep <pkg>"`。
|
||||||
|
|
||||||
|
### Argo CD 轨道
|
||||||
|
|
||||||
|
完整步骤与 `argocd.yaml` 字段见 [argocd.md](references/argocd.md)。
|
||||||
|
|
||||||
|
1. 读项目 `.skiff/deployer/argocd.yaml`(无则只问 Git 地址,写成 `repo:`)。
|
||||||
|
有 `repo_dir` 则用该目录;否则把 `repo` 浅 clone 到临时目录,用完删除。
|
||||||
|
2. 在工作副本里按**已有应用惯例**新增 Application,或只改镜像 tag / 清单。
|
||||||
|
3. 从最新默认分支拉出分支,commit、push,用 `glab`/`gh`/`tea` 开 MR;CLI 对项目 404 则把
|
||||||
|
`git push` 给出的网页建单链接交给用户。
|
||||||
|
4. **停在 MR**,不合并、不 `kubectl apply` 工作负载。
|
||||||
|
5. 无 app-of-apps 时提醒用户首次 `kubectl apply` 那份 `application.yaml`。
|
||||||
|
6. Harbor 拉镜像 Secret、TLS Secret 不入库;只在工作负载所在 ns 准备,可从其他 ns 拷贝。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
@@ -187,8 +232,9 @@ python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1
|
|||||||
默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径
|
默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径
|
||||||
(如 `/data01/docker/<svc>/`),否则会被清掉。
|
(如 `/data01/docker/<svc>/`),否则会被清掉。
|
||||||
- **镜像固定 tag**,不用 `:latest` 漂移;成对升级的服务(如 proxy 客户端/服务端)要同步升。
|
- **镜像固定 tag**,不用 `:latest` 漂移;成对升级的服务(如 proxy 客户端/服务端)要同步升。
|
||||||
- **密钥**:优先放远程 `.env` 或环境变量,不要提交新密钥进 Git。
|
- **密钥**:Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
|
||||||
- **Git 安全**:不 `--force` 推送、不硬 reset,除非用户明确要求。
|
不要提交新密钥进 Git。
|
||||||
|
- **Git 安全**:不 `--force` 推送、不硬 reset,除非用户明确要求。Argo CD 轨道不直接推默认分支。
|
||||||
- **NAS / Synology 特例**:部分 NAS 的 SSH 用户禁用 rsync 协议(Permission denied)。
|
- **NAS / Synology 特例**:部分 NAS 的 SSH 用户禁用 rsync 协议(Permission denied)。
|
||||||
表现是 sync 报错但 ssh 正常。处理顺序:
|
表现是 sync 报错但 ssh 正常。处理顺序:
|
||||||
1. 该节点 `_config.yaml` 写真实 `base_path`(如 `/volume1/docker`,避开符号链接路径)
|
1. 该节点 `_config.yaml` 写真实 `base_path`(如 `/volume1/docker`,避开符号链接路径)
|
||||||
@@ -205,10 +251,11 @@ python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
- `list.py` 输出全部服务与节点分布,数量与预期一致
|
- Compose:`list.py` 输出全部服务与节点分布,数量与预期一致
|
||||||
- 每次 sync/deploy 后 `remote.py <svc> ps` 容器 Up、`logs` 无报错
|
- 每次 sync/deploy 后 `remote.py <svc> ps` 容器 Up、`logs` 无报错
|
||||||
- 升级后额外确认镜像 tag 与 compose.yaml 一致
|
- 升级后额外确认镜像 tag 与 compose.yaml 一致
|
||||||
- 改 Traefik/Caddy 路由后 curl 对应域名验证生效
|
- 改 Traefik/Caddy 路由后 curl 对应域名验证生效
|
||||||
|
- Argo CD:MR 可打开且含本次清单;用户合并后 Application Synced;有 Ingress 则 curl healthz
|
||||||
|
|
||||||
## scripts/
|
## scripts/
|
||||||
|
|
||||||
@@ -226,3 +273,4 @@ python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1
|
|||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `references/config-reference.md` | `_config.yaml` 字段完整说明与继承合并规则 |
|
| `references/config-reference.md` | `_config.yaml` 字段完整说明与继承合并规则 |
|
||||||
|
| `references/argocd.md` | Argo CD 轨道:`argocd.yaml`、新增/升级、开 MR、ns 级 Secret |
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Argo CD GitOps 轨道
|
||||||
|
|
||||||
|
改 GitOps 仓库里的 Application / 清单 → 开 PR/MR → **用户合并** → Argo CD 同步到集群。
|
||||||
|
Agent 不直接 `kubectl apply` 工作负载,也不合并 MR。
|
||||||
|
|
||||||
|
Compose 的 `_config.yaml` / `sync.py` / `remote.py` 不用于本轨道。
|
||||||
|
|
||||||
|
## 项目配置
|
||||||
|
|
||||||
|
`argocd.yaml` 只做指针:GitOps 仓库的 Git 地址,以及可选的本地目录。Application 名、镜像、namespace、域名、Secret 名的权威在 GitOps 清单里,不要再抄一份。
|
||||||
|
不要嵌进 `_config.yaml`(脚本解析器不支持嵌套映射)。
|
||||||
|
|
||||||
|
两种接法(人类说明见 skill `README.md`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 1)只写地址:部署时浅 clone 到临时目录,开完 MR 删掉
|
||||||
|
repo: git@git.example.com:org/infra-gitops.git
|
||||||
|
|
||||||
|
# 2)已有 checkout:不 clone,直接在目录里改
|
||||||
|
# repo: git@git.example.com:org/infra-gitops.git # 可选;缺省用该目录 origin
|
||||||
|
repo_dir: ../infra-gitops # 相对路径相对项目根
|
||||||
|
|
||||||
|
# path: argocd/applications/other-name # 仅当目录不是 argocd/applications/<源项目名>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `repo` | 与 `repo_dir` 至少一个 | Git 远程地址(`git@host:group/name.git` 或 `https://...`) |
|
||||||
|
| `repo_dir` | 与 `repo` 至少一个 | 本地 checkout;相对**项目根**或绝对路径 |
|
||||||
|
| `path` | 否 | 默认 `argocd/applications/<源项目 git 仓库名>` |
|
||||||
|
|
||||||
|
`repo` 写成本地路径(`../`、`/`、`~/`)时,当作 `repo_dir`(旧写法兼容)。
|
||||||
|
|
||||||
|
有 `repo_dir` 且目录是 git 仓库 → **不 clone**,在里面改。`repo` 同时存在且与 `origin` 指向不同仓库时停下问用户。
|
||||||
|
只有 `repo` 地址 → `git clone --depth 1` 到 `mktemp -d`;开完 MR(或确认失败)后删掉临时目录。不要 clone 进源项目。
|
||||||
|
|
||||||
|
无此文件:只问 Git 地址,写成 `repo:` 再动手。
|
||||||
|
|
||||||
|
其余一律推导,不要写进这个文件:
|
||||||
|
|
||||||
|
| 需要的信息 | 从哪来 |
|
||||||
|
|------------|--------|
|
||||||
|
| 推送 remote | `repo` 地址,或 `git -C <repo_dir> remote get-url origin` |
|
||||||
|
| Application 名 | `path` 末级,或 `application.yaml` 的 `metadata.name` |
|
||||||
|
| 镜像(无 tag) | 清单里的 `image:`;首次无清单则项目 Makefile / `.env` 的 registry + name |
|
||||||
|
| 升 tag 改哪个文件 | 在 `path` 下搜该镜像 |
|
||||||
|
| namespace / host / imagePullSecret / TLS Secret | `application.yaml` 与 Ingress/Deployment |
|
||||||
|
|
||||||
|
默认 `path` 不存在:再按 `metadata.name == 源项目名` 搜;还没有则走「新增」,不要把 namespace/host 写回 `argocd.yaml`。
|
||||||
|
|
||||||
|
## 摸底
|
||||||
|
|
||||||
|
1. 读 `argocd.yaml`,按上一节得到工作副本(浅 clone 或 `repo_dir`)。`repo_dir` 先 `git fetch`;工作区有无关改动则 `git worktree add` 隔离。
|
||||||
|
2. 打开 `path/`:有 `application.yaml` 视为已接入;只有惯例目录、没有本应用则走「新增」。
|
||||||
|
3. **清单写法跟目标 GitOps 仓库已有应用走**,不要另起一套:
|
||||||
|
- 自建镜像:`application.yaml` + `manifests/`(Deployment/Service/Ingress)
|
||||||
|
- 上游 Helm:`sources[]` 里 chart + 本仓库 `values.yaml`
|
||||||
|
4. 从兄弟应用抄:`spec.project`、`destination.server`、`syncPolicy`、IngressClass、TLS 引用方式。
|
||||||
|
5. 仓库 README 写明「无 app-of-apps / 需逐个创建 Application」时,合入 main **不会**自动注册新 Application。
|
||||||
|
|
||||||
|
## 新增 Application
|
||||||
|
|
||||||
|
向用户确认清单里写不出的项:namespace、对外 host(或仅 ClusterIP)、是否要 `imagePullSecrets`、TLS Secret 名、镜像 tag。
|
||||||
|
|
||||||
|
然后在工作副本里:
|
||||||
|
|
||||||
|
1. 从最新默认分支拉出 `feat/<application>`(浅 clone 已在默认分支;`repo_dir` 从 `origin/<default>` 拉)。
|
||||||
|
2. 建 `path/application.yaml` + 清单(或 values),字段对齐兄弟应用。
|
||||||
|
3. 镜像用固定 tag,不用 `:latest`。
|
||||||
|
4. Secret、dockerconfigjson、口令 **不入库**;Deployment 只引用 Secret 名。
|
||||||
|
|
||||||
|
## 升级 / 改清单
|
||||||
|
|
||||||
|
已有 Application 时:
|
||||||
|
|
||||||
|
1. 在 `path` 下搜到的镜像行,把 `image: <name>:<old>` 改成新 tag。
|
||||||
|
2. 只改本次要求的清单;不要顺手改无关 values。
|
||||||
|
3. tag 必须已经推进镜像仓库(本轨道不负责 `docker push`,那是 builder)。
|
||||||
|
|
||||||
|
## 提交 MR
|
||||||
|
|
||||||
|
1. 分支从最新 `origin/<default>` 拉出,名称如 `feat/<application>` 或 `chore/<application>-<tag>`。
|
||||||
|
2. 只提交 GitOps 仓库内本次文件。
|
||||||
|
3. `git push -u` 到 `origin`;不 `--force`、不硬 reset。
|
||||||
|
4. 按 remote 选 CLI:**GitLab `glab mr create`**、**GitHub `gh pr create`**、**Gitea/Forgejo `tea pulls create`**。
|
||||||
|
创建前读该子命令 `--help`;指定 title、body、base、head。
|
||||||
|
5. CLI 对项目 404(token 看不到仓库)而 SSH push 已成功:把 `git push` 打印的「create merge request」网页链接交给用户,不要读 token 配置、不要改用 curl 带 token。
|
||||||
|
6. **停在 MR**。不合并、不替用户点 Sync。浅 clone 的临时目录在得到 MR 链接(或确认失败)后删除。
|
||||||
|
|
||||||
|
MR 正文写清:改了什么、镜像引用、合入后用户还要做的事(首次 apply Application、拷 Secret、验 healthz/域名)。
|
||||||
|
|
||||||
|
## 合入后交给用户
|
||||||
|
|
||||||
|
无 app-of-apps 时,**第一次**注册 Application(之后改 manifests 合入即可):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f <path>/application.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
TLS / 拉镜像 Secret 必须已在**工作负载所在 ns** 存在。没有则从已有 ns 拷贝(见下),不要新建一份写进 Git。
|
||||||
|
|
||||||
|
## Secret(ns 级,不入库)
|
||||||
|
|
||||||
|
`imagePullSecrets` 与 Ingress `tls.secretName` 都只在 Pod/Ingress 所在 namespace 生效。
|
||||||
|
不是集群每个 ns 都要建,只给**实际跑这个负载的 ns** 准备。
|
||||||
|
|
||||||
|
列出已有拉镜像 Secret(名字以 Deployment 的 `imagePullSecrets` 为准):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get secret <secret> -A
|
||||||
|
```
|
||||||
|
|
||||||
|
拷到目标 ns(类型须为 `kubernetes.io/dockerconfigjson`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get secret <secret> -n SOURCE -o json \
|
||||||
|
| jq 'del(
|
||||||
|
.metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp,
|
||||||
|
.metadata.selfLink, .metadata.ownerReferences, .metadata.managedFields,
|
||||||
|
.metadata.annotations, .status
|
||||||
|
) | .metadata.namespace = "DEST"' \
|
||||||
|
| kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
没有 `jq`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get secret <secret> -n SOURCE \
|
||||||
|
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d > /tmp/dockerconfig.json
|
||||||
|
kubectl -n DEST create secret generic <secret> \
|
||||||
|
--type=kubernetes.io/dockerconfigjson \
|
||||||
|
--from-file=.dockerconfigjson=/tmp/dockerconfig.json
|
||||||
|
rm /tmp/dockerconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
本环境没有 kubeconfig 时,把命令交给用户执行,不要假装已经建好。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- MR URL 可打开,head 含本次清单,base 为约定默认分支
|
||||||
|
- 用户合并后:Argo CD 中该 Application Synced(automated 关闭时用户需手动 Sync)
|
||||||
|
- 升级:集群里容器镜像 tag 与清单一致
|
||||||
|
- 有 Ingress:curl 域名 healthz / 业务路径;HTTPS 失败先查 TLS Secret 是否在该 ns
|
||||||
|
|
||||||
|
## 不要做
|
||||||
|
|
||||||
|
- 把 Compose 的 rsync/`remote.py` 套到集群
|
||||||
|
- 常规发布用 `kubectl apply` 工作负载绕过 GitOps
|
||||||
|
- 提交 Harbor 密码、`dockerconfigjson`、TLS 私钥
|
||||||
|
- 在未获「合并」授权时 `glab mr merge` / `gh pr merge`
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# `_config.yaml` 配置参考
|
# `_config.yaml` 配置参考
|
||||||
|
|
||||||
`_config.yaml` 供 skill 部署脚本解析,决定同步目标与排除规则。可放在**服务目录、部署根或其任意祖先目录**;子目录中的字段覆盖父目录(继承合并)。
|
`_config.yaml` 供 **Compose 轨道**脚本解析,决定同步目标与排除规则。可放在**服务目录、部署根或其任意祖先目录**;子目录中的字段覆盖父目录(继承合并)。
|
||||||
|
|
||||||
|
Argo CD 轨道用独立文件 `.skiff/deployer/argocd.yaml`,字段见 [argocd.md](argocd.md)。
|
||||||
|
不要把 `argocd:` 嵌进本文件(脚本解析器不支持嵌套映射)。
|
||||||
|
|
||||||
## 放置位置(两种布局)
|
## 放置位置(两种布局)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ description: >-
|
|||||||
## 按需读取参考资料
|
## 按需读取参考资料
|
||||||
|
|
||||||
- 开始任何流程时读取 [project-policy.md](references/project-policy.md),确定策略来源和默认值。
|
- 开始任何流程时读取 [project-policy.md](references/project-policy.md),确定策略来源和默认值。
|
||||||
- 需要选择、更新或核对版本号时读取 [versioning.md](references/versioning.md)。
|
- 需要选择、更新或核对版本号,或生成 tag 更新说明时读取
|
||||||
|
[versioning.md](references/versioning.md)。
|
||||||
- 需要 push、创建或合并 PR/MR、创建 Forge Release 时读取
|
- 需要 push、创建或合并 PR/MR、创建 Forge Release 时读取
|
||||||
[forge-adapters.md](references/forge-adapters.md)。
|
[forge-adapters.md](references/forge-adapters.md)。
|
||||||
- 遇到重复资源、并发冲突、部分成功或清理请求时读取
|
- 遇到重复资源、并发冲突、部分成功或清理请求时读取
|
||||||
@@ -172,16 +173,17 @@ PR/MR 时,停在这里并返回 URL、head/base、当前检查状态和阻塞
|
|||||||
中锁定且重新验证过的远端目标 commit。两者统一记为 release commit。核对目标版本后,
|
中锁定且重新验证过的远端目标 commit。两者统一记为 release commit。核对目标版本后,
|
||||||
再查询一次远端同名 tag。
|
再查询一次远端同名 tag。
|
||||||
|
|
||||||
创建 tag 前,根据上一个稳定 tag 到 release commit 的实际差异生成更新说明;首次发布则
|
创建 tag 前,按 [versioning.md](references/versioning.md) 生成并检查 tag message:根据
|
||||||
使用项目声明的发布基线,没有声明时核对完整可达历史并明确标记首次发布。说明概括实际
|
上一个稳定 tag 到 release commit 的实际差异填写 `Summary`、`Changes`、`Validation`
|
||||||
存在的新增、变更、修复、维护或文档变化;存在破坏性变化、迁移步骤或已知限制时必须明确
|
和 `Base commit`;首次发布则使用项目声明的发布基线,没有声明时核对完整可达历史并明确
|
||||||
列出。每项内容都应能追溯到本次提交、PR/MR 或项目 CHANGELOG。空说明、只重复版本号、
|
标记首次发布。空说明、只重复版本号或提交标题、缺少规定字段、模板占位文字、伪造变更
|
||||||
模板占位文字或无法由实际变化支持的内容都不能发布。
|
或无法由实际变化支持的内容都不能发布。
|
||||||
|
|
||||||
tag 不存在时,使用完整更新说明创建 annotated tag;项目要求签名时,必须先确认签名
|
tag 不存在时,把检查通过的完整 message 写入文件,再用
|
||||||
工具和密钥可用,创建 signed annotated tag,并在本地验证签名成功后才允许 push。使用
|
`git tag -a <tag> <release-commit> -F <release-notes-file>` 创建 annotated tag;项目
|
||||||
文件输入完整的多行说明,显式指定 release commit,并只推送这个 tag。禁止创建 lightweight
|
要求签名时,必须先确认签名工具和密钥可用,创建 signed annotated tag,并在本地验证签名
|
||||||
release tag。签名不可用或验证失败时停止,不得降级为 unsigned tag。
|
成功后才允许 push。显式指定 release commit,并只推送这个 tag。禁止使用简短 `-m` 或
|
||||||
|
创建 lightweight release tag。签名不可用或验证失败时停止,不得降级为 unsigned tag。
|
||||||
|
|
||||||
同名远端 tag 已存在且指向其他 commit 时立即停止。不要覆盖、删除或移动已经发布的
|
同名远端 tag 已存在且指向其他 commit 时立即停止。不要覆盖、删除或移动已经发布的
|
||||||
tag。同名 tag 即使指向 release commit,只要它是 lightweight tag、缺少更新说明或说明与
|
tag。同名 tag 即使指向 release commit,只要它是 lightweight tag、缺少更新说明或说明与
|
||||||
@@ -217,8 +219,8 @@ Forge Release 正文复用 tag 中经过验证的更新说明,不维护第二
|
|||||||
- required checks、review 或冲突状态不满足合并规则。
|
- required checks、review 或冲突状态不满足合并规则。
|
||||||
- 合并结果的准确 commit 无法从平台确认,或无法从远端 base 到达。
|
- 合并结果的准确 commit 无法从平台确认,或无法从远端 base 到达。
|
||||||
- 远端 tag 已指向其他 commit。
|
- 远端 tag 已指向其他 commit。
|
||||||
- 更新说明为空、只有版本号、包含占位内容、无法追溯到实际变化,或远端 tag 不是包含
|
- 更新说明为空、只有版本号或提交标题、缺少规定字段、包含占位内容、无法追溯到实际
|
||||||
计划说明的 annotated tag。
|
变化,或远端 tag 不是包含计划说明的 annotated tag。
|
||||||
- 请求的 PR/MR、合并或 Forge Release 阶段所需的平台 CLI 缺失、未认证或无法读取状态。
|
- 请求的 PR/MR、合并或 Forge Release 阶段所需的平台 CLI 缺失、未认证或无法读取状态。
|
||||||
- 项目要求 tag 签名,但签名能力不可用或本地签名验证失败。
|
- 项目要求 tag 签名,但签名能力不可用或本地签名验证失败。
|
||||||
- 操作需要 force push、管理员绕过、移动已发布 tag 或覆盖用户工作。
|
- 操作需要 force push、管理员绕过、移动已发布 tag 或覆盖用户工作。
|
||||||
|
|||||||
@@ -72,13 +72,38 @@ tag 派生版本,且发布说明不要求写回仓库时,不产生仓库修
|
|||||||
|
|
||||||
以最近一个适用于当前发布线的稳定 tag 为起点,以 release commit 为终点,结合项目
|
以最近一个适用于当前发布线的稳定 tag 为起点,以 release commit 为终点,结合项目
|
||||||
CHANGELOG、已合并 PR/MR 和实际 diff 生成更新说明。首次发布使用项目声明的发布基线;
|
CHANGELOG、已合并 PR/MR 和实际 diff 生成更新说明。首次发布使用项目声明的发布基线;
|
||||||
没有声明时核对完整可达历史,并在说明中明确这是首次发布。说明覆盖实际存在的新增、
|
没有声明时核对完整可达历史,并在说明中明确这是首次发布。
|
||||||
变更、修复、维护或文档变化;破坏性变化、迁移步骤和已知限制存在时必须单独标明。没有
|
|
||||||
某一类别时可以省略该类别,不要生成空标题或模板占位。
|
正式版本的 tag message 使用以下固定格式;内容必须基于本次实际变更,并与 CHANGELOG
|
||||||
|
或 PR/MR 发布说明保持一致:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<tag>
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- <一句话概括本版本>
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- <变更 1>
|
||||||
|
- <变更 2>
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- <验证命令及结果>
|
||||||
|
|
||||||
|
Base commit: <release commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Summary`、`Changes`、`Validation` 和 `Base commit` 四个字段必须全部存在,不能只有
|
||||||
|
版本号、提交标题或空 message。没有仓库 CHANGELOG 时,也必须按此格式补齐字段。
|
||||||
|
`Changes` 只写入实际存在的新增、变更、修复、维护或文档变化;破坏性变化、迁移步骤和
|
||||||
|
已知限制存在时必须单独标明。没有实际功能变更时不得伪造条目,应明确说明这是文档、
|
||||||
|
打包或发布元数据修订。不要生成空标题或模板占位。`Validation` 写入本次实际执行或已经
|
||||||
|
确认通过的验证命令及结果,不要编造未执行的检查。`Base commit` 必须是本次 release
|
||||||
|
commit。
|
||||||
|
|
||||||
每条说明都要能追溯到范围内的提交、PR/MR 或项目 CHANGELOG。提交消息和 PR/MR 文本只
|
每条说明都要能追溯到范围内的提交、PR/MR 或项目 CHANGELOG。提交消息和 PR/MR 文本只
|
||||||
作为待核对素材,不能覆盖实际 diff,也不能把范围外变化写入本次说明。tag 创建前冻结
|
作为待核对素材,不能覆盖实际 diff,也不能把范围外变化写入本次说明。创建 tag 前先
|
||||||
完整多行说明;tag 已推送后不再修改。
|
生成并检查完整 message,再冻结;tag 已推送后不再修改。
|
||||||
|
|
||||||
## 验证 release commit
|
## 验证 release commit
|
||||||
|
|
||||||
@@ -90,9 +115,11 @@ commit 为准。获取远端 base 或允许的维护分支后,确认 release c
|
|||||||
正式 tag 必须满足:
|
正式 tag 必须满足:
|
||||||
|
|
||||||
- tag 名按项目格式由目标版本唯一生成。
|
- tag 名按项目格式由目标版本唯一生成。
|
||||||
|
- tag 必须是 annotated tag,message 必须是冻结后的完整更新说明,最少包括
|
||||||
|
`Summary`、`Changes`、`Validation` 和 `Base commit`;不能只有版本号、提交标题或空
|
||||||
|
message。禁止 lightweight release tag。
|
||||||
- 远端没有同名 tag,或同名 tag 已经准确指向本次 commit。
|
- 远端没有同名 tag,或同名 tag 已经准确指向本次 commit。
|
||||||
- annotated tag 显式指向 release commit,并包含冻结后的完整更新说明;禁止 lightweight
|
- annotated tag 显式指向 release commit。
|
||||||
release tag。
|
|
||||||
- 项目要求签名时,push 前已成功创建 signed tag,并在本地验证签名通过;不可用或失败
|
- 项目要求签名时,push 前已成功创建 signed tag,并在本地验证签名通过;不可用或失败
|
||||||
时停止,不得改用 unsigned tag。
|
时停止,不得改用 unsigned tag。
|
||||||
- 推送后从远端重新读取 tag object,核对对象类型和完整说明,再将 annotated tag 解引用
|
- 推送后从远端重新读取 tag object,核对对象类型和完整说明,再将 annotated tag 解引用
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ SSOT 固定在 `~/.skills/skills/<name>/`。内容通过 **symlink** 分发到
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
skiff add --list
|
skiff add --list
|
||||||
skiff add <name> -a cursor -a claude -a codex -y
|
skiff add <name> -a cursor -a claude -a codex -a agents -y
|
||||||
```
|
```
|
||||||
|
|
||||||
`skiff add <name>` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.skills/skills/<name>/` 的软链,不要在 Agent 目录创建副本。
|
`skiff add <name>` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.skills/skills/<name>/` 的软链,不要在 Agent 目录创建副本。
|
||||||
|
|||||||
+19
-20
@@ -314,7 +314,7 @@ class AckDeliveryValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||||
|
|
||||||
def test_intents_must_point_at_matching_stop_points(self) -> None:
|
def test_test_environment_intent_requires_deployer_binding(self) -> None:
|
||||||
contract = valid_contract()
|
contract = valid_contract()
|
||||||
contract["intents"] = {
|
contract["intents"] = {
|
||||||
"testEnvironment": "review",
|
"testEnvironment": "review",
|
||||||
@@ -323,33 +323,32 @@ class AckDeliveryValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
errors = validate_delivery.validate_builtin(contract)
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("intents.testEnvironment" in item and "validation_ready" in item for item in errors)
|
any("已改为 deployer 绑定" in item for item in errors)
|
||||||
)
|
)
|
||||||
|
|
||||||
contract["intents"]["testEnvironment"] = "local-validation"
|
contract["intents"]["testEnvironment"] = {"via": "deployer", "env": "test"}
|
||||||
contract["profiles"]["local-validation"] = {
|
|
||||||
"stopAt": "validation_ready",
|
|
||||||
"steps": [
|
|
||||||
{"id": "build-local", "action": "build", "artifact": "service-deb"},
|
|
||||||
{
|
|
||||||
"id": "deploy-local",
|
|
||||||
"action": "deploy",
|
|
||||||
"artifact": "service-deb",
|
|
||||||
"environment": "test-server",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "health-local",
|
|
||||||
"action": "health-check",
|
|
||||||
"environment": "test-server",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||||
|
|
||||||
contract["intents"]["release"] = "missing-release"
|
contract["intents"]["release"] = "missing-release"
|
||||||
errors = validate_delivery.validate_builtin(contract)
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
self.assertTrue(any("未定义 profile 'missing-release'" in item for item in errors))
|
self.assertTrue(any("未定义 profile 'missing-release'" in item for item in errors))
|
||||||
|
|
||||||
|
def test_test_environment_binding_requires_deployer_env_directory(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
contract["intents"] = {
|
||||||
|
"testEnvironment": {"via": "deployer", "env": "test"},
|
||||||
|
"release": None,
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
errors = validate_delivery.validate_builtin(contract, root)
|
||||||
|
self.assertTrue(
|
||||||
|
any(".skiff/deployer/test" in item for item in errors)
|
||||||
|
)
|
||||||
|
env_dir = root / ".skiff" / "deployer" / "test"
|
||||||
|
env_dir.mkdir(parents=True)
|
||||||
|
self.assertEqual(validate_delivery.validate_builtin(contract, root), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -603,21 +603,53 @@ class GitIdentityTests(unittest.TestCase):
|
|||||||
["/usr/bin/git", "init", "-q", str(project)],
|
["/usr/bin/git", "init", "-q", str(project)],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
identity = launch_worker.capture_worktree_identity(
|
|
||||||
project,
|
|
||||||
str(project),
|
|
||||||
[str(project)],
|
|
||||||
)
|
|
||||||
self.assertEqual(identity["path"], str(project))
|
|
||||||
|
|
||||||
impostor = base / "impostor"
|
def registered(git: Path, root: Path) -> set[Path]:
|
||||||
impostor.mkdir()
|
return {project}
|
||||||
(impostor / ".git").symlink_to(project / ".git")
|
|
||||||
with self.assertRaisesRegex(launch_worker.LaunchError, "symlink"):
|
# v0.19 起 capture_worktree_identity 不再接收 allowedWorktrees 白名单;
|
||||||
|
# worktree 合法性由 git worktree 注册表 + 同 common-dir 约束保证。
|
||||||
|
with mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"registered_git_worktrees",
|
||||||
|
side_effect=registered,
|
||||||
|
):
|
||||||
|
identity = launch_worker.capture_worktree_identity(
|
||||||
|
project,
|
||||||
|
str(project),
|
||||||
|
)
|
||||||
|
self.assertEqual(identity["path"], str(project))
|
||||||
|
|
||||||
|
impostor = base / "impostor"
|
||||||
|
impostor.mkdir()
|
||||||
|
(impostor / ".git").symlink_to(project / ".git")
|
||||||
|
with self.assertRaisesRegex(launch_worker.LaunchError, "symlink"):
|
||||||
|
launch_worker.capture_worktree_identity(
|
||||||
|
project,
|
||||||
|
str(impostor),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unregistered_worktree_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
base = Path(temporary).resolve()
|
||||||
|
project = base / "project"
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/bin/git", "init", "-q", str(project)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
other = base / "other-repo"
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/bin/git", "init", "-q", str(other)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
launch_worker.LaunchError,
|
||||||
|
"未出现在 git worktree list",
|
||||||
|
):
|
||||||
launch_worker.capture_worktree_identity(
|
launch_worker.capture_worktree_identity(
|
||||||
project,
|
project,
|
||||||
str(impostor),
|
str(other),
|
||||||
[str(impostor)],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||||
|
VALIDATOR = SCRIPTS_DIR / "validate_regression.py"
|
||||||
|
SELECTOR = SCRIPTS_DIR / "select_regression.py"
|
||||||
|
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "regression.example.yaml"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import select_regression # noqa: E402
|
||||||
|
import validate_regression # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def empty_catalog() -> dict:
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"updatedAt": "2026-08-25T10:00:00+08:00",
|
||||||
|
"project": {"name": "demo"},
|
||||||
|
"cases": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sample_case(**overrides: object) -> dict:
|
||||||
|
case = {
|
||||||
|
"id": "REG-login-001",
|
||||||
|
"title": "登录后进入工作台",
|
||||||
|
"status": "active",
|
||||||
|
"source": {"taskId": "BUG-001", "kind": "bug"},
|
||||||
|
"suite": "smoke",
|
||||||
|
"surface": "browser",
|
||||||
|
"setup": "使用测试账号 A,未登录",
|
||||||
|
"steps": ["打开 /login", "输入账号 A 并提交"],
|
||||||
|
"expected": [
|
||||||
|
{"kind": "visible-text", "value": "工作台"},
|
||||||
|
{"kind": "url", "value": "/dashboard"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case.update(overrides)
|
||||||
|
return case
|
||||||
|
|
||||||
|
|
||||||
|
class AckRegressionValidationTests(unittest.TestCase):
|
||||||
|
def test_example_and_template_are_valid(self) -> None:
|
||||||
|
for no_site_packages in (False, True):
|
||||||
|
command = [sys.executable]
|
||||||
|
if no_site_packages:
|
||||||
|
command.append("-S")
|
||||||
|
result = subprocess.run(
|
||||||
|
[*command, str(VALIDATOR), str(EXAMPLE)],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
with self.subTest(no_site_packages=no_site_packages):
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("回归目录校验通过", result.stdout)
|
||||||
|
|
||||||
|
template = REPO_ROOT / "skills" / "ack" / "templates" / "regression.template.yaml"
|
||||||
|
rendered = template.read_text(encoding="utf-8").replace(
|
||||||
|
"<project_name>", "demo"
|
||||||
|
).replace("<YYYY-MM-DDTHH:mm:ss+TZ>", "2026-08-25T10:00:00+08:00")
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
path = Path(temp_dir) / "regression.yaml"
|
||||||
|
path.write_text(rendered, encoding="utf-8")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(VALIDATOR), str(path)],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_rejects_duplicate_ids_and_unknown_fields(self) -> None:
|
||||||
|
catalog = empty_catalog()
|
||||||
|
catalog["cases"] = [sample_case(), sample_case()]
|
||||||
|
errors = validate_regression.validate_builtin(catalog)
|
||||||
|
self.assertTrue(any("不能重复" in item for item in errors))
|
||||||
|
|
||||||
|
catalog["cases"] = [sample_case(extra="nope")]
|
||||||
|
errors = validate_regression.validate_builtin(catalog)
|
||||||
|
self.assertTrue(any("未知字段" in item for item in errors))
|
||||||
|
|
||||||
|
def test_selects_smoke_by_default_and_full_includes_all_active(self) -> None:
|
||||||
|
catalog = empty_catalog()
|
||||||
|
catalog["cases"] = [
|
||||||
|
sample_case(),
|
||||||
|
sample_case(
|
||||||
|
id="REG-notes-create-001",
|
||||||
|
suite="full",
|
||||||
|
surface="api",
|
||||||
|
source={"taskId": "FEAT-012", "kind": "feature"},
|
||||||
|
),
|
||||||
|
sample_case(id="REG-old-001", status="retired"),
|
||||||
|
]
|
||||||
|
smoke = select_regression.select_cases(
|
||||||
|
catalog, suite="smoke", case_ids=[], limit=50
|
||||||
|
)
|
||||||
|
full = select_regression.select_cases(
|
||||||
|
catalog, suite="full", case_ids=[], limit=50
|
||||||
|
)
|
||||||
|
self.assertEqual([case["id"] for case in smoke], ["REG-login-001"])
|
||||||
|
self.assertEqual(
|
||||||
|
[case["id"] for case in full],
|
||||||
|
["REG-login-001", "REG-notes-create-001"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selector_cli_and_task_link(self) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SELECTOR), str(EXAMPLE), "--format", "ids"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), "REG-login-001")
|
||||||
|
|
||||||
|
tasks = {
|
||||||
|
"project": {
|
||||||
|
"name": "notes-web",
|
||||||
|
"regressionFile": "docs/ack/regression.yaml",
|
||||||
|
},
|
||||||
|
"regressionRuns": [],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "BUG-001",
|
||||||
|
"regressionRefs": ["REG-login-001"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
catalog = validate_regression.load_yaml(EXAMPLE, "回归目录")
|
||||||
|
self.assertEqual(validate_regression.validate_tasks_link(catalog, tasks), [])
|
||||||
|
|
||||||
|
tasks["project"]["regressionFile"] = "docs/ack/other.yaml"
|
||||||
|
errors = validate_regression.validate_tasks_link(catalog, tasks)
|
||||||
|
self.assertTrue(any("必须固定为 docs/ack/regression.yaml" in item for item in errors))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+16
-4
@@ -18,18 +18,23 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"docs/ack/tasks.yaml",
|
"docs/ack/tasks.yaml",
|
||||||
"docs/ack/knowledge.yaml",
|
"docs/ack/knowledge.yaml",
|
||||||
"docs/ack/delivery.yaml",
|
"docs/ack/delivery.yaml",
|
||||||
|
"docs/ack/regression.yaml",
|
||||||
"tasks: []",
|
"tasks: []",
|
||||||
"validate_tasks.py",
|
"validate_tasks.py",
|
||||||
"validate_knowledge.py",
|
"validate_knowledge.py",
|
||||||
"validate_delivery.py",
|
"validate_delivery.py",
|
||||||
|
"validate_regression.py",
|
||||||
"select_tasks.py",
|
"select_tasks.py",
|
||||||
"select_knowledge.py",
|
"select_knowledge.py",
|
||||||
|
"select_regression.py",
|
||||||
"references/kickoff.md",
|
"references/kickoff.md",
|
||||||
"不要修改项目的 `AGENTS.md`",
|
"不要修改项目的 `AGENTS.md`",
|
||||||
"当前会话担任 Coordinator",
|
"当前会话担任 Coordinator",
|
||||||
"intents.testEnvironment",
|
"intents.testEnvironment",
|
||||||
"运行测试环境",
|
"运行测试环境",
|
||||||
"运行版本发布",
|
"运行版本发布",
|
||||||
|
"运行回归",
|
||||||
|
"via: deployer",
|
||||||
):
|
):
|
||||||
self.assertIn(expected, content)
|
self.assertIn(expected, content)
|
||||||
|
|
||||||
@@ -121,6 +126,12 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"scripts/validate_knowledge.py",
|
"scripts/validate_knowledge.py",
|
||||||
"scripts/select_tasks.py",
|
"scripts/select_tasks.py",
|
||||||
"scripts/select_knowledge.py",
|
"scripts/select_knowledge.py",
|
||||||
|
"scripts/select_regression.py",
|
||||||
|
"scripts/validate_regression.py",
|
||||||
|
"templates/regression.template.yaml",
|
||||||
|
"templates/regression.schema.json",
|
||||||
|
"examples/regression.example.yaml",
|
||||||
|
"references/regression.md",
|
||||||
"scripts/run_verification.py",
|
"scripts/run_verification.py",
|
||||||
"scripts/worker_profiles.py",
|
"scripts/worker_profiles.py",
|
||||||
"scripts/launch_worker.py",
|
"scripts/launch_worker.py",
|
||||||
@@ -131,7 +142,7 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||||
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
self.assertEqual(version, "0.18.0")
|
self.assertEqual(version, "0.19.0")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
f'ackVersion: "{version}"',
|
f'ackVersion: "{version}"',
|
||||||
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
|
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
|
||||||
@@ -153,10 +164,11 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
REPO_ROOT / "skills" / "ack" / "templates" / "tasks.template.yaml"
|
REPO_ROOT / "skills" / "ack" / "templates" / "tasks.template.yaml"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
self.assertNotIn("repoPath:", template)
|
# v0.19 起 allowedWorktrees 白名单废弃:模板只保留废弃说明注释,不再生成该字段
|
||||||
self.assertNotIn("devWorktree:", template)
|
self.assertNotIn("allowedWorktrees:", template)
|
||||||
self.assertIn("allowedWorktrees:", template)
|
self.assertIn("allowedWorktrees", template)
|
||||||
self.assertIn('knowledgeFile: "docs/ack/knowledge.yaml"', template)
|
self.assertIn('knowledgeFile: "docs/ack/knowledge.yaml"', template)
|
||||||
|
self.assertIn('regressionFile: "docs/ack/regression.yaml"', template)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -302,7 +302,13 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
with self.subTest(invalid_semver=invalid):
|
with self.subTest(invalid_semver=invalid):
|
||||||
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
||||||
|
|
||||||
current_gate, orchestration_gate, receipts_gate, delivery_gate = schema["allOf"]
|
(
|
||||||
|
current_gate,
|
||||||
|
orchestration_gate,
|
||||||
|
receipts_gate,
|
||||||
|
delivery_gate,
|
||||||
|
regression_gate,
|
||||||
|
) = schema["allOf"]
|
||||||
current_pattern = re.compile(
|
current_pattern = re.compile(
|
||||||
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
||||||
)
|
)
|
||||||
@@ -341,6 +347,7 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
"#/definitions/launchableTasks",
|
"#/definitions/launchableTasks",
|
||||||
)
|
)
|
||||||
self.assertIn("deliveryRuns", delivery_gate["then"]["required"])
|
self.assertIn("deliveryRuns", delivery_gate["then"]["required"])
|
||||||
|
self.assertIn("regressionRuns", regression_gate["then"]["required"])
|
||||||
|
|
||||||
delivery_run = schema["definitions"]["deliveryRun"]
|
delivery_run = schema["definitions"]["deliveryRun"]
|
||||||
revision_gate = delivery_run["allOf"][0]
|
revision_gate = delivery_run["allOf"][0]
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class ProfileValidationTests(unittest.TestCase):
|
|||||||
worker_profiles.validate_worker_receipt(receipt, orchestration=[])
|
worker_profiles.validate_worker_receipt(receipt, orchestration=[])
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_orca_requires_defaults_profiles_and_allowed_worktree(self) -> None:
|
def test_orca_requires_defaults_and_profiles_and_tolerates_legacy_allowlist(self) -> None:
|
||||||
routing = valid_orchestration()
|
routing = valid_orchestration()
|
||||||
routing["allowedWorktrees"] = []
|
routing["allowedWorktrees"] = []
|
||||||
routing["profiles"] = {}
|
routing["profiles"] = {}
|
||||||
@@ -396,7 +396,10 @@ class ProfileValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
errors = worker_profiles.validate_orchestration(routing)
|
errors = worker_profiles.validate_orchestration(routing)
|
||||||
|
|
||||||
self.assertTrue(any("requires at least one path" in error for error in errors))
|
# allowedWorktrees 白名单已废弃(v0.19):遗留字段容忍,不再校验
|
||||||
|
self.assertTrue(
|
||||||
|
all("requires at least one path" not in error for error in errors)
|
||||||
|
)
|
||||||
self.assertTrue(any("Orca mode requires profiles" in error for error in errors))
|
self.assertTrue(any("Orca mode requires profiles" in error for error in errors))
|
||||||
self.assertTrue(any("missing role 'developer'" in error for error in errors))
|
self.assertTrue(any("missing role 'developer'" in error for error in errors))
|
||||||
self.assertTrue(any("missing role 'test'" in error for error in errors))
|
self.assertTrue(any("missing role 'test'" in error for error in errors))
|
||||||
@@ -777,8 +780,6 @@ class ReceiptValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
errors = worker_profiles.validate_worker_receipt(receipt)
|
errors = worker_profiles.validate_worker_receipt(receipt)
|
||||||
|
|
||||||
self.assertTrue(any("must contain canonical JSON data" in error for error in errors))
|
|
||||||
|
|
||||||
def test_receipt_rejects_wrong_worktree_task_and_binding(self) -> None:
|
def test_receipt_rejects_wrong_worktree_task_and_binding(self) -> None:
|
||||||
routing = valid_orchestration()
|
routing = valid_orchestration()
|
||||||
receipt = valid_receipt(routing)
|
receipt = valid_receipt(routing)
|
||||||
@@ -793,7 +794,11 @@ class ReceiptValidationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(any("unknown task 'TASK-001'" in error for error in errors))
|
self.assertTrue(any("unknown task 'TASK-001'" in error for error in errors))
|
||||||
self.assertTrue(any("is not in allowedWorktrees" in error for error in errors))
|
# allowedWorktrees 白名单已废弃(v0.19):receipt 不再按白名单校验 worktree 路径,
|
||||||
|
# 只要求 binding 观测路径与 worktree 快照一致。
|
||||||
|
self.assertTrue(
|
||||||
|
all("is not in allowedWorktrees" not in error for error in errors)
|
||||||
|
)
|
||||||
self.assertTrue(any("does not match worktree.path" in error for error in errors))
|
self.assertTrue(any("does not match worktree.path" in error for error in errors))
|
||||||
|
|
||||||
def test_receipt_requires_safe_environment_and_live_binding(self) -> None:
|
def test_receipt_requires_safe_environment_and_live_binding(self) -> None:
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ paths = (
|
|||||||
Path('skills/ack/examples/knowledge.example.yaml'),
|
Path('skills/ack/examples/knowledge.example.yaml'),
|
||||||
Path('skills/ack/templates/delivery.template.yaml'),
|
Path('skills/ack/templates/delivery.template.yaml'),
|
||||||
Path('skills/ack/examples/delivery.example.yaml'),
|
Path('skills/ack/examples/delivery.example.yaml'),
|
||||||
|
Path('skills/ack/templates/regression.template.yaml'),
|
||||||
|
Path('skills/ack/examples/regression.example.yaml'),
|
||||||
)
|
)
|
||||||
for path in paths:
|
for path in paths:
|
||||||
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise SystemExit(f'{path}: top-level value is not a mapping')
|
raise SystemExit(f'{path}: top-level value is not a mapping')
|
||||||
print('parsed=6')
|
print('parsed=8')
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-S", "-c", script],
|
[sys.executable, "-S", "-c", script],
|
||||||
@@ -47,7 +49,7 @@ print('parsed=6')
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertEqual(result.stdout.strip(), "parsed=6")
|
self.assertEqual(result.stdout.strip(), "parsed=8")
|
||||||
|
|
||||||
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def write_skill(root: Path, name: str) -> None:
|
||||||
|
skill = root / "skills" / name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
skill.joinpath("SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: >-\n"
|
||||||
|
f" 测试 {name} skill。\n---\n\n# {name}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsTargetTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.home = Path(self.temp_dir.name)
|
||||||
|
self.skills_home = self.home / ".skills"
|
||||||
|
self.skills_home.mkdir(parents=True)
|
||||||
|
(self.skills_home / "catalog.yaml").write_text("", encoding="utf-8")
|
||||||
|
write_skill(self.skills_home, "demo-skill")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["HOME"] = str(self.home)
|
||||||
|
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "skiff", *args],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_global_add_creates_symlink_in_agents_skills(self) -> None:
|
||||||
|
result = self.run_skiff("add", "builtin/demo-skill", "-g", "-a", "agents")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
link = self.home / ".agents" / "skills" / "demo-skill"
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
self.assertEqual(
|
||||||
|
link.resolve(), (self.skills_home / "skills" / "demo-skill").resolve()
|
||||||
|
)
|
||||||
|
|
||||||
|
removed = self.run_skiff("rm", "builtin/demo-skill", "-g", "-a", "agents")
|
||||||
|
self.assertEqual(removed.returncode, 0, removed.stderr)
|
||||||
|
self.assertFalse(link.exists())
|
||||||
|
|
||||||
|
def test_project_add_targets_agents_and_codex_share_single_link(self) -> None:
|
||||||
|
project = self.home / "app"
|
||||||
|
project.mkdir()
|
||||||
|
|
||||||
|
added = self.run_skiff(
|
||||||
|
"add",
|
||||||
|
"builtin/demo-skill",
|
||||||
|
"--project",
|
||||||
|
str(project),
|
||||||
|
"-a",
|
||||||
|
"agents",
|
||||||
|
"-a",
|
||||||
|
"codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(added.returncode, 0, added.stderr)
|
||||||
|
# agents 与 codex 项目级共用 .agents/skills,软链幂等只写一份
|
||||||
|
links = list((project / ".agents" / "skills").glob("demo-skill"))
|
||||||
|
self.assertEqual(len(links), 1)
|
||||||
|
self.assertTrue(links[0].is_symlink())
|
||||||
|
manifest = (project / ".skills.yaml").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("demo-skill", manifest)
|
||||||
|
|
||||||
|
removed = self.run_skiff(
|
||||||
|
"rm", "builtin/demo-skill", "--project", str(project), "-y"
|
||||||
|
)
|
||||||
|
self.assertEqual(removed.returncode, 0, removed.stderr)
|
||||||
|
self.assertFalse((project / ".agents" / "skills" / "demo-skill").exists())
|
||||||
|
|
||||||
|
def test_status_lists_installed_skill_for_agents_target(self) -> None:
|
||||||
|
installed = self.run_skiff("add", "builtin/demo-skill", "-g", "-a", "agents")
|
||||||
|
status = self.run_skiff("status", "-a", "agents")
|
||||||
|
|
||||||
|
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||||
|
self.assertEqual(status.returncode, 0, status.stderr)
|
||||||
|
self.assertIn("demo-skill", status.stdout)
|
||||||
|
|
||||||
|
def test_opencode_target_is_no_longer_supported(self) -> None:
|
||||||
|
result = self.run_skiff("add", "builtin/demo-skill", "-g", "-a", "opencode")
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("未知 agent", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -83,13 +83,13 @@ class CreateWorkflowTests(unittest.TestCase):
|
|||||||
".cursor/skills/skiff",
|
".cursor/skills/skiff",
|
||||||
".claude/skills/skiff",
|
".claude/skills/skiff",
|
||||||
".codex/skills/skiff",
|
".codex/skills/skiff",
|
||||||
|
".agents/skills/skiff",
|
||||||
):
|
):
|
||||||
link = self.home / relative
|
link = self.home / relative
|
||||||
self.assertTrue(link.is_symlink(), relative)
|
self.assertTrue(link.is_symlink(), relative)
|
||||||
self.assertEqual(link.resolve(), expected)
|
self.assertEqual(link.resolve(), expected)
|
||||||
self.assertIn("cursor", first.stdout)
|
for agent in ("cursor", "claude", "codex", "agents"):
|
||||||
self.assertIn("claude", first.stdout)
|
self.assertIn(agent, first.stdout)
|
||||||
self.assertIn("codex", first.stdout)
|
|
||||||
|
|
||||||
def test_update_pulls_skills_home(self) -> None:
|
def test_update_pulls_skills_home(self) -> None:
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class InstallScriptTests(unittest.TestCase):
|
|||||||
".cursor/skills/skiff",
|
".cursor/skills/skiff",
|
||||||
".claude/skills/skiff",
|
".claude/skills/skiff",
|
||||||
".codex/skills/skiff",
|
".codex/skills/skiff",
|
||||||
|
".agents/skills/skiff",
|
||||||
):
|
):
|
||||||
link = home / relative
|
link = home / relative
|
||||||
self.assertTrue(link.is_symlink(), relative)
|
self.assertTrue(link.is_symlink(), relative)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class SelectorTests(unittest.TestCase):
|
|||||||
"ack",
|
"ack",
|
||||||
"builtin",
|
"builtin",
|
||||||
installed=False,
|
installed=False,
|
||||||
readonly_status="全局: cursor,claude,codex,opencode",
|
readonly_status="全局: cursor,claude,codex,agents",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
screen = Mock()
|
screen = Mock()
|
||||||
@@ -101,7 +101,7 @@ class SelectorTests(unittest.TestCase):
|
|||||||
if call.args[0] == 3
|
if call.args[0] == 3
|
||||||
)
|
)
|
||||||
self.assertIn("[ ] ack", title)
|
self.assertIn("[ ] ack", title)
|
||||||
self.assertIn("全局: cursor,claude,codex,opencode", title)
|
self.assertIn("全局: cursor,claude,codex,agents", title)
|
||||||
self.assertNotIn("初始化", title)
|
self.assertNotIn("初始化", title)
|
||||||
|
|
||||||
def test_selector_renders_description_on_indented_second_line(self) -> None:
|
def test_selector_renders_description_on_indented_second_line(self) -> None:
|
||||||
@@ -370,7 +370,7 @@ class SelectCommandTests(unittest.TestCase):
|
|||||||
other.mkdir()
|
other.mkdir()
|
||||||
global_dirs = {
|
global_dirs = {
|
||||||
target: root / target / "skills"
|
target: root / target / "skills"
|
||||||
for target in ("cursor", "claude", "codex", "opencode")
|
for target in ("cursor", "claude", "codex", "agents")
|
||||||
}
|
}
|
||||||
for directory in global_dirs.values():
|
for directory in global_dirs.values():
|
||||||
directory.mkdir(parents=True)
|
directory.mkdir(parents=True)
|
||||||
@@ -385,7 +385,7 @@ class SelectCommandTests(unittest.TestCase):
|
|||||||
note = cli._global_installation_note(
|
note = cli._global_installation_note(
|
||||||
"ack",
|
"ack",
|
||||||
expected,
|
expected,
|
||||||
["cursor", "claude", "codex", "opencode"],
|
["cursor", "claude", "codex", "agents"],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(note, "全局: cursor;全局同名冲突: claude")
|
self.assertEqual(note, "全局: cursor;全局同名冲突: claude")
|
||||||
|
|||||||
@@ -65,10 +65,18 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
'profiles: {}\n',
|
'profiles: {}\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
(skill / "templates" / "regression.template.yaml").write_text(
|
||||||
|
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||||
|
'project:\n'
|
||||||
|
' name: "<project_name>"\n'
|
||||||
|
'cases: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
for validator_name in (
|
for validator_name in (
|
||||||
"validate_tasks.py",
|
"validate_tasks.py",
|
||||||
"validate_knowledge.py",
|
"validate_knowledge.py",
|
||||||
"validate_delivery.py",
|
"validate_delivery.py",
|
||||||
|
"validate_regression.py",
|
||||||
):
|
):
|
||||||
(skill / "scripts" / validator_name).write_text(
|
(skill / "scripts" / validator_name).write_text(
|
||||||
"raise SystemExit(0)\n",
|
"raise SystemExit(0)\n",
|
||||||
@@ -105,15 +113,19 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
||||||
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
||||||
delivery_content = (target / "delivery.yaml").read_text(encoding="utf-8")
|
delivery_content = (target / "delivery.yaml").read_text(encoding="utf-8")
|
||||||
|
regression_content = (target / "regression.yaml").read_text(encoding="utf-8")
|
||||||
self.assertIn("# sample-app", project_content)
|
self.assertIn("# sample-app", project_content)
|
||||||
self.assertIn("version=1.2.3", project_content)
|
self.assertIn("version=1.2.3", project_content)
|
||||||
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
||||||
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
||||||
self.assertIn('name: "sample-app"', delivery_content)
|
self.assertIn('name: "sample-app"', delivery_content)
|
||||||
self.assertIn("enabled: false", delivery_content)
|
self.assertIn("enabled: false", delivery_content)
|
||||||
|
self.assertIn('name: "sample-app"', regression_content)
|
||||||
|
self.assertIn("cases: []", regression_content)
|
||||||
self.assertNotIn("<project_name>", tasks_content)
|
self.assertNotIn("<project_name>", tasks_content)
|
||||||
self.assertNotIn("<project_name>", knowledge_content)
|
self.assertNotIn("<project_name>", knowledge_content)
|
||||||
self.assertNotIn("<project_name>", delivery_content)
|
self.assertNotIn("<project_name>", delivery_content)
|
||||||
|
self.assertNotIn("<project_name>", regression_content)
|
||||||
|
|
||||||
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
||||||
project = self.home / "existing-app"
|
project = self.home / "existing-app"
|
||||||
@@ -130,6 +142,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "tasks.yaml").exists())
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
self.assertFalse((target / "knowledge.yaml").exists())
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
self.assertFalse((target / "delivery.yaml").exists())
|
self.assertFalse((target / "delivery.yaml").exists())
|
||||||
|
self.assertFalse((target / "regression.yaml").exists())
|
||||||
|
|
||||||
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
||||||
project = self.home / "existing-knowledge-app"
|
project = self.home / "existing-knowledge-app"
|
||||||
@@ -161,6 +174,22 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "project.md").exists())
|
self.assertFalse((target / "project.md").exists())
|
||||||
self.assertFalse((target / "tasks.yaml").exists())
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
self.assertFalse((target / "knowledge.yaml").exists())
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
|
self.assertFalse((target / "regression.yaml").exists())
|
||||||
|
|
||||||
|
def test_init_refuses_to_overwrite_existing_regression_file(self) -> None:
|
||||||
|
project = self.home / "existing-regression-app"
|
||||||
|
target = project / "docs" / "ack"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
existing = target / "regression.yaml"
|
||||||
|
existing.write_text("keep me", encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("拒绝覆盖已有路径", result.stderr)
|
||||||
|
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||||
|
self.assertFalse((target / "project.md").exists())
|
||||||
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
|
|
||||||
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
||||||
for symlink_level in ("docs", "ack"):
|
for symlink_level in ("docs", "ack"):
|
||||||
@@ -336,7 +365,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in moved_target.iterdir()),
|
sorted(path.name for path in moved_target.iterdir()),
|
||||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
[
|
||||||
|
"delivery.yaml",
|
||||||
|
"knowledge.yaml",
|
||||||
|
"project.md",
|
||||||
|
"regression.yaml",
|
||||||
|
"tasks.yaml",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
||||||
@@ -395,7 +430,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "marker").exists())
|
self.assertFalse((target / "marker").exists())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
[
|
||||||
|
"delivery.yaml",
|
||||||
|
"knowledge.yaml",
|
||||||
|
"project.md",
|
||||||
|
"regression.yaml",
|
||||||
|
"tasks.yaml",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
|
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
|
||||||
@@ -407,7 +448,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
if calls == 8:
|
if calls == 9:
|
||||||
raise OSError("simulated directory fsync failure")
|
raise OSError("simulated directory fsync failure")
|
||||||
real_fsync(file_descriptor)
|
real_fsync(file_descriptor)
|
||||||
|
|
||||||
@@ -433,7 +474,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = project / "docs" / "ack"
|
target = project / "docs" / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
[
|
||||||
|
"delivery.yaml",
|
||||||
|
"knowledge.yaml",
|
||||||
|
"project.md",
|
||||||
|
"regression.yaml",
|
||||||
|
"tasks.yaml",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
||||||
@@ -520,7 +567,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = moved_project / "docs" / "ack"
|
target = moved_project / "docs" / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
[
|
||||||
|
"delivery.yaml",
|
||||||
|
"knowledge.yaml",
|
||||||
|
"project.md",
|
||||||
|
"regression.yaml",
|
||||||
|
"tasks.yaml",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_docs_replacement_aborts_before_publish(self) -> None:
|
def test_docs_replacement_aborts_before_publish(self) -> None:
|
||||||
@@ -615,7 +668,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = moved_docs / "ack"
|
target = moved_docs / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
[
|
||||||
|
"delivery.yaml",
|
||||||
|
"knowledge.yaml",
|
||||||
|
"project.md",
|
||||||
|
"regression.yaml",
|
||||||
|
"tasks.yaml",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_ack_init_requires_knowledge_template(self) -> None:
|
def test_ack_init_requires_knowledge_template(self) -> None:
|
||||||
@@ -652,11 +711,29 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertIn("delivery.template.yaml", result.stderr)
|
self.assertIn("delivery.template.yaml", result.stderr)
|
||||||
self.assertFalse((project / "docs" / "ack").exists())
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
|
def test_ack_init_requires_regression_template(self) -> None:
|
||||||
|
project = self.home / "missing-regression-template-app"
|
||||||
|
project.mkdir()
|
||||||
|
(
|
||||||
|
self.skills_home
|
||||||
|
/ "skills"
|
||||||
|
/ "ack"
|
||||||
|
/ "templates"
|
||||||
|
/ "regression.template.yaml"
|
||||||
|
).unlink()
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("regression.template.yaml", result.stderr)
|
||||||
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
def test_ack_init_requires_all_validators(self) -> None:
|
def test_ack_init_requires_all_validators(self) -> None:
|
||||||
for validator_name in (
|
for validator_name in (
|
||||||
"validate_tasks.py",
|
"validate_tasks.py",
|
||||||
"validate_knowledge.py",
|
"validate_knowledge.py",
|
||||||
"validate_delivery.py",
|
"validate_delivery.py",
|
||||||
|
"validate_regression.py",
|
||||||
):
|
):
|
||||||
with self.subTest(validator_name=validator_name):
|
with self.subTest(validator_name=validator_name):
|
||||||
project = self.home / f"missing-{validator_name}-app"
|
project = self.home / f"missing-{validator_name}-app"
|
||||||
@@ -715,6 +792,21 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertIn("初始化交付契约校验失败", result.stderr)
|
self.assertIn("初始化交付契约校验失败", result.stderr)
|
||||||
self.assertFalse((project / "docs" / "ack").exists())
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
|
def test_regression_validator_failure_leaves_no_partial_initialization(self) -> None:
|
||||||
|
project = self.home / "invalid-regression-app"
|
||||||
|
project.mkdir()
|
||||||
|
validator = (
|
||||||
|
self.skills_home / "skills" / "ack" / "scripts" / "validate_regression.py"
|
||||||
|
)
|
||||||
|
validator.write_text("raise SystemExit(1)\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
self.assertIn("初始化回归目录校验失败", result.stderr)
|
||||||
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
||||||
project = self.home / "mutated-staging-app"
|
project = self.home / "mutated-staging-app"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
|
|||||||
Reference in New Issue
Block a user