Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7efea28b4 | |||
| 5b0e41199d | |||
| 2c3d91c75c | |||
| f08edb6452 |
@@ -36,7 +36,7 @@ AGENTS.md # 详细规范与架构说明
|
|||||||
|
|
||||||
| Skill | 说明 |
|
| Skill | 说明 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 |
|
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
|
||||||
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
||||||
@@ -48,6 +48,9 @@ skiff init ack
|
|||||||
skiff init ack --project ~/app
|
skiff init ack --project ~/app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护
|
||||||
|
DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。
|
||||||
|
|
||||||
新建 skill:
|
新建 skill:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+27
-4
@@ -1311,9 +1311,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
project_file = destination / "project.md"
|
project_file = destination / "project.md"
|
||||||
tasks_file = destination / "tasks.yaml"
|
tasks_file = destination / "tasks.yaml"
|
||||||
knowledge_file = destination / "knowledge.yaml"
|
knowledge_file = destination / "knowledge.yaml"
|
||||||
|
delivery_file = destination / "delivery.yaml"
|
||||||
managed_targets = [project_file, tasks_file]
|
managed_targets = [project_file, tasks_file]
|
||||||
if args.name == "ack":
|
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()]
|
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||||
if existing:
|
if existing:
|
||||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||||
@@ -1326,8 +1327,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
(tasks_template, tasks_file),
|
(tasks_template, tasks_file),
|
||||||
]
|
]
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
template_targets.append(
|
template_targets.extend(
|
||||||
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file)
|
(
|
||||||
|
(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()]
|
missing = [path for path, _ in template_targets if not path.is_file()]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -1335,10 +1339,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
||||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||||
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
||||||
|
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
missing_validators = [
|
missing_validators = [
|
||||||
path
|
path
|
||||||
for path in (validator, knowledge_validator)
|
for path in (validator, knowledge_validator, delivery_validator)
|
||||||
if not path.is_file()
|
if not path.is_file()
|
||||||
]
|
]
|
||||||
if missing_validators:
|
if missing_validators:
|
||||||
@@ -1394,6 +1399,23 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"初始化知识库校验失败(exit {completed.returncode})"
|
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():
|
for target, staged in staged_files.items():
|
||||||
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
||||||
@@ -1621,6 +1643,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
_print(f" 任务板: {tasks_file}")
|
_print(f" 任务板: {tasks_file}")
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
_print(f" 知识库: {knowledge_file}")
|
_print(f" 知识库: {knowledge_file}")
|
||||||
|
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
||||||
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+69
-4
@@ -9,6 +9,15 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
|
|||||||
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
||||||
`leftover`,然后继续处理其它任务。
|
`leftover`,然后继续处理其它任务。
|
||||||
|
|
||||||
|
项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile
|
||||||
|
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
|
||||||
|
稳定发布与生产部署始终保留人工批准点。
|
||||||
|
|
||||||
|
显式调用 `$ack` 也可以直接处理一次性交付:ACK 根据请求把源码发布/发布 PR、DEB、
|
||||||
|
Docker 镜像分别路由给 `manage-release`、`deb-publisher` 或
|
||||||
|
`publish-docker-image`。实际操作由独立 Operator 执行;它使用与 Test 相同的低成本
|
||||||
|
模型和 reasoning effort,但不复用 Test terminal,也不进入 Developer → Test 闭环。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
全局安装:
|
全局安装:
|
||||||
@@ -38,7 +47,8 @@ skiff init ack --project ~/code/my-app
|
|||||||
docs/ack/
|
docs/ack/
|
||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
└── knowledge.yaml
|
├── knowledge.yaml
|
||||||
|
└── delivery.yaml # 默认 enabled: false
|
||||||
```
|
```
|
||||||
|
|
||||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
||||||
@@ -52,7 +62,7 @@ skills/ack/
|
|||||||
├── README.md
|
├── README.md
|
||||||
├── VERSION
|
├── VERSION
|
||||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
├── references/ # 三角色规范、闭环流程和初始化说明
|
||||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml 模板和 schema
|
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
|
||||||
├── examples/ # 完整示例
|
├── examples/ # 完整示例
|
||||||
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
||||||
```
|
```
|
||||||
@@ -61,6 +71,8 @@ skills/ack/
|
|||||||
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
||||||
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
||||||
已经独立验证的项目知识护栏。
|
已经独立验证的项目知识护栏。
|
||||||
|
`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在
|
||||||
|
`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。
|
||||||
|
|
||||||
## 检查项目状态
|
## 检查项目状态
|
||||||
|
|
||||||
@@ -70,6 +82,8 @@ Agent 会从当前 ACK Skill 目录解析校验脚本:
|
|||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
||||||
--tasks docs/ack/tasks.yaml
|
--tasks docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
Coordinator 可以按当前任务上下文做确定性推荐:
|
||||||
@@ -115,6 +129,36 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
|
|||||||
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
||||||
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
`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`。
|
||||||
|
|
||||||
|
## 一次性交付路由
|
||||||
|
|
||||||
|
直接发布时可以说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ack 为 v1.4.0 创建 release PR,合并后打 tag。
|
||||||
|
$ack 把 1.4.0 的 amd64 DEB 发布到 testing 仓库。
|
||||||
|
$ack 把当前提交发布成 registry.example.com/team/app:1.4.0。
|
||||||
|
```
|
||||||
|
|
||||||
|
源码版本生命周期和一次性 PR/MR 路由到 `manage-release`,DEB/APT 路由到
|
||||||
|
`deb-publisher`,Docker/OCI 镜像路由到 `publish-docker-image`。普通 PR/MR 只执行
|
||||||
|
`manage-release` 的 `PR-only` 流程,不会被误当成完整版本发布;功能任务 verified 后
|
||||||
|
按 profile 自动开 PR 时仍使用 ACK 原有的 `pull-request` 动作。一次性交付要求任务板配置
|
||||||
|
`defaults.operator`;当前模板已经提供,并强制其 CLI、standard 模型和 reasoning
|
||||||
|
effort 与 Test default 相同。详见 `references/delivery-routing.md`。
|
||||||
|
|
||||||
## 启动 Worker
|
## 启动 Worker
|
||||||
|
|
||||||
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
||||||
@@ -151,7 +195,8 @@ argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git workt
|
|||||||
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
|
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
|
||||||
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
|
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
|
||||||
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
|
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
|
||||||
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
|
写入任务的 `dispatch.developer`、`dispatch.test` 或 `dispatch.operator`,并同步写入
|
||||||
|
本轮 `attemptId`。
|
||||||
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
|
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
|
||||||
不能跨任务或跨轮次改挂。
|
不能跨任务或跨轮次改挂。
|
||||||
|
|
||||||
@@ -180,8 +225,28 @@ fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功
|
|||||||
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
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 记录
|
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
||||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||||
同时存在;旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
||||||
|
`project.deliveryFile` 与 `deliveryRuns`;从 `0.12.0` 起,新模板包含与 Test 同档模型的
|
||||||
|
Operator,用于一次性交付路由。旧项目可以不迁移而继续使用原闭环,但补齐 Operator
|
||||||
|
前不能使用一次性交付。旧项目的 `kitVersion` 可以继续读取,但建议迁移为
|
||||||
|
`ackVersion`。
|
||||||
|
|||||||
+87
-16
@@ -2,15 +2,17 @@
|
|||||||
name: ack
|
name: ack
|
||||||
description: >-
|
description: >-
|
||||||
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||||
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或指挥 Coordinator/Developer/Test
|
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
||||||
工作时使用。
|
工作、配置并执行任务验证后的项目交付流程,或把一次性 PR/发布请求路由给
|
||||||
|
manage-release、deb-publisher、publish-docker-image 普通 worker 时使用。
|
||||||
---
|
---
|
||||||
|
|
||||||
# ACK 项目协作入口
|
# ACK 项目协作入口
|
||||||
|
|
||||||
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
||||||
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
||||||
`tasks.yaml` 和 `knowledge.yaml`,不要复制或链接 Skill 内容。
|
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
|
||||||
|
内容。
|
||||||
|
|
||||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
||||||
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
||||||
@@ -20,6 +22,9 @@ description: >-
|
|||||||
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
||||||
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
||||||
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
||||||
|
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
|
||||||
|
- 用户直接要求创建发布 PR/MR、发布源码版本、DEB 或 Docker 镜像:执行
|
||||||
|
“一次性交付路由”。
|
||||||
|
|
||||||
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
||||||
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
||||||
@@ -54,18 +59,24 @@ description: >-
|
|||||||
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
||||||
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
||||||
猜测并激活知识。
|
猜测并激活知识。
|
||||||
8. 更新 `updatedAt`,并运行:
|
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
||||||
|
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
|
||||||
|
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
|
||||||
|
9. 更新 `updatedAt`,并运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
||||||
--tasks docs/ack/tasks.yaml
|
--tasks docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--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 +85,7 @@ description: >-
|
|||||||
- `docs/ack/project.md`
|
- `docs/ack/project.md`
|
||||||
- `docs/ack/tasks.yaml`
|
- `docs/ack/tasks.yaml`
|
||||||
- `docs/ack/knowledge.yaml`
|
- `docs/ack/knowledge.yaml`
|
||||||
|
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||||
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
||||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
||||||
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
||||||
@@ -82,17 +94,51 @@ description: >-
|
|||||||
黑盒命令和 Base URL。
|
黑盒命令和 Base URL。
|
||||||
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
||||||
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
|
`<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 仍留在任务证据中,且
|
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
||||||
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
||||||
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test/operator` 的引用;receipt
|
||||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||||
必须同时为空或同时填写。
|
必须同时为空或同时填写。
|
||||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||||
|
`defaults.operator` 对旧项目可选;存在时必须是 standard profile,且 CLI、模型与
|
||||||
|
reasoning effort 必须和 `defaults.test` 相同。缺少它只表示一次性交付路由不可用,
|
||||||
|
不影响原 Developer/Test 闭环。
|
||||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||||
|
|
||||||
|
## 一次性交付路由
|
||||||
|
|
||||||
|
1. 读取 `references/delivery-routing.md` 并按用户原始请求分类。源码版本、release/hotfix
|
||||||
|
分支、普通或发布 PR/MR、tag 或 Forge Release 使用 `manage-release`;普通 PR/MR
|
||||||
|
只授权它的 `PR-only` 流程,不推断版本升级、合并或 tag。DEB/APT 使用
|
||||||
|
`deb-publisher`;Docker/OCI registry 使用 `publish-docker-image`。
|
||||||
|
2. 若 `docs/ack/tasks.yaml` 不存在,或结构化 orchestration 没有通过校验的
|
||||||
|
`defaults.operator`,停止并建议初始化或按当前模板升级。不要由 Coordinator 亲自
|
||||||
|
执行,也不要猜模型。用户请求有多个合理路由且无法从项目事实唯一确定时,只问一个
|
||||||
|
最小澄清问题。
|
||||||
|
3. 在权威任务板新增 `type: delivery-operation` 的最小操作记录,`operation.skill` 保存
|
||||||
|
精确 Skill 名,`operation.request` 保留本次用户请求的授权语义;若原文含凭据值,
|
||||||
|
必须替换为 `[REDACTED]`,不能落盘。`dispatch.operator` 使用
|
||||||
|
`defaults.operator`。不为一次性交付写 PRD,也不进入 Developer → Test 闭环,
|
||||||
|
不读取或修改 `delivery.yaml`。
|
||||||
|
4. 运行 `validate_tasks.py`,再只用 `launch_worker.py plan|launch --role operator`
|
||||||
|
创建 fresh worker。审阅并绑定 fingerprint/receipt 后,把
|
||||||
|
`references/delivery-routing.md` 的 Operator prompt 和原始请求投递给 worker;
|
||||||
|
Orca 模式同时读取 `references/orca-adapter.md`,登记单一操作任务与 dispatch。
|
||||||
|
Operator 使用与 Test 相同的低成本模型/effort,但它是独立角色和独立终端,不复用
|
||||||
|
Test worker。
|
||||||
|
5. 被选中的低层 Skill 决定实际步骤、确认点、硬停止与恢复。ACK 路由和项目配置都不能
|
||||||
|
扩大用户授权;Docker 路由只因用户显式调用 `$ack` 且明确要求发布镜像才视为对
|
||||||
|
`publish-docker-image` 的显式委派。
|
||||||
|
6. Coordinator 只读 worker 证据并对照低层 Skill 完成标准终检。满足原始请求才把操作
|
||||||
|
标为 `verified`;可恢复的安全停止标为 `blocked`。远端写入部分成功或状态不确定时
|
||||||
|
不自动重试,先按低层 Skill 发现真实状态。
|
||||||
|
|
||||||
## 工作
|
## 工作
|
||||||
|
|
||||||
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
|
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
|
||||||
@@ -103,13 +149,16 @@ description: >-
|
|||||||
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
||||||
- `<ack-skill-dir>/references/kickoff.md`
|
- `<ack-skill-dir>/references/kickoff.md`
|
||||||
- kickoff 指定且与当前任务相关的 references 文件
|
- kickoff 指定且与当前任务相关的 references 文件
|
||||||
|
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
|
||||||
|
`<ack-skill-dir>/references/delivery.md`
|
||||||
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
||||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||||
不全量注入知识库。
|
不全量注入知识库。
|
||||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||||
然后交给用户确认;确认前不派发实现。
|
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
||||||
5. 创建或更换 worker 时,只使用
|
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
||||||
|
5. 创建或更换 Developer、Test 或 Operator worker 时,只使用
|
||||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||||
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
`orca terminal create --command`,不得接受或拼接自由 command、额外 argv、
|
||||||
@@ -129,6 +178,25 @@ description: >-
|
|||||||
`leftover` 并继续其它任务。
|
`leftover` 并继续其它任务。
|
||||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
`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 +209,13 @@ description: >-
|
|||||||
v0.10 不自动复用既有 worker。
|
v0.10 不自动复用既有 worker。
|
||||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
||||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||||
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
|
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 只读,
|
提交、推送、创建终端、新 worktree、发布产物或部署。
|
||||||
只能通过回报提名或验证知识。
|
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
||||||
|
与 Test 只读,只能通过回报提名或验证知识;Operator 同样只读这些状态文件,只回传
|
||||||
|
一次性交付证据。`delivery.yaml` 只在显式的交付配置维护中修改。
|
||||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
||||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml` 和
|
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml`;通用资源始终从当前 ACK Skill 目录读取。
|
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
|
||||||
|
ACK Skill 目录读取。
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.10.0
|
0.12.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "ACK"
|
display_name: "ACK"
|
||||||
short_description: "初始化、检查并运行项目里的 ACK 三角色协作闭环"
|
short_description: "运行 ACK 开发验证闭环,并路由 PR、源码、DEB 与镜像发布"
|
||||||
default_prompt: "Use $ack to initialize ACK for this project or coordinate work from docs/ack."
|
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain delivery configuration, run an approved delivery profile, or route a one-off release request to a low-cost operator."
|
||||||
policy:
|
policy:
|
||||||
allow_implicit_invocation: false
|
allow_implicit_invocation: false
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ack v0.10.0。
|
> 本项目基于 ack v0.11.0。
|
||||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> `docs/ack/` 只保存 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
|
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
- Base URL:`http://localhost:5173`
|
- Base URL:`http://localhost:5173`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`docs/ack/knowledge.yaml`
|
||||||
|
- 交付契约:`docs/ack/delivery.yaml`
|
||||||
- 覆盖层文件:`docs/ack/project.md`
|
- 覆盖层文件:`docs/ack/project.md`
|
||||||
|
|
||||||
## 稳定规范(引用,不重复)
|
## 稳定规范(引用,不重复)
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
|
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
|
||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令:`references/orca-adapter.md`
|
- Orca 编排命令:`references/orca-adapter.md`
|
||||||
|
- 验证后交付:`references/delivery.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `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 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
|
||||||
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
||||||
path/args。
|
path/args。
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 执行。
|
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
||||||
|
交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
@@ -93,5 +97,7 @@ path/args。
|
|||||||
`candidate` 不派发,知识库不全量注入。
|
`candidate` 不派发,知识库不全量注入。
|
||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
||||||
|
- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与
|
||||||
|
production 部署保留显式 approval,配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
||||||
- 不提交或推送,除非用户明确要求。
|
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||||
source: "Coordinator (PM) Agent"
|
source: "Coordinator (PM) Agent"
|
||||||
ackVersion: "0.10.0"
|
ackVersion: "0.11.0"
|
||||||
project:
|
project:
|
||||||
name: "notes-web"
|
name: "notes-web"
|
||||||
repoPath: "/home/dev/notes-web"
|
repoPath: "/home/dev/notes-web"
|
||||||
@@ -11,6 +11,7 @@ project:
|
|||||||
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: "docs/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||||
|
deliveryFile: "docs/ack/delivery.yaml"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "manual"
|
mode: "manual"
|
||||||
@@ -70,6 +71,7 @@ project:
|
|||||||
developerUpgraded: "codex-dev-strong"
|
developerUpgraded: "codex-dev-strong"
|
||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
|
deliveryRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: ["BUG-002"]
|
verified: ["BUG-002"]
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
||||||
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
||||||
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
|
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与
|
||||||
|
默认关闭的 `delivery.yaml`。
|
||||||
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
||||||
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||||
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
||||||
@@ -16,10 +17,14 @@
|
|||||||
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
||||||
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
||||||
`docs/ack/knowledge.yaml`。
|
`docs/ack/knowledge.yaml`。
|
||||||
|
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
|
||||||
|
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
||||||
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
||||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||||
allowlist,默认 profile 与角色/档位一致。
|
allowlist,默认 profile 与角色/档位一致。
|
||||||
|
- [ ] 需要一次性交付路由时存在 `defaults.operator`;其 CLI、standard 模型和
|
||||||
|
reasoning effort 与 `defaults.test` 完全相同。
|
||||||
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
- [ ] `allowedWorktrees` 只列出同一 Git common-dir 下已经核对的绝对 worktree。
|
||||||
|
|
||||||
## 路径权限
|
## 路径权限
|
||||||
@@ -30,6 +35,8 @@
|
|||||||
- [ ] 私有配置只读且不提交。
|
- [ ] 私有配置只读且不提交。
|
||||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||||
|
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||||
|
- [ ] Operator 只回传一次性交付证据,不写 ACK 项目状态文件。
|
||||||
|
|
||||||
## 任务板
|
## 任务板
|
||||||
|
|
||||||
@@ -38,6 +45,32 @@
|
|||||||
- [ ] 真实任务的验收是可观测信号。
|
- [ ] 真实任务的验收是可观测信号。
|
||||||
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
|
- [ ] 已运行 `<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`;
|
||||||
|
失败不会把已验证任务回退为失败。
|
||||||
|
|
||||||
|
## 可选一次性交付路由
|
||||||
|
|
||||||
|
- [ ] PR/MR 与源码发布、DEB、Docker 镜像分别路由到 `manage-release`、
|
||||||
|
`deb-publisher`、`publish-docker-image`;普通 PR/MR 只使用 `PR-only`,没有扩大
|
||||||
|
到版本升级、合并、tag 或 Forge Release。
|
||||||
|
- [ ] `delivery-operation.operation.request` 保留用户原始请求,没有扩大授权。
|
||||||
|
- [ ] Operator 使用 `launch_worker.py --role operator` 创建的 fresh worker,没有复用
|
||||||
|
Test terminal,也没有让 Coordinator 代跑。
|
||||||
|
- [ ] 部分成功或外部状态不确定时按低层 Skill 恢复,没有盲目自动重试。
|
||||||
|
|
||||||
## 项目知识
|
## 项目知识
|
||||||
|
|
||||||
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
||||||
@@ -63,13 +96,15 @@
|
|||||||
`orca terminal create --command` 或自由 worker 命令。
|
`orca terminal create --command` 或自由 worker 命令。
|
||||||
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
|
- [ ] `launch` 使用刚审阅的 `plan.launchFingerprint` 作为
|
||||||
`--expected-launch-fingerprint`;漂移时重新 plan。
|
`--expected-launch-fingerprint`;漂移时重新 plan。
|
||||||
- [ ] Developer/Test 只使用 `read-only` 或 `workspace-write` profile;
|
- [ ] Developer/Test/Operator 只使用 `read-only` 或 `workspace-write` profile;
|
||||||
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
v0.10 没有 full-access、bypass、YOLO/force 或关闭 sandbox。
|
||||||
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
- [ ] v0.10 每次自动派发都启动 fresh worker;没有把历史 receipt checksum 或
|
||||||
Orca live metadata 当作自动复用授权。
|
Orca live metadata 当作自动复用授权。
|
||||||
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
- [ ] launcher receipt 已追加到顶层 `workerReceipts`,任务分别引用
|
||||||
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
`dispatch.developer` 与 `dispatch.test`;每个引用的 task/role/profile/attempt
|
||||||
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
都与当前任务、本轮 receipt 完全一致,没有用单一 handle 混淆两个角色。
|
||||||
|
- [ ] 一次性交付 receipt 引用 `dispatch.operator`,与当前操作、profile 和 attempt
|
||||||
|
完全一致。
|
||||||
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
|
- [ ] 多 worktree 场景只有一个权威 `tasks.yaml` 和 `knowledge.yaml`。
|
||||||
- [ ] Test 使用的服务来自正确 worktree。
|
- [ ] Test 使用的服务来自正确 worktree。
|
||||||
|
|
||||||
|
|||||||
@@ -164,11 +164,12 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
||||||
| 小改动、追求快 | 当前 worktree |
|
| 小改动、追求快 | 当前 worktree |
|
||||||
|
|
||||||
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 和
|
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
|
||||||
`knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由
|
`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在
|
||||||
Coordinator 单写。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch
|
worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护
|
||||||
也只写入这个副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、
|
时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||||
launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
||||||
|
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||||
|
|
||||||
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
|
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
|
||||||
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
|
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
|
||||||
@@ -191,7 +192,8 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
|
|||||||
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
||||||
|
|
||||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。
|
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||||
|
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
|
||||||
|
|
||||||
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
||||||
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
|
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ACK 一次性交付路由
|
||||||
|
|
||||||
|
本文件定义用户显式调用 `$ack` 后,直接要求创建发布相关 PR/MR、发布源码版本、
|
||||||
|
上传 DEB 或发布 Docker 镜像时的路由。它不要求先跑 Developer → Test 闭环,也不读取
|
||||||
|
或修改 `delivery.yaml`;项目已有的“verified 后按 profile 交付”仍按 `delivery.md`
|
||||||
|
执行。
|
||||||
|
|
||||||
|
## 1. 只按用户目标选择能力
|
||||||
|
|
||||||
|
| 用户目标 | 路由 | 不得顺带执行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 创建普通或发布 PR/MR,以及版本号、release/hotfix 分支、合并、正式 tag、Forge Release、恢复中断的源码发布 | `manage-release` | 未请求的版本升级、合并、tag、Forge Release、清理、普通产物上传 |
|
||||||
|
| 构建、检查、上传 `.deb`,发布到 APT/DEB 仓库 | `deb-publisher` | tag、Forge Release、Docker 镜像 |
|
||||||
|
| 构建并推送 Docker/OCI 镜像到 registry | `publish-docker-image` | 源码版本、DEB、额外 tag;用户说的 `docker-publisher` 视为这个已安装 Skill 的别名 |
|
||||||
|
|
||||||
|
“创建 PR/MR”路由到 `manage-release`,但必须区分权限上限:发布版本、release/hotfix
|
||||||
|
分支或版本升级对应的 PR/MR 走发布流程;普通 PR/MR 只走它的 ACK `PR-only` 入口,
|
||||||
|
不得推断版本升级、release 分支、合并、tag 或 Forge Release。ACK 功能任务在 verified
|
||||||
|
后按 profile 自动开 PR 时,仍使用 `delivery.md` 的 `pull-request` 动作,不重复建立
|
||||||
|
一次性操作。
|
||||||
|
|
||||||
|
只说“发布一下”时先从用户文字和项目发布入口判断目标。DEB、镜像和源码发布仍有两个
|
||||||
|
以上合理候选时,先问一个最小澄清问题;不要根据最近文件或历史命令猜。用户明确要求
|
||||||
|
多个产物时,为每个能力建立独立操作,按用户给出的顺序执行,不能由一个 Skill 暗中
|
||||||
|
扩大到另一个 Skill。
|
||||||
|
|
||||||
|
## 2. 用低成本 Operator 派发
|
||||||
|
|
||||||
|
一次性交付由 `operator` worker 执行,Coordinator 不亲自运行低层 Skill。机器事实仍
|
||||||
|
只读 `docs/ack/tasks.yaml.project.orchestration`:
|
||||||
|
|
||||||
|
- `defaults.operator` 必须指向 `role: operator`、`tier: standard` 的 profile。
|
||||||
|
- Operator 默认 profile 的 `cli`、`tier`、`model` 和 `reasoningEffort` 必须与
|
||||||
|
`defaults.test` 完全相同;校验器会拒绝漂移。`permissionMode` 仍按项目需要显式写
|
||||||
|
`read-only` 或 `workspace-write`,发布通常需要后者。
|
||||||
|
- 旧项目没有 `defaults.operator` 时,普通 ACK 闭环仍可运行,但一次性交付路由必须
|
||||||
|
fail closed。先按当前模板补 operator allowlist/profile/default 并校验,不能临时猜
|
||||||
|
模型或让 Coordinator 代跑。
|
||||||
|
- launcher 只给 Operator 固定的发布凭据 allowlist:`DEB_SERVER_URL`、`DEB_TOKEN`、
|
||||||
|
`DEB_REPOSITORY`、`DEB_UPLOAD_PATH` 和 `SSH_AUTH_SOCK`;Docker/Forge 使用 HOME
|
||||||
|
中自己的凭据存储。值不写入任务板或 receipt。缺少凭据时由低层 Skill 标记 blocked,
|
||||||
|
不通过 prompt、自由 env 或命令行注入秘密。
|
||||||
|
|
||||||
|
路由后在权威任务板新增一条最小记录:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "DELIVERY-001"
|
||||||
|
type: "delivery-operation"
|
||||||
|
title: "publish one DEB"
|
||||||
|
status: "open"
|
||||||
|
operation:
|
||||||
|
skill: "deb-publisher"
|
||||||
|
request: "<保留用户本次请求的授权语义;凭据值必须替换为 [REDACTED]>"
|
||||||
|
dispatch:
|
||||||
|
operator:
|
||||||
|
profileId: "<defaults.operator>"
|
||||||
|
receiptId: null
|
||||||
|
attemptId: null
|
||||||
|
taskId: null
|
||||||
|
dispatchId: null
|
||||||
|
rounds: []
|
||||||
|
```
|
||||||
|
|
||||||
|
运行 `validate_tasks.py` 后,用 `launch_worker.py plan|launch` 创建 fresh worker,参数
|
||||||
|
使用 `--role operator`、本操作 ID 和 `<operation-id>-A1`。审阅 fingerprint,成功后
|
||||||
|
把 receipt 写入 `workerReceipts` 并与 `dispatch.operator` 精确绑定,再投递下节的
|
||||||
|
prompt。Orca 模式按 `orca-adapter.md` 登记单一操作任务,并把 runtime task/dispatch
|
||||||
|
ID 写入 `dispatch.operator`;不创建 Developer/Test 子任务链。不得复用 Test terminal;
|
||||||
|
“同模型档位”不等于“同角色或同 worker”。
|
||||||
|
|
||||||
|
## 3. Operator 派发 Prompt
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是 ACK 一次性交付 Operator。请在 <worktree> 执行以下原始请求:
|
||||||
|
<operation.request>
|
||||||
|
|
||||||
|
ACK 已选择且只授权你加载:$<operation.skill>
|
||||||
|
先完整读取该 Skill 及它要求的 references,再读取可信 base 上的项目发布规则。
|
||||||
|
|
||||||
|
边界:
|
||||||
|
- 原始请求是授权上限;除已脱敏的凭据值外保持原意,不得因 ACK 路由、项目配置或
|
||||||
|
历史操作扩大远端写权限。
|
||||||
|
- 严格遵守低层 Skill 的确认点、硬停止条件、恢复流程和完成标准。
|
||||||
|
- 不修改 docs/ack/tasks.yaml、knowledge.yaml 或 delivery.yaml。
|
||||||
|
- 不把 token、密码、私钥路径或认证配置写入消息、文件、提交或日志。
|
||||||
|
- 发生部分成功或外部状态不确定时停止自动重试,先按低层 Skill 核对真实状态。
|
||||||
|
|
||||||
|
完成或阻塞后只回传:所选 Skill、实际执行到的阶段、准确目标、非敏感证据、
|
||||||
|
未完成项和安全恢复动作。不要把命令已运行等同于发布已验证。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 状态与完成
|
||||||
|
|
||||||
|
Coordinator 在派发后把操作标为 `dispatched`,只读 Operator 的结构化证据做终检:
|
||||||
|
|
||||||
|
- `manage-release` 必须按其完成标准证明 PR/MR、merged commit、远端 tag 或 Forge
|
||||||
|
Release 中用户实际要求的最远阶段。
|
||||||
|
- `deb-publisher` 必须分别证明包元数据/摘要、上传接受和仓库可见性。
|
||||||
|
- `publish-docker-image` 必须证明完整镜像引用、platform、源 commit 和远端 digest;
|
||||||
|
registry 无法查询时明确限制。
|
||||||
|
|
||||||
|
证据满足原始请求才把操作标为 `verified`;安全停止但可恢复时标为 `blocked` 并记录
|
||||||
|
恢复动作。`worker_done` 不等于完成。涉及远端写入的失败不自动进入第二轮;先由对应
|
||||||
|
低层 Skill 执行真实状态发现和恢复,再由用户决定是否继续。
|
||||||
|
|
||||||
|
Operator receipt 和操作证据写在该任务中,不写入 `deliveryRuns`。一次性交付不会因
|
||||||
|
项目其它任务未 `verified` 而被拦截,也不会触发 `delivery.yaml` 的 profile;两种流程
|
||||||
|
必须保持独立,避免同一请求被重复发布。
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 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 不能绕过它的触发与授权边界。
|
||||||
|
|
||||||
|
用户显式调用 `$ack` 直接要求创建发布 PR/MR、发布源码版本、DEB 或镜像时,走
|
||||||
|
`delivery-routing.md` 的一次性交付流程,不要求其它 ACK 任务先 `verified`,也不创建
|
||||||
|
本文件定义的 `deliveryRuns`。两种流程不能在同一请求中隐式重复执行。
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
3. `skiff` 命令可用。
|
3. `skiff` 命令可用。
|
||||||
|
|
||||||
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml`、`AGENTS.md` 或其它 Agent 指令文件。ACK 不会自动
|
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent
|
||||||
|
指令文件。ACK 不会自动
|
||||||
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
||||||
内容或其它私有配置写入 ACK 项目状态。
|
内容或其它私有配置写入 ACK 项目状态。
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ skiff init ack --project <project-root>
|
|||||||
docs/ack/
|
docs/ack/
|
||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
└── knowledge.yaml
|
├── knowledge.yaml
|
||||||
|
└── delivery.yaml # 默认 enabled: false
|
||||||
```
|
```
|
||||||
|
|
||||||
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
||||||
@@ -52,6 +54,14 @@ docs/ack/
|
|||||||
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
`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`,填入:
|
编辑 `docs/ack/project.md`,填入:
|
||||||
@@ -70,9 +80,16 @@ docs/ack/
|
|||||||
|
|
||||||
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
||||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||||
|
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
||||||
|
自动补交付配置。
|
||||||
|
- 从 `0.12.0` 起的新项目 orchestration 模板包含可选一次性交付所需的 Operator;其
|
||||||
|
默认 CLI、standard 模型和 reasoning effort 与 Test default 相同。旧项目不补也能
|
||||||
|
运行原闭环,但不能使用一次性交付路由。
|
||||||
- `updatedAt` 使用当前带时区时间。
|
- `updatedAt` 使用当前带时区时间。
|
||||||
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
||||||
真实值。
|
真实值。
|
||||||
|
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
||||||
|
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
||||||
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
||||||
`extraArgs`、`env` 或任意 executable 写进任务板。
|
`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||||
@@ -98,6 +115,14 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
|
|||||||
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
|
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
|
||||||
最终下沉为测试、lint、CI 或正式规范。
|
最终下沉为测试、lint、CI 或正式规范。
|
||||||
|
|
||||||
|
## 初始化项目交付
|
||||||
|
|
||||||
|
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表和空 profile。
|
||||||
|
不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言描述交付要求后,
|
||||||
|
Coordinator 按 `delivery.md` 维护声明式配置:工具 target 与仓库脚本分开引用,产物、
|
||||||
|
目的地、环境和有序 profile 使用稳定 ID。配置中不保存 shell、环境变量值或凭据
|
||||||
|
正文;稳定发布和生产部署必须有显式 approval 步骤。
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
||||||
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
||||||
@@ -105,17 +130,22 @@ Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
|||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--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.overlayFile` 指向真实文件。
|
||||||
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
||||||
|
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
|
||||||
- Developer 与 Test 的验证命令可执行。
|
- Developer 与 Test 的验证命令可执行。
|
||||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||||
- 顶层 `workerReceipts` 和 `dispatch.developer/test` 的 task/role/profile/attempt
|
- 若配置 Operator,其 default 与 Test 使用相同 CLI、standard 模型和 effort。
|
||||||
|
- 顶层 `workerReceipts` 和 `dispatch.developer/test/operator` 的 task/role/profile/attempt
|
||||||
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
引用一致;`receiptId` 与 `attemptId` 同时为空或同时填写。持久 receipt 只作审计,
|
||||||
v0.10 不自动复用旧终端。
|
v0.10 不自动复用旧终端。
|
||||||
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
- 网站或 API 项目写清服务启动、重启和 Base URL。
|
||||||
@@ -125,9 +155,9 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
|||||||
|
|
||||||
完成后报告:
|
完成后报告:
|
||||||
|
|
||||||
- 创建或确认的三个项目文件。
|
- 创建或确认的四个项目文件。
|
||||||
- 检测到的技术栈和验证命令。
|
- 检测到的技术栈和验证命令。
|
||||||
- 任务板和项目知识校验结果。
|
- 任务板、项目知识和交付契约校验结果。
|
||||||
- 仍需用户补充的值。
|
- 仍需用户补充的值。
|
||||||
|
|
||||||
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
||||||
|
|||||||
@@ -23,15 +23,20 @@
|
|||||||
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
||||||
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
||||||
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
||||||
|
如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与
|
||||||
|
references/delivery.md,但不要把配置本身当作执行授权。
|
||||||
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||||
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
||||||
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
||||||
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
||||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。
|
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||||
|
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||||
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
||||||
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||||
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
每个任务最多三轮,三轮不过记 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. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
||||||
|
|
||||||
|
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
|
||||||
|
发布 channel、部署环境、PR 是否创建/标 ready、停止点,以及哪些步骤会再次审批。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第 2 步:决定 worktree
|
## 第 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、各失败几轮、工作树是否干净、还有没有未处理项。
|
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||||
|
|
||||||
@@ -154,5 +171,5 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
|||||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||||
`tasks.yaml`,验证后的
|
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
||||||
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 模型路由(稳定核心)
|
# 模型路由(稳定核心)
|
||||||
|
|
||||||
本文件是**三角色默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
|
本文件是**三角色与可选 Operator 的默认模型档位、升级规则和 worker profile 选择规则**的单一规范源。
|
||||||
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
目标是在不牺牲质量的前提下降低 token 和模型成本:把昂贵的强模型留给需要判断的
|
||||||
工作,把机械执行交给较弱模型。
|
工作,把机械执行交给较弱模型。
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
|
| Coordinator (PM) | 强模型 | 需求拆解、验收信号设计、优先级、终检对齐意图、三轮失败复盘都需要高质量推理 |
|
||||||
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
| Test | 中低模型 | 按既定验收信号执行浏览器/API/脚本,主要做观察、记录、逐条 pass/fail |
|
||||||
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
| Developer | 中低模型(按任务升级) | 多数实现可照规格执行;跨系统、数据迁移、重复失败时再升级 |
|
||||||
|
| Operator(一次性交付) | 与 Test 相同的中低模型 | 按低层发布 Skill 执行机械步骤并回传证据,不承担范围决策 |
|
||||||
|
|
||||||
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
|
Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),所以强模型的 token
|
||||||
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
|
花在思考和终检上,而不是反复点击页面、跑 smoke、复制日志。这一分工天然省 token,
|
||||||
@@ -36,6 +37,7 @@ Coordinator 用强模型但**不亲自跑测试**(测试由 Test 承担),
|
|||||||
|
|
||||||
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
|
- Test:跑浏览器用例、API smoke、逐条比对期望与实际、产出证据。
|
||||||
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
|
- Developer:从清晰规格实现范围明确的任务、跑构建与单测、回报 worker_done。
|
||||||
|
- Operator:执行 `delivery-routing.md` 已选定的发布 Skill,按其停止点记录证据。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
|||||||
|
|
||||||
每个 profile 明确声明:
|
每个 profile 明确声明:
|
||||||
|
|
||||||
- `role`:`developer` 或 `test`;
|
- `role`:`developer`、`test` 或 `operator`;
|
||||||
- `cli`:受支持的 Agent CLI;
|
- `cli`:受支持的 Agent CLI;
|
||||||
- `tier`:角色模型档位;
|
- `tier`:角色模型档位;
|
||||||
- `model`:项目 allowlist 中的精确模型 ID;
|
- `model`:项目 allowlist 中的精确模型 ID;
|
||||||
@@ -83,6 +85,9 @@ worker 路由的机器可读事实只保存在 `docs/ack/tasks.yaml` 的
|
|||||||
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
`project.orchestration` 还必须声明 `allowedWorktrees`、按 CLI/角色/档位分组的
|
||||||
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
`modelAllowlist`、命名 `profiles` 和角色 `defaults`。launcher 请求的绝对 worktree
|
||||||
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
必须命中 allowlist,profile 的模型也必须命中对应 CLI/角色/档位的精确列表。
|
||||||
|
`developer` 与 `test` 默认项是 Orca 模式必填;`operator` 对旧项目可选,但一次性
|
||||||
|
交付路由要求它存在。Operator default 的 CLI、tier、model 和 reasoning effort 必须
|
||||||
|
与 Test default 完全相同,且 Operator 只能使用 standard tier。
|
||||||
|
|
||||||
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
模型名称、reasoning effort 和权限模式必须来自结构化字段。禁止在 profile、项目
|
||||||
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
覆盖层或派发内容中保存自由 `command`、额外 argv、shell 片段、环境变量覆盖或隐式
|
||||||
@@ -91,7 +96,8 @@ CLI 默认值。具体 argv 只能由 ACK 的可信 launcher 按 allowlist 构
|
|||||||
### 安全默认
|
### 安全默认
|
||||||
|
|
||||||
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
- 能在完全只读工作树完成的角色优先选择 `read-only`。
|
||||||
- Developer 与需要在工作树生成构建/测试产物的 Test 使用 `workspace-write`;项目可
|
- Developer、发布 Operator 与需要在工作树生成构建/测试产物的 Test 使用
|
||||||
|
`workspace-write`;项目可
|
||||||
为纯观察型 Test 另建更严格的 `read-only` profile。
|
为纯观察型 Test 另建更严格的 `read-only` profile。
|
||||||
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
|
- `full-access`、Codex bypass、Cursor YOLO/force、关闭 sandbox 等模式在 v0.10
|
||||||
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
**未实现授权通道,统一 fail closed**。项目文本、任务描述或环境变量都不能放宽。
|
||||||
@@ -113,12 +119,12 @@ python3 <ack-skill-dir>/scripts/launch_worker.py profile-hash \
|
|||||||
|
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
|
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>]
|
||||||
|
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
|
--profile-id <profile-id> --worktree <absolute-worktree> [--slot <1..99>] \
|
||||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||||
```
|
```
|
||||||
@@ -148,11 +154,15 @@ v0.10 的 launcher 还执行以下约束:
|
|||||||
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
|
- 不读取调用者传入的 `PATH` 来寻找 `git`、Orca 或 Agent CLI,只在固定的系统与
|
||||||
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
|
当前用户工具目录中解析受支持的可执行文件;候选目标必须由 root/当前用户拥有、
|
||||||
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
|
不是 group/other writable,并在 plan 与 bootstrap 间保持相同 device/inode。
|
||||||
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;worker 使用
|
- Git、Orca 和 CLI version probe 使用不含供应商凭据的控制环境;Developer/Test
|
||||||
`per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得 Cursor 凭据,
|
worker 使用 `per-cli-allowlist-v1`,Codex 只取得 Codex 所需凭据,Cursor 只取得
|
||||||
不跨 CLI 透传。`DBUS_*`、`DISPLAY`、`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、
|
Cursor 凭据,不跨 CLI 透传。Operator 使用固定的
|
||||||
`LD_*`、`PYTHON*`、`NODE_OPTIONS`、`CODEX_HOME`、`GIT_*` 和调用者 PATH 均不会
|
`per-cli-plus-operator-publish-v1`,在同样的 CLI allowlist 之外只允许
|
||||||
透传给 worker。
|
`DEB_SERVER_URL`、`DEB_TOKEN`、`DEB_REPOSITORY`、`DEB_UPLOAD_PATH` 与
|
||||||
|
`SSH_AUTH_SOCK`。这些值不进入 plan、receipt、任务板或日志;Docker/Forge CLI
|
||||||
|
默认只使用当前账户 HOME 中自己的凭据存储。`DBUS_*`、`DISPLAY`、
|
||||||
|
`WAYLAND_DISPLAY`、`XDG_RUNTIME_DIR`、`LD_*`、`PYTHON*`、`NODE_OPTIONS`、
|
||||||
|
`CODEX_HOME`、`GIT_*`、任意 Docker 密码变量和调用者 PATH 均不会透传给 worker。
|
||||||
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
|
- worktree 除了命中项目 allowlist、属于同一 Git common-dir,还必须精确出现在
|
||||||
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
|
`git worktree list --porcelain -z` 中;`.git` symlink 冒充的目录会失败。
|
||||||
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
|
- Orca 的 `--command` 只含固定 bootstrap 与随机 launch ID。终端创建后,父进程先
|
||||||
@@ -232,7 +242,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可
|
|||||||
|
|
||||||
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
强模型产出高密度、可复用的产物:需求、架构决策、验收信号、任务拆分、失败复盘。
|
||||||
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
|
中低模型消费这些产物,产出可核对的执行证据:测试结果、快照、API 响应、构建日志、
|
||||||
改动文件清单。
|
改动文件清单和发布目标摘要。
|
||||||
|
|
||||||
这样把昂贵推理挡在重复执行之外。
|
这样把昂贵推理挡在重复执行之外。
|
||||||
|
|
||||||
@@ -240,6 +250,7 @@ worker 自报或单独的 Orca live metadata 都不能把旧终端提升为可
|
|||||||
|
|
||||||
## 一句话
|
## 一句话
|
||||||
|
|
||||||
Coordinator 是脑,Test 是眼,Developer 是手。脑用强模型且不做机械测试,眼和手
|
Coordinator 是脑,Test 是眼,Developer 是手;一次性交付 Operator 是按已选 Skill
|
||||||
默认用安全、较低成本的结构化 profile;只有常规闭环卡住时才升级,并且所有 worker
|
操作发布系统的手。Coordinator 用强模型做判断,其余角色默认用安全、较低成本的结构化
|
||||||
都必须经 launcher 产生可核对的 receipt。
|
profile;只有常规闭环卡住时才升级,并且所有 worker 都必须经 launcher 产生可核对的
|
||||||
|
receipt。
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ orca orchestration inbox --limit 20 --json
|
|||||||
```
|
```
|
||||||
|
|
||||||
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
确认:Orca runtime 可达并取得本次 `runtimeId`;Coordinator 终端存在;当前没有冲突
|
||||||
的活跃编排任务。Developer/Test worker 必须通过本次 launcher 新建,不要求预先存在。
|
的活跃编排任务。Developer/Test worker 与一次性交付 Operator 都必须通过本次 launcher
|
||||||
|
新建,不要求预先存在。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ dispatch。未来若 Orca/ACP 增加启动参数 attestation,或 ACK 接入项
|
|||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ python3 <ack-skill-dir>/scripts/launch_worker.py plan \
|
|||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
||||||
--project-root <project-root> --task-id <task-id> \
|
--project-root <project-root> --task-id <task-id> \
|
||||||
--attempt-id <task-id>-A<round> --role <developer-or-test> \
|
--attempt-id <task-id>-A<round> --role <developer-or-test-or-operator> \
|
||||||
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
--profile-id <profile-id> --worktree <absolute-worktree> --slot <1..99> \
|
||||||
--expected-launch-fingerprint <plan 中的 sha256:...>
|
--expected-launch-fingerprint <plan 中的 sha256:...>
|
||||||
```
|
```
|
||||||
@@ -101,6 +102,11 @@ launcher fail closed,不能改用手写命令兜底。模型档位与升级规
|
|||||||
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
该路径上走 `plan` -> 带 expected fingerprint 的 `launch`,并把 receipt 留作审计。
|
||||||
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
既有会话可以由用户直接操作,但不能进入 ACK v0.10 的自动派发信任路径。
|
||||||
|
|
||||||
|
一次性交付不创建 Developer/Test 子任务链。按 `delivery-routing.md` 建立一个
|
||||||
|
`delivery-operation`,使用 fresh Operator receipt 中的 handle 创建/登记单一 Orca
|
||||||
|
任务并投递 Operator prompt;把返回的 runtime task/dispatch ID 写入
|
||||||
|
`dispatch.operator`。低层 Skill 的完成或 blocked 回报仍由 Coordinator 读证据后落盘。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 创建父任务
|
## 创建父任务
|
||||||
|
|||||||
@@ -256,6 +256,13 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
||||||
- 待验证 candidate:<remaining candidates or none>
|
- 待验证 candidate:<remaining candidates or none>
|
||||||
|
|
||||||
|
交付(未启用时写 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>
|
- <repo_path>: <git status summary>
|
||||||
- <dev_worktree>: <git status summary>
|
- <dev_worktree>: <git status summary>
|
||||||
|
|||||||
@@ -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` |
|
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
||||||
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
||||||
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||||
@@ -21,6 +21,11 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
**模型档位(正交层)。** 三角色默认按成本分层:Coordinator 用强模型,Test 与 Developer 用中低模型,必要时升级。完整档位表与升级规则见 `model-routing.md`。Coordinator 用强模型但不跑测试,这一分工天然省 token 又不破坏「验证者 ≠ 实现者」。
|
||||||
|
|
||||||
|
**一次性交付 Operator 不进入三角色开发闭环。** 用户显式调用 `$ack` 直接要求发布时,
|
||||||
|
Coordinator 按 `delivery-routing.md` 选择低层 Skill,并把原始请求交给独立 Operator。
|
||||||
|
Operator 与 Test 使用相同低成本模型/effort,但不承担独立复测;它只执行被选 Skill、
|
||||||
|
遵守其授权边界并回传发布证据,且不得写 ACK 状态文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三角色能力清单(SSOT)
|
## 三角色能力清单(SSOT)
|
||||||
@@ -109,6 +114,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,12 +151,33 @@ failed_retest(累计 3 轮) -> leftover
|
|||||||
|
|
||||||
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
||||||
|
|
||||||
|
`type: delivery-operation` 使用独立的短状态流:`open -> dispatched -> verified | blocked`。
|
||||||
|
这里的 `verified` 只表示 Coordinator 已按被路由 Skill 的完成标准核对一次性交付证据,
|
||||||
|
不表示代码经过 Test 复测,也不能作为普通功能任务的验证证据。远端部分成功不会自动
|
||||||
|
重派;恢复规则见 `delivery-routing.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` 是跨任务项目知识事实源。为避免多
|
`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源,
|
||||||
Agent 并发写冲突:
|
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
||||||
|
|
||||||
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
||||||
对它们都是只读的。
|
对它们都是只读的。
|
||||||
@@ -160,6 +187,8 @@ Agent 并发写冲突:
|
|||||||
知识。
|
知识。
|
||||||
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
|
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
|
||||||
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
|
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
|
||||||
|
- `delivery.yaml` 只在用户显式要求维护配置时修改;运行只写
|
||||||
|
`tasks.yaml.deliveryRuns`,不能反向改写能力定义。
|
||||||
|
|
||||||
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
|
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
|
||||||
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
|
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from validate_tasks import load_document, validate_builtin # noqa: E402
|
|||||||
from worker_profiles import ( # noqa: E402
|
from worker_profiles import ( # noqa: E402
|
||||||
LAUNCH_PROTOCOL_VERSION,
|
LAUNCH_PROTOCOL_VERSION,
|
||||||
canonical_sha256,
|
canonical_sha256,
|
||||||
|
environment_policy_for_role,
|
||||||
profile_hash,
|
profile_hash,
|
||||||
render_worker_argv,
|
render_worker_argv,
|
||||||
validate_routing_document,
|
validate_routing_document,
|
||||||
@@ -48,7 +49,6 @@ from worker_profiles import ( # noqa: E402
|
|||||||
|
|
||||||
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
PROTOCOL_VERSION = LAUNCH_PROTOCOL_VERSION
|
||||||
RECEIPT_VERSION = 1
|
RECEIPT_VERSION = 1
|
||||||
ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
|
||||||
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
|
TASKS_RELATIVE_PATH = Path("docs/ack/tasks.yaml")
|
||||||
MAX_CONTROL_OUTPUT = 1024 * 1024
|
MAX_CONTROL_OUTPUT = 1024 * 1024
|
||||||
MAX_RECORD_SIZE = 256 * 1024
|
MAX_RECORD_SIZE = 256 * 1024
|
||||||
@@ -82,6 +82,15 @@ WORKER_CREDENTIAL_NAMES = {
|
|||||||
"codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}),
|
"codex": frozenset({"AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"}),
|
||||||
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
"cursor-agent": frozenset({"CURSOR_API_KEY"}),
|
||||||
}
|
}
|
||||||
|
OPERATOR_CREDENTIAL_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"DEB_REPOSITORY",
|
||||||
|
"DEB_SERVER_URL",
|
||||||
|
"DEB_TOKEN",
|
||||||
|
"DEB_UPLOAD_PATH",
|
||||||
|
"SSH_AUTH_SOCK",
|
||||||
|
}
|
||||||
|
)
|
||||||
INHERITED_ENVIRONMENT_PREFIXES = (
|
INHERITED_ENVIRONMENT_PREFIXES = (
|
||||||
"LC_",
|
"LC_",
|
||||||
)
|
)
|
||||||
@@ -184,13 +193,23 @@ def control_environment() -> dict[str, str]:
|
|||||||
return _sanitized_environment(CONTROL_ENVIRONMENT_NAMES)
|
return _sanitized_environment(CONTROL_ENVIRONMENT_NAMES)
|
||||||
|
|
||||||
|
|
||||||
def worker_environment(cli: str) -> dict[str, str]:
|
def worker_environment(cli: str, role: str | None = None) -> dict[str, str]:
|
||||||
"""Return only the supported CLI's own credentials and common runtime data."""
|
"""Return only the supported CLI's own credentials and common runtime data."""
|
||||||
|
|
||||||
credential_names = WORKER_CREDENTIAL_NAMES.get(cli)
|
credential_names = WORKER_CREDENTIAL_NAMES.get(cli)
|
||||||
if credential_names is None:
|
if credential_names is None:
|
||||||
raise LaunchError(f"不支持的 worker CLI 环境: {cli}")
|
raise LaunchError(f"不支持的 worker CLI 环境: {cli}")
|
||||||
return _sanitized_environment(WORKER_ENVIRONMENT_NAMES | credential_names)
|
if role is not None:
|
||||||
|
try:
|
||||||
|
environment_policy_for_role(role)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LaunchError(f"不支持的 worker role 环境: {role}") from exc
|
||||||
|
role_credentials = (
|
||||||
|
OPERATOR_CREDENTIAL_NAMES if role == "operator" else frozenset()
|
||||||
|
)
|
||||||
|
return _sanitized_environment(
|
||||||
|
WORKER_ENVIRONMENT_NAMES | credential_names | role_credentials
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reject_duplicate_or_separator_args(argv: list[str]) -> None:
|
def reject_duplicate_or_separator_args(argv: list[str]) -> None:
|
||||||
@@ -633,15 +652,20 @@ def build_plan(
|
|||||||
raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符")
|
raise LaunchError("task-id 只允许字母、数字、点、下划线和连字符")
|
||||||
if attempt_id not in {f"{task_id}-A1", f"{task_id}-A2", f"{task_id}-A3"}:
|
if attempt_id not in {f"{task_id}-A1", f"{task_id}-A2", f"{task_id}-A3"}:
|
||||||
raise LaunchError("attempt-id 必须精确为 <task-id>-A1..A3")
|
raise LaunchError("attempt-id 必须精确为 <task-id>-A1..A3")
|
||||||
if role not in {"developer", "test"}:
|
if role not in {"developer", "test", "operator"}:
|
||||||
raise LaunchError("role 必须是 developer 或 test")
|
raise LaunchError("role 必须是 developer、test 或 operator")
|
||||||
if not PROFILE_ID_RE.fullmatch(profile_id):
|
if not PROFILE_ID_RE.fullmatch(profile_id):
|
||||||
raise LaunchError("profile-id 格式非法")
|
raise LaunchError("profile-id 格式非法")
|
||||||
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99:
|
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= 99:
|
||||||
raise LaunchError("slot 必须是 1..99 的整数")
|
raise LaunchError("slot 必须是 1..99 的整数")
|
||||||
|
|
||||||
project_root, board = load_authoritative_board(project_root_value)
|
project_root, board = load_authoritative_board(project_root_value)
|
||||||
find_task(board, task_id)
|
task = find_task(board, task_id)
|
||||||
|
is_delivery_operation = task.get("type") == "delivery-operation"
|
||||||
|
if role == "operator" and not is_delivery_operation:
|
||||||
|
raise LaunchError("operator 只能用于 delivery-operation 任务")
|
||||||
|
if role != "operator" and is_delivery_operation:
|
||||||
|
raise LaunchError("delivery-operation 任务只能由 operator 执行")
|
||||||
project = board["project"]
|
project = board["project"]
|
||||||
orchestration = project.get("orchestration")
|
orchestration = project.get("orchestration")
|
||||||
if not isinstance(orchestration, dict):
|
if not isinstance(orchestration, dict):
|
||||||
@@ -681,7 +705,7 @@ def build_plan(
|
|||||||
"cliVersion": cli_version,
|
"cliVersion": cli_version,
|
||||||
"argv": argv,
|
"argv": argv,
|
||||||
"argvHash": canonical_sha256(argv),
|
"argvHash": canonical_sha256(argv),
|
||||||
"environmentPolicy": ENVIRONMENT_POLICY,
|
"environmentPolicy": environment_policy_for_role(role),
|
||||||
}
|
}
|
||||||
current_profile_hash = profile_hash(
|
current_profile_hash = profile_hash(
|
||||||
profile,
|
profile,
|
||||||
@@ -705,7 +729,11 @@ def build_plan(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR"
|
cli_label = "CODEX" if profile["cli"] == "codex" else "CURSOR"
|
||||||
role_label = "DEV" if role == "developer" else "TEST"
|
role_label = {
|
||||||
|
"developer": "DEV",
|
||||||
|
"test": "TEST",
|
||||||
|
"operator": "OP",
|
||||||
|
}[role]
|
||||||
digest_short = launch_fingerprint.split(":", 1)[-1][:10]
|
digest_short = launch_fingerprint.split(":", 1)[-1][:10]
|
||||||
title = (
|
title = (
|
||||||
f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-"
|
f"ACK-{role_label}-{cli_label}-{str(profile['tier']).upper()}-"
|
||||||
@@ -1453,7 +1481,10 @@ def bootstrap_worker(launch_id: str) -> int:
|
|||||||
rebuilt["requested"]["argv"],
|
rebuilt["requested"]["argv"],
|
||||||
shell=False,
|
shell=False,
|
||||||
cwd=rebuilt["worktree"]["path"],
|
cwd=rebuilt["worktree"]["path"],
|
||||||
env=worker_environment(str(rebuilt["requested"]["cli"])),
|
env=worker_environment(
|
||||||
|
str(rebuilt["requested"]["cli"]),
|
||||||
|
str(rebuilt["role"]),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
current_record.update(
|
current_record.update(
|
||||||
state="bootstrap-ready",
|
state="bootstrap-ready",
|
||||||
@@ -1488,7 +1519,11 @@ def add_launch_arguments(parser: argparse.ArgumentParser) -> None:
|
|||||||
parser.add_argument("--project-root", required=True)
|
parser.add_argument("--project-root", required=True)
|
||||||
parser.add_argument("--task-id", required=True)
|
parser.add_argument("--task-id", required=True)
|
||||||
parser.add_argument("--attempt-id", required=True)
|
parser.add_argument("--attempt-id", required=True)
|
||||||
parser.add_argument("--role", required=True, choices=("developer", "test"))
|
parser.add_argument(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
choices=("developer", "test", "operator"),
|
||||||
|
)
|
||||||
parser.add_argument("--profile-id", required=True)
|
parser.add_argument("--profile-id", required=True)
|
||||||
parser.add_argument("--worktree", required=True)
|
parser.add_argument("--worktree", required=True)
|
||||||
parser.add_argument("--slot", type=int, default=1)
|
parser.add_argument("--slot", type=int, default=1)
|
||||||
|
|||||||
Executable
+809
@@ -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())
|
||||||
@@ -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]*$")
|
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
||||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||||
|
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
DELIVERY_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(
|
SEMVER_RE = re.compile(
|
||||||
r"^(0|[1-9][0-9]*)\."
|
r"^(0|[1-9][0-9]*)\."
|
||||||
r"(0|[1-9][0-9]*)\."
|
r"(0|[1-9][0-9]*)\."
|
||||||
@@ -86,14 +89,45 @@ KNOWLEDGE_CHECK_FIELDS = {
|
|||||||
"checkedBy",
|
"checkedBy",
|
||||||
"checkedAt",
|
"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 = {
|
DISPATCH_FIELDS = {
|
||||||
"taskId",
|
"taskId",
|
||||||
"dispatchId",
|
"dispatchId",
|
||||||
"worker",
|
"worker",
|
||||||
"developer",
|
"developer",
|
||||||
"test",
|
"test",
|
||||||
|
"operator",
|
||||||
"rounds",
|
"rounds",
|
||||||
}
|
}
|
||||||
|
DELIVERY_OPERATION_FIELDS = {"skill", "request"}
|
||||||
|
DELIVERY_OPERATION_SKILLS = {
|
||||||
|
"manage-release",
|
||||||
|
"deb-publisher",
|
||||||
|
"publish-docker-image",
|
||||||
|
}
|
||||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||||
"kind",
|
"kind",
|
||||||
"title",
|
"title",
|
||||||
@@ -392,6 +426,170 @@ def validate_knowledge_fields(
|
|||||||
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
|
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_delivery_runs(
|
||||||
|
value: object,
|
||||||
|
task_statuses: dict[str, object],
|
||||||
|
errors: list[str],
|
||||||
|
*,
|
||||||
|
delivery_operation_ids: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
errors.append("deliveryRuns 必须是列表")
|
||||||
|
return
|
||||||
|
|
||||||
|
excluded_task_ids = delivery_operation_ids or set()
|
||||||
|
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_id in excluded_task_ids:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.taskIds: deliveryRuns 不能引用 delivery-operation "
|
||||||
|
f"{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]:
|
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||||
import jsonschema # type: ignore
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
@@ -468,7 +666,7 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append("project.name 必须是非空字符串")
|
errors.append("project.name 必须是非空字符串")
|
||||||
validate_string_fields(
|
validate_string_fields(
|
||||||
project,
|
project,
|
||||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile"},
|
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
||||||
"project",
|
"project",
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
@@ -478,6 +676,17 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append(
|
errors.append(
|
||||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
"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")
|
ack_version = data.get("ackVersion")
|
||||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||||
@@ -562,6 +771,30 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
)
|
)
|
||||||
validate_object_fields(task, {"evidence", "verification"}, where)
|
validate_object_fields(task, {"evidence", "verification"}, where)
|
||||||
|
|
||||||
|
operation = task.get("operation")
|
||||||
|
if operation is not None:
|
||||||
|
if not isinstance(operation, dict):
|
||||||
|
errors.append(f"{where}.operation: 必须是对象")
|
||||||
|
else:
|
||||||
|
reject_unknown_fields(
|
||||||
|
operation,
|
||||||
|
DELIVERY_OPERATION_FIELDS,
|
||||||
|
f"{where}.operation",
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
skill = operation.get("skill")
|
||||||
|
if skill not in DELIVERY_OPERATION_SKILLS:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.operation.skill: 必须是 "
|
||||||
|
"manage-release/deb-publisher/publish-docker-image"
|
||||||
|
)
|
||||||
|
if not _nonempty_string(operation.get("request")):
|
||||||
|
errors.append(f"{where}.operation.request: 必须保留非空用户请求")
|
||||||
|
|
||||||
|
if task.get("type") == "delivery-operation":
|
||||||
|
if not isinstance(operation, dict):
|
||||||
|
errors.append(f"{where}: delivery-operation 必须声明 operation")
|
||||||
|
|
||||||
validate_knowledge_fields(task, where, status, errors)
|
validate_knowledge_fields(task, where, status, errors)
|
||||||
|
|
||||||
if "dispatch" not in task:
|
if "dispatch" not in task:
|
||||||
@@ -584,6 +817,12 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if task.get("type") == "delivery-operation" and (
|
||||||
|
not isinstance(dispatch, dict)
|
||||||
|
or not isinstance(dispatch.get("operator"), dict)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: delivery-operation 必须声明 dispatch.operator")
|
||||||
|
|
||||||
rounds = dispatch.get("rounds", [])
|
rounds = dispatch.get("rounds", [])
|
||||||
if not isinstance(rounds, list):
|
if not isinstance(rounds, list):
|
||||||
errors.append(f"{where}.dispatch.rounds: 必须是列表")
|
errors.append(f"{where}.dispatch.rounds: 必须是列表")
|
||||||
@@ -678,6 +917,26 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
):
|
):
|
||||||
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
|
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"))
|
||||||
|
}
|
||||||
|
delivery_operation_ids = {
|
||||||
|
task["id"]
|
||||||
|
for task in tasks
|
||||||
|
if isinstance(task, dict)
|
||||||
|
and _nonempty_string(task.get("id"))
|
||||||
|
and task.get("type") == "delivery-operation"
|
||||||
|
}
|
||||||
|
validate_delivery_runs(
|
||||||
|
data["deliveryRuns"],
|
||||||
|
task_statuses,
|
||||||
|
errors,
|
||||||
|
delivery_operation_ids=delivery_operation_ids,
|
||||||
|
)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,22 @@ RECEIPT_VERSION = 1
|
|||||||
LAUNCH_PROTOCOL_VERSION = 1
|
LAUNCH_PROTOCOL_VERSION = 1
|
||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3
|
||||||
|
|
||||||
ROLES = frozenset({"developer", "test"})
|
ROLES = frozenset({"developer", "test", "operator"})
|
||||||
|
REQUIRED_DEFAULT_ROLES = frozenset({"developer", "test"})
|
||||||
|
STANDARD_ONLY_ROLES = frozenset({"test", "operator"})
|
||||||
CLIS = frozenset({"codex", "cursor-agent"})
|
CLIS = frozenset({"codex", "cursor-agent"})
|
||||||
TIERS = frozenset({"standard", "strong"})
|
TIERS = frozenset({"standard", "strong"})
|
||||||
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
REASONING_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||||
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
PERMISSION_MODES = frozenset({"read-only", "workspace-write"})
|
||||||
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
ORCHESTRATION_MODES = frozenset({"orca", "manual"})
|
||||||
DEFAULT_KEYS = frozenset({"developer", "test", "developerUpgraded"})
|
BASE_ENVIRONMENT_POLICY = "per-cli-allowlist-v1"
|
||||||
|
OPERATOR_ENVIRONMENT_POLICY = "per-cli-plus-operator-publish-v1"
|
||||||
|
ENVIRONMENT_POLICIES = frozenset(
|
||||||
|
{BASE_ENVIRONMENT_POLICY, OPERATOR_ENVIRONMENT_POLICY}
|
||||||
|
)
|
||||||
|
DEFAULT_KEYS = frozenset(
|
||||||
|
{"developer", "test", "operator", "developerUpgraded"}
|
||||||
|
)
|
||||||
|
|
||||||
ORCHESTRATION_FIELDS = frozenset(
|
ORCHESTRATION_FIELDS = frozenset(
|
||||||
{
|
{
|
||||||
@@ -184,6 +193,16 @@ def _is_positive_int(value: Any) -> bool:
|
|||||||
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
||||||
|
|
||||||
|
|
||||||
|
def environment_policy_for_role(role: Any) -> str:
|
||||||
|
"""Return the fixed credential policy for a validated worker role."""
|
||||||
|
|
||||||
|
if role == "operator":
|
||||||
|
return OPERATOR_ENVIRONMENT_POLICY
|
||||||
|
if role in {"developer", "test"}:
|
||||||
|
return BASE_ENVIRONMENT_POLICY
|
||||||
|
raise ValueError("role must be developer/test/operator")
|
||||||
|
|
||||||
|
|
||||||
def _is_timestamp(value: Any) -> bool:
|
def _is_timestamp(value: Any) -> bool:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return False
|
return False
|
||||||
@@ -211,7 +230,7 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
permission = profile.get("permissionMode")
|
permission = profile.get("permissionMode")
|
||||||
|
|
||||||
if not isinstance(role, str) or role not in ROLES:
|
if not isinstance(role, str) or role not in ROLES:
|
||||||
errors.append(f"{where}.role: must be developer/test")
|
errors.append(f"{where}.role: must be developer/test/operator")
|
||||||
if not isinstance(cli, str) or cli not in CLIS:
|
if not isinstance(cli, str) or cli not in CLIS:
|
||||||
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
errors.append(f"{where}.cli: must be codex/cursor-agent")
|
||||||
if not isinstance(tier, str) or tier not in TIERS:
|
if not isinstance(tier, str) or tier not in TIERS:
|
||||||
@@ -231,8 +250,13 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
|||||||
elif cli == "cursor-agent" and effort is not None:
|
elif cli == "cursor-agent" and effort is not None:
|
||||||
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
errors.append(f"{where}.reasoningEffort: Cursor requires null")
|
||||||
|
|
||||||
if role == "test" and tier != "standard":
|
if (
|
||||||
errors.append(f"{where}.tier: Test may only use standard")
|
isinstance(role, str)
|
||||||
|
and role in STANDARD_ONLY_ROLES
|
||||||
|
and tier != "standard"
|
||||||
|
):
|
||||||
|
label = "Test" if role == "test" else "Operator"
|
||||||
|
errors.append(f"{where}.tier: {label} may only use standard")
|
||||||
if tier == "strong" and role != "developer":
|
if tier == "strong" and role != "developer":
|
||||||
errors.append(f"{where}.tier: strong may only be used by Developer")
|
errors.append(f"{where}.tier: strong may only be used by Developer")
|
||||||
|
|
||||||
@@ -268,8 +292,11 @@ def _validate_model_allowlist(value: Any, where: str) -> list[str]:
|
|||||||
errors.append(f"{role_where}: must not be empty")
|
errors.append(f"{role_where}: must not be empty")
|
||||||
for tier in sorted(set(tiers) - TIERS, key=repr):
|
for tier in sorted(set(tiers) - TIERS, key=repr):
|
||||||
errors.append(f"{role_where}: unknown tier {tier!r}")
|
errors.append(f"{role_where}: unknown tier {tier!r}")
|
||||||
if role == "test" and "strong" in tiers:
|
if role in STANDARD_ONLY_ROLES and "strong" in tiers:
|
||||||
errors.append(f"{role_where}: Test cannot define a strong allowlist")
|
label = "Test" if role == "test" else "Operator"
|
||||||
|
errors.append(
|
||||||
|
f"{role_where}: {label} cannot define a strong allowlist"
|
||||||
|
)
|
||||||
for tier, models in tiers.items():
|
for tier, models in tiers.items():
|
||||||
tier_where = f"{role_where}.{tier}"
|
tier_where = f"{role_where}.{tier}"
|
||||||
if tier not in TIERS:
|
if tier not in TIERS:
|
||||||
@@ -383,7 +410,7 @@ def validate_orchestration(
|
|||||||
for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr):
|
for default_key in sorted(set(defaults) - DEFAULT_KEYS, key=repr):
|
||||||
errors.append(f"{where}.defaults: unknown key {default_key!r}")
|
errors.append(f"{where}.defaults: unknown key {default_key!r}")
|
||||||
if mode == "orca":
|
if mode == "orca":
|
||||||
for role in sorted(ROLES - set(defaults)):
|
for role in sorted(REQUIRED_DEFAULT_ROLES - set(defaults)):
|
||||||
errors.append(f"{where}.defaults: missing role {role!r}")
|
errors.append(f"{where}.defaults: missing role {role!r}")
|
||||||
for default_key, profile_id in defaults.items():
|
for default_key, profile_id in defaults.items():
|
||||||
default_where = f"{where}.defaults.{default_key}"
|
default_where = f"{where}.defaults.{default_key}"
|
||||||
@@ -407,6 +434,22 @@ def validate_orchestration(
|
|||||||
if profile.get("permissionMode") not in PERMISSION_MODES:
|
if profile.get("permissionMode") not in PERMISSION_MODES:
|
||||||
errors.append(f"{default_where}: default profile has unsafe permissions")
|
errors.append(f"{default_where}: default profile has unsafe permissions")
|
||||||
|
|
||||||
|
operator_profile_id = defaults.get("operator")
|
||||||
|
test_profile_id = defaults.get("test")
|
||||||
|
operator_profile = valid_profiles.get(operator_profile_id)
|
||||||
|
test_profile = valid_profiles.get(test_profile_id)
|
||||||
|
if operator_profile_id is not None and test_profile is None:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.defaults.operator: requires a valid Test default profile"
|
||||||
|
)
|
||||||
|
elif operator_profile is not None and test_profile is not None:
|
||||||
|
for field in ("cli", "tier", "model", "reasoningEffort"):
|
||||||
|
if operator_profile.get(field) != test_profile.get(field):
|
||||||
|
errors.append(
|
||||||
|
f"{where}.defaults.operator: operator default must use "
|
||||||
|
f"the Test default {field}"
|
||||||
|
)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -502,7 +545,7 @@ def _validate_created_for(value: Any, where: str) -> list[str]:
|
|||||||
errors.append(f"{where}.attemptId: must belong to taskId")
|
errors.append(f"{where}.attemptId: must belong to taskId")
|
||||||
role = value.get("role")
|
role = value.get("role")
|
||||||
if not isinstance(role, str) or role not in ROLES:
|
if not isinstance(role, str) or role not in ROLES:
|
||||||
errors.append(f"{where}.role: must be developer/test")
|
errors.append(f"{where}.role: must be developer/test/operator")
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
@@ -571,9 +614,10 @@ def _validate_requested(value: Any, where: str) -> list[str]:
|
|||||||
elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv):
|
elif isinstance(argv, list) and all(isinstance(arg, str) for arg in argv):
|
||||||
if argv_hash != canonical_sha256(argv):
|
if argv_hash != canonical_sha256(argv):
|
||||||
errors.append(f"{where}.argvHash: does not match argv")
|
errors.append(f"{where}.argvHash: does not match argv")
|
||||||
if value.get("environmentPolicy") != "per-cli-allowlist-v1":
|
if value.get("environmentPolicy") not in ENVIRONMENT_POLICIES:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1'"
|
f"{where}.environmentPolicy: must be 'per-cli-allowlist-v1' or "
|
||||||
|
"'per-cli-plus-operator-publish-v1'"
|
||||||
)
|
)
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
@@ -722,6 +766,19 @@ def validate_worker_receipt(
|
|||||||
if binding.get("observedWorktreePath") != worktree.get("path"):
|
if binding.get("observedWorktreePath") != worktree.get("path"):
|
||||||
errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path")
|
errors.append(f"{where}.binding.observedWorktreePath: does not match worktree.path")
|
||||||
|
|
||||||
|
if isinstance(created_for, dict) and isinstance(requested, dict):
|
||||||
|
try:
|
||||||
|
expected_environment_policy = environment_policy_for_role(
|
||||||
|
created_for.get("role")
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if requested.get("environmentPolicy") != expected_environment_policy:
|
||||||
|
errors.append(
|
||||||
|
f"{where}.requested.environmentPolicy: does not match role"
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isinstance(created_for, dict)
|
isinstance(created_for, dict)
|
||||||
and isinstance(worktree, dict)
|
and isinstance(worktree, dict)
|
||||||
@@ -977,7 +1034,10 @@ __all__ = [
|
|||||||
"RECEIPT_VERSION",
|
"RECEIPT_VERSION",
|
||||||
"LAUNCH_PROTOCOL_VERSION",
|
"LAUNCH_PROTOCOL_VERSION",
|
||||||
"MAX_ROUNDS",
|
"MAX_ROUNDS",
|
||||||
|
"BASE_ENVIRONMENT_POLICY",
|
||||||
|
"OPERATOR_ENVIRONMENT_POLICY",
|
||||||
"canonical_sha256",
|
"canonical_sha256",
|
||||||
|
"environment_policy_for_role",
|
||||||
"profile_hash",
|
"profile_hash",
|
||||||
"receipt_hash",
|
"receipt_hash",
|
||||||
"render_worker_argv",
|
"render_worker_argv",
|
||||||
|
|||||||
@@ -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: {}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
||||||
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||||
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`,
|
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的
|
||||||
|
> `delivery.yaml`,
|
||||||
> 不复制或链接 Skill。
|
> 不复制或链接 Skill。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
- Base URL:`<base_url>`
|
- Base URL:`<base_url>`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`docs/ack/knowledge.yaml`
|
||||||
|
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
|
||||||
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
||||||
|
|
||||||
## 通用规范(由 ACK Skill 按需读取)
|
## 通用规范(由 ACK Skill 按需读取)
|
||||||
@@ -30,6 +32,8 @@
|
|||||||
- 验收信号与三轮策略:`references/optimization-method.md`
|
- 验收信号与三轮策略:`references/optimization-method.md`
|
||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||||
|
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||||
|
- 一次性源码/DEB/镜像发布路由(可选):`references/delivery-routing.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
|------|------------|------|
|
|------|------------|------|
|
||||||
| Developer | `codex-dev-standard` | standard |
|
| Developer | `codex-dev-standard` | standard |
|
||||||
| Test | `codex-test-standard` | standard |
|
| Test | `codex-test-standard` | standard |
|
||||||
|
| Operator | `codex-operator-standard` | standard(模型/effort 与 Test 相同) |
|
||||||
| Developer 升级 | `codex-dev-strong` | strong |
|
| Developer 升级 | `codex-dev-strong` | strong |
|
||||||
|
|
||||||
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
项目如改用 Cursor,应修改结构化 profile、allowlist 和 defaults,再运行任务板
|
||||||
@@ -64,6 +69,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令(项目覆盖层)
|
## 命令(项目覆盖层)
|
||||||
|
|
||||||
@@ -91,12 +97,15 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
|
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
|
||||||
`ACK_PROJECT_ROOT_DISPLAY`。
|
`ACK_PROJECT_ROOT_DISPLAY`。
|
||||||
|
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 执行。
|
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
||||||
|
构建/发布/部署的机器入口以 `delivery.yaml` 为准;本文件不维护第二套交付命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
- 三角色独立:Coordinator 只编排、Test 只验证、Developer 只实现(验证者 ≠ 实现者)。
|
||||||
|
- 一次性交付 Operator 不进入三角色闭环;它使用 Test 同档模型执行被路由的低层 Skill,
|
||||||
|
只回传证据,不写 ACK 状态文件。
|
||||||
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
|
- 模型分层:Coordinator 用强模型且不亲自跑测试,Test/Developer 用中低模型,必要时升级(见 references/model-routing.md)。
|
||||||
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
|
- 自动 worker 只能由 ACK 的 `scripts/launch_worker.py` 按结构化 profile 启动;
|
||||||
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
|
禁止直接拼 `orca terminal create --command`,禁止 `command`、`extraArgs`、`env`
|
||||||
@@ -112,5 +121,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
||||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
|
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
|
||||||
|
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
|
||||||
|
- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有
|
||||||
|
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||||
- 不提交或推送,除非用户明确要求。
|
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
"const": "docs/ack/knowledge.yaml",
|
"const": "docs/ack/knowledge.yaml",
|
||||||
"description": "项目知识护栏库的唯一权威路径"
|
"description": "项目知识护栏库的唯一权威路径"
|
||||||
},
|
},
|
||||||
|
"deliveryFile": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "docs/ack/delivery.yaml",
|
||||||
|
"description": "可选项目交付契约的唯一权威路径"
|
||||||
|
},
|
||||||
"orchestration": {
|
"orchestration": {
|
||||||
"$ref": "#/definitions/orchestration"
|
"$ref": "#/definitions/orchestration"
|
||||||
}
|
}
|
||||||
@@ -57,6 +62,10 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/workerReceipt" }
|
"items": { "$ref": "#/definitions/workerReceipt" }
|
||||||
},
|
},
|
||||||
|
"deliveryRuns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/deliveryRun" }
|
||||||
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/task" }
|
"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": {
|
"definitions": {
|
||||||
@@ -175,7 +204,8 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"developer": { "$ref": "#/definitions/modelTierAllowlist" },
|
"developer": { "$ref": "#/definitions/modelTierAllowlist" },
|
||||||
"test": { "$ref": "#/definitions/modelTierAllowlist" }
|
"test": { "$ref": "#/definitions/modelTierAllowlist" },
|
||||||
|
"operator": { "$ref": "#/definitions/modelTierAllowlist" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelAllowlist": {
|
"modelAllowlist": {
|
||||||
@@ -200,7 +230,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["developer", "test"]
|
"enum": ["developer", "test", "operator"]
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -230,6 +260,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"developer": { "$ref": "#/definitions/profileId" },
|
"developer": { "$ref": "#/definitions/profileId" },
|
||||||
"test": { "$ref": "#/definitions/profileId" },
|
"test": { "$ref": "#/definitions/profileId" },
|
||||||
|
"operator": { "$ref": "#/definitions/profileId" },
|
||||||
"developerUpgraded": { "$ref": "#/definitions/profileId" }
|
"developerUpgraded": { "$ref": "#/definitions/profileId" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -408,7 +439,10 @@
|
|||||||
},
|
},
|
||||||
"environmentPolicy": {
|
"environmentPolicy": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "per-cli-allowlist-v1"
|
"enum": [
|
||||||
|
"per-cli-allowlist-v1",
|
||||||
|
"per-cli-plus-operator-publish-v1"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -517,7 +551,7 @@
|
|||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["developer", "test"]
|
"enum": ["developer", "test", "operator"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -721,6 +755,181 @@
|
|||||||
"checkedAt": { "type": "string" }
|
"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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"deliveryOperation": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["skill", "request"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"skill": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"manage-release",
|
||||||
|
"deb-publisher",
|
||||||
|
"publish-docker-image"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"request": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"task": {
|
"task": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "title", "status"],
|
"required": ["id", "title", "status"],
|
||||||
@@ -756,6 +965,7 @@
|
|||||||
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
||||||
"expected": { "type": "string" },
|
"expected": { "type": "string" },
|
||||||
"actual": { "type": "string" },
|
"actual": { "type": "string" },
|
||||||
|
"operation": { "$ref": "#/definitions/deliveryOperation" },
|
||||||
"evidence": { "type": "object" },
|
"evidence": { "type": "object" },
|
||||||
"verification": { "type": "object" },
|
"verification": { "type": "object" },
|
||||||
"dispatch": {
|
"dispatch": {
|
||||||
@@ -767,6 +977,7 @@
|
|||||||
"worker": { "type": ["string", "null"] },
|
"worker": { "type": ["string", "null"] },
|
||||||
"developer": { "$ref": "#/definitions/roleDispatch" },
|
"developer": { "$ref": "#/definitions/roleDispatch" },
|
||||||
"test": { "$ref": "#/definitions/roleDispatch" },
|
"test": { "$ref": "#/definitions/roleDispatch" },
|
||||||
|
"operator": { "$ref": "#/definitions/roleDispatch" },
|
||||||
"rounds": {
|
"rounds": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/round" }
|
"items": { "$ref": "#/definitions/round" }
|
||||||
@@ -804,6 +1015,21 @@
|
|||||||
},
|
},
|
||||||
"required": ["resolution"]
|
"required": ["resolution"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": { "type": { "const": "delivery-operation" } },
|
||||||
|
"required": ["type"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["operation", "dispatch"],
|
||||||
|
"properties": {
|
||||||
|
"dispatch": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["operator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ project:
|
|||||||
devWorktree: "<dev_worktree>"
|
devWorktree: "<dev_worktree>"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: "docs/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||||
|
deliveryFile: "docs/ack/delivery.yaml"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "orca"
|
mode: "orca"
|
||||||
@@ -22,11 +23,15 @@ project:
|
|||||||
strong: ["gpt-5.6-sol"]
|
strong: ["gpt-5.6-sol"]
|
||||||
test:
|
test:
|
||||||
standard: ["gpt-5.6-luna"]
|
standard: ["gpt-5.6-luna"]
|
||||||
|
operator:
|
||||||
|
standard: ["gpt-5.6-luna"]
|
||||||
cursor-agent:
|
cursor-agent:
|
||||||
developer:
|
developer:
|
||||||
standard: ["auto"]
|
standard: ["auto"]
|
||||||
test:
|
test:
|
||||||
standard: ["auto"]
|
standard: ["auto"]
|
||||||
|
operator:
|
||||||
|
standard: ["auto"]
|
||||||
profiles:
|
profiles:
|
||||||
codex-dev-standard:
|
codex-dev-standard:
|
||||||
role: "developer"
|
role: "developer"
|
||||||
@@ -42,6 +47,13 @@ project:
|
|||||||
model: "gpt-5.6-luna"
|
model: "gpt-5.6-luna"
|
||||||
reasoningEffort: "low"
|
reasoningEffort: "low"
|
||||||
permissionMode: "workspace-write"
|
permissionMode: "workspace-write"
|
||||||
|
codex-operator-standard:
|
||||||
|
role: "operator"
|
||||||
|
cli: "codex"
|
||||||
|
tier: "standard"
|
||||||
|
model: "gpt-5.6-luna"
|
||||||
|
reasoningEffort: "low"
|
||||||
|
permissionMode: "workspace-write"
|
||||||
codex-dev-strong:
|
codex-dev-strong:
|
||||||
role: "developer"
|
role: "developer"
|
||||||
cli: "codex"
|
cli: "codex"
|
||||||
@@ -63,12 +75,21 @@ project:
|
|||||||
model: "auto"
|
model: "auto"
|
||||||
reasoningEffort: null
|
reasoningEffort: null
|
||||||
permissionMode: "workspace-write"
|
permissionMode: "workspace-write"
|
||||||
|
cursor-operator-standard:
|
||||||
|
role: "operator"
|
||||||
|
cli: "cursor-agent"
|
||||||
|
tier: "standard"
|
||||||
|
model: "auto"
|
||||||
|
reasoningEffort: null
|
||||||
|
permissionMode: "workspace-write"
|
||||||
defaults:
|
defaults:
|
||||||
developer: "codex-dev-standard"
|
developer: "codex-dev-standard"
|
||||||
test: "codex-test-standard"
|
test: "codex-test-standard"
|
||||||
|
operator: "codex-operator-standard"
|
||||||
developerUpgraded: "codex-dev-strong"
|
developerUpgraded: "codex-dev-strong"
|
||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
|
deliveryRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: []
|
verified: []
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
|
|
||||||
## 常见用法
|
## 常见用法
|
||||||
|
|
||||||
|
### 通过 ACK 只创建普通 PR
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ack 把当前分支推送并创建一个指向 main 的 PR,不要合并。
|
||||||
|
```
|
||||||
|
|
||||||
|
ACK 会把这类一次性请求路由到 `manage-release` 的 PR-only 流程。Agent 只核对当前
|
||||||
|
head、base、remote、提交边界、验证和 Forge 账号,然后创建或复用 PR;不会顺带升级
|
||||||
|
版本、创建 release 分支、合并、打 tag 或创建 Forge Release。
|
||||||
|
|
||||||
### 确定下一个版本号
|
### 确定下一个版本号
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -106,5 +116,6 @@ Agent 会先发现项目自己的版本、分支和发布规则,再检查本
|
|||||||
## 不适用的场景
|
## 不适用的场景
|
||||||
|
|
||||||
普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包
|
普通功能开发、普通 worktree 或 PR/MR 操作、代码审查、构建 Docker 镜像或上传 DEB 包
|
||||||
不需要触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布 PR/MR
|
不需要直接触发 `manage-release`。只有任务明确涉及版本发布、发布分支、版本号、发布
|
||||||
或 release tag 时,才交给这个 Skill。
|
PR/MR 或 release tag,或者用户显式调用 `$ack` 要求 PR-only 委派时,才交给这个
|
||||||
|
Skill。
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ description: >-
|
|||||||
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
|
升版本、提交或合并发布 PR/MR、打 release tag、完成发版、处理 hotfix 或继续未完成
|
||||||
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
|
发布时使用。只做普通编码、普通 worktree 或 PR/MR 操作、代码审查、构建或上传
|
||||||
DEB/Docker 等产物、管理仓库权限时不使用。
|
DEB/Docker 等产物、管理仓库权限时不使用。
|
||||||
|
由显式调用的 `$ack` 根据用户明确的普通 PR/MR 请求路由时,可只执行本文的
|
||||||
|
PR-only 流程,不把它扩大成版本发布。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Manage Release
|
# Manage Release
|
||||||
@@ -28,6 +30,18 @@ description: >-
|
|||||||
|
|
||||||
## 授权边界
|
## 授权边界
|
||||||
|
|
||||||
|
### ACK PR-only 委派
|
||||||
|
|
||||||
|
当且仅当用户显式调用 `$ack`,且 ACK 把用户原始的普通 PR/MR 请求委派给本 Skill 时,
|
||||||
|
使用 PR-only 流程:执行“发现项目规则和当前状态”,锁定准确 head、base、remote、
|
||||||
|
worktree、验证命令与活动 Forge 账号,然后只执行“推送并创建 PR/MR”及其完成检查。
|
||||||
|
用户没有明确要求时,不创建 release 分支、不修改版本或 CHANGELOG、不合并、不打 tag、
|
||||||
|
不创建 Forge Release、不清理分支或 worktree。缺少 head/base/remote、提交边界或平台
|
||||||
|
认证时按硬停止条件报告,不把普通 PR 伪装成发布。
|
||||||
|
|
||||||
|
ACK 只负责路由,不增加授权。Operator 收到的原始请求仍是权限上限;“创建 PR”只授权
|
||||||
|
为准确 head/base 创建或复用 PR,不自动授权合并。
|
||||||
|
|
||||||
按用户明确要求执行到对应阶段:
|
按用户明确要求执行到对应阶段:
|
||||||
|
|
||||||
- 分析版本或检查状态:只执行读取和计算,不修改文件或远端。
|
- 分析版本或检查状态:只执行读取和计算,不修改文件或远端。
|
||||||
@@ -46,6 +60,9 @@ description: >-
|
|||||||
|
|
||||||
## 工作流
|
## 工作流
|
||||||
|
|
||||||
|
PR-only 仍按以下章节做实时状态发现与安全检查,但跳过与用户请求无关的版本计算、
|
||||||
|
release worktree、版本文件、合并和 tag 阶段。已有用户工作不得被 stash、移动或覆盖。
|
||||||
|
|
||||||
### 1. 发现项目规则和当前状态
|
### 1. 发现项目规则和当前状态
|
||||||
|
|
||||||
从项目根目录开始:
|
从项目根目录开始:
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
name: publish-docker-image
|
name: publish-docker-image
|
||||||
description: >-
|
description: >-
|
||||||
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
||||||
$publish-docker-image 或明确说“使用 publish-docker-image skill”时使用;
|
$publish-docker-image、明确说“使用 publish-docker-image skill”,或由显式调用的 `$ack`
|
||||||
|
根据用户明确的 Docker/OCI 镜像发布请求路由时使用;
|
||||||
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ACK_DIR = REPO_ROOT / "skills" / "ack"
|
||||||
|
SCRIPTS_DIR = ACK_DIR / "scripts"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import launch_worker # noqa: E402
|
||||||
|
import validate_tasks # noqa: E402
|
||||||
|
import worker_profiles # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def operator_orchestration() -> dict:
|
||||||
|
return {
|
||||||
|
"profileVersion": 1,
|
||||||
|
"mode": "orca",
|
||||||
|
"allowedWorktrees": ["/repo/demo"],
|
||||||
|
"modelAllowlist": {
|
||||||
|
"codex": {
|
||||||
|
"developer": {"standard": ["gpt-dev"]},
|
||||||
|
"test": {"standard": ["gpt-low"]},
|
||||||
|
"operator": {"standard": ["gpt-low"]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"codex-dev-standard": {
|
||||||
|
"role": "developer",
|
||||||
|
"cli": "codex",
|
||||||
|
"tier": "standard",
|
||||||
|
"model": "gpt-dev",
|
||||||
|
"reasoningEffort": "medium",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
},
|
||||||
|
"codex-test-standard": {
|
||||||
|
"role": "test",
|
||||||
|
"cli": "codex",
|
||||||
|
"tier": "standard",
|
||||||
|
"model": "gpt-low",
|
||||||
|
"reasoningEffort": "low",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
},
|
||||||
|
"codex-operator-standard": {
|
||||||
|
"role": "operator",
|
||||||
|
"cli": "codex",
|
||||||
|
"tier": "standard",
|
||||||
|
"model": "gpt-low",
|
||||||
|
"reasoningEffort": "low",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"developer": "codex-dev-standard",
|
||||||
|
"test": "codex-test-standard",
|
||||||
|
"operator": "codex-operator-standard",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def routed_board() -> dict:
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"ackVersion": "0.12.0",
|
||||||
|
"project": {
|
||||||
|
"name": "demo",
|
||||||
|
"orchestration": operator_orchestration(),
|
||||||
|
},
|
||||||
|
"workerReceipts": [],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "DELIVERY-001",
|
||||||
|
"type": "delivery-operation",
|
||||||
|
"title": "publish one DEB",
|
||||||
|
"status": "open",
|
||||||
|
"operation": {
|
||||||
|
"skill": "deb-publisher",
|
||||||
|
"request": "发布 1.2.3 的 amd64 DEB 到 testing 仓库",
|
||||||
|
},
|
||||||
|
"dispatch": {
|
||||||
|
"operator": {
|
||||||
|
"profileId": "codex-operator-standard",
|
||||||
|
"receiptId": None,
|
||||||
|
"attemptId": None,
|
||||||
|
"taskId": None,
|
||||||
|
"dispatchId": None,
|
||||||
|
},
|
||||||
|
"rounds": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AckDeliveryRoutingTests(unittest.TestCase):
|
||||||
|
def test_operator_profile_uses_the_test_low_cost_model(self) -> None:
|
||||||
|
routing = operator_orchestration()
|
||||||
|
|
||||||
|
self.assertEqual(worker_profiles.validate_orchestration(routing), [])
|
||||||
|
|
||||||
|
routing["profiles"]["codex-operator-standard"]["model"] = "gpt-other"
|
||||||
|
routing["modelAllowlist"]["codex"]["operator"]["standard"] = [
|
||||||
|
"gpt-other"
|
||||||
|
]
|
||||||
|
errors = worker_profiles.validate_orchestration(routing)
|
||||||
|
self.assertTrue(
|
||||||
|
any("operator default must use the Test default model" in error for error in errors),
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_operator_is_standard_only_and_optional_for_legacy_projects(self) -> None:
|
||||||
|
routing = operator_orchestration()
|
||||||
|
operator = routing["profiles"]["codex-operator-standard"]
|
||||||
|
operator["tier"] = "strong"
|
||||||
|
routing["modelAllowlist"]["codex"]["operator"] = {
|
||||||
|
"strong": ["gpt-low"]
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = worker_profiles.validate_orchestration(routing)
|
||||||
|
self.assertTrue(any("Operator may only use standard" in error for error in errors))
|
||||||
|
|
||||||
|
legacy = operator_orchestration()
|
||||||
|
del legacy["defaults"]["operator"]
|
||||||
|
del legacy["profiles"]["codex-operator-standard"]
|
||||||
|
del legacy["modelAllowlist"]["codex"]["operator"]
|
||||||
|
self.assertEqual(worker_profiles.validate_orchestration(legacy), [])
|
||||||
|
|
||||||
|
def test_delivery_operation_requires_a_supported_route_and_operator_dispatch(self) -> None:
|
||||||
|
board = routed_board()
|
||||||
|
self.assertEqual(validate_tasks.validate_builtin(board), [])
|
||||||
|
|
||||||
|
missing_operation = copy.deepcopy(board)
|
||||||
|
del missing_operation["tasks"][0]["operation"]
|
||||||
|
errors = validate_tasks.validate_builtin(missing_operation)
|
||||||
|
self.assertTrue(any("delivery-operation 必须声明 operation" in error for error in errors))
|
||||||
|
|
||||||
|
unsupported = copy.deepcopy(board)
|
||||||
|
unsupported["tasks"][0]["operation"]["skill"] = "shell"
|
||||||
|
errors = validate_tasks.validate_builtin(unsupported)
|
||||||
|
self.assertTrue(any("manage-release/deb-publisher/publish-docker-image" in error for error in errors))
|
||||||
|
|
||||||
|
missing_dispatch = copy.deepcopy(board)
|
||||||
|
del missing_dispatch["tasks"][0]["dispatch"]["operator"]
|
||||||
|
errors = validate_tasks.validate_builtin(missing_dispatch)
|
||||||
|
self.assertTrue(any("delivery-operation 必须声明 dispatch.operator" in error for error in errors))
|
||||||
|
|
||||||
|
def test_delivery_operation_cannot_be_reused_as_a_profile_delivery_run(self) -> None:
|
||||||
|
board = routed_board()
|
||||||
|
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||||
|
board["tasks"][0]["status"] = "verified"
|
||||||
|
board["deliveryRuns"] = [
|
||||||
|
{
|
||||||
|
"id": "DR-duplicate-route",
|
||||||
|
"profile": "review",
|
||||||
|
"taskIds": ["DELIVERY-001"],
|
||||||
|
"status": "planned",
|
||||||
|
"sourceRevision": "a" * 40,
|
||||||
|
"configRevision": "b" * 40,
|
||||||
|
"pullRequest": None,
|
||||||
|
"artifacts": [],
|
||||||
|
"deployments": [],
|
||||||
|
"evidence": [],
|
||||||
|
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
errors = validate_tasks.validate_builtin(board)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any("deliveryRuns 不能引用 delivery-operation" in error for error in errors),
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_launcher_creates_an_operator_plan_on_the_low_cost_profile(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
project = Path(temporary).resolve()
|
||||||
|
executable = project / "codex"
|
||||||
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
executable.chmod(0o700)
|
||||||
|
routing = operator_orchestration()
|
||||||
|
routing["allowedWorktrees"] = [str(project)]
|
||||||
|
board = routed_board()
|
||||||
|
board["project"]["repoPath"] = str(project)
|
||||||
|
board["project"]["orchestration"] = routing
|
||||||
|
metadata = project.stat()
|
||||||
|
identity = {
|
||||||
|
"path": str(project),
|
||||||
|
"device": metadata.st_dev,
|
||||||
|
"inode": metadata.st_ino,
|
||||||
|
"gitCommonDir": str(project / ".git"),
|
||||||
|
"gitCommonDevice": metadata.st_dev,
|
||||||
|
"gitCommonInode": metadata.st_ino,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"load_authoritative_board",
|
||||||
|
return_value=(project, board),
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"capture_worktree_identity",
|
||||||
|
return_value=identity,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"resolve_executable",
|
||||||
|
return_value=executable,
|
||||||
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"run_text",
|
||||||
|
return_value="codex-cli 1.0",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
plan = launch_worker.build_plan(
|
||||||
|
project_root_value=str(project),
|
||||||
|
task_id="DELIVERY-001",
|
||||||
|
attempt_id="DELIVERY-001-A1",
|
||||||
|
role="operator",
|
||||||
|
profile_id="codex-operator-standard",
|
||||||
|
worktree_value=str(project),
|
||||||
|
slot=1,
|
||||||
|
)
|
||||||
|
receipt = launch_worker.build_receipt(
|
||||||
|
"c" * 64,
|
||||||
|
plan,
|
||||||
|
"runtime-1",
|
||||||
|
{
|
||||||
|
"handle": "terminal-1",
|
||||||
|
"incarnationId": "incarnation-1",
|
||||||
|
"connected": True,
|
||||||
|
"writable": True,
|
||||||
|
"worktreePath": str(project),
|
||||||
|
},
|
||||||
|
"2026-08-01T10:00:00+08:00",
|
||||||
|
)
|
||||||
|
receipt_errors = worker_profiles.validate_worker_receipt(
|
||||||
|
receipt,
|
||||||
|
orchestration=routing,
|
||||||
|
task_ids={"DELIVERY-001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(plan["role"], "operator")
|
||||||
|
self.assertEqual(plan["requested"]["model"], "gpt-low")
|
||||||
|
self.assertEqual(
|
||||||
|
plan["requested"]["environmentPolicy"],
|
||||||
|
"per-cli-plus-operator-publish-v1",
|
||||||
|
)
|
||||||
|
self.assertTrue(plan["title"].startswith("ACK-OP-CODEX-STANDARD-"))
|
||||||
|
self.assertEqual(receipt_errors, [])
|
||||||
|
|
||||||
|
def test_launcher_binds_operator_to_delivery_operation_tasks(self) -> None:
|
||||||
|
delivery_board = routed_board()
|
||||||
|
ordinary_board = copy.deepcopy(delivery_board)
|
||||||
|
ordinary_task = ordinary_board["tasks"][0]
|
||||||
|
ordinary_task["id"] = "TASK-001"
|
||||||
|
ordinary_task["type"] = "feature"
|
||||||
|
del ordinary_task["operation"]
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"load_authoritative_board",
|
||||||
|
return_value=(Path("/repo/demo"), ordinary_board),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
launch_worker.LaunchError,
|
||||||
|
"operator 只能用于 delivery-operation",
|
||||||
|
):
|
||||||
|
launch_worker.build_plan(
|
||||||
|
project_root_value="/repo/demo",
|
||||||
|
task_id="TASK-001",
|
||||||
|
attempt_id="TASK-001-A1",
|
||||||
|
role="operator",
|
||||||
|
profile_id="codex-operator-standard",
|
||||||
|
worktree_value="/repo/demo",
|
||||||
|
slot=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
launch_worker,
|
||||||
|
"load_authoritative_board",
|
||||||
|
return_value=(Path("/repo/demo"), delivery_board),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
launch_worker.LaunchError,
|
||||||
|
"delivery-operation 任务只能由 operator",
|
||||||
|
):
|
||||||
|
launch_worker.build_plan(
|
||||||
|
project_root_value="/repo/demo",
|
||||||
|
task_id="DELIVERY-001",
|
||||||
|
attempt_id="DELIVERY-001-A1",
|
||||||
|
role="test",
|
||||||
|
profile_id="codex-test-standard",
|
||||||
|
worktree_value="/repo/demo",
|
||||||
|
slot=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_operator_gets_only_fixed_release_credentials(self) -> None:
|
||||||
|
with mock.patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"OPENAI_API_KEY": "agent-token",
|
||||||
|
"DEB_TOKEN": "deb-token",
|
||||||
|
"DEB_SERVER_URL": "https://packages.example.com",
|
||||||
|
"DEB_REPOSITORY": "testing",
|
||||||
|
"SSH_AUTH_SOCK": "/tmp/agent.sock",
|
||||||
|
"GIT_SSH_COMMAND": "unsafe override",
|
||||||
|
"DOCKER_PASSWORD": "must-not-pass",
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
operator = launch_worker.worker_environment("codex", "operator")
|
||||||
|
test = launch_worker.worker_environment("codex", "test")
|
||||||
|
|
||||||
|
self.assertEqual(operator["DEB_TOKEN"], "deb-token")
|
||||||
|
self.assertEqual(operator["SSH_AUTH_SOCK"], "/tmp/agent.sock")
|
||||||
|
self.assertEqual(operator["OPENAI_API_KEY"], "agent-token")
|
||||||
|
self.assertNotIn("DEB_TOKEN", test)
|
||||||
|
self.assertNotIn("SSH_AUTH_SOCK", test)
|
||||||
|
self.assertNotIn("GIT_SSH_COMMAND", operator)
|
||||||
|
self.assertNotIn("DOCKER_PASSWORD", operator)
|
||||||
|
|
||||||
|
def test_ack_documents_the_three_routes_and_non_release_pr_boundary(self) -> None:
|
||||||
|
skill = (ACK_DIR / "SKILL.md").read_text(encoding="utf-8")
|
||||||
|
routing = (ACK_DIR / "references" / "delivery-routing.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
docker = (
|
||||||
|
REPO_ROOT / "skills" / "publish-docker-image" / "SKILL.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
release = (
|
||||||
|
REPO_ROOT / "skills" / "manage-release" / "SKILL.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("references/delivery-routing.md", skill)
|
||||||
|
for name in ("manage-release", "deb-publisher", "publish-docker-image"):
|
||||||
|
self.assertIn(name, routing)
|
||||||
|
self.assertIn("普通 PR/MR", routing)
|
||||||
|
self.assertIn("由显式调用的 `$ack`", docker)
|
||||||
|
self.assertIn("由显式调用的 `$ack`", release)
|
||||||
|
self.assertIn("PR-only", release)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -16,9 +16,11 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"docs/ack/project.md",
|
"docs/ack/project.md",
|
||||||
"docs/ack/tasks.yaml",
|
"docs/ack/tasks.yaml",
|
||||||
"docs/ack/knowledge.yaml",
|
"docs/ack/knowledge.yaml",
|
||||||
|
"docs/ack/delivery.yaml",
|
||||||
"tasks: []",
|
"tasks: []",
|
||||||
"validate_tasks.py",
|
"validate_tasks.py",
|
||||||
"validate_knowledge.py",
|
"validate_knowledge.py",
|
||||||
|
"validate_delivery.py",
|
||||||
"select_knowledge.py",
|
"select_knowledge.py",
|
||||||
"references/kickoff.md",
|
"references/kickoff.md",
|
||||||
"不要修改项目的 `AGENTS.md`",
|
"不要修改项目的 `AGENTS.md`",
|
||||||
@@ -56,9 +58,14 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"scripts/run_verification.py",
|
"scripts/run_verification.py",
|
||||||
"scripts/worker_profiles.py",
|
"scripts/worker_profiles.py",
|
||||||
"scripts/launch_worker.py",
|
"scripts/launch_worker.py",
|
||||||
|
"templates/delivery.template.yaml",
|
||||||
|
"templates/delivery.schema.json",
|
||||||
|
"examples/delivery.example.yaml",
|
||||||
|
"references/delivery.md",
|
||||||
|
"references/delivery-routing.md",
|
||||||
):
|
):
|
||||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
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.12.0")
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'ackVersion: "<接入时的 ack skill 版本>"',
|
'ackVersion: "<接入时的 ack skill 版本>"',
|
||||||
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
with self.subTest(invalid_semver=invalid):
|
with self.subTest(invalid_semver=invalid):
|
||||||
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
||||||
|
|
||||||
current_gate, orchestration_gate, receipts_gate = schema["allOf"]
|
current_gate, orchestration_gate, receipts_gate, delivery_gate = schema["allOf"]
|
||||||
current_pattern = re.compile(
|
current_pattern = re.compile(
|
||||||
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
||||||
)
|
)
|
||||||
@@ -218,6 +218,15 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
gate["then"]["properties"]["tasks"]["$ref"],
|
gate["then"]["properties"]["tasks"]["$ref"],
|
||||||
"#/definitions/launchableTasks",
|
"#/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"]
|
role_dispatch = schema["definitions"]["roleDispatch"]
|
||||||
self.assertIn("attemptId", role_dispatch["required"])
|
self.assertIn("attemptId", role_dispatch["required"])
|
||||||
@@ -231,6 +240,126 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
"string",
|
"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(
|
@unittest.skipUnless(
|
||||||
importlib.util.find_spec("jsonschema") is not None,
|
importlib.util.find_spec("jsonschema") is not None,
|
||||||
"jsonschema is required for the schema-only contract test",
|
"jsonschema is required for the schema-only contract test",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
|||||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
import validate_knowledge # noqa: E402
|
import validate_knowledge # noqa: E402
|
||||||
|
import validate_delivery # noqa: E402
|
||||||
import validate_tasks # noqa: E402
|
import validate_tasks # noqa: E402
|
||||||
from yaml_subset import YamlSubsetError, load_yaml_subset # 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/examples/tasks.example.yaml'),
|
||||||
Path('skills/ack/templates/knowledge.template.yaml'),
|
Path('skills/ack/templates/knowledge.template.yaml'),
|
||||||
Path('skills/ack/examples/knowledge.example.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:
|
for path in paths:
|
||||||
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise SystemExit(f'{path}: top-level value is not a mapping')
|
raise SystemExit(f'{path}: top-level value is not a mapping')
|
||||||
print('parsed=4')
|
print('parsed=6')
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-S", "-c", script],
|
[sys.executable, "-S", "-c", script],
|
||||||
@@ -44,7 +47,7 @@ print('parsed=4')
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
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:
|
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -63,6 +66,23 @@ print('parsed=4')
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertIn("任务板校验通过", result.stdout)
|
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:
|
def test_supported_subset_types_and_block_scalars(self) -> None:
|
||||||
document = load_yaml_subset(
|
document = load_yaml_subset(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+119
-9
@@ -37,6 +37,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
' devWorktree: "<dev_worktree>"\n',
|
' devWorktree: "<dev_worktree>"\n',
|
||||||
encoding="utf-8",
|
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(
|
(skill / "templates" / "knowledge.template.yaml").write_text(
|
||||||
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||||
'project:\n'
|
'project:\n'
|
||||||
@@ -44,7 +51,25 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
' repoPath: "<repo_path>"\n',
|
' repoPath: "<repo_path>"\n',
|
||||||
encoding="utf-8",
|
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(
|
(skill / "scripts" / validator_name).write_text(
|
||||||
"raise SystemExit(0)\n",
|
"raise SystemExit(0)\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -79,12 +104,16 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
project_content = (target / "project.md").read_text(encoding="utf-8")
|
project_content = (target / "project.md").read_text(encoding="utf-8")
|
||||||
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
||||||
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
||||||
|
delivery_content = (target / "delivery.yaml").read_text(encoding="utf-8")
|
||||||
self.assertIn("# sample-app", project_content)
|
self.assertIn("# sample-app", project_content)
|
||||||
self.assertIn("version=1.2.3", project_content)
|
self.assertIn("version=1.2.3", project_content)
|
||||||
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
||||||
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
||||||
|
self.assertIn('name: "sample-app"', delivery_content)
|
||||||
|
self.assertIn("enabled: false", delivery_content)
|
||||||
self.assertNotIn("<project_name>", tasks_content)
|
self.assertNotIn("<project_name>", tasks_content)
|
||||||
self.assertNotIn("<project_name>", knowledge_content)
|
self.assertNotIn("<project_name>", knowledge_content)
|
||||||
|
self.assertNotIn("<project_name>", delivery_content)
|
||||||
|
|
||||||
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
||||||
project = self.home / "existing-app"
|
project = self.home / "existing-app"
|
||||||
@@ -100,6 +129,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||||
self.assertFalse((target / "tasks.yaml").exists())
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
self.assertFalse((target / "knowledge.yaml").exists())
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
|
self.assertFalse((target / "delivery.yaml").exists())
|
||||||
|
|
||||||
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
||||||
project = self.home / "existing-knowledge-app"
|
project = self.home / "existing-knowledge-app"
|
||||||
@@ -116,6 +146,22 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "project.md").exists())
|
self.assertFalse((target / "project.md").exists())
|
||||||
self.assertFalse((target / "tasks.yaml").exists())
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
|
|
||||||
|
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:
|
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
||||||
for symlink_level in ("docs", "ack"):
|
for symlink_level in ("docs", "ack"):
|
||||||
with self.subTest(symlink_level=symlink_level):
|
with self.subTest(symlink_level=symlink_level):
|
||||||
@@ -147,6 +193,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((outside / "project.md").exists())
|
self.assertFalse((outside / "project.md").exists())
|
||||||
self.assertFalse((outside / "tasks.yaml").exists())
|
self.assertFalse((outside / "tasks.yaml").exists())
|
||||||
self.assertFalse((outside / "knowledge.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:
|
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
|
||||||
project = self.home / "path-traversal-app"
|
project = self.home / "path-traversal-app"
|
||||||
@@ -289,7 +336,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in moved_target.iterdir()),
|
sorted(path.name for path in moved_target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
||||||
@@ -348,7 +395,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "marker").exists())
|
self.assertFalse((target / "marker").exists())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["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:
|
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:
|
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
if calls == 7:
|
if calls == 8:
|
||||||
raise OSError("simulated directory fsync failure")
|
raise OSError("simulated directory fsync failure")
|
||||||
real_fsync(file_descriptor)
|
real_fsync(file_descriptor)
|
||||||
|
|
||||||
@@ -386,7 +433,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = project / "docs" / "ack"
|
target = project / "docs" / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
||||||
@@ -473,7 +520,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = moved_project / "docs" / "ack"
|
target = moved_project / "docs" / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_docs_replacement_aborts_before_publish(self) -> None:
|
def test_docs_replacement_aborts_before_publish(self) -> None:
|
||||||
@@ -568,7 +615,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = moved_docs / "ack"
|
target = moved_docs / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_ack_init_requires_knowledge_template(self) -> None:
|
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.assertIn("knowledge.template.yaml", result.stderr)
|
||||||
self.assertFalse((project / "docs" / "ack").exists())
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
def test_ack_init_requires_both_validators(self) -> None:
|
def test_ack_init_requires_delivery_template(self) -> None:
|
||||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
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):
|
with self.subTest(validator_name=validator_name):
|
||||||
project = self.home / f"missing-{validator_name}-app"
|
project = self.home / f"missing-{validator_name}-app"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
@@ -632,6 +700,21 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertIn("初始化知识库校验失败", result.stderr)
|
self.assertIn("初始化知识库校验失败", result.stderr)
|
||||||
self.assertFalse((project / "docs" / "ack").exists())
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
|
def test_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:
|
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
||||||
project = self.home / "mutated-staging-app"
|
project = self.home / "mutated-staging-app"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
@@ -677,6 +760,32 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
|
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:
|
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
|
||||||
skill = self.skills_home / "skills" / "plain"
|
skill = self.skills_home / "skills" / "plain"
|
||||||
(skill / "templates").mkdir(parents=True)
|
(skill / "templates").mkdir(parents=True)
|
||||||
@@ -697,6 +806,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertTrue((target / "project.md").is_file())
|
self.assertTrue((target / "project.md").is_file())
|
||||||
self.assertTrue((target / "tasks.yaml").is_file())
|
self.assertTrue((target / "tasks.yaml").is_file())
|
||||||
self.assertFalse((target / "knowledge.yaml").exists())
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
|
self.assertFalse((target / "delivery.yaml").exists())
|
||||||
|
|
||||||
def test_init_rejects_missing_project_directory(self) -> None:
|
def test_init_rejects_missing_project_directory(self) -> None:
|
||||||
project = self.home / "missing-app"
|
project = self.home / "missing-app"
|
||||||
|
|||||||
Reference in New Issue
Block a user