3 Commits

35 changed files with 3161 additions and 67 deletions
+4 -1
View File
@@ -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
+27 -4
View File
@@ -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")
+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 \
| 要保持基线分支干净 | 新 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
+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 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 <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-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 不能绕过它的触发与授权边界。
+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_ready,stable/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.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 <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: []
+110
View File
@@ -0,0 +1,110 @@
# manage-release
`manage-release` 帮助 Agent 管理从开发版本到发布的 Git 流程,包括 worktree、分支、
版本号、PR/MR、合并和 release tag。它可以只处理其中一个阶段,也可以从准备开发目录
一直执行到发布。
## 使用前准备
- 项目已经是 Git 仓库,并配置了可访问的远端。
- 项目已有版本文件或发布约定;没有时可以让 Agent 先给出版本建议。
- 创建或合并 PR/MR、创建 Forge Release 时,准备好 GitHub、GitLab、Gitea 或 Forgejo
对应的已认证 CLI。只发布 Git tag 时不要求 Forge CLI,但 Git remote 必须可读写。
- 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作
不会执行。
## 常见用法
### 确定下一个版本号
```text
使用 manage-release 检查当前版本和这次改动,建议下一个版本号。只分析,不修改文件。
```
适合还没决定该发 patch、minor 还是 major 时使用。Agent 会读取项目版本规则、现有 tag
和实际改动,给出建议及依据。
### 开始开发新版本
```text
使用 manage-release,从 main 为 1.6.0 创建独立 worktree 和分支。先不要 push,也不要创建 PR。
```
Agent 会检查已有 worktree、分支和 tag,确认没有冲突后准备开发目录。这个请求只授权
本地操作。
### 把开发分支提交为 PR
```text
使用 manage-release 检查当前分支,更新版本号和 CHANGELOG,运行项目测试,然后 push 并创建指向 main 的 PR。不要合并。
```
适合开发已经完成,只需要整理版本信息并送审。Agent 会停在 PR 创建成功的位置,返回
PR 地址、测试结果和待处理项。
### 合并通过检查的 PR
```text
使用 manage-release 检查当前 PR。如果 CI、review 和冲突检查都通过,就按项目规则合并;不要打 tag。
```
Agent 只会在仓库要求全部满足后合并,不会使用管理员权限绕过保护规则。合并完成后会
返回准确的 merged commit。
### 为已合并版本发布 tag
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。确认 tag 指向包含该版本号的 merged commit,不要创建 Forge Release。
```
适合依靠 Git tag 触发后续 CI 发布的项目。Agent 会分别验证版本文件、目标 commit 和
远端 tag,不会给未合并分支打正式 tag。
### 完成端到端发布
```text
使用 manage-release 完成 1.6.0 发布:从 main 创建 worktree 和分支,完成版本更新和验证,创建 PR,检查通过后合并,推送 v1.6.0 tag,并创建 Forge Release。
```
这条请求授权完整流程。遇到 review 未通过、CI 失败、版本冲突或已有同名 tag 时,
Agent 会停止并说明卡在哪一步,不会绕过检查继续发布。
### 发布紧急修复版本
```text
使用 manage-release 从 main 准备 1.6.1 hotfix。创建独立 worktree,完成修复后开 PR;检查通过就合并并发布 v1.6.1。
```
适合线上问题修复。Skill 仍走正常 PR 和保护分支流程,不会因为是 hotfix 就直接推送
main 或覆盖已有 tag。
### 从中断位置继续
```text
使用 manage-release 检查 v1.6.0 当前发布状态,从尚未完成的步骤继续。不要重复创建分支、PR、tag 或 release。
```
适合合并后打 tag 失败、tag 已推送但 release 未创建,或者 Agent 会话中断的情况。
Skill 会从 Git 和代码托管平台重新判断状态,再继续缺失的步骤。
## Agent 会做什么
Agent 会先发现项目自己的版本、分支和发布规则,再检查本地 Git 与远端状态。它只执行
请求中明确授权的阶段,并在合并、tag 或 release 条件不满足时停止。完整流程结束后,
结果中会分别列出 worktree、分支、版本、PR/MR、merged commit、tag 和 Forge Release
状态。
## 如何判断完成
- 只分析版本时,结果包含建议版本及依据。
- 创建开发环境时,结果包含 worktree 路径、分支和基线 commit。
- 创建或合并 PR/MR 时,结果包含 URL、检查状态和 merged commit。
- 发布 tag 时,远端 tag 解引用后的 commit 与 merged commit 一致。
- 创建 Forge Release 时,结果包含可访问的 release URL。
- 中途停止时,结果说明停在哪一步、为什么停止,以及下次如何继续。
## 不适用的场景
普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包
不需要触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布 PR/MR
或 release tag 时,才交给这个 Skill。
+194
View File
@@ -0,0 +1,194 @@
---
name: manage-release
description: >-
管理 Git 项目从开发版本到发布的完整生命周期:发现并遵循项目分支与版本策略,
创建或复用隔离 worktree 和版本分支,确定并同步版本号,推送并创建、检查或合并
PR/MR,在合并后的准确提交上创建并推送 release tag,并按需创建 Forge Release、
恢复中断流程或安全清理。用户要求开始发布相关的新版本、为发布开 worktree 或分支、
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
DEB/Docker 等产物、管理仓库权限时不使用。
---
# Manage Release
根据项目现有规则管理源码版本生命周期。把项目策略当作规则来源,把 Git 与代码托管
平台的实时状态当作运行状态;不要创建另一份会与分支、PR/MR 或 tag 分叉的状态文件。
## 按需读取参考资料
- 开始任何流程时读取 [project-policy.md](references/project-policy.md),确定策略来源和默认值。
- 需要选择、更新或核对版本号时读取 [versioning.md](references/versioning.md)。
- 需要 push、创建或合并 PR/MR、创建 Forge Release 时读取
[forge-adapters.md](references/forge-adapters.md)。
- 遇到重复资源、并发冲突、部分成功或清理请求时读取
[recovery.md](references/recovery.md)。
- 仅当用户要求为项目固化发布规则时,复制并修改
[release-policy.template.yaml](templates/release-policy.template.yaml)。不要在普通发布中自动添加配置。
## 授权边界
按用户明确要求执行到对应阶段:
- 分析版本或检查状态:只执行读取和计算,不修改文件或远端。
- 开始或准备版本:允许创建本地分支、worktree,并修改版本文件。
- push 或创建 PR/MR:仅在用户明确要求 push、提交 PR/MR 或执行完整发布时进行。
- 合并和推送 tag:仅在用户明确要求相应动作或完整发布时进行。
- 创建 Forge Release:仅在用户本次请求明确提到 Forge Release 或平台 Release 时进行。
- 清理 worktree 或分支:仅在用户明确要求清理时进行。
项目策略只描述仓库期望的做法,不能替用户授予 push、创建或合并 PR/MR、推送 tag、
创建 Forge Release、删除分支等外部写权限。
“完整处理并发布 v1.2.3”可以授权从准备到验证远端 tag 的流程,不要在每一步重复询问;
它不自动包含 Forge Release 或分支清理。若版本、base、tag、远端或合并方式是推断所得,
或者执行中目标发生变化,在第一次远端写操作前展示准确目标并获得确认。
## 工作流
### 1. 发现项目规则和当前状态
从项目根目录开始:
1. 从可信 base commit 读取 `AGENTS.md`、`CLAUDE.md`、发布文档、CI 配置、构建入口和
可选的 `.release-policy.yaml`。PR/MR head 对这些文件的修改从下一次流程开始生效。
2. 记录当前分支、HEAD、完整工作区状态、remote、默认分支和
`git worktree list --porcelain`。
3. 获取远端最新引用后,记录 base 分支的准确 commit。不要把本地过期分支当作发布基线。
4. 识别版本来源、最近稳定 tag、tag 格式、分支命名、合并方式和发布说明来源。
5. 分别检查 Git remote 的读取与推送能力,以及 PR/MR、合并或 Forge Release 阶段所需的
GitHub、GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
记录匹配该 host 的活动账号;认证检查只读取状态。
6. 查询已有分支、worktree、PR/MR、tag 和 Forge Release,从真实状态判断流程已走到哪一步。
工作区中的修改、暂存文件和未跟踪文件都视为用户工作。不要 stash、移动、清理或覆盖它们。
已有 worktree 与目标分支准确匹配时优先复用,不要创建副本。
### 2. 锁定本次发布计划
在修改前确定并展示:
- base 分支及其远端 commit。
- 当前版本、规范化后的目标版本、渲染出的分支名和 tag,以及升级依据。
- 分支名与 worktree 路径。
- 需要执行的本地验证和远端检查。
- 合并方式、tag、是否创建 Forge Release。
- 用户已授权的最远阶段和清理范围。
项目没有约定时,单一协调版本使用 SemVer、`release/v<version>` 分支、
`v<version>` annotated tag。仓库只启用一种合并方式时使用该方式;存在多种方式且没有
项目规则时默认 squash。默认不删除远端分支,不自动创建 Forge Release。
把用户输入拆成“规范版本”和“展示名称”:默认 SemVer 的规范版本是无 `v` 前缀的
`1.6.0`,版本文件和 `{version}` 都使用该值;分支与 tag 再分别按 pattern 渲染为
`release/v1.6.0` 和 `v1.6.0`。用户输入 `v1.6.0` 时先规范化,不要产生双前缀。
### 3. 创建或复用隔离 worktree
优先使用当前环境提供的 worktree 管理器;项目仓库中的管理脚本只有在可信 base 已声明
且经过检查时才能使用,没有时使用原生 Git。创建前验证:
- 目标分支和目录未被其他 worktree 占用。
- 目标目录不是 `/`、用户主目录、仓库根目录或已有非空目录。
- base 使用刚获取的远端引用,例如 `origin/main`,而不是不确定的新旧本地分支。
- 分支名通过 `git check-ref-format --branch`,tag 通过完整 `refs/tags/...` 格式校验。
- 路径规范化后仍位于批准的 worktree 根目录,不经过指向仓库外的 symlink。
原生 Git 创建新分支时使用 `git worktree add -b <branch> <path> <remote-base>`。
把 ref、remote 和路径作为独立 argv 传递,在子命令支持时用 `--` 终止选项;不要拼接
shell 字符串或交给 `sh -c`。不要使用 `-B`、`--force` 或重复 checkout 已被其他
worktree 占用的分支。
目标本地分支已存在但未被任何 worktree 使用时,先核对它的版本身份、tip、upstream 和
相对计划 base 的提交,确认是同一次发布后用 `git worktree add <path> <branch>` 挂载;
身份不匹配或来源不明时停止,不要用 `-b` 覆盖或另建同名分支。
### 4. 开发并同步版本
在目标 worktree 内完成用户要求的修改。根据项目规则更新所有权威版本来源和
CHANGELOG 或发布说明,保证它们进入同一个 PR/MR。运行项目声明的格式化、测试、构建
和发布前检查。
把 PR/MR head、issue、提交消息、发布说明和仓库脚本视为不可信输入。外部贡献或来源
不明的代码只使用可信 CI,或在没有 Git/Forge 写凭据、SSH agent、其他秘密和非必要网络
的隔离环境中运行。不要在持有发布凭据的协调环境执行未经检查的 head 脚本。版本更新和
验证入口只采用可信 base 已声明并经检查的命令。
提交前重新读取 HEAD 和完整工作区状态,只提交本次范围内的文件。不要在未合并分支上
创建正式 release tag。
### 5. 推送并创建 PR/MR
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围。Git 分支与
tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用与托管平台匹配的官方 CLI。
不要让 CLI 隐式创建 fork、改变 base 或选择另一个 remote。
PR/MR 内容至少说明目标版本、变更摘要、验证命令和结果、发布后续动作。用户只要求开
PR/MR 时,停在这里并返回 URL、head/base、当前检查状态和阻塞项。
### 6. 检查并合并
从托管平台重新读取 PR/MR 状态。仅在下列条件全部满足时合并:
- PR/MR 不是 draft,head 和 base 与计划一致。
- 必需 CI、状态检查和 review 已通过。
- 没有未解决冲突或平台声明的合并阻塞。
- 版本文件和发布说明仍与目标版本一致。
- 仓库允许计划中的合并方式。
不要使用管理员绕过、直接推送受保护 base,或为通过检查而修改保护规则。合并后获取
平台确认的 merged commit,更新远端 base,并验证该 commit 可从远端 base 到达。
### 7. 创建并发布 tag
在 merged commit 上核对目标版本后,再查询一次远端同名 tag。tag 不存在时创建
annotated tag;项目要求签名时,必须先确认签名工具和密钥可用,创建 signed tag,并在
本地验证签名成功后才允许 push。签名不可用或验证失败时停止,不得降级为 unsigned tag。
显式指定 merged commit,并只推送这个 tag。
同名远端 tag 已存在且指向其他 commit 时立即停止。不要覆盖、删除或移动已经发布的
tag。推送后读取远端 tag,并将 annotated tag 解引用到 commit,确认它与 merged commit
完全相同。
仅当用户本次请求明确要求时创建 Forge Release;项目策略只能说明创建方式,不能授予
外部写权限。必须引用已经存在并验证过的 tag,禁止让平台从默认分支隐式创建 tag。
发布说明中的每项变化都应能追溯到本次 PR/MR、提交或项目 CHANGELOG。
### 8. 验证、恢复和清理
分别报告以下状态,不要用“发布成功”掩盖其中某一步未完成:
- 版本文件和本地验证。
- PR/MR URL、合并状态和 merged commit。
- 远端 tag 及其解引用后的 commit。
- Forge Release URL 和可见性,若本次要求创建。
- worktree、本地分支和远端分支是否保留。
清理时只移除干净且已确认不再使用的 linked worktree,不使用强制删除。删除本地或远端
分支前确认其提交已合并且可从 base 或 tag 到达;本地和远端分支删除都必须有明确授权。
## 硬停止条件
遇到以下任一情况时停止相应写操作并说明恢复路径:
- base、目标版本、版本来源、remote,或请求阶段所需的托管平台无法唯一确定。
- 项目策略包含未知字段、错误类型、仓库外路径或不安全 ref。
- 多个权威版本来源不一致。
- 目标分支、worktree、PR/MR、tag 或 release 已存在但身份不匹配。
- required checks、review 或冲突状态不满足合并规则。
- 合并结果的准确 commit 无法从平台确认,或无法从远端 base 到达。
- 远端 tag 已指向其他 commit。
- 请求的 PR/MR、合并或 Forge Release 阶段所需的平台 CLI 缺失、未认证或无法读取状态。
- 项目要求 tag 签名,但签名能力不可用或本地签名验证失败。
- 操作需要 force push、管理员绕过、移动已发布 tag 或覆盖用户工作。
- 不可信代码只能在带仓库写凭据、SSH agent、秘密或非必要网络的环境中运行。
不要在命令参数、文件、提交、PR/MR、日志或最终回复中暴露 token、密码、私钥路径或
认证配置。CLI 不可用时给出人工交接信息,不要擅自改用带 token 的 `curl`。
## 完成标准
只分析时,给出当前状态、建议版本、依据和下一步。开始版本时,给出 worktree 路径、
分支和基线 commit。创建 PR/MR 时,给出 URL 与检查状态。合并时,给出 merged commit。
发布 tag 时,证明远端 tag 解引用到该 commit。创建 Forge Release 时,再给出 release URL
与可见性。任何部分未完成都要标明阻塞阶段和可恢复动作。
@@ -0,0 +1,77 @@
# 代码托管平台适配
## 区分 Git 与 Forge 能力
fetch、查询 remote refs、推送分支和推送单个 tag 属于 Git transport 能力,可直接通过
已配置的 Git remote 完成。创建或查看 PR/MR、合并以及创建 Forge Release 才需要平台
CLI。只发布 tag 时,不要因为缺少 Forge CLI 而停止;仍要验证 remote、认证目标和推送
授权,并在 push 后读回远端 ref。
## 选择 Forge 适配器
从 push remote URL 识别平台,并使用对应官方 CLI:
| 平台 | CLI | PR/MR 命令域 | Release 命令域 |
|---|---|---|---|
| GitHub | `gh` | `gh pr` | `gh release` |
| GitLab | `glab` | `glab mr` | `glab release` |
| Gitea / Forgejo | `tea` | `tea pulls` | `tea releases` |
先运行 CLI 的版本、认证状态和只读查看命令。记录匹配 remote host 的活动账号;存在多
个账号或 profile 时,必须唯一确定本次使用的身份。CLI 版本之间的 flags 可能不同,
每次执行写操作前读取对应子命令的 `--help`,不要凭记忆拼接参数。
当请求确实涉及 PR/MR、合并或 Forge Release,且 remote host 无法识别、CLI 缺失、认证
失败或 CLI 指向另一个实例时,停止对应平台阶段并报告:
- host、owner/repository、remote 名称。
- base、head 和当前 commit。
- 已完成的本地验证。
- 尚需人工执行的创建 PR/MR、合并或 release 步骤。
不要自动安装 CLI,也不要把 token 放进命令行后改用 `curl`。用户明确提供受支持的其他
集成时,可以使用该集成,但仍遵守同一套授权和验证闸门。
## 创建 PR/MR
使用显式的 repository、base、head、title 和 body,避免交互式默认值改变目标。把动态
值作为独立 argv 或结构化输入传递,不拼接 shell 命令。创建前:
1. 确认当前分支已经推送到计划中的 remote。
2. 查询同一 head/base 是否已有 open PR/MR,有则复用并更新状态,不重复创建。
3. 确认 head commit 与本地已验证 commit 相同。
4. 确认 CLI 不会隐式创建 fork 或推送到另一个 remote。
5. 再次读取活动账号,向用户展示 host、repository、账号和授权的最远阶段。
PR/MR body 包含目标版本、变更摘要、验证命令与结果、发布后续。创建后读取平台返回的
URL、编号、head/base 和状态,不以命令退出码代替远端读回。
## 检查和合并
用结构化输出读取 draft、head commit、base、review、required checks、冲突或
mergeability、允许的合并方式。平台字段缺失时,使用平台提供的只读详情命令补足;不能
证明闸门通过时保持未合并。
GitHub 使用 `gh pr view`、`gh pr checks` 和 `gh pr merge`;GitLab 使用
`glab mr view`、pipeline 状态和 `glab mr merge`;Gitea 或 Forgejo 使用
`tea pulls` 的查看、检查和合并能力。实际参数以当前 CLI 的 `--help` 为准。
若用户要求检查通过后自动合并,优先使用仓库已经启用的 auto-merge 或 merge queue。
不要用管理员选项绕过 review、CI、conversation resolution 或 protected branch。
合并后重新读取 PR/MR,取得平台确认的 merged commit。随后 fetch 远端 base,并验证该
commit 已进入远端 base。只看到本地 merge commit 或分支关闭不足以证明平台已合并。
## 创建 Forge Release
把 Git tag 和 Forge Release 当成两个独立状态。先创建、推送并验证 tag,再创建 release。
- GitHub 创建 release 时使用能够拒绝缺失 tag 的选项,例如当前 CLI 支持的
`--verify-tag`。
- GitLab、Gitea 或 Forgejo 创建 release 前,先用只读命令证明 tag 已存在并指向计划的
commit;不要使用会顺便创建 tag 的默认行为。
- 预发布版本按项目规则标记 prerelease,不要自动把它标为 latest 或 stable。
- 发布后重新读取 release URL、tag 和可见性。
Forge Release 创建失败但 tag 已成功推送时,保留 tag 并从 release 阶段恢复,不要重新
合并或创建另一个 tag。
@@ -0,0 +1,73 @@
# 项目发布策略
## 策略优先级
按以下顺序确定规则,并在来源冲突时停止说明,不要静默覆盖:
1. 用户本次请求中明确给出的目标和授权范围。
2. 项目 `AGENTS.md`、`CLAUDE.md`、发布文档和可选的 `.release-policy.yaml`。
3. 构建脚本、包清单、CI 工作流和历史发布使用的实际入口。
4. 代码托管平台当前的默认分支、保护规则、检查和允许的合并方式。
5. 本 Skill 的默认值。
用户请求若与项目硬规则或平台保护冲突,展示冲突并请求处理方式。不要借用户的一句
“直接发布”绕过仓库声明的检查或保护。
开始流程时记录 base commit,并读取该提交对应的项目策略。本次发布分支对策略文件的
修改不应改变正在执行的流程;新策略从下一次发布开始生效。
项目策略不能授予外部写权限。即使配置声明创建 Forge Release 或删除远端分支,也必须
由用户本次请求明确授权相应动作。
## 可选配置
项目反复出现同一种歧义时,用户可以要求创建 `.release-policy.yaml`。从
`templates/release-policy.template.yaml` 复制后,只保留项目真正需要固定的字段。
配置只保存可提交的项目策略:
- `schema`:配置结构版本,当前为 `1`。
- `base_branch`:发布 PR/MR 的目标分支。
- `branch_pattern`:版本分支格式,支持无展示前缀的规范 `{version}`。
- `version.scheme`:`semver` 或项目已经使用的其他方案。
- `version.sources`:构建和运行实际读取的权威版本文件。
- `tag_pattern`:release tag 格式,支持无展示前缀的规范 `{version}`。
- `merge_method`:`squash`、`merge` 或 `rebase`。
- `forge_release`:项目是否建议在 tag 后创建 Forge Release;它不授予创建权限。
- `tag_signing`:`off`、`optional` 或 `required`。
- `delete_remote_branch`:发布完成后是否建议清理远端源分支;它只是计划偏好,不是删除
权限或自动执行指令,仍需用户在本次请求中明确授权。
不要在配置中保存 token、账号、私钥路径、个人工作目录或一次性发布状态。不要加入任意
shell 命令字段;本地验证继续从项目文档、CI 和构建入口发现。
读取配置后校验字段集合、类型和枚举值,遇到未知字段时停止。`branch_pattern` 和
`tag_pattern` 展开后分别用 Git branch/tag ref 规则校验;不要把配置值拼进 shell 字符串。
`version.sources` 中每一项必须是仓库相对路径,拒绝绝对路径和 `..`。规范化并解析
symlink 后,目标必须仍在当前 worktree 内;权威来源通常还应由 Git 跟踪。
先按版本方案把用户输入规范化。默认 SemVer 接受 `1.6.0` 或作为用户输入的 `v1.6.0`,
但内部 `{version}` 固定为 `1.6.0`;版本文件写入规范值,branch/tag pattern 分别负责添加
展示前缀。自定义版本方案按项目文档定义规范值,不能从 pattern 猜测后重复添加前缀。
## 无配置时的发现顺序
1. 从 remote HEAD 和平台信息确定默认 base,不能确定时询问。
2. 从项目文档、CI 和构建入口找版本文件及更新方式。
3. 从已有分支和已合并 PR/MR 识别命名与合并方式。
4. 从稳定 tag 识别前缀和版本方案,只把 tag 当作交叉验证。
5. 无项目约定时使用 `release/v<version>`、SemVer、`v<version>` annotated tag 和
squash merge;默认不创建 Forge Release,不删除远端分支。
## 每次执行的计划快照
在第一次写操作前列出以下事实:
- base 分支和远端 commit。
- 当前版本、目标版本、版本来源和升级依据。
- worktree 路径与分支名。
- PR/MR 托管平台和合并方式。
- tag 与 Forge Release 计划。
- 用户授权的最远阶段和清理范围。
任何事实在执行中改变时刷新计划。改变会影响已授权的远端写目标时,再次获得确认。
@@ -0,0 +1,52 @@
# 中断恢复与安全清理
## 恢复原则
每次重入都从 Git 和代码托管平台读取状态,不依赖上一轮聊天结论。先记录准确的
base、head、PR/MR、merged commit、tag 和 release,再执行唯一缺失的下一步。
网络或平台调用返回超时、未知状态或非结构化错误时,先用只读命令刷新远端状态。不要把
重试当成默认动作;创建 PR/MR、合并、推 tag 和创建 release 都可能已经成功。
## 常见部分状态
| 当前状态 | 继续方式 | 禁止事项 |
|---|---|---|
| worktree 已创建,无改动 | 继续开发,或经授权移除干净 worktree | 不使用强制删除 |
| 本地发布分支已存在,没有 worktree | 核对版本身份、tip、upstream 和 base 后挂载现有分支 | 不用 `-b` 覆盖分支 |
| 分支已推送,没有 PR/MR | 确认 head/base 后创建一次 PR/MR | 不重复推送新分支 |
| PR/MR 已存在,未合并 | 复用 URL,刷新 checks、review 和冲突状态 | 不创建第二个 PR/MR |
| PR/MR 已合并,没有 tag | 获取 merged commit,在该 commit 上继续发布 | 不重新合并 |
| tag 已推送,没有 release | 验证 tag 后创建 Forge Release | 不创建替代 tag |
| release 已创建,验证未完成 | 读回 release、tag 和可见性 | 不直接声称发布完成 |
| 同名资源身份不匹配 | 停止并报告差异 | 不覆盖、关闭或删除未知资源 |
## 并发冲突
两个 Agent 或维护者可能同时准备相同版本。创建分支、PR/MR、tag 或 release 前都重新查询
目标是否存在。推 tag 前立即检查远端引用;push 因同名引用失败时保持失败,不要 force。
base 在开发期间前进时,让 PR/MR 和仓库规则决定是否需要更新分支。已经推送的发布分支
不在本 Skill 中重写历史;需要更新时优先创建普通提交,或交给平台的 update branch、
merge queue 和 auto-merge。任何 force push 都是硬停止条件。
## 回滚边界
- 未推送的本地版本提交可以在用户授权下修改或放弃,但不要覆盖其他工作。
- 已推送但未合并的 PR/MR 可以关闭,分支默认保留。
- 已合并变更通过新的 revert PR/MR 回滚,不重写 base 历史。
- 已发布 tag 默认不可变。tag 错误时停止,由维护者决定撤销发布或发布新版本。
- Forge Release 失败不回滚已经正确推送的 tag;从 release 阶段恢复。
## 清理规则
清理始终放在发布验证之后:
1. 确认 worktree 没有修改、暂存或未跟踪文件。
2. 确认分支提交已从远端 base 或已验证 tag 到达。
3. 仅移除目标 linked worktree,不操作主 worktree,不使用 `--force`。
4. 删除本地分支前再次确认已合并。
5. 删除本地或远端分支都必须有用户本次请求的明确授权;项目策略不能代替授权。
6. 使用 `git worktree prune --dry-run` 查看陈旧记录;不要把 prune 当作普通清理步骤。
任何清理条件不满足时保留现场,并报告路径、分支、未提交状态和后续人工动作。
@@ -0,0 +1,79 @@
# 版本号与 tag
## 确定权威版本来源
按项目实际构建链路寻找版本来源,不要遍历到一个看起来像版本号的字符串就修改。优先级:
1. 项目发布文档或 `.release-policy.yaml` 明确声明的文件。
2. 构建、打包或运行入口直接读取的清单,例如 `VERSION`、`package.json`、
`pyproject.toml`、`Cargo.toml` 或语言工具链的版本配置。
3. 由权威文件生成的镜像文件、锁文件或发布元数据。
4. 最近稳定 tag,只用于验证当前版本和发布历史。
记录每个权威文件的当前值和更新方式。多个权威来源不一致时停止,不要选择修改时间最新
的文件,也不要只改其中一个。
配置声明的版本文件必须使用仓库相对路径。拒绝绝对路径、`..` 和解析后逃出目标
worktree 的 symlink;修改前确认规范化后的准确路径。不要用 glob 或模糊搜索结果执行
批量替换。
## 选择目标版本
项目已有 CalVer、单调构建号、独立包版本或自定义方案时继续使用,不要迁移到 SemVer。
项目没有规则且只有一个协调版本时使用 SemVer:
- 不兼容的公开 API 或行为变化增加 major。
- 向后兼容的新功能增加 minor。
- 向后兼容的修复增加 patch。
根据实际兼容性判断,不要只凭 commit 前缀自动升级。项目明确采用 Conventional Commits
或已有版本工具时,可以把它们的结果作为项目规则。破坏性影响无法确定时给出建议并请
用户确定准确版本。
预发布版本沿用项目格式;没有格式时使用 `-alpha.N`、`-beta.N` 或 `-rc.N`。正式 tag
默认不加入 build metadata。不要从带 build metadata 的 tag 推断兼容性顺序。
独立多包版本、多个维护分支和 release train 不属于默认单版本流程。无法确定一个目标
版本时停止并说明需要增加包或发布线维度。
## 规范化与渲染
内部始终分别记录规范版本、分支名和 tag 名。默认 SemVer 的规范版本不含 `v`,例如
用户输入 `v1.6.0` 时规范化为 `1.6.0`;版本文件写入 `1.6.0`,再将它代入
`release/v{version}` 和 `v{version}`。不要把完整 tag 名直接代入 `{version}`。
项目采用自定义前缀或非 SemVer 时,以项目版本来源定义规范值,以 branch/tag pattern
定义展示形式。规范化结果无法唯一确定时停止并让用户确认。
## 同步版本
在发布分支中一次性更新所有权威版本来源、生成文件和项目要求的 CHANGELOG。运行项目
自己的版本更新工具时,先检查它会修改哪些文件,避免隐式发布、提交或上传。
提交前确认:
- 所有权威来源都是目标版本。
- 生成文件与来源一致。
- CHANGELOG 或发布说明描述的是本次实际变更。
- 项目构建和测试读取到了新版本。
## 验证 merged commit
合并后以代码托管平台返回的 merged commit 为准。获取远端 base 后,确认该 commit 可从
远端 base 到达,并直接读取该 commit 中的版本文件。不要用仍停留在 feature worktree
中的文件证明已合并版本。
正式 tag 必须满足:
- tag 名按项目格式由目标版本唯一生成。
- 远端没有同名 tag,或同名 tag 已经准确指向本次 commit。
- annotated tag 显式指向 merged commit。
- 项目要求签名时,push 前已成功创建 signed tag,并在本地验证签名通过;不可用或失败
时停止,不得改用 unsigned tag。
- 推送后从远端重新读取,并将 annotated tag 解引用到 commit。
展开 tag pattern 后用 `git check-ref-format refs/tags/<tag>` 校验。把 tag 和 commit 作为
独立 argv 传递,不拼接 shell 字符串。
tag 已在远端指向其他 commit 时停止。不要 force push、删除或移动已发布 tag;由维护者
决定撤销发布或创建新的修订版本。
@@ -0,0 +1,17 @@
# 复制为项目根目录的 .release-policy.yaml,并按项目实际约定修改。
# 此文件描述发布策略,不授予任何远端写操作权限。
schema: 1
base_branch: main
# {version} 是规范版本,例如 1.6.0;前缀由 pattern 添加。
branch_pattern: release/v{version}
version:
scheme: semver
sources:
- VERSION
tag_pattern: v{version}
merge_method: squash
# 只表示项目偏好;创建 Forge Release 仍需用户本次请求明确授权。
forge_release: false
tag_signing: optional
# 只表示清理偏好;删除仍需用户在本次请求中明确授权。
delete_remote_branch: false
+291
View File
@@ -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()
+7 -1
View File
@@ -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(
+130 -1
View File
@@ -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",
+22 -2
View File
@@ -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(
"""
+119 -9
View File
@@ -37,6 +37,13 @@ class SkillInitTests(unittest.TestCase):
' devWorktree: "<dev_worktree>"\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: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'project:\n'
@@ -44,7 +51,25 @@ class SkillInitTests(unittest.TestCase):
' repoPath: "<repo_path>"\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: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'project:\n'
' name: "<project_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("<project_name>", tasks_content)
self.assertNotIn("<project_name>", knowledge_content)
self.assertNotIn("<project_name>", 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"