diff --git a/README.md b/README.md index 26e6222..8b5d940 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ AGENTS.md # 详细规范与架构说明 | Skill | 说明 | |-------|------| -| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 | +| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 | | [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 | | [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 | @@ -48,6 +48,9 @@ skiff init ack skiff init ack --project ~/app ``` +初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护 +DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。 + 新建 skill: ```bash diff --git a/skiff/cli.py b/skiff/cli.py index 50f1ddb..4417c9c 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -1311,9 +1311,10 @@ def cmd_init(args: argparse.Namespace) -> None: project_file = destination / "project.md" tasks_file = destination / "tasks.yaml" knowledge_file = destination / "knowledge.yaml" + delivery_file = destination / "delivery.yaml" managed_targets = [project_file, tasks_file] if args.name == "ack": - managed_targets.append(knowledge_file) + managed_targets.extend((knowledge_file, delivery_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) @@ -1326,8 +1327,11 @@ def cmd_init(args: argparse.Namespace) -> None: (tasks_template, tasks_file), ] if args.name == "ack": - template_targets.append( - (skill_source / "templates" / "knowledge.template.yaml", knowledge_file) + template_targets.extend( + ( + (skill_source / "templates" / "knowledge.template.yaml", knowledge_file), + (skill_source / "templates" / "delivery.template.yaml", delivery_file), + ) ) missing = [path for path, _ in template_targets if not path.is_file()] if missing: @@ -1335,10 +1339,11 @@ def cmd_init(args: argparse.Namespace) -> None: raise SystemExit(f"skill 缺少初始化模板: {paths}") validator = skill_source / "scripts" / "validate_tasks.py" knowledge_validator = skill_source / "scripts" / "validate_knowledge.py" + delivery_validator = skill_source / "scripts" / "validate_delivery.py" if args.name == "ack": missing_validators = [ path - for path in (validator, knowledge_validator) + for path in (validator, knowledge_validator, delivery_validator) if not path.is_file() ] if missing_validators: @@ -1394,6 +1399,23 @@ def cmd_init(args: argparse.Namespace) -> None: raise SystemExit( f"初始化知识库校验失败(exit {completed.returncode})" ) + if args.name == "ack" and delivery_validator.is_file(): + completed = subprocess.run( + [ + sys.executable, + str(delivery_validator), + str(staged_files[delivery_file]), + "--tasks", + str(staged_files[tasks_file]), + "--project-root", + str(staging), + ], + 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]: @@ -1621,6 +1643,7 @@ def cmd_init(args: argparse.Namespace) -> None: _print(f" 任务板: {tasks_file}") if args.name == "ack": _print(f" 知识库: {knowledge_file}") + _print(f" 交付契约: {delivery_file}(默认关闭)") _print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL") diff --git a/skills/ack/README.md b/skills/ack/README.md index 344f5fe..c322765 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -9,6 +9,10 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协 关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为 `leftover`,然后继续处理其它任务。 +项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile +构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭, +稳定发布与生产部署始终保留人工批准点。 + ## 安装 全局安装: @@ -38,7 +42,8 @@ skiff init ack --project ~/code/my-app docs/ack/ ├── project.md ├── tasks.yaml -└── knowledge.yaml +├── knowledge.yaml +└── delivery.yaml # 默认 enabled: false ``` 不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill @@ -52,7 +57,7 @@ skills/ack/ ├── README.md ├── VERSION ├── references/ # 三角色规范、闭环流程和初始化说明 -├── templates/ # project.md、tasks.yaml、knowledge.yaml 模板和 schema +├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema ├── examples/ # 完整示例 └── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher ``` @@ -61,6 +66,8 @@ skills/ack/ `docs/ack/project.md` 只保存当前项目的命令、路径和权限差异; `docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、 已经独立验证的项目知识护栏。 +`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在 +`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。 ## 检查项目状态 @@ -70,6 +77,8 @@ Agent 会从当前 ACK Skill 目录解析校验脚本: python3 /scripts/validate_tasks.py docs/ack/tasks.yaml python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml \ --tasks docs/ack/tasks.yaml +python3 /scripts/validate_delivery.py docs/ack/delivery.yaml \ + --tasks docs/ack/tasks.yaml --project-root ``` Coordinator 可以按当前任务上下文做确定性推荐: @@ -115,6 +124,19 @@ python3 /scripts/run_verification.py \ 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 +## 配置与运行交付 + +用户可以直接向 `/ack` 描述项目差异,例如“这个项目用 `make deb` 构建 DEB,推到 +preview APT 源,再部署到开发机”。ACK 会把它维护成 +`docs/ack/delivery.yaml` 中的声明式 entrypoint、artifact、destination、environment +和 profile,校验后展示 diff;首次配置保持关闭,确认后才启用。 + +交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存 +凭据值。ACK 在任务进入 `verified` 后,按用户确认的 profile 执行,并把 revision、 +PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml.deliveryRuns`。默认 +profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。 +具体契约见 `references/delivery.md`。 + ## 启动 Worker worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型 @@ -180,8 +202,26 @@ fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功 Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与 可观测验收信号;用户确认后才派发实现和复测。 +首次配置交付可以说: + +```text +/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库, +部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。 +``` + +之后处理需求时只需在确认计划中选择 profile: + +```text +/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。 +``` + +ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或 +权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。 + ## 版本 当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录 `ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须 -同时存在;旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 +同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明 +`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的 +`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index d1eb594..d934f2b 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -2,15 +2,16 @@ name: ack description: >- 初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求 - 初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或指挥 Coordinator/Developer/Test - 工作时使用。 + 初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test + 工作,或配置并执行任务验证后的项目交付流程时使用。 --- # ACK 项目协作入口 本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目 状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、 -`tasks.yaml` 和 `knowledge.yaml`,不要复制或链接 Skill 内容。 +`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill +内容。 开始时解析当前 `SKILL.md` 所在目录,记为 ``。所有通用规范、模板和 脚本都相对此目录访问,不依赖固定的全局安装路径。 @@ -20,6 +21,7 @@ description: >- - 用户要求初始化、接入或安装 ACK:执行“初始化”。 - 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。 - 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。 +- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。 始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git 项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md` @@ -54,18 +56,24 @@ description: >- 7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留 `verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中 猜测并激活知识。 -8. 更新 `updatedAt`,并运行: +8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile; + 不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK + 闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。 +9. 更新 `updatedAt`,并运行: ```bash python3 /scripts/validate_tasks.py docs/ack/tasks.yaml python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml \ --tasks docs/ack/tasks.yaml + python3 /scripts/validate_delivery.py docs/ack/delivery.yaml \ + --tasks docs/ack/tasks.yaml --project-root ``` -9. 检查 `project.md`、`tasks.yaml` 与 `knowledge.yaml` 是否仍有 `<...>` 占位符。 +10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有 + `<...>` 占位符。 结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出 缺失值。 -10. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、 +11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、 不推送。 ## 检查 @@ -74,6 +82,7 @@ description: >- - `docs/ack/project.md` - `docs/ack/tasks.yaml` - `docs/ack/knowledge.yaml` + - `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验) 2. 读取 `/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有 `kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法 SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时 @@ -82,8 +91,11 @@ description: >- 黑盒命令和 Base URL。 4. 使用 `/scripts/validate_tasks.py` 校验任务板,使用 `/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks - docs/ack/tasks.yaml` 校验项目知识和跨文件引用。只报告证据明确的问题,不因可选 - 字段缺失而宣称失败。 + docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了 + `project.deliveryFile`,再使用 `/scripts/validate_delivery.py + docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root ` + 校验交付能力、顺序、安全边界和跨文件引用。只报告证据明确的问题,不因旧项目 + 缺少可选交付配置而宣称失败。 5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且 `stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。 6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、 @@ -103,12 +115,15 @@ description: >- `docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目 - `/references/kickoff.md` - kickoff 指定且与当前任务相关的 references 文件 + - 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和 + `/references/delivery.md` 3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和 worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active` 知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文; 不全量注入知识库。 4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验, - 然后交给用户确认;确认前不派发实现。 + 然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的 + 步骤放入同一份计划。确认前不派发实现,也不执行交付。 5. 创建或更换 worker 时,只使用 `/scripts/launch_worker.py plan|launch` 读取 `tasks.yaml.project.orchestration` 的 profile。不得直接执行 @@ -129,6 +144,25 @@ description: >- `leftover` 并继续其它任务。 9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范; `knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。 +10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划 + 包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据 + 写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery + run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署 + 必须在对应步骤再次取得明确批准。 + +## 交付配置维护 + +1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布 + 入口和 CI;把用户自然语言描述转换为结构化 entrypoint、artifact、destination、 + environment 与 profile。配置只引用仓库内脚本或声明式工具 target,不保存 shell。 +2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加 + `deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它 + 项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。 +3. 运行 delivery、tasks 和跨文件校验;需要的脚本不存在、不可执行、引用不完整或 + 涉及凭据正文时 fail closed。凭据只写 secret 名称,值由外部环境提供。 +4. 用户确认后才把配置设为启用。配置修改只影响下一次 delivery run;已确认或正在 + 执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改 + 扩大权限。 ## 边界 @@ -141,10 +175,13 @@ description: >- v0.10 不自动复用既有 worker。 - launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按 launch ID、外部 record 和 Orca live state 完成人工核对。 -- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。 -- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 只读, - 只能通过回报提名或验证知识。 +- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自 + 提交、推送、创建终端、新 worktree、发布产物或部署。 +- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer + 与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置 + 维护中修改。 - 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py` 按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 -- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml` 和 - `docs/ack/knowledge.yaml`;通用资源始终从当前 ACK Skill 目录读取。 +- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、 + `docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前 + ACK Skill 目录读取。 diff --git a/skills/ack/VERSION b/skills/ack/VERSION index 78bc1ab..d9df1bb 100644 --- a/skills/ack/VERSION +++ b/skills/ack/VERSION @@ -1 +1 @@ -0.10.0 +0.11.0 diff --git a/skills/ack/agents/openai.yaml b/skills/ack/agents/openai.yaml index d896c30..337c950 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 ACK for this project or coordinate work from docs/ack." + short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环" + default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile." policy: allow_implicit_invocation: false diff --git a/skills/ack/examples/delivery.example.yaml b/skills/ack/examples/delivery.example.yaml new file mode 100644 index 0000000..f6f5c97 --- /dev/null +++ b/skills/ack/examples/delivery.example.yaml @@ -0,0 +1,149 @@ +version: 1 +updatedAt: "2026-08-01T10:00:00+08:00" +project: + name: "notes-api" + +enabled: true +defaultProfile: "review" + +entrypoints: + verify: + kind: make + target: verify + args: [] + requiredSecrets: [] + workingDirectory: "." + timeoutSeconds: 1800 + build-deb: + kind: make + target: build-deb + args: [] + requiredSecrets: [] + workingDirectory: "." + timeoutSeconds: 3600 + build-image: + kind: make + target: build-image + args: [] + requiredSecrets: [] + workingDirectory: "." + timeoutSeconds: 3600 + upload-deb: + kind: script + path: "scripts/upload-preview-deb.sh" + args: [] + requiredSecrets: ["DEB_TOKEN"] + workingDirectory: "." + timeoutSeconds: 900 + upload-image: + kind: script + path: "scripts/publish-preview-image.sh" + args: [] + requiredSecrets: ["REGISTRY_TOKEN"] + workingDirectory: "." + timeoutSeconds: 1800 + deploy-test: + kind: script + path: "scripts/deploy-test.sh" + args: [] + requiredSecrets: [] + workingDirectory: "." + timeoutSeconds: 900 + health-test: + kind: script + path: "scripts/check-test.sh" + args: [] + requiredSecrets: [] + workingDirectory: "." + timeoutSeconds: 300 + rollback-test: + kind: script + path: "scripts/rollback-test.sh" + args: [] + requiredSecrets: [] + workingDirectory: "." + timeoutSeconds: 900 + +artifacts: + service-deb: + type: deb + build: build-deb + outputs: ["dist/*.deb"] + service-image: + type: oci-image + build: build-image + image: "registry.example.com/notes/service" + platforms: ["linux/amd64", "linux/arm64"] + +destinations: + preview-apt: + type: apt-repository + channel: preview + endpoint: "https://packages.example.com" + repository: "testing" + upload: upload-deb + preview-registry: + type: oci-registry + channel: preview + registry: "registry.example.com" + repository: "notes/service" + upload: upload-image + +environments: + test-server: + type: ssh-host + classification: development + target: "notes-test" + deploy: deploy-test + healthCheck: health-test + rollback: rollback-test + mutex: "notes-test-deploy" + +profiles: + review: + stopAt: review_ready + steps: + - id: verify + action: verify + entrypoint: verify + - id: open-pr + action: pull-request + draft: true + remote: origin + baseBranch: main + - id: build-deb + action: build + artifact: service-deb + - id: publish-deb + action: publish + artifact: service-deb + destination: preview-apt + - id: deploy-test + action: deploy + artifact: service-deb + environment: test-server + - id: smoke-test + action: health-check + environment: test-server + - id: ready + action: mark-ready + review-image: + stopAt: review_ready + steps: + - id: verify + action: verify + entrypoint: verify + - id: open-pr + action: pull-request + draft: true + remote: origin + baseBranch: main + - id: build-image + action: build + artifact: service-image + - id: publish-image + action: publish + artifact: service-image + destination: preview-registry + - id: ready + action: mark-ready diff --git a/skills/ack/examples/project.example.md b/skills/ack/examples/project.example.md index 51c9bc2..ea05105 100644 --- a/skills/ack/examples/project.example.md +++ b/skills/ack/examples/project.example.md @@ -1,10 +1,10 @@ # notes-web Agent 协作协议(示例,项目覆盖层) -> 本项目基于 ack v0.10.0。 +> 本项目基于 ack v0.11.0。 > 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。 > 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。 > ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 -> `docs/ack/` 只保存 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。 +> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。 ## 项目概览 @@ -14,6 +14,7 @@ - Base URL:`http://localhost:5173` - 任务板:`docs/ack/tasks.yaml` - 项目知识:`docs/ack/knowledge.yaml` +- 交付契约:`docs/ack/delivery.yaml` - 覆盖层文件:`docs/ack/project.md` ## 稳定规范(引用,不重复) @@ -25,6 +26,7 @@ - 优化方法(验收信号、三轮策略):`references/optimization-method.md` - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令:`references/orca-adapter.md` +- 验证后交付:`references/delivery.md` ## Worker 路由 @@ -52,6 +54,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 通过回报提名或验证 | +| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 | ## 命令 @@ -75,8 +78,9 @@ curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。 执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接 path/args。 -项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和 -`scripts/validate_knowledge.py` 执行。 +项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、 +`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。 +交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。 ## 硬规则(其余见 references/) @@ -93,5 +97,7 @@ path/args。 `candidate` 不派发,知识库不全量注入。 - Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报 `knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。 +- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与 + production 部署保留显式 approval,配置变更只影响下一次 run。 - 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。 -- 不提交或推送,除非用户明确要求。 +- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。 diff --git a/skills/ack/examples/tasks.example.yaml b/skills/ack/examples/tasks.example.yaml index 9e04a68..0458311 100644 --- a/skills/ack/examples/tasks.example.yaml +++ b/skills/ack/examples/tasks.example.yaml @@ -3,7 +3,7 @@ version: 1 updatedAt: "2026-07-06T09:40:00+08:00" source: "Coordinator (PM) Agent" -ackVersion: "0.10.0" +ackVersion: "0.11.0" project: name: "notes-web" repoPath: "/home/dev/notes-web" @@ -11,6 +11,7 @@ project: devWorktree: "/home/dev/notes-web-wt/fix-preview" overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" + deliveryFile: "docs/ack/delivery.yaml" orchestration: profileVersion: 1 mode: "manual" @@ -70,6 +71,7 @@ project: developerUpgraded: "codex-dev-strong" workerReceipts: [] +deliveryRuns: [] summary: verified: ["BUG-002"] diff --git a/skills/ack/references/adoption-checklist.md b/skills/ack/references/adoption-checklist.md index 6589b1b..44b6727 100644 --- a/skills/ack/references/adoption-checklist.md +++ b/skills/ack/references/adoption-checklist.md @@ -4,7 +4,8 @@ - [ ] ACK Skill 已全局安装或安装到当前项目。 - [ ] 已运行 `skiff init ack --project `。 -- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。 +- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 + 默认关闭的 `delivery.yaml`。 - [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的 `project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。 - [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。 @@ -16,6 +17,8 @@ - [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。 - [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为 `docs/ack/knowledge.yaml`。 +- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有 + `deliveryRuns: []`;旧项目未采用交付能力时可无这两项。 - [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。 - [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。 - [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目 @@ -30,6 +33,7 @@ - [ ] 私有配置只读且不提交。 - [ ] `tasks.yaml` 只有 Coordinator 写。 - [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。 +- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。 ## 任务板 @@ -38,6 +42,22 @@ - [ ] 真实任务的验收是可观测信号。 - [ ] 已运行 `/scripts/validate_tasks.py` 并通过。 +## 可选交付 + +- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。 +- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有 + shell、自由 command、凭据值或环境变量值。 +- [ ] artifact、destination、environment 和 profile 引用均通过 + `validate_delivery.py --tasks ... --project-root ...` 校验。 +- [ ] 默认 profile 只使用 preview/staging 与非 production 环境,停止在 + `review_ready`。 +- [ ] stable 发布和 production 部署之前存在 approval 步骤;production 环境有 + rollback 入口。 +- [ ] 本次 ACK 计划明确 profile、task IDs、目标、停止点和审批步骤;配置本身没有 + 被当作提交、推送、发布或部署授权。 +- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`; + 失败不会把已验证任务回退为失败。 + ## 项目知识 - [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`, diff --git a/skills/ack/references/closed-loop.md b/skills/ack/references/closed-loop.md index 967798f..8fe4039 100644 --- a/skills/ack/references/closed-loop.md +++ b/skills/ack/references/closed-loop.md @@ -164,11 +164,12 @@ python3 /scripts/launch_worker.py launch \ | 要保持基线分支干净 | 新 worktree(feature 分支)| | 小改动、追求快 | 当前 worktree | -**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 和 -`knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由 -Coordinator 单写。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch -也只写入这个副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、 -launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。 +**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、 +`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在 +worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护 +时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个 +副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与 +receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。 两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。 v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价 @@ -191,7 +192,8 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful 7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。 手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator -写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。 +写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有 +选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。 手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff, 但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md new file mode 100644 index 0000000..cf13f3c --- /dev/null +++ b/skills/ack/references/delivery.md @@ -0,0 +1,123 @@ +# ACK 交付阶段 + +本文件定义可选的 `verified -> review_ready/released` 交付阶段。开发、独立复测和 +Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入 +交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入 +`docs/ack/tasks.yaml.deliveryRuns`。 + +## 1. 配置与授权不是一回事 + +`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。ACK 在 +kickoff 的既有用户确认点同时展示本次 profile、remote、产物目标、环境和停止点;用户 +确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、 +channel、environment 或 source revision 漂移时重新确认。 + +`approval` 步骤始终是运行时硬门。`stable` 发布和 `production` 部署不能由 kickoff +的一般确认代替,必须在该步骤取得本次明确授权。配置、历史 approval 或项目文档不能 +替用户授权合并 PR、创建正式 tag、覆盖版本、删除分支或生产发布。 + +## 2. 配置快照与变更生效 + +普通任务在 kickoff 时从可信 base commit 读取交付契约并记录 `configRevision`。本次 +分支对 `delivery.yaml`、引用的部署入口、CI 或 Agent 指令文件的修改不能扩大当前运行 +权限;这些改动经审核合并后从下一次任务生效。 + +用户明确要求维护交付配置时: + +1. 读取现有配置、项目构建入口、CI、打包和部署事实。 +2. 用自然语言总结将新增、删除或改变的 artifact、destination、environment、profile + 和权限边界。 +3. 只做最小配置修改,不把项目脚本复制进 ACK。 +4. 运行 `validate_delivery.py`;可安全执行的本地入口使用 dry-run 或无凭据环境检查。 +5. 把配置或入口变更作为待审核变更交付。本轮不使用新配置执行 publish/deploy。 + +普通功能任务中若发现配置漂移,记录 `contract_drift` 并停止受影响的交付步骤,不为了 +通过流程而静默修改配置或跳过步骤。 + +## 3. 交付契约结构 + +- `entrypoints`:项目已有的 Make、Just、Task、Dagger 或仓库内可执行脚本入口。 +- `artifacts`:`deb`、`oci-image` 或 `file`,引用一个 build entrypoint。 +- `destinations`:APT、OCI registry 或 CI artifact;`channel` 区分 preview、staging、 + stable。 +- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、 + deploy 和 health check,production 还必须声明 rollback。 +- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `review_ready`,不能 + 发布 stable 或部署 production。 + +配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径 +或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部 +凭据系统或执行环境注入。复杂逻辑放在受版本控制的项目入口中。entrypoint 使用 argv +语义执行,不能拼成 `sh -c` 字符串。 + +## 4. 运行前检查 + +1. 从 `tasks.yaml.project.deliveryFile` 解析文件;未引用或 `enabled=false` 时保持旧 ACK + 行为,收尾停在 `verified`。 +2. 运行: + + ```bash + python3 /scripts/validate_delivery.py \ + docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml \ + --project-root + ``` + +3. 确认选中 profile 是 kickoff 已确认的 profile,所有 task 已是 `verified`,工作区与 + 服务对应正确 source revision。 +4. 检查 referenced entrypoint、delivery config、CI 和凭据边界是否在本次变更中被 + 修改;被修改时禁止用它们执行带远端写权限或 secret 的步骤。 +5. 检查本次步骤引用的 `requiredSecrets` 是否由外部环境提供,只报告名称和是否存在, + 不读取、打印或持久化值。缺失时在第一次相关写操作前标记 blocked。 +6. 将已确认工作树固化为本地 source revision,再创建 `deliveryRuns` 的 `planned` + 记录,绑定 task IDs、profile、source revision 和 config revision;推送仍等到对应 + `pull-request` 步骤。 + +## 5. 步骤语义 + +按 profile 中的顺序执行,不自行插入或省略步骤: + +- `verify`:运行指定 entrypoint,失败即停止。 +- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft + PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户 + 明确要求时才调用独立的 `manage-release`。没有对应能力或认证时标记 blocked,不用 + 带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。 +- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和 + SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source + revision,不能在目标机器重新拉源码构建。 +- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 可使用已安装的 + `deb-publisher`;Docker 只有在用户明确指定 `$publish-docker-image` 时才加载该 + explicit-only skill,否则必须走契约中已审查的 upload entrypoint。项目入口只接受 + 刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用 + `latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。 +- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标 + mutex 后执行,不能并发部署同一目标。 +- `health-check`:在对应 deploy 成功后运行环境 health check,记录可观测证据。失败时 + 按项目入口执行 rollback;rollback 未证明成功时不得声称恢复。 +- `approval`:停止并展示准确 artifact、destination/environment、source revision 和 + 回滚计划,等待用户本次确认。 +- `mark-ready`:所有前序步骤成功后将 Draft PR/MR 标为 ready,并写入最终证据。 + +## 6. 状态与恢复 + +`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、 +`blocked`、`failed`、`review_ready`、`released` 或 `skipped`。部署或 Forge 暂时失败不把 +任务改回 `failed_retest`。 + +重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同 +ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成同一运行的资源。 + +若恢复过程中修改了任何 tracked file,原 source revision 和交付证据失效:回到 ACK +验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变 +时,才可从失败步骤继续。 + +`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、 +部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用 +“完成”掩盖其中某一阶段失败或待审批。 + +## 7. 与低层 Skill 的边界 + +ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的 +上传、镜像或 Git 发布实现。`deb-publisher`、`publish-docker-image` 和 +`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的 +项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式 +调用时,ACK 不能绕过它的触发与授权边界。 diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index 200a668..8ae9b0a 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -12,7 +12,8 @@ 3. `skiff` 命令可用。 不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、 -`docs/ack/knowledge.yaml`、`AGENTS.md` 或其它 Agent 指令文件。ACK 不会自动 +`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent +指令文件。ACK 不会自动 修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env` 内容或其它私有配置写入 ACK 项目状态。 @@ -36,7 +37,8 @@ skiff init ack --project docs/ack/ ├── project.md ├── tasks.yaml -└── knowledge.yaml +├── knowledge.yaml +└── delivery.yaml # 默认 enabled: false ``` 如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、 @@ -52,6 +54,14 @@ docs/ack/ `knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、 知识库和跨文件引用校验。 +### 旧项目补充交付配置 + +`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户 +明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在 +任务板补 `project.deliveryFile: docs/ack/delivery.yaml` 与顶层 +`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认 +解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。 + ## 完善项目覆盖层 编辑 `docs/ack/project.md`,填入: @@ -70,9 +80,13 @@ docs/ack/ - `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时存在。 +- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级 + 自动补交付配置。 - `updatedAt` 使用当前带时区时间。 - `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用 真实值。 +- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层 + `deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。 - `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型 allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、 `extraArgs`、`env` 或任意 executable 写进任务板。 @@ -98,6 +112,14 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且 `/scripts/run_verification.py`,不直接运行 path/args。关键约束应 最终下沉为测试、lint、CI 或正式规范。 +## 初始化项目交付 + +新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表和空 profile。 +不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言描述交付要求后, +Coordinator 按 `delivery.md` 维护声明式配置:工具 target 与仓库脚本分开引用,产物、 +目的地、环境和有序 profile 使用稳定 ID。配置中不保存 shell、环境变量值或凭据 +正文;稳定发布和生产部署必须有显式 approval 步骤。 + ## 校验 Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行: @@ -105,13 +127,17 @@ Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行: ```bash python3 /scripts/validate_tasks.py docs/ack/tasks.yaml python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml +python3 /scripts/validate_delivery.py docs/ack/delivery.yaml \ + --tasks docs/ack/tasks.yaml --project-root ``` 同时确认: -- `project.md`、`tasks.yaml` 和 `knowledge.yaml` 没有未替换的 `<...>` 占位符。 +- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的 + `<...>` 占位符。 - `project.overlayFile` 指向真实文件。 - `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。 +- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。 - Developer 与 Test 的验证命令可执行。 - `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许 `read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。 @@ -125,9 +151,9 @@ python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml -- 完成后报告: -- 创建或确认的三个项目文件。 +- 创建或确认的四个项目文件。 - 检测到的技术栈和验证命令。 -- 任务板和项目知识校验结果。 +- 任务板、项目知识和交付契约校验结果。 - 仍需用户补充的值。 只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出 diff --git a/skills/ack/references/kickoff.md b/skills/ack/references/kickoff.md index db562d4..959122a 100644 --- a/skills/ack/references/kickoff.md +++ b/skills/ack/references/kickoff.md @@ -23,15 +23,20 @@ 1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并 用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读 references/roles-and-permissions.md、closed-loop.md、optimization-method.md。 + 如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与 + references/delivery.md,但不要把配置本身当作执行授权。 2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。 3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的 knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。 4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。 -5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。 +5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付, + 同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。 6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化 Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker, dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml; 每个任务最多三轮,三轮不过记 leftover 并升级我复盘。 +7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写 + deliveryRuns;默认停在 review_ready,stable/production 步骤再次向我确认。 ``` --- @@ -55,6 +60,9 @@ python3 /scripts/validate_knowledge.py docs/ack/knowledge.yaml -- 5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。 +如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、 +发布 channel、部署环境、PR 是否创建/标 ready、停止点,以及哪些步骤会再次审批。 + --- ## 第 2 步:决定 worktree @@ -143,7 +151,16 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo --- -## 第 5 步:收尾 +## 第 5 步:可选交付 + +所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按 +`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和 +config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入 +`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和 +production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把 +delivery run 标为 `blocked` 或 `failed`。 + +## 第 6 步:收尾 一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。 @@ -154,5 +171,5 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo 产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从 `tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落 -`tasks.yaml`,验证后的 +`tasks.yaml` → 可选 delivery profile 到审核点,验证后的 跨任务知识由 Coordinator 落 `knowledge.yaml`。 diff --git a/skills/ack/references/prompt-templates.md b/skills/ack/references/prompt-templates.md index db1138a..dab2fa5 100644 --- a/skills/ack/references/prompt-templates.md +++ b/skills/ack/references/prompt-templates.md @@ -256,6 +256,13 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发 - 新增或更新: - 待验证 candidate: +交付(未启用时写 n/a): +- run/profile/status: +- PR/MR: +- 产物: +- 部署: +- 待审批或未完成: + 工作树状态: - : - : diff --git a/skills/ack/references/roles-and-permissions.md b/skills/ack/references/roles-and-permissions.md index 372fa2b..d850b3c 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`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 | | Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` | | Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 | | User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 | @@ -109,6 +109,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 通过回报提名或验证 | +| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 | --- @@ -145,12 +146,28 @@ failed_retest(累计 3 轮) -> leftover 三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。 +## 交付状态(与任务状态正交) + +任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在 +`tasks.yaml.deliveryRuns`: + +```text +planned -> running -> review_ready | released + -> blocked | failed +planned -> skipped +``` + +`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户 +审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败 +不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、 +审批点和恢复规则见 `delivery.md`。 + --- ## 项目状态写入约定(并发安全) -`tasks.yaml` 是任务事实源,`knowledge.yaml` 是跨任务项目知识事实源。为避免多 -Agent 并发写冲突: +`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源, +`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突: - **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer 对它们都是只读的。 @@ -160,6 +177,8 @@ Agent 并发写冲突: 知识。 - 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。 - 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。 +- `delivery.yaml` 只在用户显式要求维护配置时修改;运行只写 + `tasks.yaml.deliveryRuns`,不能反向改写能力定义。 全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。 关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和 diff --git a/skills/ack/scripts/validate_delivery.py b/skills/ack/scripts/validate_delivery.py new file mode 100755 index 0000000..aaf9e7b --- /dev/null +++ b/skills/ack/scripts/validate_delivery.py @@ -0,0 +1,809 @@ +#!/usr/bin/env python3 +"""校验 ACK 项目交付契约。 + +权威结构位于 templates/delivery.schema.json。jsonschema 是可选依赖;内置规则始终 +检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。 + +用法: + python3 validate_delivery.py docs/ack/delivery.yaml + python3 validate_delivery.py docs/ack/delivery.yaml \ + --tasks docs/ack/tasks.yaml --project-root + +退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import stat +import sys +from pathlib import Path, PurePosixPath +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"^[a-z][a-z0-9-]{0,63}$") +RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$") +PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$") +SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") +REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + +TOP_LEVEL_FIELDS = { + "version", + "updatedAt", + "project", + "enabled", + "defaultProfile", + "entrypoints", + "artifacts", + "destinations", + "environments", + "profiles", +} +ENTRYPOINT_FIELDS = { + "kind", + "target", + "function", + "path", + "args", + "requiredSecrets", + "workingDirectory", + "timeoutSeconds", +} +ARTIFACT_FIELDS = {"type", "build", "outputs", "image", "platforms"} +DESTINATION_FIELDS = { + "type", + "channel", + "registry", + "repository", + "endpoint", + "artifactName", + "upload", +} +ENVIRONMENT_FIELDS = { + "type", + "classification", + "target", + "deploy", + "healthCheck", + "rollback", + "mutex", +} +PROFILE_FIELDS = {"stopAt", "steps"} +STEP_FIELDS = { + "id", + "action", + "entrypoint", + "artifact", + "destination", + "environment", + "gate", + "draft", + "remote", + "baseBranch", +} + +ENTRYPOINT_KINDS = {"make", "just", "task", "dagger", "script"} +ARTIFACT_TYPES = {"deb", "oci-image", "file"} +DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"} +CHANNELS = {"preview", "staging", "stable"} +ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"} +CLASSIFICATIONS = {"development", "staging", "production"} +STOP_POINTS = {"verified", "review_ready", "released"} +ACTIONS = { + "verify", + "pull-request", + "build", + "publish", + "deploy", + "health-check", + "approval", + "mark-ready", +} +ACTION_FIELDS = { + "verify": {"entrypoint"}, + "pull-request": {"draft", "remote", "baseBranch"}, + "build": {"artifact"}, + "publish": {"artifact", "destination"}, + "deploy": {"artifact", "environment"}, + "health-check": {"environment"}, + "approval": {"gate"}, + "mark-ready": set(), +} + +SECRET_PATTERNS = ( + ("private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")), + ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")), + ("OpenAI-style token", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")), + ("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")), + ("URL credentials", re.compile(r"https?://[^/\s:@]+:[^/\s@]+@")), + ( + "inline secret assignment", + re.compile( + r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)" + r"\s*[:=]\s*[\"']?[^\s,\"']{8,}" + ), + ), +) + + +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_document(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 _safe_relative_path(value: Any, *, allow_glob: bool = False) -> bool: + if not _nonempty(value) or value.startswith("/") or "\\" in value: + return False + if not RELATIVE_PATH_RE.fullmatch(value): + return False + if not allow_glob and any(marker in value for marker in "*?"): + return False + parts = PurePosixPath(value).parts + return ".." not in parts and all(part not in {"", "/"} for part in parts) + + +def _safe_branch_name(value: Any) -> bool: + if not _nonempty(value) or len(value) > 255: + return False + if value == "@" or value.startswith(("/", ".", "-")): + return False + if value.endswith(("/", ".", ".lock")): + return False + if "@{" in value or ".." in value or "//" in value: + return False + return re.search(r"[\x00-\x20\x7f~^:?*\[\\]", value) is None + + +def _validate_path_binding( + project_root: Path, + relative_path: str, + where: str, + *, + expected: str, +) -> list[str]: + errors: list[str] = [] + current = project_root + parts = PurePosixPath(relative_path).parts + if relative_path == ".": + parts = () + for index, part in enumerate(parts): + current = current / part + try: + metadata = current.lstat() + except FileNotFoundError: + return [f"{where}: 路径不存在: {relative_path!r}"] + except OSError as exc: + return [f"{where}: 路径不可访问: {relative_path!r}: {exc}"] + if stat.S_ISLNK(metadata.st_mode): + return [f"{where}: 路径不能包含 symlink: {relative_path!r}"] + if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode): + return [f"{where}: 中间路径不是目录: {relative_path!r}"] + + try: + metadata + except UnboundLocalError: + metadata = project_root.lstat() + if expected == "directory" and not stat.S_ISDIR(metadata.st_mode): + errors.append(f"{where}: 必须指向目录: {relative_path!r}") + if expected == "executable": + if not stat.S_ISREG(metadata.st_mode): + errors.append(f"{where}: 必须指向普通文件: {relative_path!r}") + elif metadata.st_mode & 0o111 == 0: + errors.append(f"{where}: 脚本不可执行: {relative_path!r}") + return errors + + +def _scan_secrets(value: Any, where: str, errors: list[str]) -> None: + if isinstance(value, dict): + for key, item in value.items(): + _scan_secrets(item, f"{where}.{key}", errors) + return + if isinstance(value, list): + for index, item in enumerate(value): + _scan_secrets(item, f"{where}[{index}]", errors) + return + if not isinstance(value, str): + return + for label, pattern in SECRET_PATTERNS: + if pattern.search(value): + errors.append(f"{where}: 疑似包含敏感信息({label})") + + +def _validate_ids(values: Any, where: str, errors: list[str]) -> dict[str, Any]: + if not isinstance(values, dict): + errors.append(f"{where}: 必须是对象") + return {} + for key in values: + if not isinstance(key, str) or ID_RE.fullmatch(key) is None: + errors.append(f"{where}: ID {key!r} 必须使用小写连字符格式") + return values + + +def _validate_entrypoints( + values: dict[str, Any], + errors: list[str], + project_root: Path | None, +) -> None: + for entrypoint_id, value in values.items(): + where = f"entrypoints.{entrypoint_id}" + if not _mapping(value): + errors.append(f"{where}: 必须是对象") + continue + _reject_unknown(value, ENTRYPOINT_FIELDS, where, errors) + kind = value.get("kind") + if kind not in ENTRYPOINT_KINDS: + errors.append(f"{where}.kind: 必须是 {sorted(ENTRYPOINT_KINDS)}") + required_selector = { + "make": "target", + "just": "target", + "task": "target", + "dagger": "function", + "script": "path", + }.get(kind) + for selector in ("target", "function", "path"): + if selector == required_selector: + if not _nonempty(value.get(selector)): + errors.append(f"{where}.{selector}: {kind} 入口必须填写非空值") + elif selector in value: + errors.append(f"{where}.{selector}: kind={kind!r} 不允许此字段") + + args = value.get("args") + if not isinstance(args, list) or any(not isinstance(item, str) for item in args): + errors.append(f"{where}.args: 必须是字符串列表") + required_secrets = value.get("requiredSecrets") + if ( + not isinstance(required_secrets, list) + or any( + not isinstance(item, str) or SECRET_NAME_RE.fullmatch(item) is None + for item in required_secrets + ) + or ( + isinstance(required_secrets, list) + and len(required_secrets) != len(set(required_secrets)) + ) + ): + errors.append( + f"{where}.requiredSecrets: 必须是唯一的大写 secret 名称列表" + ) + working_directory = value.get("workingDirectory") + if not _safe_relative_path(working_directory): + errors.append(f"{where}.workingDirectory: 必须是安全的仓库内相对路径") + timeout = value.get("timeoutSeconds") + if ( + not isinstance(timeout, int) + or isinstance(timeout, bool) + or not 1 <= timeout <= 86400 + ): + errors.append(f"{where}.timeoutSeconds: 必须是 1..86400 的整数") + + if kind == "script" and not _safe_relative_path(value.get("path")): + errors.append(f"{where}.path: 必须是安全的仓库内相对路径") + if project_root is not None: + if _safe_relative_path(working_directory): + errors.extend( + _validate_path_binding( + project_root, + working_directory, + f"{where}.workingDirectory", + expected="directory", + ) + ) + if kind == "script" and _safe_relative_path(value.get("path")): + errors.extend( + _validate_path_binding( + project_root, + value["path"], + f"{where}.path", + expected="executable", + ) + ) + + +def _validate_artifacts( + values: dict[str, Any], + entrypoints: dict[str, Any], + errors: list[str], +) -> None: + for artifact_id, value in values.items(): + where = f"artifacts.{artifact_id}" + if not _mapping(value): + errors.append(f"{where}: 必须是对象") + continue + _reject_unknown(value, ARTIFACT_FIELDS, where, errors) + artifact_type = value.get("type") + if artifact_type not in ARTIFACT_TYPES: + errors.append(f"{where}.type: 必须是 {sorted(ARTIFACT_TYPES)}") + build = value.get("build") + if build not in entrypoints: + errors.append(f"{where}.build: 未定义 entrypoint {build!r}") + outputs = value.get("outputs") + if artifact_type in {"deb", "file"}: + if ( + not isinstance(outputs, list) + or not outputs + or any(not _safe_relative_path(item, allow_glob=True) for item in outputs) + ): + errors.append(f"{where}.outputs: deb/file 必须填写安全的产物路径列表") + if "image" in value or "platforms" in value: + errors.append(f"{where}: deb/file 不允许 image 或 platforms") + if artifact_type == "oci-image": + if not _nonempty(value.get("image")): + errors.append(f"{where}.image: oci-image 必须填写镜像名") + platforms = value.get("platforms") + if ( + not isinstance(platforms, list) + or not platforms + or any(not isinstance(item, str) or PLATFORM_RE.fullmatch(item) is None for item in platforms) + or len(platforms) != len(set(platforms)) + ): + errors.append(f"{where}.platforms: 必须是唯一的 os/arch 列表") + if "outputs" in value: + errors.append(f"{where}: oci-image 不允许 outputs") + + +def _validate_destinations( + values: dict[str, Any], + entrypoints: dict[str, Any], + errors: list[str], +) -> None: + for destination_id, value in values.items(): + where = f"destinations.{destination_id}" + if not _mapping(value): + errors.append(f"{where}: 必须是对象") + continue + _reject_unknown(value, DESTINATION_FIELDS, where, errors) + destination_type = value.get("type") + if destination_type not in DESTINATION_TYPES: + errors.append(f"{where}.type: 必须是 {sorted(DESTINATION_TYPES)}") + type_fields = { + "apt-repository": {"endpoint", "repository"}, + "oci-registry": {"registry", "repository"}, + "ci-artifact": {"artifactName"}, + }.get(destination_type, set()) + allowed_fields = {"type", "channel", "upload"} | type_fields + for field in sorted(set(value) - allowed_fields): + errors.append(f"{where}.{field}: type={destination_type!r} 不允许此字段") + if value.get("channel") not in CHANNELS: + errors.append(f"{where}.channel: 必须是 {sorted(CHANNELS)}") + upload = value.get("upload") + if upload is not None and upload not in entrypoints: + errors.append(f"{where}.upload: 未定义 entrypoint {upload!r}") + if destination_type == "apt-repository": + if not _nonempty(value.get("endpoint")): + errors.append(f"{where}.endpoint: APT 目标必须填写服务地址") + if not _nonempty(value.get("repository")): + errors.append(f"{where}.repository: APT 目标必须填写仓库名") + if destination_type == "oci-registry": + for field in ("registry", "repository"): + if not _nonempty(value.get(field)): + errors.append(f"{where}.{field}: OCI 目标必须填写非空值") + if destination_type == "ci-artifact" and not _nonempty(value.get("artifactName")): + errors.append(f"{where}.artifactName: CI artifact 必须填写名称") + + +def _validate_environments( + values: dict[str, Any], + entrypoints: dict[str, Any], + errors: list[str], +) -> None: + for environment_id, value in values.items(): + where = f"environments.{environment_id}" + if not _mapping(value): + errors.append(f"{where}: 必须是对象") + continue + _reject_unknown(value, ENVIRONMENT_FIELDS, where, errors) + if value.get("type") not in ENVIRONMENT_TYPES: + errors.append(f"{where}.type: 必须是 {sorted(ENVIRONMENT_TYPES)}") + classification = value.get("classification") + if classification not in CLASSIFICATIONS: + errors.append(f"{where}.classification: 必须是 {sorted(CLASSIFICATIONS)}") + if not _nonempty(value.get("target")): + errors.append(f"{where}.target: 必须是非空目标别名") + for field in ("deploy", "healthCheck"): + reference = value.get(field) + if reference not in entrypoints: + errors.append(f"{where}.{field}: 未定义 entrypoint {reference!r}") + rollback = value.get("rollback") + if rollback is not None and rollback not in entrypoints: + errors.append(f"{where}.rollback: 未定义 entrypoint {rollback!r}") + if classification == "production" and rollback is None: + errors.append(f"{where}.rollback: production 环境必须提供回滚入口") + if not _nonempty(value.get("mutex")): + errors.append(f"{where}.mutex: 必须填写部署互斥锁 ID") + + +def _artifact_destination_compatible(artifact_type: str, destination_type: str) -> bool: + return destination_type in { + "deb": {"apt-repository", "ci-artifact"}, + "oci-image": {"oci-registry", "ci-artifact"}, + "file": {"ci-artifact"}, + }.get(artifact_type, set()) + + +def _validate_profiles( + values: dict[str, Any], + *, + default_profile: Any, + entrypoints: dict[str, Any], + artifacts: dict[str, Any], + destinations: dict[str, Any], + environments: dict[str, Any], + errors: list[str], +) -> None: + for profile_id, value in values.items(): + where = f"profiles.{profile_id}" + if not _mapping(value): + errors.append(f"{where}: 必须是对象") + continue + _reject_unknown(value, PROFILE_FIELDS, where, errors) + stop_at = value.get("stopAt") + if stop_at not in STOP_POINTS: + errors.append(f"{where}.stopAt: 必须是 {sorted(STOP_POINTS)}") + steps = value.get("steps") + if not isinstance(steps, list): + errors.append(f"{where}.steps: 必须是列表") + continue + + seen_step_ids: set[str] = set() + built_artifacts: set[str] = set() + published_artifacts: set[str] = set() + deployed_environments: set[str] = set() + approvals: set[str] = set() + has_pull_request = False + has_mark_ready = False + + for index, step in enumerate(steps): + step_where = f"{where}.steps[{index}]" + if not _mapping(step): + errors.append(f"{step_where}: 必须是对象") + continue + _reject_unknown(step, STEP_FIELDS, step_where, errors) + step_id = step.get("id") + if not isinstance(step_id, str) or ID_RE.fullmatch(step_id) is None: + errors.append(f"{step_where}.id: 必须使用小写连字符格式") + elif step_id in seen_step_ids: + errors.append(f"{step_where}.id: 不能重复 {step_id!r}") + else: + seen_step_ids.add(step_id) + + action = step.get("action") + if action not in ACTIONS: + errors.append(f"{step_where}.action: 必须是 {sorted(ACTIONS)}") + continue + required_fields = ACTION_FIELDS[action] + for field in sorted(required_fields): + if field not in step: + errors.append(f"{step_where}.{field}: action={action!r} 时必填") + allowed_fields = {"id", "action"} | required_fields + for field in sorted(set(step) - allowed_fields): + errors.append(f"{step_where}.{field}: action={action!r} 不允许此字段") + + if action == "verify" and step.get("entrypoint") not in entrypoints: + errors.append( + f"{step_where}.entrypoint: 未定义 entrypoint {step.get('entrypoint')!r}" + ) + if action == "pull-request": + if not isinstance(step.get("draft"), bool): + errors.append(f"{step_where}.draft: 必须是布尔值") + if ( + not isinstance(step.get("remote"), str) + or REMOTE_RE.fullmatch(step["remote"]) is None + ): + errors.append(f"{step_where}.remote: 必须是安全的 Git remote 名称") + if not _safe_branch_name(step.get("baseBranch")): + errors.append(f"{step_where}.baseBranch: 必须是安全的 Git 分支名") + has_pull_request = True + if action == "build": + artifact_id = step.get("artifact") + if artifact_id not in artifacts: + errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}") + else: + built_artifacts.add(artifact_id) + if action == "publish": + artifact_id = step.get("artifact") + destination_id = step.get("destination") + if artifact_id not in artifacts: + errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}") + elif artifact_id not in built_artifacts: + errors.append(f"{step_where}: publish 前必须先 build {artifact_id!r}") + if destination_id not in destinations: + errors.append( + f"{step_where}.destination: 未定义 destination {destination_id!r}" + ) + elif artifact_id in artifacts: + artifact_type = artifacts[artifact_id].get("type") + destination_type = destinations[destination_id].get("type") + if not _artifact_destination_compatible(artifact_type, destination_type): + errors.append( + f"{step_where}: artifact {artifact_type!r} 不能发布到 " + f"{destination_type!r}" + ) + if destinations[destination_id].get("channel") == "stable" and "release" not in approvals: + errors.append(f"{step_where}: stable 发布前必须有 release approval") + published_artifacts.add(artifact_id) + if action == "deploy": + artifact_id = step.get("artifact") + environment_id = step.get("environment") + if artifact_id not in artifacts: + errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}") + elif artifact_id not in built_artifacts: + errors.append(f"{step_where}: deploy 前必须先 build {artifact_id!r}") + if environment_id not in environments: + errors.append( + f"{step_where}.environment: 未定义 environment {environment_id!r}" + ) + else: + classification = environments[environment_id].get("classification") + if classification == "production" and "production" not in approvals: + errors.append(f"{step_where}: production 部署前必须有 production approval") + if ( + classification == "production" + and artifact_id not in published_artifacts + ): + errors.append(f"{step_where}: production 部署前必须先 publish 同一产物") + deployed_environments.add(environment_id) + if action == "health-check": + environment_id = step.get("environment") + if environment_id not in environments: + errors.append( + f"{step_where}.environment: 未定义 environment {environment_id!r}" + ) + elif environment_id not in deployed_environments: + errors.append( + f"{step_where}: health-check 前必须先 deploy {environment_id!r}" + ) + if action == "approval": + gate = step.get("gate") + if gate not in {"release", "production"}: + errors.append(f"{step_where}.gate: 必须是 release/production") + else: + if gate == "release" and not built_artifacts: + errors.append(f"{step_where}: release approval 前必须先 build 产物") + if gate == "production" and not published_artifacts: + errors.append(f"{step_where}: production approval 前必须先 publish 产物") + approvals.add(gate) + if action == "mark-ready": + if not has_pull_request: + errors.append(f"{step_where}: mark-ready 前必须先创建 pull-request") + has_mark_ready = True + if index != len(steps) - 1: + errors.append(f"{step_where}: mark-ready 必须是 profile 最后一步") + + if stop_at in {"review_ready", "released"} and ( + not has_pull_request or not has_mark_ready + ): + errors.append( + f"{where}: {stop_at} 必须包含 pull-request 和末尾 mark-ready" + ) + if stop_at == "released" and not ({"release", "production"} & approvals): + errors.append(f"{where}: released profile 必须包含 release 或 production approval") + + if profile_id == default_profile: + if stop_at != "review_ready": + errors.append(f"{where}: defaultProfile 必须停在 review_ready") + used_destinations = { + step.get("destination") + for step in steps + if isinstance(step, dict) and step.get("action") == "publish" + } + used_environments = { + step.get("environment") + for step in steps + if isinstance(step, dict) and step.get("action") == "deploy" + } + if any( + destinations.get(item, {}).get("channel") == "stable" + for item in used_destinations + ): + errors.append(f"{where}: defaultProfile 不能发布 stable 目标") + if any( + environments.get(item, {}).get("classification") == "production" + for item in used_environments + ): + errors.append(f"{where}: defaultProfile 不能部署 production 环境") + + +def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> 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 "updatedAt" in data and not _nonempty(data.get("updatedAt")): + errors.append("updatedAt 必须是非空字符串") + project = data.get("project") + if not _mapping(project): + errors.append("project 必须是对象") + project = {} + else: + _reject_unknown(project, {"name"}, "project", errors) + if not _nonempty(project.get("name")): + errors.append("project.name 必须是非空字符串") + + enabled = data.get("enabled") + if not isinstance(enabled, bool): + errors.append("enabled 必须是布尔值") + default_profile = data.get("defaultProfile") + if default_profile is not None and ( + not isinstance(default_profile, str) or ID_RE.fullmatch(default_profile) is None + ): + errors.append("defaultProfile 必须是 null 或小写连字符 ID") + + entrypoints = _validate_ids(data.get("entrypoints"), "entrypoints", errors) + artifacts = _validate_ids(data.get("artifacts"), "artifacts", errors) + destinations = _validate_ids(data.get("destinations"), "destinations", errors) + environments = _validate_ids(data.get("environments"), "environments", errors) + profiles = _validate_ids(data.get("profiles"), "profiles", errors) + + _validate_entrypoints(entrypoints, errors, project_root) + _validate_artifacts(artifacts, entrypoints, errors) + _validate_destinations(destinations, entrypoints, errors) + _validate_environments(environments, entrypoints, errors) + _validate_profiles( + profiles, + default_profile=default_profile, + entrypoints=entrypoints, + artifacts=artifacts, + destinations=destinations, + environments=environments, + errors=errors, + ) + + if enabled: + if default_profile not in profiles: + errors.append("enabled=true 时 defaultProfile 必须引用已定义 profile") + elif not profiles[default_profile].get("steps"): + errors.append("enabled=true 时 defaultProfile.steps 不能为空") + elif default_profile is not None and default_profile not in profiles: + errors.append("defaultProfile 必须引用已定义 profile") + + _scan_secrets(data, "", errors) + return errors + + +def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list[str]: + errors: list[str] = [] + project = tasks.get("project") + if not isinstance(project, dict): + return ["tasks.project 必须是对象"] + if project.get("deliveryFile") != "docs/ack/delivery.yaml": + errors.append("tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml") + delivery_project = delivery.get("project") + if ( + isinstance(delivery_project, dict) + and _nonempty(delivery_project.get("name")) + and _nonempty(project.get("name")) + and delivery_project["name"] != project["name"] + ): + errors.append("delivery.project.name 必须与 tasks.project.name 一致") + if not isinstance(tasks.get("deliveryRuns"), list): + errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表") + 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 main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约") + parser.add_argument("delivery", nargs="?", default="docs/ack/delivery.yaml") + parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml") + parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径") + parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)") + args = parser.parse_args(argv) + + delivery_path = Path(args.delivery) + if not delivery_path.is_file(): + sys.stderr.write(f"找不到交付契约: {delivery_path}\n") + return 2 + project_root = Path(args.project_root).resolve() if args.project_root else None + if project_root is not None and not project_root.is_dir(): + sys.stderr.write(f"项目根目录不存在: {project_root}\n") + return 2 + + delivery = _load_document(delivery_path, "交付契约") + errors = validate_builtin(delivery, project_root) + + 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_document(tasks_path, "任务板") + errors.extend(validate_tasks_link(delivery, tasks)) + + schema_path = ( + Path(args.schema) + if args.schema + else Path(__file__).resolve().parent.parent / "templates" / "delivery.schema.json" + ) + if args.schema and not schema_path.is_file(): + sys.stderr.write(f"找不到 schema: {schema_path}\n") + return 2 + if schema_path.is_file(): + try: + errors.extend(validate_with_schema(delivery, schema_path)) + except ImportError: + sys.stderr.write("提示: 未安装 jsonschema,仅执行内置语义规则\n") + except (OSError, json.JSONDecodeError) as exc: + sys.stderr.write(f"schema 读取失败: {exc}\n") + return 2 + + if errors: + for error in errors: + sys.stderr.write(f"- {error}\n") + return 1 + + sys.stdout.write("交付契约校验通过\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 c429067..bef7659 100755 --- a/skills/ack/scripts/validate_tasks.py +++ b/skills/ack/scripts/validate_tasks.py @@ -49,6 +49,9 @@ KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"} 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}$") +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( r"^(0|[1-9][0-9]*)\." r"(0|[1-9][0-9]*)\." @@ -86,6 +89,30 @@ KNOWLEDGE_CHECK_FIELDS = { "checkedBy", "checkedAt", } +DELIVERY_RUN_FIELDS = { + "id", + "profile", + "taskIds", + "status", + "sourceRevision", + "configRevision", + "pullRequest", + "artifacts", + "deployments", + "evidence", + "updatedAt", +} +DELIVERY_STATUSES = { + "planned", + "running", + "blocked", + "failed", + "review_ready", + "released", + "skipped", +} +DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"} +DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"} DISPATCH_FIELDS = { "taskId", "dispatchId", @@ -392,6 +419,162 @@ def validate_knowledge_fields( errors.append(f"{check_where}: verified 任务不能保留失败的知识检查") +def validate_delivery_runs( + value: object, + task_statuses: dict[str, object], + errors: list[str], +) -> None: + if not isinstance(value, list): + errors.append("deliveryRuns 必须是列表") + return + + seen_run_ids: set[str] = set() + for index, run in enumerate(value): + where = f"deliveryRuns[{index}]" + if not isinstance(run, dict): + errors.append(f"{where}: 必须是对象") + continue + reject_unknown_fields(run, DELIVERY_RUN_FIELDS, where, errors) + missing = sorted(DELIVERY_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 DELIVERY_RUN_ID_RE.fullmatch(run_id) is None: + errors.append(f"{where}.id: 必须使用 DR- 格式") + elif run_id in seen_run_ids: + errors.append(f"{where}.id: 不能重复 {run_id!r}") + else: + seen_run_ids.add(run_id) + + profile = run.get("profile") + if not isinstance(profile, str) or DELIVERY_PROFILE_RE.fullmatch(profile) is None: + errors.append(f"{where}.profile: 必须使用小写连字符 ID") + status = run.get("status") + if status not in DELIVERY_STATUSES: + errors.append(f"{where}.status: 必须是 {sorted(DELIVERY_STATUSES)}") + + task_ids = run.get("taskIds") + if ( + not isinstance(task_ids, list) + or not task_ids + or any(not _nonempty_string(task_id) for task_id in task_ids) + ): + errors.append(f"{where}.taskIds: 必须是非空任务 ID 列表") + task_ids = [] + elif len(task_ids) != len(set(task_ids)): + errors.append(f"{where}.taskIds: 不能包含重复值") + for task_id in task_ids: + if task_id not in task_statuses: + errors.append(f"{where}.taskIds: 未知任务 {task_id!r}") + elif task_statuses[task_id] != "verified": + errors.append( + f"{where}: delivery run 只能引用 verified 任务," + f"{task_id!r} 当前是 {task_statuses[task_id]!r}" + ) + + 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") + pull_request = run.get("pullRequest") + if pull_request is not None and not isinstance(pull_request, str): + errors.append(f"{where}.pullRequest: 必须是字符串或 null") + if status != "skipped": + for field in ("sourceRevision", "configRevision"): + if not _nonempty_string(run.get(field)): + errors.append(f"{where}.{field}: status={status!r} 时必须填写") + if status in {"review_ready", "released"}: + if not _nonempty_string(run.get("pullRequest")): + errors.append(f"{where}.pullRequest: status={status!r} 时必须填写") + + artifacts = run.get("artifacts") + if not isinstance(artifacts, list): + errors.append(f"{where}.artifacts: 必须是列表") + else: + seen_artifacts: set[str] = set() + for artifact_index, artifact in enumerate(artifacts): + artifact_where = f"{where}.artifacts[{artifact_index}]" + if not isinstance(artifact, dict): + errors.append(f"{artifact_where}: 必须是对象") + continue + reject_unknown_fields( + artifact, + DELIVERY_ARTIFACT_FIELDS, + artifact_where, + errors, + ) + artifact_id = artifact.get("id") + if ( + not isinstance(artifact_id, str) + or DELIVERY_PROFILE_RE.fullmatch(artifact_id) is None + ): + errors.append(f"{artifact_where}.id: 必须使用小写连字符 ID") + elif artifact_id in seen_artifacts: + errors.append(f"{artifact_where}.id: 不能重复 {artifact_id!r}") + else: + seen_artifacts.add(artifact_id) + if artifact.get("type") not in {"deb", "oci-image", "file"}: + errors.append(f"{artifact_where}.type: 必须是 deb/oci-image/file") + if not _nonempty_string(artifact.get("reference")): + errors.append(f"{artifact_where}.reference: 必须是非空字符串") + digest = artifact.get("digest") + if digest is not None and ( + not isinstance(digest, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None + ): + errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null") + if status in {"review_ready", "released"} and not _nonempty_string(digest): + errors.append( + f"{artifact_where}.digest: status={status!r} 时必须填写" + ) + + deployments = run.get("deployments") + if not isinstance(deployments, list): + errors.append(f"{where}.deployments: 必须是列表") + else: + seen_environments: set[str] = set() + for deployment_index, deployment in enumerate(deployments): + deployment_where = f"{where}.deployments[{deployment_index}]" + if not isinstance(deployment, dict): + errors.append(f"{deployment_where}: 必须是对象") + continue + reject_unknown_fields( + deployment, + DELIVERY_DEPLOYMENT_FIELDS, + deployment_where, + errors, + ) + environment = deployment.get("environment") + if ( + not isinstance(environment, str) + or DELIVERY_PROFILE_RE.fullmatch(environment) is None + ): + errors.append(f"{deployment_where}.environment: 必须使用小写连字符 ID") + elif environment in seen_environments: + errors.append(f"{deployment_where}.environment: 不能重复 {environment!r}") + else: + seen_environments.add(environment) + if deployment.get("result") not in {"succeeded", "failed", "rolled_back"}: + errors.append( + f"{deployment_where}.result: 必须是 succeeded/failed/rolled_back" + ) + if not _nonempty_string(deployment.get("evidence")): + errors.append(f"{deployment_where}.evidence: 必须是非空字符串") + + evidence = run.get("evidence") + if not isinstance(evidence, list) or any( + not _nonempty_string(item) for item in evidence + ): + errors.append(f"{where}.evidence: 必须是字符串列表") + elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence: + errors.append(f"{where}.evidence: status={status!r} 时不能为空") + 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 @@ -468,7 +651,7 @@ def validate_builtin(data: dict) -> list[str]: errors.append("project.name 必须是非空字符串") validate_string_fields( project, - {"repoPath", "baseUrl", "devWorktree", "overlayFile"}, + {"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"}, "project", ) if ( @@ -478,6 +661,17 @@ def validate_builtin(data: dict) -> list[str]: errors.append( "project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml" ) + if ( + "deliveryFile" in project + and project.get("deliveryFile") != "docs/ack/delivery.yaml" + ): + errors.append( + "project.deliveryFile 必须固定为 docs/ack/delivery.yaml" + ) + if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list): + errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表") + if "deliveryRuns" in data and "deliveryFile" not in project: + errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在") ack_version = data.get("ackVersion") version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None @@ -678,6 +872,14 @@ def validate_builtin(data: dict) -> list[str]: ): errors.append(f"{where}: leftover 必须填 resolution.leftoverReason") + if "deliveryRuns" in data: + task_statuses = { + task.get("id"): task.get("status") + for task in tasks + if isinstance(task, dict) and _nonempty_string(task.get("id")) + } + validate_delivery_runs(data["deliveryRuns"], task_statuses, errors) + return errors diff --git a/skills/ack/templates/delivery.schema.json b/skills/ack/templates/delivery.schema.json new file mode 100644 index 0000000..8f60300 --- /dev/null +++ b/skills/ack/templates/delivery.schema.json @@ -0,0 +1,228 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://git.yumee.top/laily/skills/skills/ack/templates/delivery.schema.json", + "title": "ACK project delivery contract", + "description": "docs/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。", + "type": "object", + "required": [ + "version", + "project", + "enabled", + "defaultProfile", + "entrypoints", + "artifacts", + "destinations", + "environments", + "profiles" + ], + "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" } + } + }, + "enabled": { "type": "boolean" }, + "defaultProfile": { + "type": ["string", "null"], + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "entrypoints": { + "type": "object", + "propertyNames": { "$ref": "#/definitions/id" }, + "additionalProperties": { "$ref": "#/definitions/entrypoint" } + }, + "artifacts": { + "type": "object", + "propertyNames": { "$ref": "#/definitions/id" }, + "additionalProperties": { "$ref": "#/definitions/artifact" } + }, + "destinations": { + "type": "object", + "propertyNames": { "$ref": "#/definitions/id" }, + "additionalProperties": { "$ref": "#/definitions/destination" } + }, + "environments": { + "type": "object", + "propertyNames": { "$ref": "#/definitions/id" }, + "additionalProperties": { "$ref": "#/definitions/environment" } + }, + "profiles": { + "type": "object", + "propertyNames": { "$ref": "#/definitions/id" }, + "additionalProperties": { "$ref": "#/definitions/profile" } + } + }, + "definitions": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/*?+-]+$" + }, + "stringList": { + "type": "array", + "items": { "type": "string" } + }, + "secretName": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,127}$" + }, + "entrypoint": { + "type": "object", + "required": [ + "kind", + "args", + "requiredSecrets", + "workingDirectory", + "timeoutSeconds" + ], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["make", "just", "task", "dagger", "script"] + }, + "target": { "type": "string", "minLength": 1 }, + "function": { "type": "string", "minLength": 1 }, + "path": { "$ref": "#/definitions/relativePath" }, + "args": { "$ref": "#/definitions/stringList" }, + "requiredSecrets": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/definitions/secretName" } + }, + "workingDirectory": { "$ref": "#/definitions/relativePath" }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + } + } + }, + "artifact": { + "type": "object", + "required": ["type", "build"], + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["deb", "oci-image", "file"] }, + "build": { "$ref": "#/definitions/id" }, + "outputs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/definitions/relativePath" } + }, + "image": { "type": "string", "minLength": 1 }, + "platforms": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9]+/[A-Za-z0-9._-]+$" } + } + } + }, + "destination": { + "type": "object", + "required": ["type", "channel"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["apt-repository", "oci-registry", "ci-artifact"] + }, + "channel": { + "type": "string", + "enum": ["preview", "staging", "stable"] + }, + "registry": { "type": "string", "minLength": 1 }, + "repository": { "type": "string", "minLength": 1 }, + "endpoint": { "type": "string", "minLength": 1 }, + "artifactName": { "type": "string", "minLength": 1 }, + "upload": { "$ref": "#/definitions/id" } + } + }, + "environment": { + "type": "object", + "required": [ + "type", + "classification", + "target", + "deploy", + "healthCheck", + "mutex" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["ssh-host", "docker-compose", "kubernetes", "custom"] + }, + "classification": { + "type": "string", + "enum": ["development", "staging", "production"] + }, + "target": { "type": "string", "minLength": 1 }, + "deploy": { "$ref": "#/definitions/id" }, + "healthCheck": { "$ref": "#/definitions/id" }, + "rollback": { "$ref": "#/definitions/id" }, + "mutex": { "type": "string", "minLength": 1 } + } + }, + "step": { + "type": "object", + "required": ["id", "action"], + "additionalProperties": false, + "properties": { + "id": { "$ref": "#/definitions/id" }, + "action": { + "type": "string", + "enum": [ + "verify", + "pull-request", + "build", + "publish", + "deploy", + "health-check", + "approval", + "mark-ready" + ] + }, + "entrypoint": { "$ref": "#/definitions/id" }, + "artifact": { "$ref": "#/definitions/id" }, + "destination": { "$ref": "#/definitions/id" }, + "environment": { "$ref": "#/definitions/id" }, + "gate": { "type": "string", "enum": ["release", "production"] }, + "draft": { "type": "boolean" }, + "remote": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + }, + "baseBranch": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + "profile": { + "type": "object", + "required": ["stopAt", "steps"], + "additionalProperties": false, + "properties": { + "stopAt": { + "type": "string", + "enum": ["verified", "review_ready", "released"] + }, + "steps": { + "type": "array", + "items": { "$ref": "#/definitions/step" } + } + } + } + } +} diff --git a/skills/ack/templates/delivery.template.yaml b/skills/ack/templates/delivery.template.yaml new file mode 100644 index 0000000..80ced57 --- /dev/null +++ b/skills/ack/templates/delivery.template.yaml @@ -0,0 +1,24 @@ +# 复制为 docs/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。 +version: 1 +updatedAt: "" +project: + name: "" + +enabled: false +defaultProfile: null + +# 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。 +# 不在此保存 shell、env 或凭据正文。 +entrypoints: {} + +# 支持 deb、oci-image 和 file。复杂构建逻辑留在项目已有入口中。 +artifacts: {} + +# 支持 apt-repository、oci-registry 和 ci-artifact。 +destinations: {} + +# 支持 ssh-host、docker-compose、kubernetes 和 custom。 +environments: {} + +# enabled=true 时,defaultProfile 必须指向一个非空 profile。 +profiles: {} diff --git a/skills/ack/templates/project.template.md b/skills/ack/templates/project.template.md index 88b053b..d1ce6c1 100644 --- a/skills/ack/templates/project.template.md +++ b/skills/ack/templates/project.template.md @@ -8,7 +8,8 @@ > 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK > 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 > 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。 -> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`, +> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的 +> `delivery.yaml`, > 不复制或链接 Skill。 ## 项目概览 @@ -19,6 +20,7 @@ - Base URL:`` - 任务板:`docs/ack/tasks.yaml` - 项目知识:`docs/ack/knowledge.yaml` +- 交付契约:`docs/ack/delivery.yaml`(默认关闭) - 覆盖层文件:``(默认 `docs/ack/project.md`) ## 通用规范(由 ACK Skill 按需读取) @@ -30,6 +32,7 @@ - 验收信号与三轮策略:`references/optimization-method.md` - 派发 prompt 模板:`references/prompt-templates.md` - Orca 编排命令(可选):`references/orca-adapter.md` +- 验证后交付与配置维护(可选):`references/delivery.md` ## Worker 路由 @@ -64,6 +67,7 @@ receipt 全部以 `docs/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 通过回报提名或验证 | +| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 | ## 命令(项目覆盖层) @@ -91,8 +95,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 `ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于 `ACK_PROJECT_ROOT_DISPLAY`。 -项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和 -`scripts/validate_knowledge.py` 执行。 +项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、 +`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。 +构建/发布/部署的机器入口以 `delivery.yaml` 为准;本文件不维护第二套交付命令。 ## 硬规则(其余见 references/) @@ -112,5 +117,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检 - Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报 `knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。 - ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。 +- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在 + 任务 `verified` 且本次 profile/目标/停止点得到确认后运行。 +- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有 + approval 步骤并再次获得明确批准。配置变更只影响下一次 run。 - 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。 -- 不提交或推送,除非用户明确要求。 +- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。 diff --git a/skills/ack/templates/tasks.schema.json b/skills/ack/templates/tasks.schema.json index 35ac4de..28554b3 100644 --- a/skills/ack/templates/tasks.schema.json +++ b/skills/ack/templates/tasks.schema.json @@ -37,6 +37,11 @@ "const": "docs/ack/knowledge.yaml", "description": "项目知识护栏库的唯一权威路径" }, + "deliveryFile": { + "type": "string", + "const": "docs/ack/delivery.yaml", + "description": "可选项目交付契约的唯一权威路径" + }, "orchestration": { "$ref": "#/definitions/orchestration" } @@ -57,6 +62,10 @@ "type": "array", "items": { "$ref": "#/definitions/workerReceipt" } }, + "deliveryRuns": { + "type": "array", + "items": { "$ref": "#/definitions/deliveryRun" } + }, "tasks": { "type": "array", "items": { "$ref": "#/definitions/task" } @@ -128,6 +137,26 @@ } } } + }, + { + "if": { + "properties": { + "project": { + "type": "object", + "required": ["deliveryFile"], + "properties": { + "deliveryFile": {} + } + } + }, + "required": ["project"] + }, + "then": { + "required": ["deliveryRuns"], + "properties": { + "deliveryRuns": {} + } + } } ], "definitions": { @@ -721,6 +750,165 @@ "checkedAt": { "type": "string" } } }, + "deliveryArtifactEvidence": { + "type": "object", + "required": ["id", "type", "reference"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "type": { "type": "string", "enum": ["deb", "oci-image", "file"] }, + "reference": { "type": "string", "minLength": 1 }, + "digest": { + "type": ["string", "null"], + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + }, + "deliveryDeploymentEvidence": { + "type": "object", + "required": ["environment", "result", "evidence"], + "additionalProperties": false, + "properties": { + "environment": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "result": { + "type": "string", + "enum": ["succeeded", "failed", "rolled_back"] + }, + "evidence": { "type": "string", "minLength": 1 } + } + }, + "deliveryRun": { + "type": "object", + "required": [ + "id", + "profile", + "taskIds", + "status", + "sourceRevision", + "configRevision", + "pullRequest", + "artifacts", + "deployments", + "evidence", + "updatedAt" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "profile": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "taskIds": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "status": { + "type": "string", + "enum": [ + "planned", + "running", + "blocked", + "failed", + "review_ready", + "released", + "skipped" + ] + }, + "sourceRevision": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{7,64}$" + }, + "configRevision": { + "type": ["string", "null"], + "pattern": "^[0-9a-f]{7,64}$" + }, + "pullRequest": { "type": ["string", "null"] }, + "artifacts": { + "type": "array", + "items": { "$ref": "#/definitions/deliveryArtifactEvidence" } + }, + "deployments": { + "type": "array", + "items": { "$ref": "#/definitions/deliveryDeploymentEvidence" } + }, + "evidence": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "updatedAt": { "type": "string", "minLength": 1 } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "enum": [ + "planned", + "running", + "blocked", + "failed", + "review_ready", + "released" + ] + } + }, + "required": ["status"] + }, + "then": { + "properties": { + "sourceRevision": { "type": "string" }, + "configRevision": { "type": "string" } + } + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "blocked", + "failed", + "review_ready", + "released", + "skipped" + ] + } + }, + "required": ["status"] + }, + "then": { + "properties": { + "evidence": { "minItems": 1 } + } + } + }, + { + "if": { + "properties": { + "status": { "enum": ["review_ready", "released"] } + }, + "required": ["status"] + }, + "then": { + "properties": { + "sourceRevision": { "type": "string" }, + "configRevision": { "type": "string" }, + "pullRequest": { "type": "string", "minLength": 1 }, + "evidence": { "minItems": 1 } + } + } + } + ] + }, "task": { "type": "object", "required": ["id", "title", "status"], diff --git a/skills/ack/templates/tasks.template.yaml b/skills/ack/templates/tasks.template.yaml index 755a703..371486b 100644 --- a/skills/ack/templates/tasks.template.yaml +++ b/skills/ack/templates/tasks.template.yaml @@ -10,6 +10,7 @@ project: devWorktree: "" overlayFile: "docs/ack/project.md" knowledgeFile: "docs/ack/knowledge.yaml" + deliveryFile: "docs/ack/delivery.yaml" orchestration: profileVersion: 1 mode: "orca" @@ -69,6 +70,7 @@ project: developerUpgraded: "codex-dev-strong" workerReceipts: [] +deliveryRuns: [] summary: verified: [] diff --git a/tests/test_ack_delivery.py b/tests/test_ack_delivery.py new file mode 100644 index 0000000..3555cf1 --- /dev/null +++ b/tests/test_ack_delivery.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import copy +import os +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_delivery.py" +EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "delivery.example.yaml" +sys.path.insert(0, str(SCRIPTS_DIR)) + +import validate_delivery # noqa: E402 + + +def valid_contract() -> dict: + entrypoints = { + name: { + "kind": "make", + "target": name, + "args": [], + "requiredSecrets": [], + "workingDirectory": ".", + "timeoutSeconds": 300, + } + for name in ("verify", "build", "upload", "deploy", "health", "rollback") + } + return { + "version": 1, + "updatedAt": "2026-08-01T10:00:00+08:00", + "project": {"name": "demo"}, + "enabled": True, + "defaultProfile": "review", + "entrypoints": entrypoints, + "artifacts": { + "service-deb": { + "type": "deb", + "build": "build", + "outputs": ["dist/*.deb"], + } + }, + "destinations": { + "preview-apt": { + "type": "apt-repository", + "channel": "preview", + "endpoint": "https://packages.example.com", + "repository": "testing", + "upload": "upload", + } + }, + "environments": { + "test-server": { + "type": "ssh-host", + "classification": "development", + "target": "test-server", + "deploy": "deploy", + "healthCheck": "health", + "rollback": "rollback", + "mutex": "test-server-deploy", + } + }, + "profiles": { + "review": { + "stopAt": "review_ready", + "steps": [ + {"id": "verify", "action": "verify", "entrypoint": "verify"}, + { + "id": "open-pr", + "action": "pull-request", + "draft": True, + "remote": "origin", + "baseBranch": "main", + }, + {"id": "build", "action": "build", "artifact": "service-deb"}, + { + "id": "publish", + "action": "publish", + "artifact": "service-deb", + "destination": "preview-apt", + }, + { + "id": "deploy", + "action": "deploy", + "artifact": "service-deb", + "environment": "test-server", + }, + { + "id": "health", + "action": "health-check", + "environment": "test-server", + }, + {"id": "ready", "action": "mark-ready"}, + ], + } + }, + } + + +class AckDeliveryValidationTests(unittest.TestCase): + def test_example_is_valid_with_and_without_site_packages(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) + + def test_default_profile_rejects_stable_and_production_targets(self) -> None: + contract = valid_contract() + contract["destinations"]["preview-apt"]["channel"] = "stable" + contract["environments"]["test-server"]["classification"] = "production" + + errors = validate_delivery.validate_builtin(contract) + + self.assertTrue(any("stable 发布前必须有 release approval" in item for item in errors)) + self.assertTrue(any("production 部署前必须有 production approval" in item for item in errors)) + self.assertTrue(any("defaultProfile 不能发布 stable" in item for item in errors)) + self.assertTrue(any("defaultProfile 不能部署 production" in item for item in errors)) + + def test_nondefault_release_profile_supports_stable_and_production_with_gates(self) -> None: + contract = valid_contract() + contract["destinations"]["stable-apt"] = { + "type": "apt-repository", + "channel": "stable", + "endpoint": "https://packages.example.com", + "repository": "stable", + "upload": "upload", + } + contract["environments"]["prod-server"] = { + "type": "ssh-host", + "classification": "production", + "target": "prod-server", + "deploy": "deploy", + "healthCheck": "health", + "rollback": "rollback", + "mutex": "prod-server-deploy", + } + contract["profiles"]["release"] = { + "stopAt": "released", + "steps": [ + {"id": "verify-release", "action": "verify", "entrypoint": "verify"}, + { + "id": "open-release-pr", + "action": "pull-request", + "draft": True, + "remote": "origin", + "baseBranch": "main", + }, + {"id": "build-release", "action": "build", "artifact": "service-deb"}, + {"id": "approve-release", "action": "approval", "gate": "release"}, + { + "id": "publish-release", + "action": "publish", + "artifact": "service-deb", + "destination": "stable-apt", + }, + {"id": "approve-production", "action": "approval", "gate": "production"}, + { + "id": "deploy-production", + "action": "deploy", + "artifact": "service-deb", + "environment": "prod-server", + }, + { + "id": "health-production", + "action": "health-check", + "environment": "prod-server", + }, + {"id": "ready-release", "action": "mark-ready"}, + ], + } + + self.assertEqual(validate_delivery.validate_builtin(contract), []) + + def test_publish_and_health_check_require_prior_steps(self) -> None: + contract = valid_contract() + steps = contract["profiles"]["review"]["steps"] + steps[2], steps[3] = steps[3], steps[2] + steps[4], steps[5] = steps[5], steps[4] + + errors = validate_delivery.validate_builtin(contract) + + self.assertTrue(any("publish 前必须先 build" in item for item in errors)) + self.assertTrue(any("health-check 前必须先 deploy" in item for item in errors)) + + def test_pull_request_requires_explicit_safe_remote_and_base(self) -> None: + contract = valid_contract() + step = contract["profiles"]["review"]["steps"][1] + del step["remote"] + step["baseBranch"] = "../main" + + errors = validate_delivery.validate_builtin(contract) + + self.assertTrue(any(".remote: action='pull-request' 时必填" in item for item in errors)) + self.assertTrue(any(".remote: 必须是安全的 Git remote 名称" in item for item in errors)) + self.assertTrue(any(".baseBranch: 必须是安全的 Git 分支名" in item for item in errors)) + + def test_unknown_shell_and_inline_secret_are_rejected(self) -> None: + contract = valid_contract() + contract["entrypoints"]["verify"]["shell"] = "make verify" + contract["entrypoints"]["verify"]["requiredSecrets"] = ["token-value"] + contract["destinations"]["preview-apt"]["repository"] = ( + "token=abcdefghijklmnop" + ) + contract["destinations"]["preview-apt"]["registry"] = "unexpected.example" + + errors = validate_delivery.validate_builtin(contract) + + self.assertTrue(any("未知字段 'shell'" in item for item in errors)) + self.assertTrue(any("requiredSecrets" in item for item in errors)) + self.assertTrue(any("type='apt-repository' 不允许此字段" in item for item in errors)) + self.assertTrue(any("疑似包含敏感信息" in item for item in errors)) + + def test_project_script_must_be_executable_and_not_a_symlink(self) -> None: + contract = valid_contract() + contract["entrypoints"]["verify"] = { + "kind": "script", + "path": "scripts/verify.sh", + "args": [], + "requiredSecrets": [], + "workingDirectory": ".", + "timeoutSeconds": 300, + } + + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + scripts = root / "scripts" + scripts.mkdir() + target = scripts / "target.sh" + target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + target.chmod(0o755) + os.symlink("target.sh", scripts / "verify.sh") + + errors = validate_delivery.validate_builtin(contract, root) + self.assertTrue(any("路径不能包含 symlink" in item for item in errors)) + + (scripts / "verify.sh").unlink() + plain = scripts / "verify.sh" + plain.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + plain.chmod(0o644) + errors = validate_delivery.validate_builtin(contract, root) + self.assertTrue(any("脚本不可执行" in item for item in errors)) + + def test_tasks_link_requires_fixed_path_runs_and_same_project(self) -> None: + contract = valid_contract() + tasks = { + "project": {"name": "other", "deliveryFile": "delivery.yaml"}, + "tasks": [], + } + + errors = validate_delivery.validate_tasks_link(contract, tasks) + + self.assertIn( + "tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml", + errors, + ) + self.assertIn("delivery.project.name 必须与 tasks.project.name 一致", errors) + self.assertIn("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表", errors) + + def test_disabled_empty_contract_remains_valid(self) -> None: + contract = copy.deepcopy(valid_contract()) + contract.update( + { + "enabled": False, + "defaultProfile": None, + "entrypoints": {}, + "artifacts": {}, + "destinations": {}, + "environments": {}, + "profiles": {}, + } + ) + + self.assertEqual(validate_delivery.validate_builtin(contract), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index 591563d..20eac4b 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -16,9 +16,11 @@ class AckSkillContentTests(unittest.TestCase): "docs/ack/project.md", "docs/ack/tasks.yaml", "docs/ack/knowledge.yaml", + "docs/ack/delivery.yaml", "tasks: []", "validate_tasks.py", "validate_knowledge.py", + "validate_delivery.py", "select_knowledge.py", "references/kickoff.md", "不要修改项目的 `AGENTS.md`", @@ -56,9 +58,13 @@ class AckSkillContentTests(unittest.TestCase): "scripts/run_verification.py", "scripts/worker_profiles.py", "scripts/launch_worker.py", + "templates/delivery.template.yaml", + "templates/delivery.schema.json", + "examples/delivery.example.yaml", + "references/delivery.md", ): self.assertTrue((ack_dir / relative_path).is_file(), relative_path) - self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.10.0") + self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.11.0") self.assertIn( 'ackVersion: "<接入时的 ack skill 版本>"', (ack_dir / "templates" / "tasks.template.yaml").read_text( diff --git a/tests/test_ack_tasks_validation.py b/tests/test_ack_tasks_validation.py index 3e048f5..bb803f2 100644 --- a/tests/test_ack_tasks_validation.py +++ b/tests/test_ack_tasks_validation.py @@ -180,7 +180,7 @@ class AckTaskValidationTests(unittest.TestCase): with self.subTest(invalid_semver=invalid): self.assertIsNone(ack_pattern.fullmatch(invalid)) - current_gate, orchestration_gate, receipts_gate = schema["allOf"] + current_gate, orchestration_gate, receipts_gate, delivery_gate = schema["allOf"] current_pattern = re.compile( current_gate["if"]["properties"]["ackVersion"]["pattern"] ) @@ -218,6 +218,15 @@ class AckTaskValidationTests(unittest.TestCase): gate["then"]["properties"]["tasks"]["$ref"], "#/definitions/launchableTasks", ) + self.assertIn("deliveryRuns", delivery_gate["then"]["required"]) + + delivery_run = schema["definitions"]["deliveryRun"] + revision_gate = delivery_run["allOf"][0] + self.assertIn("planned", revision_gate["if"]["properties"]["status"]["enum"]) + self.assertEqual( + revision_gate["then"]["properties"]["sourceRevision"]["type"], + "string", + ) role_dispatch = schema["definitions"]["roleDispatch"] self.assertIn("attemptId", role_dispatch["required"]) @@ -231,6 +240,126 @@ class AckTaskValidationTests(unittest.TestCase): "string", ) + def test_delivery_run_is_separate_and_requires_verified_tasks(self) -> None: + board = valid_manual_routing_board() + board["project"]["deliveryFile"] = "docs/ack/delivery.yaml" + board["tasks"][0]["status"] = "verified" + board["deliveryRuns"] = [ + { + "id": "DR-demo-1", + "profile": "review", + "taskIds": ["T-1"], + "status": "review_ready", + "sourceRevision": "a" * 40, + "configRevision": "b" * 40, + "pullRequest": "https://forge.example/demo/pulls/1", + "artifacts": [ + { + "id": "service-deb", + "type": "deb", + "reference": "demo_1.0.0_amd64.deb", + "digest": "sha256:" + "c" * 64, + } + ], + "deployments": [ + { + "environment": "test-server", + "result": "succeeded", + "evidence": "health endpoint returned 200", + } + ], + "evidence": ["CI run 42 passed"], + "updatedAt": "2026-08-01T10:00:00+08:00", + } + ] + + self.assert_board_accepted_in_all_modes(board) + + board["tasks"][0]["status"] = "open" + self.assert_board_rejected_in_all_modes( + board, + "delivery run 只能引用 verified 任务", + ) + + def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None: + board = valid_manual_routing_board() + board["deliveryRuns"] = [] + self.assert_board_rejected_in_all_modes( + board, + "deliveryRuns 存在时 project.deliveryFile 必须存在", + ) + + board = valid_manual_routing_board() + board["project"]["deliveryFile"] = "docs/ack/delivery.yaml" + self.assert_board_rejected_in_all_modes( + board, + "引用 deliveryFile 的任务板必须包含 deliveryRuns 列表", + ) + + def test_delivery_run_binds_revisions_and_final_artifact_digest(self) -> None: + board = valid_manual_routing_board() + board["project"]["deliveryFile"] = "docs/ack/delivery.yaml" + board["deliveryRuns"] = [ + { + "id": "DR-demo-2", + "profile": "review", + "taskIds": ["T-1"], + "status": "planned", + "sourceRevision": None, + "configRevision": None, + "pullRequest": None, + "artifacts": [], + "deployments": [], + "evidence": [], + "updatedAt": "2026-08-01T10:00:00+08:00", + } + ] + self.assert_board_rejected_in_all_modes( + board, + "delivery run 只能引用 verified 任务", + "sourceRevision: status='planned' 时必须填写", + "configRevision: status='planned' 时必须填写", + ) + + board["tasks"][0]["status"] = "verified" + run = board["deliveryRuns"][0] + run.update( + { + "status": "review_ready", + "sourceRevision": "a" * 40, + "configRevision": "b" * 40, + "pullRequest": "https://forge.example/demo/pulls/2", + "artifacts": [ + { + "id": "service-deb", + "type": "deb", + "reference": "demo_1.0.0_amd64.deb", + "digest": None, + } + ], + "evidence": ["CI run 43 passed"], + } + ) + self.assert_board_rejected_in_all_modes( + board, + "digest: status='review_ready' 时必须填写", + ) + + run.update( + { + "status": "skipped", + "sourceRevision": None, + "configRevision": None, + "pullRequest": None, + "artifacts": [], + "evidence": [], + } + ) + self.assert_board_rejected_in_all_modes( + board, + "evidence: status='skipped' 时不能为空", + ) + @unittest.skipUnless( importlib.util.find_spec("jsonschema") is not None, "jsonschema is required for the schema-only contract test", diff --git a/tests/test_ack_yaml_fallback.py b/tests/test_ack_yaml_fallback.py index ecd9bd9..d358abc 100644 --- a/tests/test_ack_yaml_fallback.py +++ b/tests/test_ack_yaml_fallback.py @@ -12,6 +12,7 @@ SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts" sys.path.insert(0, str(SCRIPTS_DIR)) import validate_knowledge # noqa: E402 +import validate_delivery # noqa: E402 import validate_tasks # noqa: E402 from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402 @@ -28,12 +29,14 @@ paths = ( Path('skills/ack/examples/tasks.example.yaml'), Path('skills/ack/templates/knowledge.template.yaml'), Path('skills/ack/examples/knowledge.example.yaml'), + Path('skills/ack/templates/delivery.template.yaml'), + Path('skills/ack/examples/delivery.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=4') +print('parsed=6') """ result = subprocess.run( [sys.executable, "-S", "-c", script], @@ -44,7 +47,7 @@ print('parsed=4') ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "parsed=4") + self.assertEqual(result.stdout.strip(), "parsed=6") def test_tasks_validator_runs_without_site_packages(self) -> None: result = subprocess.run( @@ -63,6 +66,23 @@ print('parsed=4') self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("任务板校验通过", result.stdout) + def test_delivery_validator_runs_without_site_packages(self) -> None: + result = subprocess.run( + [ + sys.executable, + "-S", + str(SCRIPTS_DIR / "validate_delivery.py"), + str(REPO_ROOT / "skills" / "ack" / "templates" / "delivery.template.yaml"), + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("交付契约校验通过", result.stdout) + def test_supported_subset_types_and_block_scalars(self) -> None: document = load_yaml_subset( """ diff --git a/tests/test_skill_init.py b/tests/test_skill_init.py index 4c6598a..4b30eed 100644 --- a/tests/test_skill_init.py +++ b/tests/test_skill_init.py @@ -37,6 +37,13 @@ class SkillInitTests(unittest.TestCase): ' devWorktree: ""\n', encoding="utf-8", ) + tasks_template = skill / "templates" / "tasks.template.yaml" + tasks_template.write_text( + tasks_template.read_text(encoding="utf-8") + + ' deliveryFile: "docs/ack/delivery.yaml"\n' + + 'deliveryRuns: []\n', + encoding="utf-8", + ) (skill / "templates" / "knowledge.template.yaml").write_text( 'updatedAt: ""\n' 'project:\n' @@ -44,7 +51,25 @@ class SkillInitTests(unittest.TestCase): ' repoPath: ""\n', encoding="utf-8", ) - for validator_name in ("validate_tasks.py", "validate_knowledge.py"): + (skill / "templates" / "delivery.template.yaml").write_text( + 'version: 1\n' + 'updatedAt: ""\n' + 'project:\n' + ' name: ""\n' + 'enabled: false\n' + 'defaultProfile: null\n' + 'entrypoints: {}\n' + 'artifacts: {}\n' + 'destinations: {}\n' + 'environments: {}\n' + 'profiles: {}\n', + encoding="utf-8", + ) + for validator_name in ( + "validate_tasks.py", + "validate_knowledge.py", + "validate_delivery.py", + ): (skill / "scripts" / validator_name).write_text( "raise SystemExit(0)\n", encoding="utf-8", @@ -79,12 +104,16 @@ class SkillInitTests(unittest.TestCase): project_content = (target / "project.md").read_text(encoding="utf-8") tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8") knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8") + delivery_content = (target / "delivery.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.assertNotIn("", tasks_content) self.assertNotIn("", knowledge_content) + self.assertNotIn("", delivery_content) def test_init_refuses_to_overwrite_existing_files(self) -> None: project = self.home / "existing-app" @@ -100,6 +129,7 @@ class SkillInitTests(unittest.TestCase): self.assertEqual(existing.read_text(encoding="utf-8"), "keep me") self.assertFalse((target / "tasks.yaml").exists()) self.assertFalse((target / "knowledge.yaml").exists()) + self.assertFalse((target / "delivery.yaml").exists()) def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None: project = self.home / "existing-knowledge-app" @@ -116,6 +146,22 @@ class SkillInitTests(unittest.TestCase): self.assertFalse((target / "project.md").exists()) self.assertFalse((target / "tasks.yaml").exists()) + def test_init_refuses_to_overwrite_existing_delivery_file(self) -> None: + project = self.home / "existing-delivery-app" + target = project / "docs" / "ack" + target.mkdir(parents=True) + existing = target / "delivery.yaml" + existing.write_text("keep me", encoding="utf-8") + + result = self.run_skiff("init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("拒绝覆盖已有路径", result.stderr) + self.assertEqual(existing.read_text(encoding="utf-8"), "keep me") + self.assertFalse((target / "project.md").exists()) + self.assertFalse((target / "tasks.yaml").exists()) + self.assertFalse((target / "knowledge.yaml").exists()) + def test_init_rejects_symlinked_destination_directories(self) -> None: for symlink_level in ("docs", "ack"): with self.subTest(symlink_level=symlink_level): @@ -147,6 +193,7 @@ class SkillInitTests(unittest.TestCase): self.assertFalse((outside / "project.md").exists()) self.assertFalse((outside / "tasks.yaml").exists()) self.assertFalse((outside / "knowledge.yaml").exists()) + self.assertFalse((outside / "delivery.yaml").exists()) def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None: project = self.home / "path-traversal-app" @@ -289,7 +336,7 @@ class SkillInitTests(unittest.TestCase): self.assertEqual(list((project / "docs" / "ack").iterdir()), []) self.assertEqual( sorted(path.name for path in moved_target.iterdir()), - ["knowledge.yaml", "project.md", "tasks.yaml"], + ["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"], ) def test_transaction_container_replacement_cannot_forge_payload(self) -> None: @@ -348,7 +395,7 @@ class SkillInitTests(unittest.TestCase): self.assertFalse((target / "marker").exists()) self.assertEqual( sorted(path.name for path in target.iterdir()), - ["knowledge.yaml", "project.md", "tasks.yaml"], + ["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"], ) def test_post_publish_fsync_failure_preserves_complete_state(self) -> None: @@ -360,7 +407,7 @@ class SkillInitTests(unittest.TestCase): def fail_directory_fsync_after_publish(file_descriptor: int) -> None: nonlocal calls calls += 1 - if calls == 7: + if calls == 8: raise OSError("simulated directory fsync failure") real_fsync(file_descriptor) @@ -386,7 +433,7 @@ class SkillInitTests(unittest.TestCase): target = project / "docs" / "ack" self.assertEqual( sorted(path.name for path in target.iterdir()), - ["knowledge.yaml", "project.md", "tasks.yaml"], + ["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"], ) def test_project_root_replacement_aborts_before_publish(self) -> None: @@ -473,7 +520,7 @@ class SkillInitTests(unittest.TestCase): target = moved_project / "docs" / "ack" self.assertEqual( sorted(path.name for path in target.iterdir()), - ["knowledge.yaml", "project.md", "tasks.yaml"], + ["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"], ) def test_docs_replacement_aborts_before_publish(self) -> None: @@ -568,7 +615,7 @@ class SkillInitTests(unittest.TestCase): target = moved_docs / "ack" self.assertEqual( sorted(path.name for path in target.iterdir()), - ["knowledge.yaml", "project.md", "tasks.yaml"], + ["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"], ) def test_ack_init_requires_knowledge_template(self) -> None: @@ -588,8 +635,29 @@ class SkillInitTests(unittest.TestCase): self.assertIn("knowledge.template.yaml", result.stderr) self.assertFalse((project / "docs" / "ack").exists()) - def test_ack_init_requires_both_validators(self) -> None: - for validator_name in ("validate_tasks.py", "validate_knowledge.py"): + def test_ack_init_requires_delivery_template(self) -> None: + project = self.home / "missing-delivery-template-app" + project.mkdir() + ( + self.skills_home + / "skills" + / "ack" + / "templates" + / "delivery.template.yaml" + ).unlink() + + result = self.run_skiff("init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("delivery.template.yaml", result.stderr) + self.assertFalse((project / "docs" / "ack").exists()) + + def test_ack_init_requires_all_validators(self) -> None: + for validator_name in ( + "validate_tasks.py", + "validate_knowledge.py", + "validate_delivery.py", + ): with self.subTest(validator_name=validator_name): project = self.home / f"missing-{validator_name}-app" project.mkdir() @@ -632,6 +700,21 @@ class SkillInitTests(unittest.TestCase): self.assertIn("初始化知识库校验失败", result.stderr) self.assertFalse((project / "docs" / "ack").exists()) + def test_delivery_validator_failure_leaves_no_partial_initialization(self) -> None: + project = self.home / "invalid-delivery-app" + project.mkdir() + validator = ( + self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py" + ) + validator.write_text("raise SystemExit(1)\n", encoding="utf-8") + + result = self.run_skiff("init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Traceback", result.stderr) + self.assertIn("初始化交付契约校验失败", result.stderr) + self.assertFalse((project / "docs" / "ack").exists()) + def test_validator_cannot_replace_staged_bytes_before_install(self) -> None: project = self.home / "mutated-staging-app" project.mkdir() @@ -677,6 +760,32 @@ class SkillInitTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file()) + def test_ack_delivery_init_validates_mirrored_staging_root(self) -> None: + project = self.home / "staged-delivery-app" + project.mkdir() + validator = ( + self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py" + ) + validator.write_text( + "import sys\n" + "from pathlib import Path\n" + "required = ['--tasks', '--project-root']\n" + "if any(item not in sys.argv for item in required):\n" + " raise SystemExit(3)\n" + "root = Path(sys.argv[sys.argv.index('--project-root') + 1])\n" + "delivery = Path(sys.argv[1])\n" + "tasks = Path(sys.argv[sys.argv.index('--tasks') + 1])\n" + "expected = root / 'docs' / 'ack'\n" + "raise SystemExit(0 if delivery.parent == expected and " + "tasks.parent == expected else 4)\n", + encoding="utf-8", + ) + + result = self.run_skiff("init", "ack", "--project", str(project)) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((project / "docs" / "ack" / "delivery.yaml").is_file()) + def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None: skill = self.skills_home / "skills" / "plain" (skill / "templates").mkdir(parents=True) @@ -697,6 +806,7 @@ class SkillInitTests(unittest.TestCase): self.assertTrue((target / "project.md").is_file()) self.assertTrue((target / "tasks.yaml").is_file()) self.assertFalse((target / "knowledge.yaml").exists()) + self.assertFalse((target / "delivery.yaml").exists()) def test_init_rejects_missing_project_directory(self) -> None: project = self.home / "missing-app"