feat(ack): add project delivery workflow

This commit is contained in:
2026-08-01 12:35:42 +08:00
parent f08edb6452
commit 2c3d91c75c
28 changed files with 2559 additions and 67 deletions
+43 -3
View File
@@ -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 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
```
Coordinator 可以按当前任务上下文做确定性推荐:
@@ -115,6 +124,19 @@ python3 <ack-skill-dir>/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`
+51 -14
View File
@@ -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` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
脚本都相对此目录访问,不依赖固定的全局安装路径。
@@ -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 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <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. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
@@ -82,8 +91,11 @@ description: >-
黑盒命令和 Base URL。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。只报告证据明确的问题,不因可选
字段缺失而宣称失败。
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml --project-root <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` 条目
- `<ack-skill-dir>/references/kickoff.md`
- kickoff 指定且与当前任务相关的 references 文件
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
`<ack-skill-dir>/references/delivery.md`
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
不全量注入知识库。
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
然后交给用户确认;确认前不派发实现。
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
步骤放入同一份计划。确认前不派发实现,也不执行交付。
5. 创建或更换 worker 时,只使用
`<ack-skill-dir>/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 目录读取。
+1 -1
View File
@@ -1 +1 @@
0.10.0
0.11.0
+2 -2
View File
@@ -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
+149
View File
@@ -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
+11 -5
View File
@@ -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 明确包含
+3 -1
View File
@@ -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"]
+21 -1
View File
@@ -4,7 +4,8 @@
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `skiff init ack --project <project-root>`
- [ ] `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 @@
- [ ] 真实任务的验收是可观测信号。
- [ ] 已运行 `<ack-skill-dir>/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: []`
+8 -6
View File
@@ -164,11 +164,12 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
| 要保持基线分支干净 | 新 worktreefeature 分支)|
| 小改动、追求快 | 当前 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 授权与撤销机制仍是 Deferredlauncher 遇到 full-access 或等价
@@ -191,7 +192,8 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferredlauncher 遇到 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
+123
View File
@@ -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 checkproduction 还必须声明 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 <ack-skill-dir>/scripts/validate_delivery.py \
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml \
--project-root <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-256OCI 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 不能绕过它的触发与授权边界。
+31 -5
View File
@@ -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 <project-root>
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 独立验证且
`<ack-skill-dir>/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 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <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 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
完成后报告:
- 创建或确认的个项目文件。
- 创建或确认的个项目文件。
- 检测到的技术栈和验证命令。
- 任务板项目知识校验结果。
- 任务板项目知识和交付契约校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
+20 -3
View File
@@ -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_readystable/production 步骤再次向我确认。
```
---
@@ -55,6 +60,9 @@ python3 <ack-skill-dir>/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`
@@ -256,6 +256,13 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
- 待验证 candidate<remaining candidates or none>
交付(未启用时写 n/a):
- run/profile/status<delivery run id / profile / review_ready|released|blocked|failed>
- PR/MR<URL and head/base>
- 产物:<reference, digest, destination>
- 部署:<environment, health/rollback evidence>
- 待审批或未完成:<approval/blocked step or none>
工作树状态:
- <repo_path>: <git status summary>
- <dev_worktree>: <git status summary>
+22 -3
View File
@@ -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
| `<local_config>` | 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 或正式规范;知识条目保存触发条件、原因和
+809
View File
@@ -0,0 +1,809 @@
#!/usr/bin/env python3
"""校验 ACK 项目交付契约。
权威结构位于 templates/delivery.schema.jsonjsonschema 是可选依赖内置规则始终
检查引用步骤顺序默认 profile 安全边界敏感信息和仓库内入口路径
用法:
python3 validate_delivery.py docs/ack/delivery.yaml
python3 validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <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, "<root>", 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, "<root>", 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 "<root>"
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())
+203 -1
View File
@@ -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-<id> 格式")
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
+228
View File
@@ -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" }
}
}
}
}
}
@@ -0,0 +1,24 @@
# 复制为 docs/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
project:
name: "<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: {}
+13 -4
View File
@@ -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`<base_url>`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
- 覆盖层文件:`<overlay_file_path>`(默认 `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` 与顶层
| `<local_config_paths>` | 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 明确包含
+188
View File
@@ -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"],
+2
View File
@@ -10,6 +10,7 @@ project:
devWorktree: "<dev_worktree>"
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: []