5 Commits

Author SHA1 Message Date
laily a7a201962a feat: update release 2026-08-25 14:42:26 +08:00
laily ad6695245b feat(ack): bind test env to deployer and add regression mode
ACK 0.19.0 hands test-environment deploys to the deployer skill,
documents bug-fix as a first-class scenario, and adds
docs/ack/regression.yaml harvest plus a /ack regression run.
2026-08-25 14:41:05 +08:00
laily ee31278947 feat(deployer): add Argo CD / GitOps workflow with MR-based deployment 2026-08-24 20:37:55 +08:00
laily 282a6809ac test(ack): align allowedWorktrees deprecation tests with v0.19 contract
e4d4319 deprecated the allowedWorktrees whitelist (git worktree registry +
same common-dir checks replace it) but left 4 tests asserting the old
contract:

- test_ack_skill: template must NOT contain allowedWorktrees: anymore,
  only the deprecation comment
- test_ack_worker_profiles: legacy allowlist field is tolerated (no
  'requires at least one path'); receipt no longer validated against it
- test_ack_launch_worker: capture_worktree_identity dropped the
  allowlist arg; assert registered-worktree pass, symlink impostor and
  unregistered-rejection under the new signature
2026-08-24 15:40:02 +08:00
laily a8efa5f359 feat(skiff): replace opencode target with agents standard dir
- targets: cursor, claude, codex, agents (~/.agents/skills, .agents/skills)
- agents dir covers OMP native discovery; drop omp/opencode targets
- project-level agents/cursor/codex share .agents/skills via idempotent symlinks
- migrate local state: remove ~/.config/opencode/skills links, reinstall into ~/.agents/skills
2026-08-24 14:25:08 +08:00
53 changed files with 2433 additions and 298 deletions
+4 -4
View File
@@ -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) |
--- ---
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
0.18.0 0.19.0
+2 -2
View File
@@ -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
+3 -14
View File
@@ -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:
+10 -4
View File
@@ -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 -1
View File
@@ -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"]
+15 -4
View File
@@ -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 或手动模式。
+6 -6
View File
@@ -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 绑定为准,不另写一套启动命令。
--- ---
+22 -15
View File
@@ -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 的合法调用路径。
+28 -9
View File
@@ -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 \
完成后报告: 完成后报告:
- 创建或确认的四个项目文件。 - 创建或确认的项目文件(含回归目录)。
- 检测到的技术栈和验证命令。 - 检测到的技术栈和验证命令。
- 任务板、项目知识和交付契约校验结果。 - 任务板、项目知识和交付契约校验结果。
- 仍需用户补充的值。 - 仍需用户补充的值。
+14 -8
View File
@@ -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。
+10 -6
View File
@@ -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` 后必须给出新增/更新/退役/无回归四选一。
--- ---
+41 -2
View File
@@ -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。
```
+73
View File
@@ -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
+132
View File
@@ -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())
+60 -21
View File
@@ -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:
+378
View File
@@ -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())
+262
View File
@@ -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
+12 -2
View File
@@ -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"],
+2 -1
View File
@@ -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
+14 -9
View File
@@ -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: []
+176
View File
@@ -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 }
}
} }
} }
} }
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+150
View File
@@ -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:` 嵌进本文件(脚本解析器不支持嵌套映射)。
## 放置位置(两种布局) ## 放置位置(两种布局)
+14 -12
View File
@@ -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 或覆盖用户工作。
+34 -7
View File
@@ -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 解引用
+1 -1
View File
@@ -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
View File
@@ -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()
+44 -12
View File
@@ -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)],
) )
+149
View File
@@ -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
View File
@@ -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__":
+8 -1
View File
@@ -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]
+10 -5
View File
@@ -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:
+4 -2
View File
@@ -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(
+108
View File
@@ -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()
+3 -3
View File
@@ -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 (
+1
View File
@@ -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)
+4 -4
View File
@@ -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")
+98 -6
View File
@@ -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()