diff --git a/AGENTS.md b/AGENTS.md index 3846bc6..df3d271 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # pouch -自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。仓库名、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。旧名是 `skills` / `skiff`。 +自研 Agent Skills 与配套规范资料的单一事实来源(SSOT)。仓库名、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。旧名是 `skills` / `pouch`。 | 仓库 | 地址 | 职责 | @@ -59,7 +59,7 @@ AGENTS.md # 本文档 | Skill | 说明 | | ---------------------------------------------------------------------- | ------------------------------------------------- | | [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 | -| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 | +| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行三角色闭环、测试环境、发版与回归 | | [pouch](skills/pouch/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md | diff --git a/README.md b/README.md index fe55ed5..521a7f4 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ pouch list pouch status ``` -从旧的 `~/.skills` / `skiff` 迁移: +从旧的 `~/.skills` / `pouch` 迁移: ```bash mv ~/.skills ~/.pouch ~/.pouch/install.sh ``` -`install.sh` 会安装 `pouch` 命令,并保留 `skiff` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。 +`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。 ## 仓库结构 @@ -58,8 +58,9 @@ pouch init ack pouch init ack --project ~/app ``` -初始化会生成默认关闭的 `.pouch/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护 -DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。 +初始化会生成默认关闭的 `.pouch/ack/delivery.yaml` 和空的 `.pouch/ack/regression.yaml`。 +项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务 +验证通过后收获回归用例。 新建 skill: diff --git a/pouch/cli.py b/pouch/cli.py index 4d3002d..95e7e87 100644 --- a/pouch/cli.py +++ b/pouch/cli.py @@ -1313,9 +1313,10 @@ def cmd_init(args: argparse.Namespace) -> None: tasks_file = destination / "tasks.yaml" knowledge_file = destination / "knowledge.yaml" delivery_file = destination / "delivery.yaml" + regression_file = destination / "regression.yaml" managed_targets = [project_file, tasks_file] 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()] if existing: paths = ", ".join(str(path.relative_to(project)) for path in existing) @@ -1332,6 +1333,10 @@ def cmd_init(args: argparse.Namespace) -> None: ( (skill_source / "templates" / "knowledge.template.yaml", knowledge_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()] @@ -1341,10 +1346,16 @@ def cmd_init(args: argparse.Namespace) -> None: validator = skill_source / "scripts" / "validate_tasks.py" knowledge_validator = skill_source / "scripts" / "validate_knowledge.py" delivery_validator = skill_source / "scripts" / "validate_delivery.py" + regression_validator = skill_source / "scripts" / "validate_regression.py" if args.name == "ack": missing_validators = [ 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 missing_validators: @@ -1417,6 +1428,21 @@ def cmd_init(args: argparse.Namespace) -> None: raise SystemExit( 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(): if staged.read_text(encoding="utf-8") != rendered_files[target]: @@ -1645,6 +1671,7 @@ def cmd_init(args: argparse.Namespace) -> None: if args.name == "ack": _print(f" 知识库: {knowledge_file}") _print(f" 交付契约: {delivery_file}(默认关闭)") + _print(f" 回归目录: {regression_file}") _print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL") diff --git a/skills/ack/README.md b/skills/ack/README.md index d95b77e..a997408 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -9,10 +9,26 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协 关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为 `leftover`,然后继续处理其它任务。 -项目还可以在同一份 `.pouch/ack/delivery.yaml` 里声明测试环境部署和版本发布。 -用户告诉 ACK 这两件事怎么做之后,再说「重新布测试环境」或「发布一个版本」, -ACK 按对应 intent 执行。任务全部验证后仍可按 profile 做常规交付。配置默认关闭, -稳定发布与生产部署始终保留人工批准点。 +测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份 +`.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进 +`.pouch/ack/regression.yaml`;之后可以单独跑回归。 + +ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。 + +## 使用场景 + +| 场景 | 怎么说 | 结果 | +| --- | --- | --- | +| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/` | +| 检查 | `/ack 检查配置` | 只读校验,默认不改文件 | +| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 | +| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 | +| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer | +| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/` | +| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 | +| 回归 | `/ack 回归` | 先布测试环境,再按 `regression.yaml` 用浏览器或 API 跑 | + +做需求和修 bug 在 `verified` 之后,都要更新回归目录。 ## 安装 @@ -28,8 +44,6 @@ pouch add ack -g pouch add ack ``` -ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。 - ## 初始化项目 ```bash @@ -44,7 +58,8 @@ pouch init ack --project ~/code/my-app ├── project.md ├── tasks.yaml ├── knowledge.yaml -└── delivery.yaml # 默认 enabled: false +├── delivery.yaml # 默认 enabled: false +└── regression.yaml # 默认 cases: [] ``` 不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill @@ -62,17 +77,19 @@ skills/ack/ ├── README.md ├── VERSION ├── references/ # 三角色规范、闭环流程和初始化说明 -├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema +├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml、regression.yaml 模板和 schema ├── examples/ # 完整示例 -└── scripts/ # 状态校验、任务/知识选择、安全验证执行与结构化 worker launcher +└── scripts/ # 状态校验、任务/知识/回归选择、安全验证执行与结构化 worker launcher ``` `SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范; `.pouch/ack/project.md` 只保存当前项目的命令、路径和权限差异; `.pouch/ack/tasks.yaml` 保存当前任务状态;`.pouch/ack/knowledge.yaml` 保存跨任务复用、 已经独立验证的项目知识护栏。 -`.pouch/ack/delivery.yaml` 是测试环境部署和版本发布的唯一契约,也声明常规构建、 -发布和部署能力;每次执行结果另记在 `tasks.yaml.deliveryRuns`。 +`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一契约;测试环境由 ACK +内部调用 deployer,运行证据记在 `tasks.yaml.deliveryRuns`。 +`.pouch/ack/regression.yaml` 是黑盒回归用例目录,运行证据记在 +`tasks.yaml.regressionRuns`。 ## 检查项目状态 @@ -84,6 +101,8 @@ python3 /scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ --tasks .pouch/ack/tasks.yaml --project-root +python3 /scripts/validate_regression.py .pouch/ack/regression.yaml \ + --tasks .pouch/ack/tasks.yaml ``` Coordinator 可以按当前任务上下文做确定性推荐: @@ -95,6 +114,10 @@ python3 /scripts/select_tasks.py .pouch/ack/tasks.yaml \ python3 /scripts/select_knowledge.py .pouch/ack/knowledge.yaml \ --component web --path web/app.py --tag long-running-service --limit 10 + +python3 /scripts/select_regression.py .pouch/ack/regression.yaml +python3 /scripts/select_regression.py .pouch/ack/regression.yaml \ + --suite full --case-id REG-login-001 ``` 任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、 @@ -134,30 +157,31 @@ python3 /scripts/run_verification.py \ 状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库, 同时只补 `project.knowledgeFile: .pouch/ack/knowledge.yaml`,再运行跨文件校验。 -只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`。知识正文不能作为自由 shell -执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的 -`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 +只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`。知识正文 +不能作为自由 shell 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK +不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 ## 配置与运行交付 -用户可以直接向 `/ack` 说明两件独立操作,并写进同一份契约: +测试环境走 deployer 的项目内布局(通常是 `.pouch/deployer/test`),发版仍用 +delivery profile。可以直接说: ```text -/ack 测试时先 go build -o garden ./cmd/garden,再启动这个二进制; -发版方式以后再告诉你。 +/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你。 ``` -ACK 把它维护成 `.pouch/ack/delivery.yaml` 的 `intents.testEnvironment` / -`intents.release`、entrypoint、artifact、environment 和 profile。首次配置保持 -关闭,确认后才启用。之后用户可以说: +ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成 +`intents.release` 指向的 profile。首次配置保持关闭,确认后才启用。之后可以说: ```text /ack 重新布一下测试环境,我要测试 /ack 发布一个版本 ``` -对应 intent 未配置时先问清楚并写回同一文件,不猜测。intent 运行不要求当前有 -`verified` 任务;`deliveryRuns.intent` 记录是测试环境还是发版。 +对应 intent 未配置时先问清楚并写回同一文件,不猜测。运行测试环境时 ACK 加载 +deployer skill,不复制 compose 命令。本地进程启动写在 `project.md`,不算这个 +模式。intent 运行不要求当前有 `verified` 任务;`deliveryRuns.intent` 记录是 +测试环境还是发版。 交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存 凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认 @@ -235,7 +259,42 @@ Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verifi ``` Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与 -可观测验收信号;用户确认后才派发实现和复测。 +可观测验收信号;用户确认后才派发实现和复测。任务 `verified` 后会把本轮黑盒路径 +收进 `.pouch/ack/regression.yaml`,确认后才写入。 + +## 修复 bug + +可以直接说: + +```text +/ack 修这个 bug:<现象、复现、期望> +``` + +Coordinator 写短问题说明、复现步骤和可观测验收,不写大 PRD;你确认后再派发。 +Developer 先补会失败的用例再修,后续闭环与做需求相同。 + +配了飞书 `bugIntake` 时,飞书是审核前的唯一协作区。你只需维护标题、详细描述和 +附件;Coordinator 整理问题说明、期望效果和验收标准并写回飞书。你针对当前 +revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。 + +```text +/ack 处理飞书里待整理的 bug +``` + +## 运行回归 + +平时做需求和修 bug 结束后,ACK 按本轮内容更新 `.pouch/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 路由支持 `cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的 `cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带 -`--always-approve`,sandbox 仍必开。旧项目可以不迁移而继续使用原闭环。旧项目的 -`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 +`--always-approve`,sandbox 仍必开;从 `0.19.0` 起 `intents.testEnvironment` 改为 +deployer 绑定,ACK 内部调用 deployer skill 布测试环境,并增加 +`.pouch/ack/regression.yaml` 与「运行回归」模式。旧的测试环境 profile ID 字符串不再 +执行,需要迁到 `{via: deployer, env: }`。旧项目可以不迁移回归目录而继续使用 +原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 9c40c27..38def72 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -2,16 +2,17 @@ name: ack description: >- 初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求 - 初始化 ACK、检查 .pouch/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test - 工作,配置测试环境与发版方式,重新部署测试环境,或发布版本时使用。 + 初始化 ACK、检查 .pouch/ack 配置、按 ACK 规划需求或修复 bug、指挥 + Coordinator/Developer/Test 工作,配置测试环境与发版方式,重新部署测试环境 + (内部调用 deployer),发布版本,或运行回归测试时使用。 --- # ACK 项目协作入口 本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目 状态模板,`scripts/` 保存校验工具。目标项目只在 `.pouch/ack/` 保存 `project.md`、 -`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill -内容。 +`tasks.yaml`、`knowledge.yaml`、默认关闭的 `delivery.yaml` 和空的 +`regression.yaml`,不要复制或链接 Skill 内容。 开始时解析当前 `SKILL.md` 所在目录,记为 ``。所有通用规范、模板和 脚本都相对此目录访问,不依赖固定的全局安装路径。 @@ -20,12 +21,16 @@ description: >- - 用户要求初始化、接入或安装 ACK:执行“初始化”。 - 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。 -- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。 +- 用户要求用 ACK 做需求、修复 bug 或继续任务:执行“工作”。修 bug 不写大 PRD; + 飞书收件仍走本模式。 - 用户用自然语言说明怎么部署测试环境、怎么发布版本,或要求增加、修改、关闭交付 - 流程:执行“交付配置维护”。测试环境和发版必须写进同一份 + 流程:执行“交付配置维护”。测试环境绑定 deployer,发版写在同一份 `.pouch/ack/delivery.yaml`。 - 用户要求部署、重新部署测试环境,或按已配置方式开始测试:执行“运行测试环境”。 + 内部加载 deployer skill,不在 ACK 里复制 compose/rsync 命令。 - 用户要求发布版本:执行“运行版本发布”。 +- 用户要求回归、跑回归测试:执行“运行回归”。先布测试环境,再派 Test 按 + `.pouch/ack/regression.yaml` 用浏览器或 API 执行。 始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git 项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md` @@ -67,7 +72,10 @@ description: >- 8. 检查 `.pouch/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile; 不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK 闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。 -9. 更新 `updatedAt`,并运行: +9. 检查 `.pouch/ack/regression.yaml`。新项目保留 `cases: []`。旧项目没有该文件时仍 + 可继续原闭环;用户授权后从 `templates/regression.template.yaml` 生成,并只补 + `project.regressionFile` 与顶层 `regressionRuns: []`。不要从聊天虚构用例。 +10. 更新 `updatedAt`,并运行: ```bash python3 /scripts/validate_tasks.py .pouch/ack/tasks.yaml @@ -75,14 +83,16 @@ description: >- --tasks .pouch/ack/tasks.yaml python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ --tasks .pouch/ack/tasks.yaml --project-root + python3 /scripts/validate_regression.py .pouch/ack/regression.yaml \ + --tasks .pouch/ack/tasks.yaml ``` -10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有 - `<...>` 占位符。 - 结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出 - 缺失值。 -11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、 - 不推送。 +11. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 与 + `regression.yaml` 是否仍有 `<...>` 占位符。 + 结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出 + 缺失值。 +12. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、 + 不推送。 ## 检查 @@ -91,6 +101,7 @@ description: >- - `.pouch/ack/tasks.yaml` - `.pouch/ack/knowledge.yaml` - `.pouch/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验) + - `.pouch/ack/regression.yaml`(旧项目可无;存在或被任务板引用时必须校验) 需要查看任务内容时,使用 `/scripts/select_tasks.py` 解析完整任务板并 只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整 `tasks.yaml` 注入上下文。完整性仍由校验器检查。 @@ -105,8 +116,10 @@ description: >- .pouch/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了 `project.deliveryFile`,再使用 `/scripts/validate_delivery.py .pouch/ack/delivery.yaml --tasks .pouch/ack/tasks.yaml --project-root ` - 校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目 - 缺少可选交付配置而宣称失败。 + 校验交付能力、顺序、安全边界和跨文件引用。如果存在回归目录或任务板声明了 + `project.regressionFile`,再使用 `/scripts/validate_regression.py + .pouch/ack/regression.yaml --tasks .pouch/ack/tasks.yaml` 校验用例与跨文件引用。 + 只报告证据明确的问题,不因旧项目缺少可选交付或回归配置而宣称失败。 5. 若存在 `project.bugIntake`,运行 `python3 /scripts/feishu_bug_intake.py check .pouch/ack/tasks.yaml`。 它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见 @@ -136,6 +149,8 @@ description: >- - kickoff 指定且与当前任务相关的 references 文件 - 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和 `/references/delivery.md` + - 若 `tasks.yaml.project.regressionFile` 存在,再读取该 `regression.yaml` 和 + `/references/regression.md` 3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和 worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` 知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文; @@ -160,9 +175,10 @@ description: >- `dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。 4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验, - 然后交给用户确认;若启用了交付,必须默认把 `defaultProfile`、目标、停止点和需要 - 审批的步骤放入同一份计划,不能静默省略。用户可明确取消本轮交付;确认前不派发 - 实现,也不执行交付。 + 然后交给用户确认。修 bug 写短问题说明、复现步骤和可观测验收,不写大 PRD; + Developer 先补会失败的用例再修。若启用了交付,必须默认把 `defaultProfile`、 + 目标、停止点和需要审批的步骤放入同一份计划,不能静默省略。用户可明确取消 + 本轮交付;确认前不派发实现,也不执行交付。 5. 创建或更换 worker 时,只使用 `/scripts/launch_worker.py plan|launch` 读取 `tasks.yaml.project.orchestration` 的 profile。不得直接执行 @@ -180,8 +196,11 @@ description: >- 注入;卡在审批提示、未回车或额度限制时按环境失败处理并报告),等待期间用 `scripts/worker_probe.py` 滚动检查活性,不盲等 `worker_done`。Coordinator 读取 证据终检并唯一写入 `tasks.yaml`。Developer 回报 `knowledgeApplied` 和 - `knowledgeCandidates`,Test 回报 `knowledgeChecks`;`candidate` 只有在独立验证和 - gate 后才能由 Coordinator 写入或激活。 + `knowledgeCandidates`,Test 回报 `knowledgeChecks` 和 `regressionCandidates`; + `candidate` 只有在独立验证和 gate 后才能由 Coordinator 写入或激活。 + 任务进入 `verified` 且改了用户可见行为或 API 后,按 `references/regression.md` + 给出新增/更新/退役/无回归四选一,用户确认后写入 `.pouch/ack/regression.yaml`, + 并把 case id 记入 `regressionRefs`。Test 只提名,不写该文件。 7. 执行知识项的 `verification.ref` 时,只调用 `/scripts/run_verification.py .pouch/ack/knowledge.yaml --project-root `。不要直接执行选择器返回的 path/args, @@ -211,11 +230,13 @@ description: >- ## 交付配置维护 -1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布 - 入口和 CI;把用户自然语言描述转换为结构化 `intents`、entrypoint、artifact、 - destination、environment 与 profile。测试环境部署和版本发布都写进这一份 - `delivery.yaml`,不要拆成第二份文档。配置只引用仓库内脚本或声明式工具 target, - 不保存 shell。 +1. 读取 `references/delivery.md`、deployer skill、模板、schema、现有 + `delivery.yaml`、项目构建/发布入口和 CI。测试环境写成 + `intents.testEnvironment: {via: deployer, env: }`,并按 deployer skill + 准备 `.pouch/deployer/`;不要把 compose/rsync 命令写进 ACK。发版仍指向 + 本文件的 profile。不要拆成第二份文档。配置只引用仓库内脚本或声明式工具 + target,不保存 shell。本地 `npm run dev` / `go run` 写在 `project.md` 的 + Developer 白盒命令里,不算测试环境部署。 2. 若旧项目首次启用,生成 `.pouch/ack/delivery.yaml`,在 `tasks.yaml.project` 增加 `deliveryFile: .pouch/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它 项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。 @@ -227,13 +248,39 @@ description: >- ## 运行测试环境 -1. 读取 `.pouch/ack/delivery.yaml` 与 `references/delivery.md`。 +1. 读取 `.pouch/ack/delivery.yaml`、`references/delivery.md` 和 deployer skill 的 + `SKILL.md`。 2. `enabled` 不为 true,或 `intents.testEnvironment` 为 null:停止,请用户说明如何 部署测试环境,转入交付配置维护。不猜测编译或启动命令。 -3. 不要求任务已 `verified`。按该 profile 执行 build → deploy → health-check。 -4. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`, - `intent: testEnvironment`,`taskIds` 可为空。 -5. 派发 Test 前若该 intent 已启用,必须先完成本步骤。 +3. `intents.testEnvironment` 必须是 `{via: deployer, env: }`。若仍是旧的 + profile ID 字符串:停止,展示迁移说明,转入交付配置维护。不要执行 ACK + delivery profile 来布测试环境。 +4. 不要求任务已 `verified`。按 deployer 的项目内环境布局操作 + `.pouch/deployer/`:list 确认服务,再按服务 sync + up(或用户要求的 + recreate),并用 ps/logs/健康检查验证。不要复制 deployer 脚本,不要发明 + 第二套 compose 命令。 +5. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`, + `intent: testEnvironment`,`profile` 记 `deployer-`,`taskIds` 可为空。 +6. 派发 Test 前若该 intent 已启用,必须先完成本步骤。deployer 未安装、环境目录 + 不存在或健康检查失败:fail closed,报告 `userAction`,不把环境失败写成产品 + 失败。 + +## 运行回归 + +1. 若 `.pouch/ack` 不存在,停止并建议先初始化。 +2. 若没有 `.pouch/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 完成人工核对。 - 不覆盖已有 `.pouch/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自 提交、推送、创建终端、新 worktree、发布产物或部署。 -- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer - 与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置 - 维护中修改。 +- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml`、`regression.yaml`、 + `deliveryRuns` 和 `regressionRuns`;Developer 与 Test 只读,只能通过回报提名 + 或验证。`delivery.yaml` 只在显式的交付配置维护中修改。 - 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py` 按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 - 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务 视图,写回前仍运行完整任务板校验。 - 项目只保存 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、 - `.pouch/ack/knowledge.yaml` 和可选的 `.pouch/ack/delivery.yaml`;通用资源始终从当前 - ACK Skill 目录读取。 + `.pouch/ack/knowledge.yaml`、可选的 `.pouch/ack/delivery.yaml` 和可选的 + `.pouch/ack/regression.yaml`;通用资源始终从当前 ACK Skill 目录读取。 +- 不把完整 `regression.yaml` 注入上下文;使用 `select_regression.py` 获取有预算的 + 用例视图,写回前仍运行完整校验。 diff --git a/skills/ack/agents/openai.yaml b/skills/ack/agents/openai.yaml index b91d034..0e01e87 100644 --- a/skills/ack/agents/openai.yaml +++ b/skills/ack/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "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." + short_description: "初始化、检查并运行 ACK 开发、验证、测试环境、发版与回归闭环" + 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: allow_implicit_invocation: false diff --git a/skills/ack/examples/delivery.example.yaml b/skills/ack/examples/delivery.example.yaml index 43439d0..5b76c80 100644 --- a/skills/ack/examples/delivery.example.yaml +++ b/skills/ack/examples/delivery.example.yaml @@ -7,7 +7,9 @@ enabled: true defaultProfile: "review" intents: - testEnvironment: test-env + testEnvironment: + via: deployer + env: test release: null entrypoints: @@ -104,19 +106,6 @@ environments: mutex: "notes-test-deploy" 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: stopAt: review_ready steps: diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index 4c52dfb..872741a 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -4,7 +4,8 @@ > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `.pouch/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 -> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。 +> `.pouch/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` +> 与 `regression.yaml`。 ## 项目概览 @@ -15,6 +16,7 @@ - 任务板:`.pouch/ack/tasks.yaml` - 项目知识:`.pouch/ack/knowledge.yaml` - 交付契约:`.pouch/ack/delivery.yaml` +- 回归目录:`.pouch/ack/regression.yaml` - 覆盖层文件:`.pouch/ack/project.md` ## 稳定规范(引用,不重复) @@ -27,6 +29,7 @@ - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令:`references/orca-adapter.md` - 验证后交付:`references/delivery.md` +- 回归目录与运行:`references/regression.md` ## Worker 路由 @@ -55,6 +58,7 @@ | `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 | | `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 | | `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 | 项目交付能力,不是执行授权 | ## 命令 @@ -80,8 +84,10 @@ ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动 执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接 path/args。 项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、 -`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。 -交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。 +`scripts/validate_knowledge.py`、`scripts/validate_delivery.py` 和 +`scripts/validate_regression.py` 执行。 +测试环境由 ACK 内部调用 deployer;发版机器入口以 `delivery.yaml` 为准,本覆盖层 +不维护第二套发布或部署命令。 ## 硬规则(其余见 references/) diff --git a/skills/ack/examples/regression.example.yaml b/skills/ack/examples/regression.example.yaml new file mode 100644 index 0000000..b08dce0 --- /dev/null +++ b/skills/ack/examples/regression.example.yaml @@ -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=回归笔记" diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 80f862e..e57ebb1 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -10,6 +10,7 @@ project: overlayFile: ".pouch/ack/project.md" knowledgeFile: ".pouch/ack/knowledge.yaml" deliveryFile: ".pouch/ack/delivery.yaml" + regressionFile: ".pouch/ack/regression.yaml" orchestration: profileVersion: 1 mode: "manual" @@ -96,6 +97,7 @@ project: workerReceipts: [] deliveryRuns: [] +regressionRuns: [] summary: verified: ["BUG-002"] diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index f19042d..5d9f6a8 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -4,8 +4,8 @@ - [ ] ACK Skill 已全局安装或安装到当前项目。 - [ ] 已运行 `pouch init ack --project `。 -- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 - 默认关闭的 `delivery.yaml`。 +- [ ] `.pouch/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml`、 + 默认关闭的 `delivery.yaml` 与空的 `regression.yaml`。 - [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的 `project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。 - [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。 @@ -19,6 +19,8 @@ `.pouch/ack/knowledge.yaml`。 - [ ] 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,顶层有 `deliveryRuns: []`;旧项目未采用交付能力时可无这两项。 +- [ ] 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,顶层有 + `regressionRuns: []`;旧项目未采用回归能力时可无这两项。 - [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。 - [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。 - [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 @@ -36,6 +38,7 @@ - [ ] 私有配置只读且不提交。 - [ ] `tasks.yaml` 只有 Coordinator 写。 - [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。 +- [ ] `regression.yaml` 只有 Coordinator 写;Test 只通过 `regressionCandidates` 提名。 - [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。 ## 任务板 @@ -48,8 +51,9 @@ ## 可选交付 - [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。 -- [ ] 测试环境部署和版本发布都写在同一份 `delivery.yaml` 的 `intents` 中,没有第二份 - 操作文档。未说明的 intent 保持 `null`。 +- [ ] 测试环境绑定 deployer(`intents.testEnvironment.via: deployer`),发版写在 + 同一份 `delivery.yaml` 的 `intents.release`。没有第二份操作文档。未说明的 + intent 保持 `null`。旧 profile ID 字符串已迁移。 - [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有 shell、自由 command、凭据值或环境变量值。 - [ ] artifact、destination、environment 和 profile 引用均通过 @@ -81,6 +85,13 @@ - [ ] 关键约束已规划下沉到测试、lint、CI 或正式规范。 - [ ] ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 +## 回归 + +- [ ] 新项目 `regression.yaml` 为 `cases: []`,没有虚构用例。 +- [ ] 已运行 `validate_regression.py --tasks ...` 并通过。 +- [ ] 任务 `verified` 后给出新增/更新/退役/无回归四选一,确认后才写入目录。 +- [ ] 运行回归先走 deployer 测试环境,再派独立 Test;失败不自动开修。 + ## 编排 - [ ] 已选择 Orca 或手动模式。 diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index d237b50..cf5ab5d 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -43,8 +43,8 @@ Coordinator 发现或读取 open 任务 -> wait:滚动 check --wait + 定期 worker_probe(识别审批/未回车/额度停滞) 直到 Developer 的 worker_done / escalation(含 knowledgeApplied / knowledgeCandidates) -> writeback fixed_by_dev - -> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先执行该 profile - 拉起待测服务,再派 Test;Test 不发明编译或启动命令 + -> 若 delivery.yaml intents.testEnvironment 已启用:Coordinator 先按 deployer + 绑定拉起 `.pouch/deployer/`,再派 Test;Test 不发明编译或启动命令 -> 为 Test 独立解析安全 profile;安全重置同角色空闲 worker,或重新 plan/launch fresh worker -> dispatch 给 Test(retesting) -> 确认 Test 已开始执行(terminal read 确认任务注入;未开始按环境失败处理) @@ -174,9 +174,9 @@ python3 /scripts/launch_worker.py launch \ | 小改动、追求快 | 当前 worktree | **项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、 -`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在 -worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护 -时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个 +`knowledge.yaml`、可选 `delivery.yaml` 和可选 `regression.yaml` 都只认一个权威 +副本(通常在基线/协调所在 worktree)。Coordinator 单写任务、知识、回归目录、 +`deliveryRuns` 与 `regressionRuns`;交付能力只在显式配置维护时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个 副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。 @@ -271,7 +271,7 @@ frontendDir: worktreePath: ``` -如果开发在 `` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以该 profile 为准,不另写一套启动命令。 +如果开发在 `` 修复,但服务跑的是另一个 worktree,必须**停止并重启正确服务**后再测。长跑服务或静态前端尤其要确认加载的是最新构建产物。若项目配置了 `intents.testEnvironment`,重启方式以 deployer 绑定为准,不另写一套启动命令。 --- diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md index 513ee5f..c7d3900 100644 --- a/skills/ack/references/delivery.md +++ b/skills/ack/references/delivery.md @@ -54,28 +54,33 @@ channel、environment 或 source revision 漂移时重新确认。 ## 3.1 测试环境与发版写在同一份契约 -`.pouch/ack/delivery.yaml` 是测试环境部署和版本发布的唯一文档。不要另写操作手册, +`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一文档。不要另写操作手册, 也不要把其中一项写进 `project.md`。用户用自然语言说明「怎么布测试环境」或 -「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`、entrypoint、 -artifact、environment 和 profile。 +「怎么发版」时,Coordinator 把两者都维护进这份文件的 `intents`。 ```yaml intents: - testEnvironment: local-binary # profile ID,或 null - release: null # profile ID,或 null + testEnvironment: + via: deployer + env: test # 项目 .pouch/deployer/test;尚未说明时为 null + release: null # profile ID,或 null ``` -- `testEnvironment` 指向 `stopAt: validation_ready` 的 profile:build 产物、部署到 - development/staging、健康检查。用户说「重新布测试环境」「我要测试」时执行它; - 派发 Test 复测前,若该 intent 已配置且 `enabled: true`,Coordinator 也先执行它。 - 不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动命令。 +- `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」 + 「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.pouch/deployer/` + 按服务执行 sync + up 和健康检查。派发 Test 复测或跑回归前,若该 intent 已配置 + 且 `enabled: true`,Coordinator 也先执行它。不要求当前有 `verified` 任务。 + Test 不对这个 intent 发明编译或启动命令。旧的 profile ID 字符串不再执行,必须 + 迁到 `{via: deployer, env: }`。本地进程启动写在 `project.md`,不算这个 + intent。 - `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。 口头「发版」不能代替 stable/production 的 `approval` 步骤。 - 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」 写入同一文件后再执行。不猜测 Makefile、镜像仓库或发布通道。 - 用户触发的 intent 运行写入 `tasks.yaml.deliveryRuns`,`intent` 填 - `testEnvironment` 或 `release`,`taskIds` 可为空。绑定任务的常规交付 run 不填 - `intent`,仍只能引用 `verified` 任务。 + `testEnvironment` 或 `release`,`taskIds` 可为空。测试环境 run 的 `profile` 记 + `deployer-`。绑定任务的常规交付 run 不填 `intent`,仍只能引用 + `verified` 任务。 ## 4. 运行前检查 @@ -147,7 +152,9 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成 ## 7. 与低层 Skill 的边界 ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的 -上传、镜像或 Git 发布实现。`builder` 和 -`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的 -项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式 -调用时,ACK 不能绕过它的触发与授权边界。 +上传、镜像、Git 发布或远程 Compose 实现。`builder`、`manage-release` 和 +`deployer` 仍是可独立使用、独立安装的能力。运行测试环境时 ACK 必须加载 +deployer skill,不能把 compose/rsync/远程 docker 命令写进 ACK。发版步骤缺失 +builder 或 manage-release 时,ACK 使用契约中已审查的项目 entrypoint,二者都不可用 +时把对应步骤标为 `blocked`。低层 skill 自身要求显式调用时,ACK 不能绕过它的 +触发与授权边界;ACK 内部调用 deployer 布测试环境是该 skill 的合法调用路径。 diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index 37ab084..f795f6c 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -12,7 +12,8 @@ 3. `pouch` 命令可用。 不要覆盖已有的 `.pouch/ack/project.md`、`.pouch/ack/tasks.yaml`、 -`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent +`.pouch/ack/knowledge.yaml`、`.pouch/ack/delivery.yaml`、`.pouch/ack/regression.yaml`、 +`AGENTS.md` 或其它 Agent 指令文件。ACK 不会自动 修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env` 内容或其它私有配置写入 ACK 项目状态。 @@ -38,7 +39,8 @@ pouch init ack --project ├── project.md ├── tasks.yaml ├── knowledge.yaml -└── delivery.yaml # 默认 enabled: false +├── delivery.yaml # 默认 enabled: false +└── regression.yaml # 默认 cases: [] ``` 如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、 @@ -62,6 +64,13 @@ pouch init ack --project `deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认 解析结果后才启用。不要重跑 `pouch init ack`,也不要改写已有任务或知识。 +### 旧项目补充回归目录 + +`regression.yaml` 对旧项目是可选能力。用户明确要求回归或授权补齐时,从 +`templates/regression.template.yaml` 生成 `.pouch/ack/regression.yaml`,并只补 +`project.regressionFile: .pouch/ack/regression.yaml` 与顶层 `regressionRuns: []`。 +保持 `cases: []`,不要从聊天虚构用例。 + ## 完善项目覆盖层 编辑 `.pouch/ack/project.md`,填入: @@ -89,6 +98,8 @@ pouch init ack --project 不再参与路径绑定。 - 新项目的 `project.deliveryFile` 固定为 `.pouch/ack/delivery.yaml`,并保留顶层 `deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。 +- 新项目的 `project.regressionFile` 固定为 `.pouch/ack/regression.yaml`,并保留顶层 + `regressionRuns: []`。旧项目只有在采用回归能力时才补这两个字段。 - `allowedWorktrees` 已废弃(v0.19 起),新任务板不生成该字段;worker 默认在 `--project-root` 工作。模型 allowlist、profiles 和 defaults 使用项目实际允许值。 不要把完整启动命令、`extraArgs`、`env` 或任意 executable 写进任务板。 @@ -118,10 +129,16 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且 新项目的 `.pouch/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及 `intents.testEnvironment: null` 与 `intents.release: null`。 -不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言说明测试环境或发版 -方式后,Coordinator 按 `delivery.md` 把两者都写入这一份契约:`intents` 指向对应 -profile,工具 target 与仓库脚本分开引用。配置中不保存 shell、环境变量值或凭据 -正文;稳定发布和生产部署必须有显式 approval 步骤。 +不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator +按 deployer skill 准备 `.pouch/deployer/`,并把 +`intents.testEnvironment` 写成 `{via: deployer, env: }`;发版仍指向 profile。 +配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式 +approval 步骤。 + +## 初始化回归目录 + +新项目的 `.pouch/ack/regression.yaml` 保持 `cases: []`。不要从 README 或聊天猜测 +用例。任务 `verified` 后按 `regression.md` 收获。 ## 校验 @@ -132,12 +149,14 @@ python3 /scripts/validate_tasks.py .pouch/ack/tasks.yaml python3 /scripts/validate_knowledge.py .pouch/ack/knowledge.yaml --tasks .pouch/ack/tasks.yaml python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ --tasks .pouch/ack/tasks.yaml --project-root +python3 /scripts/validate_regression.py .pouch/ack/regression.yaml \ + --tasks .pouch/ack/tasks.yaml ``` 同时确认: -- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的 - `<...>` 占位符。 +- `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 和 + `regression.yaml` 没有未替换的 `<...>` 占位符。 - `project.overlayFile` 指向真实文件。 - `project.knowledgeFile` 指向 `.pouch/ack/knowledge.yaml`。 - 新项目的 `project.deliveryFile` 指向 `.pouch/ack/delivery.yaml`;交付默认关闭。 @@ -154,7 +173,7 @@ python3 /scripts/validate_delivery.py .pouch/ack/delivery.yaml \ 完成后报告: -- 创建或确认的四个项目文件。 +- 创建或确认的项目文件(含回归目录)。 - 检测到的技术栈和验证命令。 - 任务板、项目知识和交付契约校验结果。 - 仍需用户补充的值。 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index 8634c03..15c7268 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -37,11 +37,12 @@ Developer/Test profile;优先选择同一轮内角色/profile/worktree 匹配的空闲 worker, 只有历史消息已可信清理并取得新会话身份才复用,否则审阅 plan 后用 expected fingerprint 创建 fresh worker; - dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先拉起测试环境 → + dispatch 开发 → worker_done → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; 每个任务最多三轮有效产品复验,三轮不过记 leftover 并升级我复盘;环境失败单独 记录、恢复并告诉我下一步,不占产品复验轮次。 -7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写 +7. 所选任务都 verified 后,按 regression.md 给出新增/更新/退役/无回归,确认后写入 + .pouch/ack/regression.yaml。只有本次计划包含交付时才按 profile 顺序执行并写 deliveryRuns;启用 delivery 时不能省略 defaultProfile,默认停在 validation_ready 或 review_ready,stable/production 步骤再次向我确认。 ``` @@ -155,7 +156,7 @@ worktree 走同一套 `plan` -> 带 expected fingerprint 的 `launch`。在调 task-create → dispatch 给 DEV → 先确认 DEV 已开始执行(read/probe;未开始按环境失败处理)→ 滚动 wait 等 worker_done → 每个角色先检查可安全重置的空闲 worker;不符合即通过 plan + expected fingerprint launch fresh worker → 每轮使用 Coordinator 分配的稳定 -A - → 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result + → 回写 fixed_by_dev → 若 intents.testEnvironment 已启用则先用 deployer 拉起测试环境 → dispatch 给 TEST 复测 → 等 retest_result → Developer 回 knowledgeApplied / knowledgeCandidates,Test 回 knowledgeChecks → 环境无法完成:记录 environmentIncidents,报告影响与用户下一步,恢复后重新复验(不计轮次) → Coordinator 读证据终检 → 过则 verified,产品失败则 failed_retest 再派 DEV(最多累计 3 轮) @@ -174,8 +175,13 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo ## 第 5 步:可选交付 -用户说「重新布测试环境」或「发布一个版本」时,按 `delivery.md` §3.1 的 -`intents` 执行对应 profile,不另找文档。intent 为 null 时先做交付配置维护。 +用户说「重新布测试环境」时加载 deployer skill 执行 +`intents.testEnvironment` 绑定;「发布一个版本」时按 `delivery.md` §3.1 的 +release profile 执行。intent 为 null 时先做交付配置维护。 + +所选任务 `verified` 后,按 `regression.md` 把本轮黑盒路径收获进 +`.pouch/ack/regression.yaml`(新增/更新/退役/无回归四选一,用户确认后写入)。 +用户说「回归」时先布测试环境,再派 Test 按目录执行。 所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按 `delivery.md` 执行所选 profile。启用交付时必须在计划中默认列出 `defaultProfile`, @@ -203,6 +209,6 @@ Coordinator 最后标记整轮任务完成后,用 `scripts/reclaim_workers.py` 产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从 `tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落 -`tasks.yaml` → 可选 delivery profile 到审核点,验证后的 -跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收仅属于 verified -任务的 worker,保留 blocked/failed/leftover worker。 +`tasks.yaml` → 可选收获回归用例到 `regression.yaml` → 可选 delivery profile +到审核点,验证后的跨任务知识由 Coordinator 落 `knowledge.yaml` → 整轮完成后回收 +仅属于 verified 任务的 worker,保留 blocked/failed/leftover worker。 diff --git a/skills/ack/references/optimization-method.md b/skills/ack/references/optimization-method.md index c042f3e..8b73503 100644 --- a/skills/ack/references/optimization-method.md +++ b/skills/ack/references/optimization-method.md @@ -176,14 +176,18 @@ one dispatch = one bug = one acceptance path ## 8. 优先让测试可执行化 -如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。这类可执行测试由 Test 拥有并维护(见 `roles-and-permissions.md` 权限表的 ``)。优先级: +如果某个问题需要多轮修复,说明它值得沉淀成自动化检查。黑盒回归目录是 +`.pouch/ack/regression.yaml`(见 `regression.md`);可执行脚本仍由 Test 维护在 +``。优先级: -1. API smoke。 -2. 浏览器脚本或 case 文档。 -3. 单元测试。 -4. 人工检查清单。 +1. 把本轮验收信号收获进 `regression.yaml`。 +2. API smoke。 +3. 浏览器脚本或 `automationRef`。 +4. 单元测试。 +5. 人工检查清单。 -目标不是全部自动化,而是把最容易反复误判的路径自动化。 +目标不是全部自动化,而是把最容易反复误判的路径变成可重复跑的回归用例。任务 +`verified` 后必须给出新增/更新/退役/无回归四选一。 --- diff --git a/skills/ack/references/prompt-templates.md b/skills/ack/references/prompt-templates.md index 724fe5d..580df37 100644 --- a/skills/ack/references/prompt-templates.md +++ b/skills/ack/references/prompt-templates.md @@ -128,7 +128,7 @@ Developer 本轮声称(仅供参考,不作数): - : 复测要求(见 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 / 脚本。 - 逐条验证下列验收信号,不要只看静态文案,要验证交互后的真实状态: 1. @@ -137,7 +137,7 @@ Developer 本轮声称(仅供参考,不作数): - 若 worker、权限、服务、测试数据、浏览器或工具导致验收无法完成,明确回报 `environmentFailure`,不要把“未验证”写成产品 `signals-failed`;若已有独立产品失败 证据,则分别列出产品信号与环境限制。 -- 需要时把易反复误判的路径沉淀成可执行测试(见 optimization-method.md §8)。 +- 需要时把本轮通过的黑盒路径写成 `regressionCandidates`(见 regression.md),不要直接改 `.pouch/ack/regression.yaml`。 - 对每条适用的 `knowledgeRef`,把它的 verification.ref 交给 `/scripts/run_verification.py .pouch/ack/knowledge.yaml --project-root `,并回报 `knowledgeChecks`。 @@ -215,6 +215,16 @@ knowledgeChecks: - ref: result: evidence: +regressionCandidates: + - title: + surface: browser/api + suite: smoke/full + setup: + steps: [] + expected: + - kind: visible-text/api-status/api-field/url/interaction + value: + sourceKind: feature/bug knowledgeCandidates: - kind: guardrail/pitfall/verification title: @@ -263,6 +273,10 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发 - 新增或更新: - 待验证 candidate: +回归: +- 收获:新增/更新/退役/无回归 +- 最近一次回归运行: + 交付(未启用时写 n/a): - run/profile/status: - PR/MR: @@ -274,3 +288,28 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发 - : - : ``` + +--- + +## 7. 派发给 Test 跑回归 + +```text +请按回归目录对当前测试环境做独立黑盒回归。 +suite: +baseUrl: + +用例(来自 select_regression.py,不要发明步骤): +- : surface= + setup: + steps: + expected: + +要求: +- 先确认 Base URL 可访问;不可访问时回报 environmentFailure,不要编造产品失败。 +- surface=browser 必须走真实页面交互,不能改成只打 API。 +- 逐条对照 expected 回报 pass/fail 与证据。 +- 不要修改源码,不要写 tasks.yaml / knowledge.yaml / regression.yaml。 + +完成后按 §5 的复测报告格式回报,signals 使用 REG-id。 +``` + diff --git a/skills/ack/references/regression.md b/skills/ack/references/regression.md new file mode 100644 index 0000000..3174039 --- /dev/null +++ b/skills/ack/references/regression.md @@ -0,0 +1,73 @@ +# ACK 回归目录与运行 + +本文件是回归用例目录、收获时机和运行模式的 SSOT。三角色闭环仍见 +`closed-loop.md`;测试环境部署见 `delivery.md` 与 deployer skill。 + +回归不是新 skill,也不写入 `knowledge.yaml`。知识是护栏;回归是可观测的黑盒 +用例清单。 + +## 1. 目录 + +项目状态是 `.pouch/ack/regression.yaml`。任务板用 +`project.regressionFile: .pouch/ack/regression.yaml` 声明,运行证据写在 +`tasks.yaml.regressionRuns`。新项目初始化会生成空目录;旧项目没有该文件时, +「运行回归」先停下来,用户授权后再从模板补齐。 + +只有 Coordinator 写 `regression.yaml`。Test 在复测报告里提名 +`regressionCandidates`;用户确认后 Coordinator 落盘,并把 case id 写入任务的 +`regressionRefs`。不要把用例散落到第二份文档,也不要让 Test 直接改 +`.pouch/ack/`。 + +`tests/browser/**` 仍可由 Test 维护可执行脚本。用例可用可选 `automationRef` +指向那些脚本;没有脚本时 Test worker 按目录里的步骤和验收信号执行。 + +## 2. 用例 + +每条用例是给独立 Test worker 的说明书,复用 +`optimization-method.md` §1 的可观测信号。ACK 不提供 YAML-DSL 解释器,也不强制 +Playwright。 + +读取时用 `scripts/select_regression.py`,不要把完整目录注入上下文: + +```bash +python3 /scripts/select_regression.py .pouch/ack/regression.yaml +python3 /scripts/select_regression.py .pouch/ack/regression.yaml \ + --suite full +python3 /scripts/select_regression.py .pouch/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 时按点名范围执行。 diff --git a/skills/ack/references/roles-and-permissions.md b/skills/ack/references/roles-and-permissions.md index e7cba0b..26119e7 100644 --- a/skills/ack/references/roles-and-permissions.md +++ b/skills/ack/references/roles-and-permissions.md @@ -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` | | Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 | | User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 | @@ -110,6 +110,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo | `` | Read-only | Read-only | Read-only | 本地私有配置,不提交 | | `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 | | `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 | 声明项目交付能力,不保存凭据或执行授权 | --- @@ -178,11 +179,12 @@ planned -> skipped ## 项目状态写入约定(并发安全) -`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源, -`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突: +`tasks.yaml` 是任务、交付运行与回归运行事实源,`knowledge.yaml` 是跨任务项目知识 +事实源,`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 只能通过 `knowledgeCandidates` 提名知识;candidate 保存在 当前任务证据中,在 Test 独立验证和 Coordinator gate 前不写成可派发的 active diff --git a/skills/ack/scripts/select_regression.py b/skills/ack/scripts/select_regression.py new file mode 100644 index 0000000..f30d36c --- /dev/null +++ b/skills/ack/scripts/select_regression.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""选择 ACK 回归目录中的 active 用例。 + +默认返回 smoke 套件。本脚本只输出数据,不执行 steps 或 automationRef。 + +用法: + python3 select_regression.py .pouch/ack/regression.yaml + python3 select_regression.py .pouch/ack/regression.yaml --suite full + python3 select_regression.py .pouch/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=".pouch/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- 格式: {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()) diff --git a/skills/ack/scripts/validate_delivery.py b/skills/ack/scripts/validate_delivery.py index 69563b0..28506b3 100755 --- a/skills/ack/scripts/validate_delivery.py +++ b/skills/ack/scripts/validate_delivery.py @@ -102,9 +102,9 @@ CLASSIFICATIONS = {"development", "staging", "production"} STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"} INTENT_FIELDS = {"testEnvironment", "release"} INTENT_STOP_AT = { - "testEnvironment": "validation_ready", "release": "released", } +TEST_ENVIRONMENT_FIELDS = {"via", "env"} ACTIONS = { "verify", "pull-request", @@ -681,10 +681,47 @@ def _validate_profiles( 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: },不能再使用 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 / ".pouch" / "deployer" / env + if not env_dir.is_dir(): + env_dir = project_root / ".skiff" / "deployer" / env + if not env_dir.is_dir(): + errors.append( + "intents.testEnvironment.env: 找不到 " + f".pouch/deployer/{env};先按 deployer skill 配置项目测试环境" + ) + + def _validate_intents( values: Any, profiles: dict[str, Any], errors: list[str], + project_root: Path | None = None, ) -> None: if values is None: return @@ -692,25 +729,29 @@ def _validate_intents( errors.append("intents: 必须是对象") return _reject_unknown(values, INTENT_FIELDS, "intents", errors) - for field in sorted(INTENT_FIELDS): - if field not in values: - errors.append(f"intents.{field}: 必填") - continue - profile_id = values[field] - if profile_id is None: - continue - if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None: - errors.append(f"intents.{field}: 必须是 null 或小写连字符 profile ID") - continue - profile = profiles.get(profile_id) - if profile is None: - errors.append(f"intents.{field}: 未定义 profile {profile_id!r}") - continue - expected_stop = INTENT_STOP_AT[field] - if _mapping(profile) and profile.get("stopAt") != expected_stop: - errors.append( - f"intents.{field}: profile {profile_id!r} 必须 stopAt {expected_stop}" - ) + if "testEnvironment" not in values: + errors.append("intents.testEnvironment: 必填") + else: + _validate_test_environment_intent( + values.get("testEnvironment"), errors, project_root + ) + if "release" not in values: + errors.append("intents.release: 必填") + return + profile_id = values["release"] + if profile_id is None: + return + if not isinstance(profile_id, str) or ID_RE.fullmatch(profile_id) is None: + errors.append("intents.release: 必须是 null 或小写连字符 profile ID") + return + profile = profiles.get(profile_id) + if profile is None: + 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]: @@ -758,7 +799,7 @@ def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> environments=environments, errors=errors, ) - _validate_intents(data.get("intents"), profiles, errors) + _validate_intents(data.get("intents"), profiles, errors, project_root) if enabled: if default_profile not in profiles: diff --git a/skills/ack/scripts/validate_regression.py b/skills/ack/scripts/validate_regression.py new file mode 100644 index 0000000..5df890c --- /dev/null +++ b/skills/ack/scripts/validate_regression.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""校验 ACK 项目回归目录及其任务引用。 + +权威结构位于 templates/regression.schema.json。jsonschema 是可选依赖;内置规则 +始终检查用例 ID、验收信号和跨文件引用。本脚本只解析数据,不执行 steps。 + +用法: + python3 validate_regression.py .pouch/ack/regression.yaml + python3 validate_regression.py .pouch/ack/regression.yaml \ + --tasks .pouch/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, "", 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- 格式") + 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 "" + 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 != ".pouch/ack/regression.yaml": + errors.append( + "[tasks] project.regressionFile 必须固定为 .pouch/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- 格式") + 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=".pouch/ack/regression.yaml" + ) + parser.add_argument("--tasks", help="关联的 .pouch/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()) diff --git a/skills/ack/scripts/validate_tasks.py b/skills/ack/scripts/validate_tasks.py index 7c75f39..afbb32e 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -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}$") 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}$") +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}$") GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$") SEMVER_RE = re.compile( @@ -117,6 +119,51 @@ DELIVERY_STATUSES = { } DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"} 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 = { "title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt", @@ -636,6 +683,205 @@ def validate_delivery_runs( 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- 格式") + 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- 格式") + 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- 格式") + 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- 列表") + 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- 格式") + + 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- 格式") + 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]: import jsonschema # type: ignore @@ -779,6 +1025,19 @@ def validate_builtin(data: dict) -> list[str]: errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表") if "deliveryRuns" in data and "deliveryFile" not in project: errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在") + if ( + "regressionFile" in project + and project.get("regressionFile") != ".pouch/ack/regression.yaml" + ): + errors.append( + "project.regressionFile 必须固定为 .pouch/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") 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") validate_knowledge_fields(task, where, status, errors) + validate_regression_fields(task, where, errors) if "dispatch" not in task: dispatch = {} @@ -1129,6 +1389,8 @@ def validate_builtin(data: dict) -> list[str]: if isinstance(task, dict) and _nonempty_string(task.get("id")) } validate_delivery_runs(data["deliveryRuns"], task_statuses, errors) + if "regressionRuns" in data: + validate_regression_runs(data["regressionRuns"], errors) return errors diff --git a/skills/ack/templates/delivery.schema.json b/skills/ack/templates/delivery.schema.json index 0ad80d4..f21e88f 100644 --- a/skills/ack/templates/delivery.schema.json +++ b/skills/ack/templates/delivery.schema.json @@ -70,8 +70,18 @@ "additionalProperties": false, "properties": { "testEnvironment": { - "type": ["string", "null"], - "pattern": "^[a-z][a-z0-9-]{0,63}$" + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["via", "env"], + "properties": { + "via": { "const": "deployer" }, + "env": { "$ref": "#/definitions/id" } + } + } + ] }, "release": { "type": ["string", "null"], diff --git a/skills/ack/templates/delivery.template.yaml b/skills/ack/templates/delivery.template.yaml index 42b30f6..86f5d7e 100644 --- a/skills/ack/templates/delivery.template.yaml +++ b/skills/ack/templates/delivery.template.yaml @@ -7,7 +7,8 @@ project: enabled: false defaultProfile: null -# 测试环境部署和版本发布都写在本文件。null 表示用户尚未说明该操作。 +# 测试环境由 ACK 内部调用 deployer;发版仍指向本文件的 profile。 +# null 表示用户尚未说明该操作。 intents: testEnvironment: null release: null diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index e6c179a..9d20da5 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -8,8 +8,8 @@ > 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK > 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。 -> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的 -> `delivery.yaml`, +> `.pouch/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml`、默认关闭的 +> `delivery.yaml` 与空的 `regression.yaml`, > 不复制或链接 Skill。 ## 项目概览 @@ -21,6 +21,7 @@ - 任务板:`.pouch/ack/tasks.yaml` - 项目知识:`.pouch/ack/knowledge.yaml` - 交付契约:`.pouch/ack/delivery.yaml`(默认关闭) +- 回归目录:`.pouch/ack/regression.yaml` - 覆盖层文件:``(默认 `.pouch/ack/project.md`) ## 通用规范(由 ACK Skill 按需读取) @@ -33,6 +34,7 @@ - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令(可选):`references/orca-adapter.md` - 验证后交付与配置维护(可选):`references/delivery.md` +- 回归目录与运行(可选):`references/regression.md` ## Worker 路由 @@ -67,6 +69,7 @@ receipt 全部以 `.pouch/ack/tasks.yaml` 的 `project.orchestration` 与顶层 | `` | Read-only | Read-only | Read-only | 本地私有配置 | | `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 | | `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 | 项目交付能力,不是执行授权 | ## 命令(项目覆盖层) @@ -79,7 +82,7 @@ Developer 白盒验证: ``` -Test 黑盒复测(服务启动以 `delivery.yaml` 的 `intents.testEnvironment` 为准): +Test 黑盒复测(服务启动以 deployer 绑定的 `intents.testEnvironment` 为准): ```bash @@ -96,9 +99,10 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 `ACK_PROJECT_ROOT_DISPLAY`。 项目状态校验由 `/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` 为准; -本文件不维护第二套交付命令。 +测试环境由 ACK 内部调用 deployer。本文件不维护第二套交付命令。 ## 硬规则(其余见 references/) @@ -112,16 +116,17 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 - 复用仅限同轮空闲、身份匹配且历史消息可信清理的 worker;否则重新 plan/launch。 - 整轮结束后回收只属于 verified 任务的终端;blocked/failed/leftover 终端保留且不设 TTL。 - `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` 知识; `candidate` 不派发,知识库不全量注入。 - Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报 `knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。 - ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 - `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署。测试环境 - 与发版都写在这份契约的 `intents` 里;用户明确要求重新部署测试环境或发布版本时 - 才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后运行。 + 绑定 deployer,发版写在 `intents.release`;用户明确要求重新部署测试环境或发布 + 版本时才执行对应 intent。常规交付仍在任务 `verified` 且本次 profile 得到确认后 + 运行。回归目录在 `.pouch/ack/regression.yaml`,由用户明确要求「回归」时运行。 - 默认交付 profile 最多到 `validation_ready` 或 `review_ready`;stable 发布或 production 部署必须有 approval 步骤并再次获得明确批准。配置变更只影响下一次 run。 - 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。 diff --git a/skills/ack/templates/regression.schema.json b/skills/ack/templates/regression.schema.json new file mode 100644 index 0000000..6b138e2 --- /dev/null +++ b/skills/ack/templates/regression.schema.json @@ -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": ".pouch/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" } + } + } + } +} diff --git a/skills/ack/templates/regression.template.yaml b/skills/ack/templates/regression.template.yaml new file mode 100644 index 0000000..6e97053 --- /dev/null +++ b/skills/ack/templates/regression.template.yaml @@ -0,0 +1,7 @@ +# 复制为 .pouch/ack/regression.yaml。只有 Coordinator 写入;Test 通过回报提名。 +# 结构见 templates/regression.schema.json。 +version: 1 +updatedAt: "" +project: + name: "" +cases: [] diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 11b5faa..2dbf1d4 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -69,6 +69,11 @@ "const": ".pouch/ack/delivery.yaml", "description": "可选项目交付契约的唯一权威路径" }, + "regressionFile": { + "type": "string", + "const": ".pouch/ack/regression.yaml", + "description": "可选项目回归目录的唯一权威路径" + }, "bugIntake": { "$ref": "#/definitions/feishuBugIntake" }, @@ -122,6 +127,12 @@ "$ref": "#/definitions/deliveryRun" } }, + "regressionRuns": { + "type": "array", + "items": { + "$ref": "#/definitions/regressionRun" + } + }, "tasks": { "type": "array", "items": { @@ -243,6 +254,32 @@ "deliveryRuns": {} } } + }, + { + "if": { + "properties": { + "project": { + "type": "object", + "required": [ + "regressionFile" + ], + "properties": { + "regressionFile": {} + } + } + }, + "required": [ + "project" + ] + }, + "then": { + "required": [ + "regressionRuns" + ], + "properties": { + "regressionRuns": {} + } + } } ], "definitions": { @@ -1750,6 +1787,20 @@ "$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": { "if": { "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 } + } } } } diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 91747ad..a963801 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -9,6 +9,7 @@ project: overlayFile: ".pouch/ack/project.md" knowledgeFile: ".pouch/ack/knowledge.yaml" deliveryFile: ".pouch/ack/delivery.yaml" + regressionFile: ".pouch/ack/regression.yaml" # 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。 # bugIntake: # provider: "feishu-base" @@ -114,6 +115,7 @@ project: workerReceipts: [] deliveryRuns: [] +regressionRuns: [] summary: verified: [] @@ -137,6 +139,8 @@ tasks: knowledgeApplied: [] knowledgeCandidates: [] knowledgeChecks: [] + regressionRefs: [] + regressionCandidates: [] # 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。 # source: diff --git a/skills/deployer/README.md b/skills/deployer/README.md index de7f635..f4aaa15 100644 --- a/skills/deployer/README.md +++ b/skills/deployer/README.md @@ -17,6 +17,7 @@ - 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉) - 新加一个服务、把服务从一台机器挪到另一台、或下线旧服务 - 想给当前项目加 prod/test/dev 三套远程环境并随时部署其中一套 +- ACK 说「重新布测试环境」或跑回归前要先拉起 `.pouch/deployer/test` - 需要一张「哪台机器跑哪些服务」的清单 - 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署 diff --git a/skills/deployer/SKILL.md b/skills/deployer/SKILL.md index 9b3f5f9..8a6458c 100644 --- a/skills/deployer/SKILL.md +++ b/skills/deployer/SKILL.md @@ -6,7 +6,8 @@ description: >- 开 PR/MR,用户合并后由 Argo CD 同步)。当用户要求部署、同步、升级、重启远程 Compose 服务,向节点装 deb,新增/迁移/下线服务,梳理节点清单,make deploy TGT、 _config.yaml、rsync、tar over SSH、NAS 部署失败;或要求 ArgoCD / GitOps / K8s - 部署、更新 Application、升镜像 tag、开 MR 让用户合并部署时使用。 + 部署、更新 Application、升镜像 tag、开 MR 让用户合并部署;或 ACK 要求拉起/ + 重布项目测试环境时使用。 --- # deployer:Compose 节点与 Argo CD GitOps @@ -27,6 +28,7 @@ description: >- - sync 失败排查、证书丢失、改了配置不生效等运维问题 - 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步 - Argo CD / GitOps / 集群部署:新增 Application、改清单、升镜像 tag、开 MR 等用户合并 +- ACK Coordinator 拉起或重布项目测试环境(`.pouch/deployer/`) ## 不适用 @@ -96,6 +98,13 @@ my-project/ (如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖; `_config.yaml` 写 `name:` 可显式指定。 +## 被 ACK 调用 + +ACK 的「运行测试环境」和回归前布环境会加载本 skill,对项目 +`.pouch/deployer/`(通常是 `test`)按下面 Compose 轨道执行。ACK 只负责何时 +布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、 +Argo CD 合入和节点级批量操作仍须用户明确要求,不能因为 ACK 调用就扩大范围。 + ## 步骤 ### Compose 轨道 diff --git a/skills/manage-release/SKILL.md b/skills/manage-release/SKILL.md index 3deaac7..54cca81 100644 --- a/skills/manage-release/SKILL.md +++ b/skills/manage-release/SKILL.md @@ -17,7 +17,8 @@ description: >- ## 按需读取参考资料 - 开始任何流程时读取 [project-policy.md](references/project-policy.md),确定策略来源和默认值。 -- 需要选择、更新或核对版本号时读取 [versioning.md](references/versioning.md)。 +- 需要选择、更新或核对版本号,或生成 tag 更新说明时读取 + [versioning.md](references/versioning.md)。 - 需要 push、创建或合并 PR/MR、创建 Forge Release 时读取 [forge-adapters.md](references/forge-adapters.md)。 - 遇到重复资源、并发冲突、部分成功或清理请求时读取 @@ -172,16 +173,17 @@ PR/MR 时,停在这里并返回 URL、head/base、当前检查状态和阻塞 中锁定且重新验证过的远端目标 commit。两者统一记为 release commit。核对目标版本后, 再查询一次远端同名 tag。 -创建 tag 前,根据上一个稳定 tag 到 release commit 的实际差异生成更新说明;首次发布则 -使用项目声明的发布基线,没有声明时核对完整可达历史并明确标记首次发布。说明概括实际 -存在的新增、变更、修复、维护或文档变化;存在破坏性变化、迁移步骤或已知限制时必须明确 -列出。每项内容都应能追溯到本次提交、PR/MR 或项目 CHANGELOG。空说明、只重复版本号、 -模板占位文字或无法由实际变化支持的内容都不能发布。 +创建 tag 前,按 [versioning.md](references/versioning.md) 生成并检查 tag message:根据 +上一个稳定 tag 到 release commit 的实际差异填写 `Summary`、`Changes`、`Validation` +和 `Base commit`;首次发布则使用项目声明的发布基线,没有声明时核对完整可达历史并明确 +标记首次发布。空说明、只重复版本号或提交标题、缺少规定字段、模板占位文字、伪造变更 +或无法由实际变化支持的内容都不能发布。 -tag 不存在时,使用完整更新说明创建 annotated tag;项目要求签名时,必须先确认签名 -工具和密钥可用,创建 signed annotated tag,并在本地验证签名成功后才允许 push。使用 -文件输入完整的多行说明,显式指定 release commit,并只推送这个 tag。禁止创建 lightweight -release tag。签名不可用或验证失败时停止,不得降级为 unsigned tag。 +tag 不存在时,把检查通过的完整 message 写入文件,再用 +`git tag -a -F ` 创建 annotated tag;项目 +要求签名时,必须先确认签名工具和密钥可用,创建 signed annotated tag,并在本地验证签名 +成功后才允许 push。显式指定 release commit,并只推送这个 tag。禁止使用简短 `-m` 或 +创建 lightweight release tag。签名不可用或验证失败时停止,不得降级为 unsigned tag。 同名远端 tag 已存在且指向其他 commit 时立即停止。不要覆盖、删除或移动已经发布的 tag。同名 tag 即使指向 release commit,只要它是 lightweight tag、缺少更新说明或说明与 @@ -217,8 +219,8 @@ Forge Release 正文复用 tag 中经过验证的更新说明,不维护第二 - required checks、review 或冲突状态不满足合并规则。 - 合并结果的准确 commit 无法从平台确认,或无法从远端 base 到达。 - 远端 tag 已指向其他 commit。 -- 更新说明为空、只有版本号、包含占位内容、无法追溯到实际变化,或远端 tag 不是包含 - 计划说明的 annotated tag。 +- 更新说明为空、只有版本号或提交标题、缺少规定字段、包含占位内容、无法追溯到实际 + 变化,或远端 tag 不是包含计划说明的 annotated tag。 - 请求的 PR/MR、合并或 Forge Release 阶段所需的平台 CLI 缺失、未认证或无法读取状态。 - 项目要求 tag 签名,但签名能力不可用或本地签名验证失败。 - 操作需要 force push、管理员绕过、移动已发布 tag 或覆盖用户工作。 diff --git a/skills/manage-release/references/versioning.md b/skills/manage-release/references/versioning.md index 54ddffa..9604b9e 100644 --- a/skills/manage-release/references/versioning.md +++ b/skills/manage-release/references/versioning.md @@ -72,13 +72,38 @@ tag 派生版本,且发布说明不要求写回仓库时,不产生仓库修 以最近一个适用于当前发布线的稳定 tag 为起点,以 release commit 为终点,结合项目 CHANGELOG、已合并 PR/MR 和实际 diff 生成更新说明。首次发布使用项目声明的发布基线; -没有声明时核对完整可达历史,并在说明中明确这是首次发布。说明覆盖实际存在的新增、 -变更、修复、维护或文档变化;破坏性变化、迁移步骤和已知限制存在时必须单独标明。没有 -某一类别时可以省略该类别,不要生成空标题或模板占位。 +没有声明时核对完整可达历史,并在说明中明确这是首次发布。 + +正式版本的 tag message 使用以下固定格式;内容必须基于本次实际变更,并与 CHANGELOG +或 PR/MR 发布说明保持一致: + +```text + + +Summary: +- <一句话概括本版本> + +Changes: +- <变更 1> +- <变更 2> + +Validation: +- <验证命令及结果> + +Base commit: +``` + +`Summary`、`Changes`、`Validation` 和 `Base commit` 四个字段必须全部存在,不能只有 +版本号、提交标题或空 message。没有仓库 CHANGELOG 时,也必须按此格式补齐字段。 +`Changes` 只写入实际存在的新增、变更、修复、维护或文档变化;破坏性变化、迁移步骤和 +已知限制存在时必须单独标明。没有实际功能变更时不得伪造条目,应明确说明这是文档、 +打包或发布元数据修订。不要生成空标题或模板占位。`Validation` 写入本次实际执行或已经 +确认通过的验证命令及结果,不要编造未执行的检查。`Base commit` 必须是本次 release +commit。 每条说明都要能追溯到范围内的提交、PR/MR 或项目 CHANGELOG。提交消息和 PR/MR 文本只 -作为待核对素材,不能覆盖实际 diff,也不能把范围外变化写入本次说明。tag 创建前冻结 -完整多行说明;tag 已推送后不再修改。 +作为待核对素材,不能覆盖实际 diff,也不能把范围外变化写入本次说明。创建 tag 前先 +生成并检查完整 message,再冻结;tag 已推送后不再修改。 ## 验证 release commit @@ -90,9 +115,11 @@ commit 为准。获取远端 base 或允许的维护分支后,确认 release c 正式 tag 必须满足: - tag 名按项目格式由目标版本唯一生成。 +- tag 必须是 annotated tag,message 必须是冻结后的完整更新说明,最少包括 + `Summary`、`Changes`、`Validation` 和 `Base commit`;不能只有版本号、提交标题或空 + message。禁止 lightweight release tag。 - 远端没有同名 tag,或同名 tag 已经准确指向本次 commit。 -- annotated tag 显式指向 release commit,并包含冻结后的完整更新说明;禁止 lightweight - release tag。 +- annotated tag 显式指向 release commit。 - 项目要求签名时,push 前已成功创建 signed tag,并在本地验证签名通过;不可用或失败 时停止,不得改用 unsigned tag。 - 推送后从远端重新读取 tag object,核对对象类型和完整说明,再将 annotated tag 解引用 diff --git a/tests/test_ack_delivery.py b/tests/test_ack_delivery.py index 6936e5a..85df10e 100644 --- a/tests/test_ack_delivery.py +++ b/tests/test_ack_delivery.py @@ -314,7 +314,7 @@ class AckDeliveryValidationTests(unittest.TestCase): 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["intents"] = { "testEnvironment": "review", @@ -323,33 +323,32 @@ class AckDeliveryValidationTests(unittest.TestCase): errors = validate_delivery.validate_builtin(contract) 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["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", - }, - ], - } + contract["intents"]["testEnvironment"] = {"via": "deployer", "env": "test"} self.assertEqual(validate_delivery.validate_builtin(contract), []) contract["intents"]["release"] = "missing-release" errors = validate_delivery.validate_builtin(contract) 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(".pouch/deployer/test" in item for item in errors) + ) + env_dir = root / ".pouch" / "deployer" / "test" + env_dir.mkdir(parents=True) + self.assertEqual(validate_delivery.validate_builtin(contract, root), []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ack_regression.py b/tests/test_ack_regression.py new file mode 100644 index 0000000..02f52dd --- /dev/null +++ b/tests/test_ack_regression.py @@ -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( + "", "demo" + ).replace("", "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": ".pouch/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"] = ".pouch/ack/other.yaml" + errors = validate_regression.validate_tasks_link(catalog, tasks) + self.assertTrue(any("必须固定为 .pouch/ack/regression.yaml" in item for item in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 7812b08..c598d40 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -18,18 +18,23 @@ class AckSkillContentTests(unittest.TestCase): ".pouch/ack/tasks.yaml", ".pouch/ack/knowledge.yaml", ".pouch/ack/delivery.yaml", + ".pouch/ack/regression.yaml", "tasks: []", "validate_tasks.py", "validate_knowledge.py", "validate_delivery.py", + "validate_regression.py", "select_tasks.py", "select_knowledge.py", + "select_regression.py", "references/kickoff.md", "不要修改项目的 `AGENTS.md`", "当前会话担任 Coordinator", "intents.testEnvironment", "运行测试环境", "运行版本发布", + "运行回归", + "via: deployer", ): self.assertIn(expected, content) @@ -121,6 +126,12 @@ class AckSkillContentTests(unittest.TestCase): "scripts/validate_knowledge.py", "scripts/select_tasks.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/worker_profiles.py", "scripts/launch_worker.py", @@ -157,6 +168,7 @@ class AckSkillContentTests(unittest.TestCase): self.assertNotIn("allowedWorktrees:", template) self.assertIn("allowedWorktrees", template) self.assertIn('knowledgeFile: ".pouch/ack/knowledge.yaml"', template) + self.assertIn('regressionFile: ".pouch/ack/regression.yaml"', template) if __name__ == "__main__": diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py index ad97c32..4c1bde4 100644 --- a/tests/test_ack_tasks_validation.py +++ b/tests/test_ack_tasks_validation.py @@ -302,7 +302,13 @@ class AckTaskValidationTests(unittest.TestCase): with self.subTest(invalid_semver=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_gate["if"]["properties"]["ackVersion"]["pattern"] ) @@ -341,6 +347,7 @@ class AckTaskValidationTests(unittest.TestCase): "#/definitions/launchableTasks", ) self.assertIn("deliveryRuns", delivery_gate["then"]["required"]) + self.assertIn("regressionRuns", regression_gate["then"]["required"]) delivery_run = schema["definitions"]["deliveryRun"] revision_gate = delivery_run["allOf"][0] diff --git a/tests/test_ack_yaml_fallback.py b/tests/test_ack_yaml_fallback.py index d358abc..8a2d37b 100644 --- a/tests/test_ack_yaml_fallback.py +++ b/tests/test_ack_yaml_fallback.py @@ -31,12 +31,14 @@ paths = ( Path('skills/ack/examples/knowledge.example.yaml'), Path('skills/ack/templates/delivery.template.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: value = load_yaml_subset(path.read_text(encoding='utf-8')) if not isinstance(value, dict): raise SystemExit(f'{path}: top-level value is not a mapping') -print('parsed=6') +print('parsed=8') """ result = subprocess.run( [sys.executable, "-S", "-c", script], @@ -47,7 +49,7 @@ print('parsed=6') ) 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: result = subprocess.run( diff --git a/tests/test_skill_init.py b/tests/test_skill_init.py index bb4b39d..1d9a3d0 100644 --- a/tests/test_skill_init.py +++ b/tests/test_skill_init.py @@ -65,10 +65,18 @@ class SkillInitTests(unittest.TestCase): 'profiles: {}\n', encoding="utf-8", ) + (skill / "templates" / "regression.template.yaml").write_text( + 'updatedAt: ""\n' + 'project:\n' + ' name: ""\n' + 'cases: []\n', + encoding="utf-8", + ) for validator_name in ( "validate_tasks.py", "validate_knowledge.py", "validate_delivery.py", + "validate_regression.py", ): (skill / "scripts" / validator_name).write_text( "raise SystemExit(0)\n", @@ -105,15 +113,19 @@ class SkillInitTests(unittest.TestCase): tasks_content = (target / "tasks.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") + regression_content = (target / "regression.yaml").read_text(encoding="utf-8") self.assertIn("# sample-app", project_content) self.assertIn("version=1.2.3", project_content) self.assertIn(f'repoPath: "{project}"', tasks_content) self.assertIn(f'repoPath: "{project}"', knowledge_content) self.assertIn('name: "sample-app"', delivery_content) self.assertIn("enabled: false", delivery_content) + self.assertIn('name: "sample-app"', regression_content) + self.assertIn("cases: []", regression_content) self.assertNotIn("", tasks_content) self.assertNotIn("", knowledge_content) self.assertNotIn("", delivery_content) + self.assertNotIn("", regression_content) def test_init_refuses_to_overwrite_existing_files(self) -> None: project = self.home / "existing-app" @@ -130,6 +142,7 @@ class SkillInitTests(unittest.TestCase): self.assertFalse((target / "tasks.yaml").exists()) self.assertFalse((target / "knowledge.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: project = self.home / "existing-knowledge-app" @@ -161,6 +174,22 @@ class SkillInitTests(unittest.TestCase): self.assertFalse((target / "project.md").exists()) self.assertFalse((target / "tasks.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 / ".pouch" / "ack" + target.mkdir(parents=True) + existing = target / "regression.yaml" + existing.write_text("keep me", encoding="utf-8") + + result = self.run_pouch("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: for symlink_level in (".pouch", "ack"): @@ -336,7 +365,13 @@ class SkillInitTests(unittest.TestCase): self.assertEqual(list((project / ".pouch" / "ack").iterdir()), []) self.assertEqual( 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: @@ -395,7 +430,13 @@ class SkillInitTests(unittest.TestCase): self.assertFalse((target / "marker").exists()) self.assertEqual( 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: @@ -407,7 +448,7 @@ class SkillInitTests(unittest.TestCase): def fail_directory_fsync_after_publish(file_descriptor: int) -> None: nonlocal calls calls += 1 - if calls == 8: + if calls == 9: raise OSError("simulated directory fsync failure") real_fsync(file_descriptor) @@ -433,7 +474,13 @@ class SkillInitTests(unittest.TestCase): target = project / ".pouch" / "ack" self.assertEqual( 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: @@ -520,7 +567,13 @@ class SkillInitTests(unittest.TestCase): target = moved_project / ".pouch" / "ack" self.assertEqual( 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_pouch_dir_replacement_aborts_before_publish(self) -> None: @@ -615,7 +668,13 @@ class SkillInitTests(unittest.TestCase): target = moved_pouch / "ack" self.assertEqual( 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: @@ -652,11 +711,29 @@ class SkillInitTests(unittest.TestCase): self.assertIn("delivery.template.yaml", result.stderr) self.assertFalse((project / ".pouch" / "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_pouch("init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("regression.template.yaml", result.stderr) + self.assertFalse((project / ".pouch" / "ack").exists()) + def test_ack_init_requires_all_validators(self) -> None: for validator_name in ( "validate_tasks.py", "validate_knowledge.py", "validate_delivery.py", + "validate_regression.py", ): with self.subTest(validator_name=validator_name): project = self.home / f"missing-{validator_name}-app" @@ -715,6 +792,21 @@ class SkillInitTests(unittest.TestCase): self.assertIn("初始化交付契约校验失败", result.stderr) self.assertFalse((project / ".pouch" / "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_pouch("init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Traceback", result.stderr) + self.assertIn("初始化回归目录校验失败", result.stderr) + self.assertFalse((project / ".pouch" / "ack").exists()) + def test_validator_cannot_replace_staged_bytes_before_install(self) -> None: project = self.home / "mutated-staging-app" project.mkdir()