14 Commits

Author SHA1 Message Date
laily 0954ad542a feat(ack): add Feishu bug review approval gate 2026-08-03 15:26:02 +08:00
laily 4d078e8258 docs(ack): sync version references and research sources 2026-08-03 00:41:07 +08:00
laily 6855ac7078 feat: update feishu intake 2026-08-02 14:08:11 +08:00
laily e9b34747cc fix(orc): support trusted release shell workflow 2026-08-01 21:06:42 +08:00
laily f5bf35c722 feat(orc): centralize host-aware routing 2026-08-01 20:04:55 +08:00
laily 34bbb97406 Merge pull request 'feat: add ORC engineering orchestration' (#6) from support-release into main
Reviewed-on: laily/.skills#6
2026-08-01 18:01:22 +08:00
laily 337f1a9098 feat(orc): add tiered engineering orchestration 2026-08-01 17:49:25 +08:00
laily f02a34e751 revert(ack): restore three-role collaboration scope
This reverts commit a0f1c15b85.
2026-08-01 17:48:33 +08:00
laily e7f42e835a Merge pull request 'feat(ack): add one-off delivery routing' (#5) from support-release into main
Reviewed-on: laily/.skills#5
2026-08-01 15:33:23 +08:00
laily a0f1c15b85 feat(ack): add one-off delivery routing 2026-08-01 15:29:34 +08:00
laily f8d03fad4d feat(ack): add Feishu bug intake 2026-08-01 14:37:34 +08:00
laily 5b0e41199d Merge pull request 'feat(ack): add project delivery workflow' (#4) from add-extro-info into main
Reviewed-on: laily/.skills#4
2026-08-01 14:24:32 +08:00
laily 2c3d91c75c feat(ack): add project delivery workflow 2026-08-01 12:35:42 +08:00
laily f08edb6452 Merge pull request 'feat: add manage-release skill' (#3) from release into main
Reviewed-on: laily/.skills#3
2026-08-01 10:19:39 +08:00
51 changed files with 8167 additions and 79 deletions
+1
View File
@@ -59,6 +59,7 @@ AGENTS.md # 本文档
| Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
+5 -1
View File
@@ -36,7 +36,8 @@ AGENTS.md # 详细规范与架构说明
| Skill | 说明 |
|-------|------|
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作闭环 |
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
@@ -48,6 +49,9 @@ skiff init ack
skiff init ack --project ~/app
```
初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护
DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。
新建 skill:
```bash
+106
View File
@@ -0,0 +1,106 @@
# ACK 飞书多维表格 Bug 收件箱
## 目标
让用户在飞书多维表格中跨设备记录文字和截图,由 ACK Coordinator 在同一记录补全并
反复修订修复逻辑与验收标准。用户明确审核通过后,才将批准版本整理为独立、可验收、
可幂等追踪的 ACK 任务并启动三角色闭环。
## 非目标
- 不用 Skill 承担人工记录入口。
- 不抓取公开网页或依赖浏览器登录态。
- 不把 App Secret、access token 或飞书用户凭据写入项目。
- 不在审核前把草案写入 `tasks.yaml`,也不把 ACK 执行状态持续双向同步到飞书。
- 不把多条互不相关的 Bug 合成一次 Developer 派发。
## 项目配置契约
可选配置位于 `docs/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
配置存在时必须包含:
| 字段 | 约束 |
|------|------|
| `provider` | 固定为 `feishu-base` |
| `workflow` | 审核前协作固定为 `reviewed-writeback-v1`;缺省表示旧只读模式 |
| `profile` | `lark-cli` profile 名称;每次命令显式传入 |
| `baseToken` | 飞书 Base token |
| `tableId` | 数据表 ID |
| `viewId` | 只包含可导入记录的 `ACK Ready` 视图 ID |
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、
`acceptance`、`priority`、`attachments`、`updatedAt`。启用审核前协作时还必须映射
`fixLogic`。字段值只作为单个 argv 传给 `lark-cli`,不经过 shell。
## CLI 契约
- 可执行文件固定为可信路径中的 `lark-cli`。
- 允许官方 npm 包生成且 package/bin 映射校验通过的 `run.js` wrapper,并解析到包内
native binary 执行;拒绝缺少 native binary 或其它同名软链接目标。
- profile 由项目显式选择;不得执行 `profile use` 或读取 active profile作为回退。
- 子进程使用最小环境,不继承可能覆盖 profile/config/凭据或注入运行时的环境变量。
- 记录读取使用 `base +record-list`、JSON 输出、指定 Base/table/view 和投影字段。
- 草案写回只通过读取器的 `write-draft` 子命令调用 `base +record-upsert`,并且只允许修改
配置映射的 `fixLogic` 与 `acceptance`。
- 批准后只通过 `import-approved` 生成规范任务字段和可重算的 `approvedPayloadHash`;任务
校验器拒绝审核字段与该 hash 不一致的 reviewed task。
- 记录超过一页时按 offset/limit 继续读取,并设置总页数/记录数上限。
- 附件仅通过 `base +record-download-attachment` 下载到调用者显式提供的临时目录。
- 附件数量、单文件大小、批次总大小和子进程文件写入均有硬上限;落盘大小必须与
元数据一致。
- 标准输出必须是单一 JSON;CLI stderr 只作为错误摘要,不把可能的凭据写入结果。
## 标准化结果
读取器输出批次元数据和 `records`。每条记录至少包含:
- `sourceRef`:对 provider、profile、Base、table、record ID 做域隔离 SHA-256
后得到的稳定匿名引用;原始 profile、Base token 与 record ID 不拼入引用文本;
- `recordId` 与 `updatedAt`;
- 绑定来源事实、附件元数据和审核字段的 `draftRevision`;
- title、actual、expected、steps、可选 fixLogic、acceptance、priority;
- `enrichmentRequired`:缺失但允许 Coordinator 整理的 steps、fixLogic、acceptance、priority;
- 附件的 name/type/size 与可选本地临时路径;附件 token 只在下载命令内部使用;
- 原始字段中无法映射但不影响导入的警告。
输出不得包含 App ID、App Secret、Base token、附件 file token、tenant/user access
token 或 CLI 配置文件内容。
## Coordinator 整理规则
1. 先运行读取器 `check` 确认 `lark-cli` 和项目 profile,再单独核对 app 已获得所需读写
scope;`check` 不把用户 token 状态当作 app/bot scope 证明。
2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift
整理动作;需要看截图时使用临时下载目录。
3. 将记录分类并在飞书补全 `fixLogic` 与 `acceptance`;用户反馈时继续写回同一记录。
4. 用户必须明确批准当前 `draftRevision`;通过前不写 `tasks.yaml`、不派发角色、不修改
应用代码。通过后每条导入任务保存 `source.kind=feishu-base`、`source.ref`、
`source.recordId`、`source.updatedAt`、`source.workflow`、`source.approvedRevision`、
`source.approvedPayloadHash`,并把截图观察转成文字证据。
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
8. title、actual、expected、updatedAt 是不可推断的来源事实,部分缺失时 fail closed;
steps、fixLogic、acceptance、priority 缺失时由 Coordinator 根据来源事实和项目上下文
补齐并写回飞书。只有批准后的最终版本才导入任务板。
9. 没有附件且所有 Bug 内容字段均为空的误建行跳过并输出 `blank_record_skipped`;带部分
来源事实的残缺行不得静默跳过。
## 可观测验收信号
1. 两个假租户 profile 同时存在时,项目指定 `tenant-b`,所有记录和附件命令都显式包含 `--profile tenant-b`,不会读取 active 的 `tenant-a`。
2. 只读取配置的 `ACK Ready` view ID,并投影配置字段;不查询整张表或其它视图。
3. 一条含截图附件的假记录被标准化为稳定 `sourceRef`、完整文字字段和本地附件路径,输出中不存在任何 secret/token 凭据。
4. CLI 缺失、profile 缺失、畸形 JSON、错位矩阵、分页越界、路径穿越或附件下载失败均返回非零退出码且不输出伪成功结果。
5. ACK 文档明确要求按 `source.ref` 幂等整理;同一读取结果重复提交不会生成第二个来源任务。
6. 现有无 `bugIntake` 的 ACK 项目仍能通过任务板校验并按原流程工作。
7. 缺少 steps、acceptance、priority 的记录仍生成 create/refresh 计划并列出
`enrichmentRequired`;全空误建行被跳过,缺 title/actual/expected 的部分记录失败。
## 最脆弱假设与降级
本设计假设官方 `lark-cli` 的 Base JSON 输出保持 `fields`、`record_id_list` 与行矩阵
对应关系。读取器必须校验三者长度和字段映射;若上游输出契约变化,立即失败并提示
升级适配器,不能错列生成 Bug。飞书或 CLI 不可用时,只停止新的同步,已经进入
`tasks.yaml` 的任务继续按 ACK 闭环执行。
+9
View File
@@ -0,0 +1,9 @@
# 复制为 docs/ack/knowledge.yaml,替换占位符。结构见 templates/knowledge.schema.json。
# Developer/Test 只能在任务证据中提出 candidate;只有 Coordinator 写入这里。
version: 1
updatedAt: "2026-08-01T12:43:06+08:00"
project:
name: "skills"
# 检查 ID 映射到仓库内相对 path 和结构化 args;这里的内容不会被校验器执行。
verificationRegistry: {}
entries: []
+71
View File
@@ -0,0 +1,71 @@
# Agent Skills 仓库 ACK 协作协议(项目覆盖层)
> 本项目基于 ACK Skill v0.12.0。通用规范由 `/ack` 从 Skill 自身的
> `references/` 读取;本文件只保存当前仓库差异。
## 项目概览
- 项目:`skills`
- 技术栈:Python 3、Markdown、JSON Schema
- 运行命令:`python3 -m unittest discover -s tests -p 'test_*.py'`
- Base URL:`n/a`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 覆盖层文件:`docs/ack/project.md`
## 通用规范(由 ACK Skill 按需读取)
- 开始需求:`references/kickoff.md`
- 角色、权限、状态机与完成定义:`references/roles-and-permissions.md`
- 模型档位与升级规则:`references/model-routing.md`
- 闭环流程与 worktree 对齐:`references/closed-loop.md`
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/orca-adapter.md`
## Worker 路由
机器可校验的 worker profile、允许 worktree 和 receipt 只以
`docs/ack/tasks.yaml` 为准。本次 Developer 使用当前 `record-bug` worktree;Test
必须使用 fresh worker,并在独立临时项目、独立假 CLI 配置目录中完成黑盒演练。
## 路径权限
| 路径 | Coordinator | Test | Developer | 说明 |
|------|:-----------:|:----:|:---------:|------|
| `docs/ack-feishu-bug-intake.md` | R/W | Read-only | Read-only | 本需求规格与验收契约 |
| `docs/ack/` | R/W | Read-only | Read-only | ACK 项目状态,只有 Coordinator 写 |
| `skills/ack/` | Read-only | Read-only | R/W | ACK Skill 实现、模板与通用规范 |
| `tests/test_ack_feishu_intake_unit.py` | Read-only | Read-only | R/W | Developer 白盒测试 |
| `tests/test_ack_feishu_intake_e2e.py` | Read-only | R/W | Read-only | Test 独立黑盒演练与回归测试 |
| 其它 `tests/test_ack*.py` | Read-only | Read-only | Read-only | 既有 ACK 回归测试 |
| 临时测试目录 | Read-only | R/W | R/W | 必须由 `tempfile`/`mktemp -d` 创建,不保存凭据 |
| `~/.lark-cli/` | Read-only | Read-only | Read-only | 不读取、不修改真实飞书 profile;测试只能使用假 CLI |
## 命令
Developer 白盒验证:
```bash
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'
python3 -m unittest tests.test_ack_skill tests.test_ack_tasks_validation
python3 -m compileall -q skills/ack/scripts
```
Test 黑盒复测:
```bash
python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'
python3 -m unittest discover -s tests -p 'test_ack*.py'
python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 skills/ack/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
```
## 本次任务硬规则
- 只支持官方 `lark-cli`,所有读取命令必须显式携带项目配置的 `--profile`,不能依赖当前 active profile。
- 项目只保存 profile 名称、Base/table/view 标识和字段映射;App Secret、token 与用户凭据不得进入仓库、任务板或日志。
- 飞书接入第一版只读,不更新或删除表格记录,不切换 CLI active profile。
- 外部 CLI 缺失、profile 不存在、返回结构异常、附件下载失败时 fail closed,不回退到网页抓取。
- 只有 `ACK Ready` 视图中的记录可进入整理;每条记录保留稳定 `record_id` 来源引用,重复读取不得制造重复 ACK 来源。
- 不修改 `AGENTS.md`、`CLAUDE.md`,不提交或推送。
+533
View File
@@ -0,0 +1,533 @@
version: 1
updatedAt: "2026-08-01T13:34:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.12.0"
project:
name: "skills"
repoPath: "/home/ace/orca/workspaces/.skills/record-bug"
baseUrl: "n/a"
devWorktree: "/home/ace/orca/workspaces/.skills/record-bug"
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
orchestration:
profileVersion: 1
mode: "orca"
allowedWorktrees:
- "/home/ace/orca/workspaces/.skills/record-bug"
modelAllowlist:
codex:
developer:
standard: ["gpt-5.6-terra"]
strong: ["gpt-5.6-sol"]
test:
standard: ["gpt-5.6-luna"]
profiles:
codex-dev-standard:
role: "developer"
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
codex-test-standard:
role: "test"
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
codex-dev-strong:
role: "developer"
cli: "codex"
tier: "strong"
model: "gpt-5.6-sol"
reasoningEffort: "high"
permissionMode: "workspace-write"
defaults:
developer: "codex-dev-standard"
test: "codex-test-standard"
developerUpgraded: "codex-dev-strong"
workerReceipts:
- receiptVersion: 1
id: "WR-1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
launchId: "1c9755604c0de98ce5ccc5913faa4984e9d975879250095b3553a796bccb261d"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:7de2f9ebf3065e7c044150fa87aed39582a8018b63a0dabac22194bf52ad52b7"
slot: 1
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A1"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_4d6278c9-83ac-4876-a29d-022adc346e6c"
incarnationId: "6daffb4c-e705-47f3-b7c2-d1ee1fb16c56"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T04:46:51.629061Z"
createdAt: "2026-08-01T04:46:42.869861Z"
receiptHash: "sha256:93e7e4454438a4d1c9a64eb7429563bb4b71348715cc4cb3e42c96cb9ca75fa4"
- receiptVersion: 1
id: "WR-035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
launchId: "035285dde990c7f59be5842a6c98eab8f715dd9c4400fba79aba341fab53ffc3"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:981fe3c23594573f564de9ab321e724324962958ed689ad3c0c98d7d165c1aa9"
slot: 2
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A1"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_ab858ba7-b972-45fb-bf50-61e96ced2830"
incarnationId: "9e20c158-7ac3-4a18-8dca-f9fec50126d6"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T04:57:33.909375Z"
createdAt: "2026-08-01T04:57:25.086609Z"
receiptHash: "sha256:1245532d66814f5d3b2a6a3b22293ead597f04390e628333569a7ba8763d0a84"
- receiptVersion: 1
id: "WR-3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
launchId: "3202fbd30c60dfc6614787e82d0f8e10701f811fdc3d43af54b8cc269d8165f1"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:8264af6531e9828c0f994845b3a24931e22abea72d8a1d54a7c764577e544873"
slot: 3
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A2"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c"
incarnationId: "f77f05ff-fb2b-4099-ab03-070f61671673"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:01:26.824435Z"
createdAt: "2026-08-01T05:01:17.965038Z"
receiptHash: "sha256:c20c47c7491b5b399d2c29fce67a7035b432969bda0ccb74948eb03c3d085380"
- receiptVersion: 1
id: "WR-12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
launchId: "12c3784e1719302b77728917b913c3233ef022f811a94050d1842e2d54971ebd"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:621c7d5d6b7182d9a1ebf211fa11a80d7b100a3b682f8800f61031a651f5bf2a"
slot: 4
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A2"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_1ec9d2e1-a1b6-45db-8477-097e39a93ee4"
incarnationId: "a6f79ac3-9cc6-40a1-a38a-e7b7c2a49b02"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:05:36.189001Z"
createdAt: "2026-08-01T05:05:27.500696Z"
receiptHash: "sha256:824227bda0e78558807e10da9d89ec32ee4d7f37d782b866bca4629c5ac9e8fb"
- receiptVersion: 1
id: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
launchId: "90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
profileId: "codex-dev-standard"
profileHash: "sha256:fd5bc08d5d8a154175675a2de3b29cabbf016fdae0ed5f76167c63bced0bc47b"
launchFingerprint: "sha256:97ef498caf4a5f0d48fef22773c942a5065dfcf5cecefa22a6ad21df8d04bcd1"
slot: 5
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A3"
role: "developer"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-terra"
reasoningEffort: "medium"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-terra"
- "--config"
- "model_reasoning_effort=medium"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:a17f4a56e4324a6c170773dffdf0b986c4f070e04fe404ba8395135c686182ee"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_88079d90-29a3-48ac-9b97-bec011abc249"
incarnationId: "5d4f785f-e0b5-4ab3-9de3-72ebc2da3b06"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:16:40.565911Z"
createdAt: "2026-08-01T05:16:31.788668Z"
receiptHash: "sha256:c62417b2ee8e9815228a18b2ee1be84b2844dada50eb9703a061e08e51e57eb7"
- receiptVersion: 1
id: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
launchId: "8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
profileId: "codex-test-standard"
profileHash: "sha256:30a1cf4d0f1b180ca1aebd759cf8a9425047ed9563f4cf3eb3fe8b3935ca2078"
launchFingerprint: "sha256:6c7a45f7dd119bb6ef3bfaed7961c30f257285d940f248e0b16678dcad1bd19d"
slot: 6
createdFor:
taskId: "FEISHU-001"
attemptId: "FEISHU-001-A3"
role: "test"
worktree:
path: "/home/ace/orca/workspaces/.skills/record-bug"
device: 2049
inode: 1705498
gitCommonDir: "/home/ace/.skills/.git"
gitCommonDevice: 2049
gitCommonInode: 558841
requested:
cli: "codex"
tier: "standard"
model: "gpt-5.6-luna"
reasoningEffort: "low"
permissionMode: "workspace-write"
executable: "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
executableDevice: 2049
executableInode: 1453403
cliVersion: "codex-cli 0.146.0"
argv:
- "/home/ace/.codex/packages/standalone/releases/0.146.0-x86_64-unknown-linux-musl/bin/codex"
- "--strict-config"
- "--model"
- "gpt-5.6-luna"
- "--config"
- "model_reasoning_effort=low"
- "--sandbox"
- "workspace-write"
- "--ask-for-approval"
- "never"
- "--cd"
- "/home/ace/orca/workspaces/.skills/record-bug"
argvHash: "sha256:ad1991c9d70fc574c856a2f7cb51a7e93eca5d286dfc689b09b414f7144f6369"
environmentPolicy: "per-cli-allowlist-v1"
binding:
orchestrator: "orca"
runtimeId: "30613bea-9f1e-4d6b-949f-58e43f77688a"
handle: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
incarnationId: "6e37ac75-5725-410c-87af-63f8bb42a911"
observedWorktreePath: "/home/ace/orca/workspaces/.skills/record-bug"
connected: true
writable: true
boundAt: "2026-08-01T05:25:08.564556Z"
createdAt: "2026-08-01T05:24:59.720042Z"
receiptHash: "sha256:dcbed5b2a3fa99312deba61951253992741eca885068e8f649613bd97eb3f59f"
summary:
verified: ["FEISHU-001"]
open: []
failedRetest: []
leftovers: []
tasks:
- id: "FEISHU-001"
type: "feature"
title: "ACK 从飞书多维表格按项目 profile 读取并整理 Bug"
priority: "P1"
status: "verified"
assignee: "test"
component: "skills/ack"
specRefs:
- "docs/ack-feishu-bug-intake.md"
testRefs:
- "tests/test_ack_feishu_intake_unit.py"
- "tests/test_ack_feishu_intake_e2e.py"
knowledgeRefs: []
knowledgeApplied: []
knowledgeCandidates: []
knowledgeChecks: []
description: >
扩展完整 ACK Skill,使项目可以在 tasks.yaml 中声明飞书多维表格 Bug 收件箱和
lark-cli profile。ACK 通过可信、无 shell 的读取器读取 ACK Ready 视图、按需
下载截图并输出可幂等整理的标准化记录;不同项目不会串用飞书租户。
stepsToReproduce:
- "创建两个假 lark-cli profile:tenant-a 为 active,项目配置 tenant-b"
- "在 tenant-b 的 ACK Ready 视图返回一条带截图附件的 Bug 记录"
- "运行 ACK 飞书读取器 check/fetch,并重复提交同一标准化结果"
expected: >
读取器每次显式使用 tenant-b,仅读取配置 view,正确标准化文字与截图附件,
对所有错误 fail closed;ACK 按稳定 source.ref 整理,重复来源不产生新任务,
未配置飞书的旧项目保持兼容。
actual: >
当前 ACK 没有飞书 Bug 数据源、CLI profile 配置、附件读取或来源幂等规则。
evidence:
browser: "飞书多维表格记录可包含文字字段和截图附件;本次使用假 CLI 离线复现"
api: "官方 lark-cli base +record-list 与 +record-download-attachment"
logs: "本机当前没有 lark-cli,验证必须通过隔离假 CLI 完成"
verification:
commands:
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_unit.py'"
- "python3 -m unittest discover -s tests -p 'test_ack_feishu_intake_e2e.py'"
- "python3 -m unittest discover -s tests -p 'test_ack*.py'"
- "python3 skills/ack/scripts/validate_tasks.py docs/ack/tasks.yaml"
browser:
page: "n/a"
checks:
- "假 CLI 记录证明所有数据命令显式使用 tenant-b profile"
- "标准化输出包含唯一 sourceRef 与本地截图路径"
- "重复来源整理结果只对应一个 ACK 任务"
dispatch:
developer:
profileId: "codex-dev-standard"
receiptId: "WR-90c5c8a69173ce07d1db3f6065eaf956ca6cacbdc5396ac3a224e04a7fe8ed21"
attemptId: "FEISHU-001-A3"
taskId: "task_9bc1dba1e21a"
dispatchId: "ctx_0791582a526f"
test:
profileId: "codex-test-standard"
receiptId: "WR-8d81f78e66ba7c82c7a6a734b35e5941ef0aa3f03f8ef694baae75eeaf9ebfd7"
attemptId: "FEISHU-001-A3"
taskId: "task_4f70d2496d13"
dispatchId: "ctx_66ab3460dda2"
rounds:
- round: 1
attemptId: "FEISHU-001-A1"
result: failed
evidence: >
Isolated fake tenant and screenshot intake passed profile isolation,
download containment, fail-closed behavior, organization rules and
legacy validation, but normalized output leaked baseToken inside
sourceRef and exposed the attachment file token.
- round: 2
attemptId: "FEISHU-001-A2"
result: failed
evidence: >
Strengthened isolated E2E passed after the secret-output fix, but
Coordinator final gate compared the adapter with official lark-cli
source: profile list returns a raw JSON array and has no --format
flag, while the adapter requires an object and sends --format.
Executable lookup also trusts arbitrary PATH instead of the fixed
trusted directories required by the product spec; template source
reference still documents the obsolete raw form.
- round: 3
attemptId: "FEISHU-001-A3"
result: passed
evidence: >
Fresh Test ran a file-backed fake Feishu submission containing all
Bug text fields and a screenshot through the production check/fetch
entry points. Six focused E2E tests and five unit tests passed. The
run verified the official raw profile array and data.data matrix,
explicit tenant-b selection, trusted executable resolution,
screenshot containment, stable opaque sourceRef deduplication and
fail-closed handling for malformed, ambiguous, non-progressing and
over-limit responses. Validators, skiff check, compileall and diff
checks also passed.
resolution:
fixedBy: "term_88079d90-29a3-48ac-9b97-bec011abc249"
verifiedBy: "term_44ab2675-445a-4f51-a0f5-83960bc1ca3b"
verifiedAt: "2026-08-01T13:31:40+08:00"
leftoverReason: null
evidence:
developer: >
FEISHU-001-A1 implemented ACK 0.12.0 Feishu Base intake. Focused unit
tests, ACK contract tests, compileall, task validation and diff check
passed. The fresh worker could not deliver worker_done through its
workspace-write sandbox because Orca relay/FUSE was unavailable;
Coordinator preserved the terminal evidence in Orca task
task_ed87b3386a80 and did not treat it as final verification.
testRound1: >
Fresh Test term_ab858ba7-b972-45fb-bf50-61e96ced2830 created the
isolated E2E fixture and found secret-safe output failing while all
other targeted signals passed. Evidence is retained in Orca task
task_251a9b5265cd; Test relay was unavailable for the same sandbox
reason, so this remains a failed retest rather than verification.
developerRound2: >
Fresh Developer term_d1b02227-6bd6-4bd3-a432-bb50f64fca8c changed
sourceRef to a domain-separated SHA-256 digest and kept file tokens
internal to download calls. Focused unit, ACK contract, read-only E2E,
compileall and diff checks passed; independent round-2 Test is still
required.
coordinatorGateRound2: >
Targeted Test evidence was green, but final source-contract review
against official larksuite/cli cmd/profile/list.go rejected the round:
actual profile list output is a JSON array, the command exposes no
format flag, and the current reader resolves lark-cli through caller
PATH rather than fixed trusted directories.
developerRound3: >
Fresh Developer term_88079d90-29a3-48ac-9b97-bec011abc249 added the
trusted resolver, official raw profile-list and data.data parsing,
strict envelopes, sanitized execution PATH, revised unit coverage and
complete setup/source examples. Five focused unit tests, 34 ACK
contracts (one skipped), compileall, task validation and diff check
passed; final independent Test remains required.
testRound3: >
Fresh Test term_44ab2675-445a-4f51-a0f5-83960bc1ca3b passed six
isolated E2E cases and all five Feishu unit tests. It exercised the
production entry points with a real fake-CLI subprocess and a separate
submitted-bug JSON file, downloaded a screenshot into a temporary
directory, repeated the fetch to prove a stable opaque sourceRef, and
simulated ACK source.ref organization without duplicates. The four
failures in the wider legacy suite reproduce unchanged on base commit
f08edb6 and are classified as existing environment/layout failures.
Orca worker_done transport was unavailable inside the worker sandbox
because FUSE was absent; the full fresh-terminal evidence is retained
in Orca task task_4f70d2496d13.
+27 -4
View File
@@ -1311,9 +1311,10 @@ def cmd_init(args: argparse.Namespace) -> None:
project_file = destination / "project.md"
tasks_file = destination / "tasks.yaml"
knowledge_file = destination / "knowledge.yaml"
delivery_file = destination / "delivery.yaml"
managed_targets = [project_file, tasks_file]
if args.name == "ack":
managed_targets.append(knowledge_file)
managed_targets.extend((knowledge_file, delivery_file))
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
@@ -1326,8 +1327,11 @@ def cmd_init(args: argparse.Namespace) -> None:
(tasks_template, tasks_file),
]
if args.name == "ack":
template_targets.append(
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file)
template_targets.extend(
(
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
)
)
missing = [path for path, _ in template_targets if not path.is_file()]
if missing:
@@ -1335,10 +1339,11 @@ def cmd_init(args: argparse.Namespace) -> None:
raise SystemExit(f"skill 缺少初始化模板: {paths}")
validator = skill_source / "scripts" / "validate_tasks.py"
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
if args.name == "ack":
missing_validators = [
path
for path in (validator, knowledge_validator)
for path in (validator, knowledge_validator, delivery_validator)
if not path.is_file()
]
if missing_validators:
@@ -1394,6 +1399,23 @@ def cmd_init(args: argparse.Namespace) -> None:
raise SystemExit(
f"初始化知识库校验失败(exit {completed.returncode})"
)
if args.name == "ack" and delivery_validator.is_file():
completed = subprocess.run(
[
sys.executable,
str(delivery_validator),
str(staged_files[delivery_file]),
"--tasks",
str(staged_files[tasks_file]),
"--project-root",
str(staging),
],
check=False,
)
if completed.returncode != 0:
raise SystemExit(
f"初始化交付契约校验失败(exit {completed.returncode})"
)
for target, staged in staged_files.items():
if staged.read_text(encoding="utf-8") != rendered_files[target]:
@@ -1621,6 +1643,7 @@ def cmd_init(args: argparse.Namespace) -> None:
_print(f" 任务板: {tasks_file}")
if args.name == "ack":
_print(f" 知识库: {knowledge_file}")
_print(f" 交付契约: {delivery_file}(默认关闭)")
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
+54 -4
View File
@@ -9,6 +9,10 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
`leftover`,然后继续处理其它任务。
项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
稳定发布与生产部署始终保留人工批准点。
## 安装
全局安装:
@@ -38,7 +42,8 @@ skiff init ack --project ~/code/my-app
docs/ack/
├── project.md
├── tasks.yaml
└── knowledge.yaml
├── knowledge.yaml
└── delivery.yaml # 默认 enabled: false
```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
@@ -52,15 +57,17 @@ skills/ack/
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md、tasks.yaml、knowledge.yaml 模板和 schema
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
└── scripts/ # 状态校验、任务/知识选择、安全验证执行与结构化 worker launcher
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
已经独立验证的项目知识护栏。
`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在
`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。
## 检查项目状态
@@ -70,15 +77,26 @@ Agent 会从当前 ACK Skill 目录解析校验脚本:
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
```
Coordinator 可以按当前任务上下文做确定性推荐:
```bash
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
--task-id BUG-001
python3 <ack-skill-dir>/scripts/select_knowledge.py docs/ack/knowledge.yaml \
--component web --path web/app.py --tag long-running-service --limit 10
```
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
上下文。
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
@@ -115,6 +133,19 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
## 配置与运行交付
用户可以直接向 `/ack` 描述项目差异,例如“这个项目用 `make deb` 构建 DEB,推到
preview APT 源,再部署到开发机”。ACK 会把它维护成
`docs/ack/delivery.yaml` 中的声明式 entrypoint、artifact、destination、environment
和 profile,校验后展示 diff;首次配置保持关闭,确认后才启用。
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
凭据值。ACK 在任务进入 `verified` 后,按用户确认的 profile 执行,并把 revision、
PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml.deliveryRuns`。默认
profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。
具体契约见 `references/delivery.md`。
## 启动 Worker
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
@@ -180,8 +211,27 @@ fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。
首次配置交付可以说:
```text
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
```
之后处理需求时只需在确认计划中选择 profile:
```text
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。
```
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
同时存在;旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型。旧项目可以不迁移
而继续使用原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
+82 -18
View File
@@ -2,15 +2,16 @@
name: ack
description: >-
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或指挥 Coordinator/Developer/Test
工作时使用。
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
工作,或配置并执行任务验证后的项目交付流程时使用。
---
# ACK 项目协作入口
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
`tasks.yaml` 和 `knowledge.yaml`,不要复制或链接 Skill 内容。
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
内容。
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
脚本都相对此目录访问,不依赖固定的全局安装路径。
@@ -20,6 +21,7 @@ description: >-
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
@@ -54,18 +56,24 @@ description: >-
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
猜测并激活知识。
8. 更新 `updatedAt`,并运行:
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
9. 更新 `updatedAt`,并运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
--tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
```
9. 检查 `project.md`、`tasks.yaml` 与 `knowledge.yaml` 是否仍有 `<...>` 占位符。
10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有
`<...>` 占位符。
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
缺失值。
10. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
不推送。
## 检查
@@ -74,6 +82,10 @@ description: >-
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- `docs/ack/knowledge.yaml`
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
需要查看任务内容时,使用 `<ack-skill-dir>/scripts/select_tasks.py` 解析完整任务板并
只输出项目配置、摘要和可工作任务;不要用 `cat`、整文件 `sed` 或等价方式把完整
`tasks.yaml` 注入上下文。完整性仍由校验器检查。
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
@@ -82,33 +94,61 @@ description: >-
黑盒命令和 Base URL。
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。只报告证据明确的问题,不因可选
字段缺失而宣称失败。
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
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. 若存在 `project.bugIntake`,运行
`python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml`。
它只接受 `feishu-base` 和显式 profile;详细的飞书配置、凭据初始化和读取方式见
`references/feishu-bug-intake.md`。
6. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
必须同时为空或同时填写。
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
## 工作
1. 若 `docs/ack` 不存在,停止并建议先用 `/ack` 初始化;不要静默初始化。
2. 依次读取:
- `docs/ack/project.md`
- `docs/ack/tasks.yaml`
- 运行 `python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml`,只读取
`project`、`summary` 和默认可工作状态的任务;已知当前任务时传
`--task-id <ack-task-id>`。选择器会解析并校验完整任务板,并只附带选中任务引用的
receipt 与 delivery run。命中超过默认预算时用 `--task-id` / `--status` 缩小,
不直接回退为输出完整 `tasks.yaml`。
- 通过 `<ack-skill-dir>/scripts/select_knowledge.py` 从
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
- `<ack-skill-dir>/references/kickoff.md`
- kickoff 指定且与当前任务相关的 references 文件
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
`<ack-skill-dir>/references/delivery.md`
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
不全量注入知识库。
`project.bugIntake.workflow` 为 `reviewed-writeback-v1` 时,按
`references/feishu-bug-intake.md` 把飞书作为审核前的唯一协作区:先运行 check/plan,
读取用户填写的 Bug;Coordinator 根据来源事实与项目上下文补全修复逻辑和可观测验收
标准,只通过安全适配器写回同一飞书记录并回读确认。用户反馈后继续只在飞书修订。
用户针对当前 `draftRevision` 明确审核通过前,不创建或刷新 `tasks.yaml` 任务、不启动
worker、不派发 Developer/Test,也不修改应用代码。审核通过后重新读取,要求 revision
与批准值完全一致,才通过 `import-approved` 生成规范 `taskDraft`,原样写入最终版本、
`source.workflow`、`source.approvedRevision` 与 `source.approvedPayloadHash`;校验器重算
payload hash 通过后才进入三角色闭环。未声明 workflow 的旧八字段配置只按
`read-only-v1` 兼容,不得写回;
标题、实际表现和预期结果不可推断;整行空白记录按批次 warning 跳过。
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
然后交给用户确认;确认前不派发实现。
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
步骤放入同一份计划。确认前不派发实现,也不执行交付。
5. 创建或更换 worker 时,只使用
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
@@ -129,6 +169,25 @@ description: >-
`leftover` 并继续其它任务。
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署
必须在对应步骤再次取得明确批准。
## 交付配置维护
1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布
入口和 CI;把用户自然语言描述转换为结构化 entrypoint、artifact、destination、
environment 与 profile。配置只引用仓库内脚本或声明式工具 target,不保存 shell。
2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
`deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
3. 运行 delivery、tasks 和跨文件校验;需要的脚本不存在、不可执行、引用不完整或
涉及凭据正文时 fail closed。凭据只写 secret 名称,值由外部环境提供。
4. 用户确认后才把配置设为启用。配置修改只影响下一次 delivery run;已确认或正在
执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改
扩大权限。
## 边界
@@ -141,10 +200,15 @@ description: >-
v0.10 不自动复用既有 worker。
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
launch ID、外部 record 和 Orca live state 完成人工核对。
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 只读,
只能通过回报提名或验证知识。
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
提交、推送、创建终端、新 worktree、发布产物或部署。
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
维护中修改。
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml` 和
`docs/ack/knowledge.yaml`;通用资源始终从当前 ACK Skill 目录读取。
- 不把完整 `tasks.yaml` 注入上下文;使用 `select_tasks.py` 获取有预算的项目与任务
视图,写回前仍运行完整任务板校验。
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
ACK Skill 目录读取。
+1 -1
View File
@@ -1 +1 @@
0.10.0
0.14.0
+2 -2
View File
@@ -1,6 +1,6 @@
interface:
display_name: "ACK"
short_description: "初始化、检查并运行项目里的 ACK 三角色协作闭环"
default_prompt: "Use $ack to initialize ACK for this project or coordinate work from docs/ack."
short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环"
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile."
policy:
allow_implicit_invocation: false
+149
View File
@@ -0,0 +1,149 @@
version: 1
updatedAt: "2026-08-01T10:00:00+08:00"
project:
name: "notes-api"
enabled: true
defaultProfile: "review"
entrypoints:
verify:
kind: make
target: verify
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 1800
build-deb:
kind: make
target: build-deb
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 3600
build-image:
kind: make
target: build-image
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 3600
upload-deb:
kind: script
path: "scripts/upload-preview-deb.sh"
args: []
requiredSecrets: ["DEB_TOKEN"]
workingDirectory: "."
timeoutSeconds: 900
upload-image:
kind: script
path: "scripts/publish-preview-image.sh"
args: []
requiredSecrets: ["REGISTRY_TOKEN"]
workingDirectory: "."
timeoutSeconds: 1800
deploy-test:
kind: script
path: "scripts/deploy-test.sh"
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 900
health-test:
kind: script
path: "scripts/check-test.sh"
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 300
rollback-test:
kind: script
path: "scripts/rollback-test.sh"
args: []
requiredSecrets: []
workingDirectory: "."
timeoutSeconds: 900
artifacts:
service-deb:
type: deb
build: build-deb
outputs: ["dist/*.deb"]
service-image:
type: oci-image
build: build-image
image: "registry.example.com/notes/service"
platforms: ["linux/amd64", "linux/arm64"]
destinations:
preview-apt:
type: apt-repository
channel: preview
endpoint: "https://packages.example.com"
repository: "testing"
upload: upload-deb
preview-registry:
type: oci-registry
channel: preview
registry: "registry.example.com"
repository: "notes/service"
upload: upload-image
environments:
test-server:
type: ssh-host
classification: development
target: "notes-test"
deploy: deploy-test
healthCheck: health-test
rollback: rollback-test
mutex: "notes-test-deploy"
profiles:
review:
stopAt: review_ready
steps:
- id: verify
action: verify
entrypoint: verify
- id: open-pr
action: pull-request
draft: true
remote: origin
baseBranch: main
- id: build-deb
action: build
artifact: service-deb
- id: publish-deb
action: publish
artifact: service-deb
destination: preview-apt
- id: deploy-test
action: deploy
artifact: service-deb
environment: test-server
- id: smoke-test
action: health-check
environment: test-server
- id: ready
action: mark-ready
review-image:
stopAt: review_ready
steps:
- id: verify
action: verify
entrypoint: verify
- id: open-pr
action: pull-request
draft: true
remote: origin
baseBranch: main
- id: build-image
action: build
artifact: service-image
- id: publish-image
action: publish
artifact: service-image
destination: preview-registry
- id: ready
action: mark-ready
+11 -5
View File
@@ -1,10 +1,10 @@
# notes-web Agent 协作协议(示例,项目覆盖层)
> 本项目基于 ack v0.10.0。
> 本项目基于 ack v0.14.0。
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> `docs/ack/` 只保存 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。
## 项目概览
@@ -14,6 +14,7 @@
- Base URL:`http://localhost:5173`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 交付契约:`docs/ack/delivery.yaml`
- 覆盖层文件:`docs/ack/project.md`
## 稳定规范(引用,不重复)
@@ -25,6 +26,7 @@
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令:`references/orca-adapter.md`
- 验证后交付:`references/delivery.md`
## Worker 路由
@@ -52,6 +54,7 @@
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
## 命令
@@ -75,8 +78,9 @@ curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
path/args。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和
`scripts/validate_knowledge.py` 执行。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。
## 硬规则(其余见 references/)
@@ -93,5 +97,7 @@ path/args。
`candidate` 不派发,知识库不全量注入。
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与
production 部署保留显式 approval,配置变更只影响下一次 run。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
- 不提交或推送,除非用户明确要求。
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
+15 -1
View File
@@ -3,7 +3,7 @@
version: 1
updatedAt: "2026-07-06T09:40:00+08:00"
source: "Coordinator (PM) Agent"
ackVersion: "0.10.0"
ackVersion: "0.14.0"
project:
name: "notes-web"
repoPath: "/home/dev/notes-web"
@@ -11,6 +11,7 @@ project:
devWorktree: "/home/dev/notes-web-wt/fix-preview"
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
deliveryFile: "docs/ack/delivery.yaml"
orchestration:
profileVersion: 1
mode: "manual"
@@ -70,6 +71,7 @@ project:
developerUpgraded: "codex-dev-strong"
workerReceipts: []
deliveryRuns: []
summary:
verified: ["BUG-002"]
@@ -102,8 +104,20 @@ tasks:
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
checkedBy: "test-worker-1"
checkedAt: "2026-07-06T09:36:00+08:00"
# 飞书导入时使用不透明 digest;不要在 source.ref 中放 profile、Base、table 或 record ID。
source:
kind: "feishu-base"
ref: "feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
recordId: "recExample"
updatedAt: "2026-07-06T09:00:00Z"
description: >
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
fixLogic: >
修正确认区对预览响应的字段映射,完整渲染四类 diff,同时保持取消操作不触发写入。
acceptanceCriteria:
- "预览后确认区显示 title、修改前值、修改后值和 coverChanged"
- "取消预览不会产生保存请求"
- "原先确认区为空的问题不再出现"
stepsToReproduce:
- "打开 /fix?fileId=1"
- "搜索并选择候选项"
+21 -1
View File
@@ -4,7 +4,8 @@
- [ ] ACK Skill 已全局安装或安装到当前项目。
- [ ] 已运行 `skiff init ack --project <project-root>`。
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与
默认关闭的 `delivery.yaml`。
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
@@ -16,6 +17,8 @@
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
`docs/ack/knowledge.yaml`。
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
@@ -30,6 +33,7 @@
- [ ] 私有配置只读且不提交。
- [ ] `tasks.yaml` 只有 Coordinator 写。
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
## 任务板
@@ -38,6 +42,22 @@
- [ ] 真实任务的验收是可观测信号。
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
## 可选交付
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
shell、自由 command、凭据值或环境变量值。
- [ ] artifact、destination、environment 和 profile 引用均通过
`validate_delivery.py --tasks ... --project-root ...` 校验。
- [ ] 默认 profile 只使用 preview/staging 与非 production 环境,停止在
`review_ready`。
- [ ] stable 发布和 production 部署之前存在 approval 步骤;production 环境有
rollback 入口。
- [ ] 本次 ACK 计划明确 profile、task IDs、目标、停止点和审批步骤;配置本身没有
被当作提交、推送、发布或部署授权。
- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`;
失败不会把已验证任务回退为失败。
## 项目知识
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
+8 -6
View File
@@ -164,11 +164,12 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
| 要保持基线分支干净 | 新 worktree(feature 分支)|
| 小改动、追求快 | 当前 worktree |
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 和
`knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由
Coordinator 单写。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch
也只写入这个副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、
launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在
worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护
时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
@@ -191,7 +192,8 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
+123
View File
@@ -0,0 +1,123 @@
# ACK 交付阶段
本文件定义可选的 `verified -> review_ready/released` 交付阶段。开发、独立复测和
Coordinator 终检仍由 ACK 原有闭环负责;只有选中的任务全部 `verified` 后才能进入
交付。项目配置位于 `docs/ack/delivery.yaml`,运行证据写入
`docs/ack/tasks.yaml.deliveryRuns`。
## 1. 配置与授权不是一回事
`delivery.yaml` 描述项目能怎样构建、上传和部署,不能单独授予远端写权限。ACK 在
kickoff 的既有用户确认点同时展示本次 profile、remote、产物目标、环境和停止点;用户
确认该任务计划后,才允许执行计划中准确列出的 `review_ready` 步骤。目标、remote、
channel、environment 或 source revision 漂移时重新确认。
`approval` 步骤始终是运行时硬门。`stable` 发布和 `production` 部署不能由 kickoff
的一般确认代替,必须在该步骤取得本次明确授权。配置、历史 approval 或项目文档不能
替用户授权合并 PR、创建正式 tag、覆盖版本、删除分支或生产发布。
## 2. 配置快照与变更生效
普通任务在 kickoff 时从可信 base commit 读取交付契约并记录 `configRevision`。本次
分支对 `delivery.yaml`、引用的部署入口、CI 或 Agent 指令文件的修改不能扩大当前运行
权限;这些改动经审核合并后从下一次任务生效。
用户明确要求维护交付配置时:
1. 读取现有配置、项目构建入口、CI、打包和部署事实。
2. 用自然语言总结将新增、删除或改变的 artifact、destination、environment、profile
和权限边界。
3. 只做最小配置修改,不把项目脚本复制进 ACK。
4. 运行 `validate_delivery.py`;可安全执行的本地入口使用 dry-run 或无凭据环境检查。
5. 把配置或入口变更作为待审核变更交付。本轮不使用新配置执行 publish/deploy。
普通功能任务中若发现配置漂移,记录 `contract_drift` 并停止受影响的交付步骤,不为了
通过流程而静默修改配置或跳过步骤。
## 3. 交付契约结构
- `entrypoints`:项目已有的 Make、Just、Task、Dagger 或仓库内可执行脚本入口。
- `artifacts`:`deb`、`oci-image` 或 `file`,引用一个 build entrypoint。
- `destinations`:APT、OCI registry 或 CI artifact;`channel` 区分 preview、staging、
stable。
- `environments`:SSH host、Docker Compose、Kubernetes 或 custom;必须声明环境等级、
deploy 和 health check,production 还必须声明 rollback。
- `profiles`:按顺序执行的步骤和停止点。默认 profile 必须停在 `review_ready`,不能
发布 stable 或部署 production。
配置不允许 `shell`、自由 `command`、`env`、外部 executable、token、密码、私钥路径
或凭据 URL。entrypoint 的 `requiredSecrets` 只能列大写 secret 名称,值必须由外部
凭据系统或执行环境注入。复杂逻辑放在受版本控制的项目入口中。entrypoint 使用 argv
语义执行,不能拼成 `sh -c` 字符串。
## 4. 运行前检查
1. 从 `tasks.yaml.project.deliveryFile` 解析文件;未引用或 `enabled=false` 时保持旧 ACK
行为,收尾停在 `verified`。
2. 运行:
```bash
python3 <ack-skill-dir>/scripts/validate_delivery.py \
docs/ack/delivery.yaml --tasks docs/ack/tasks.yaml \
--project-root <project-root>
```
3. 确认选中 profile 是 kickoff 已确认的 profile,所有 task 已是 `verified`,工作区与
服务对应正确 source revision。
4. 检查 referenced entrypoint、delivery config、CI 和凭据边界是否在本次变更中被
修改;被修改时禁止用它们执行带远端写权限或 secret 的步骤。
5. 检查本次步骤引用的 `requiredSecrets` 是否由外部环境提供,只报告名称和是否存在,
不读取、打印或持久化值。缺失时在第一次相关写操作前标记 blocked。
6. 将已确认工作树固化为本地 source revision,再创建 `deliveryRuns` 的 `planned`
记录,绑定 task IDs、profile、source revision 和 config revision;推送仍等到对应
`pull-request` 步骤。
## 5. 步骤语义
按 profile 中的顺序执行,不自行插入或省略步骤:
- `verify`:运行指定 entrypoint,失败即停止。
- `pull-request`:在精确 source revision 上提交、推送任务分支并创建或复用 Draft
PR/MR。普通任务使用项目已确认的 Forge 流程;只有本次是版本发布生命周期且用户
明确要求时才调用独立的 `manage-release`。没有对应能力或认证时标记 blocked,不用
带 token 的临时 curl 兜底。remote 与 base branch 必须来自该步骤,不能临时猜测。
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
revision,不能在目标机器重新拉源码构建。
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 可使用已安装的
`deb-publisher`;Docker 只有在用户明确指定 `$publish-docker-image` 时才加载该
explicit-only skill,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
mutex 后执行,不能并发部署同一目标。
- `health-check`:在对应 deploy 成功后运行环境 health check,记录可观测证据。失败时
按项目入口执行 rollback;rollback 未证明成功时不得声称恢复。
- `approval`:停止并展示准确 artifact、destination/environment、source revision 和
回滚计划,等待用户本次确认。
- `mark-ready`:所有前序步骤成功后将 Draft PR/MR 标为 ready,并写入最终证据。
## 6. 状态与恢复
`task.status=verified` 表示代码正确性通过;交付状态单独记录为 `planned`、`running`、
`blocked`、`failed`、`review_ready`、`released` 或 `skipped`。部署或 Forge 暂时失败不把
任务改回 `failed_retest`。
重复运行先核对已有 branch、PR/MR、artifact 和部署目标,复用身份匹配的资源。相同
ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成同一运行的资源。
若恢复过程中修改了任何 tracked file,原 source revision 和交付证据失效:回到 ACK
验证闭环,Test 重新复测后才能创建新的 delivery run。只有外部瞬时失败且 Git 内容未变
时,才可从失败步骤继续。
`review_ready` 至少记录:source/config revision、PR/MR URL、所有产物引用与 digest、
部署环境和健康检查证据。最终回复分别报告代码验证、PR、产物、部署和未完成项,不能用
“完成”掩盖其中某一阶段失败或待审批。
## 7. 与低层 Skill 的边界
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
上传、镜像或 Git 发布实现。`deb-publisher`、`publish-docker-image` 和
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
调用时,ACK 不能绕过它的触发与授权边界。
+141
View File
@@ -0,0 +1,141 @@
# 飞书 Base Bug 整理与审核
飞书 Base 是 Bug 在**审核通过前的唯一协作区**。用户先在飞书记录 Bug 描述,
Coordinator 读取记录和项目现状,补全或修改修复逻辑、验收标准,再写回同一条飞书
记录。用户可以继续在飞书反馈,Coordinator 按反馈反复修订。只有用户明确表示审核
通过后,Coordinator 才把最终版本写入 `docs/ack/tasks.yaml`,随后启动 ACK 三角色闭环。
审核前不创建或刷新 `tasks.yaml` 中的 ACK 任务,不得启动 worker,不得派发
Developer/Test,也不得修改应用代码。飞书字段已填满、记录进入某个 view、用户暂时没有
回复,都不等于审核通过。
## 项目配置
项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明 `provider: feishu-base`、
`workflow: reviewed-writeback-v1`、显式 `profile`、`baseToken`、`tableId`、用于候选 Bug
的 `viewId` 和字段映射;不要保存 App Secret、access token 或任何 profile 凭据。
原有八字段配置在未声明 `workflow` 时按 `read-only-v1` 兼容读取,不得写回或进入本节
流程。启用审核前协作必须显式选择 `reviewed-writeback-v1`,并在 `title`、`actual`、
`expected`、`stepsToReproduce`、`acceptance`、`priority`、`attachments`、`updatedAt`
之外配置可写文本字段 `fixLogic`。其中 `fixLogic` 和 `acceptance` 由 Coordinator 维护;
用户提供的标题、实际表现、预期结果和附件属于来源事实,Coordinator 不得覆盖。
## 一次性安装与 profile 设置
在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo
shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm
包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin
映射,然后直接执行该官方包下载的 native binary:
```bash
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
```
随后在受控终端中用 stdin 提供 App Secret,选择 Feishu brand,避免 secret 进入 shell
history、进程参数或项目文件:
```bash
printf '%s' "$FEISHU_APP_SECRET" | lark-cli profile add \
--name project-feishu --app-id "$FEISHU_APP_ID" \
--app-secret-stdin --brand feishu
```
项目配置只填写 `profile: project-feishu`。不要执行 `profile use`,也不要依赖 active
profile;读取、附件下载和写回都必须显式传 `--profile project-feishu`。profile 至少需要
`base:record:read`、`base:record:write` 和 `docs:document.media:download`。
不要把 `lark-cli auth check` 当作 app/bot scope 的证明,因为它检查的是当前用户的
stored user token。
## 读取与整理计划
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py check docs/ack/tasks.yaml
tmpdir=$(mktemp -d)
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py fetch docs/ack/tasks.yaml \
--output-dir "$tmpdir"
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py plan docs/ack/tasks.yaml \
--output-dir "$tmpdir"
```
`fetch` 只调用官方 `base +record-list` 和附件下载命令,输出标准化 JSON,并为每条记录
计算覆盖来源事实、附件元数据和审核字段的 `draftRevision`。`plan` 只生成
`create` / `refresh` / `unchanged` / `drift` 候选动作,不修改飞书或任务板。**审核通过前
不得执行这些候选动作**;它们只用于查重和预览最终导入结果。
标题、实际表现、预期结果和更新时间属于来源事实,任一缺失时读取失败。复现步骤、
修复逻辑、验收标准和优先级属于 Coordinator 可整理字段;缺失时记录与 action 返回
`enrichmentRequired`。没有附件且所有 Bug 内容字段都为空的误建行会跳过,并在批次
`warnings` 中返回 `blank_record_skipped`。
子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的
`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入。每条记录最多 10 个
附件、单批最多 100 个,单个附件最多 20 MiB、合计最多 200 MiB,整批下载最多 5 分钟;
始终使用新的临时目录作为 `--output-dir`。
## 审核前协作循环
对每条候选 Bug,Coordinator 按以下顺序工作:
1. 读取用户填写的来源事实、附件和项目代码/测试,确认问题边界;证据不足时把不确定点
明确写成假设,不伪装成用户原文。
2. 整理复现步骤和优先级,并生成:
- `fixLogic`:说明根因判断、计划修改的位置与行为、需要保持的不变量,以及回归风险;
它是待审核的实现方向,不宣称代码已经修改。
- `acceptance`:写成可观测、可独立复测的标准,至少覆盖用户可见结果、真实状态或 API
结果,以及原失败不再出现;不扩张用户没有表达的产品范围。
3. 把本轮草案保存为不超过 64 KiB 的临时 JSON:只包含非空字符串 `fixLogic` 和非空
字符串数组 `acceptance`。通过安全适配器写回当前飞书记录:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py write-draft \
docs/ack/tasks.yaml --record-id <record-id> \
--expected-source-ref <sourceRef> \
--expected-draft-revision <draftRevision> --input <draft.json>
```
`write-draft` 会在写前确认 record 属于配置 view,且 `sourceRef`、`draftRevision` 与调用者
看到的版本一致;它只允许覆盖配置映射的 `fixLogic` 和 `acceptance`,拒绝 symlink、超限
或多余字段输入,并复用读取器的可信 CLI、显式 profile、最小环境和成功 envelope 校验。
不得绕过适配器直接调用 PATH 中的 CLI,也不得改标题、实际表现、预期结果、附件或其它
用户字段。
4. `write-draft` 会回读同一记录、确认落盘值一致并返回当前 `draftRevision`。把这个 revision
连同记录交给用户审核;不能只展示没有 revision 的自由文本。
5. 用户提出意见时,重新读取最新记录和反馈,修订后再次写回、回读、等待审核;不要在
本地或聊天中维护一份与飞书分叉的“最终草案”。
任一写回、回读或字段校验失败时停止本轮并报告,不能转入任务板或三角色处理。
## 审核门禁与导入
只有用户针对当前飞书记录的明确 `draftRevision` 表示“审核通过”“可以执行”或等价授权,
才解除门禁。授权必须发生在最后一次草案写回和回读之后;之后若用户字段、附件、
`fixLogic` 或 `acceptance` 再次变化,revision 会变化,原授权失效,必须重新审核。
审核通过后,Coordinator 才执行以下动作:
1. 通过适配器重新读取并生成规范任务草案;命令会要求当前 `sourceRef` 和
`draftRevision` 精确等于用户批准值:
```bash
python3 <ack-skill-dir>/scripts/feishu_bug_intake.py import-approved \
docs/ack/tasks.yaml --record-id <record-id> \
--expected-source-ref <approved-sourceRef> \
--expected-draft-revision <approved-draftRevision>
```
不一致时停止并重新审核。输出的 `taskDraft` 包含批准后的规范字段以及
`source.workflow`、`source.approvedRevision`、`source.approvedPayloadHash`。
2. 按 `source.ref` 查重,把 `taskDraft` 原样合并到新的 ACK task,只补任务 ID、状态、
dispatch 等 ACK 运行字段;不得手工改写 `fixLogic`、`acceptanceCriteria` 或其它审核字段。
可在 `evidence.intakeEnrichment` 标明 Coordinator 推断依据,但它不替代规范字段。
3. 运行 `validate_tasks.py`。校验器会重算 `approvedPayloadHash`;只有写入和校验都成功,
任务才可作为已确认的 `open` 任务
进入 ACK 闭环。
4. 按 Coordinator → Developer → Test 的标准流程派发、实现和独立复测。
相同来源只更新尚未派发的 `open` 任务;`dispatched`、`fixed_by_dev`、`retesting`、
`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,由用户决定是否
新建任务。项目启用 reviewed workflow 后,历史 `verified` / `leftover` 只读来源继续保留;
其它仍可执行的 legacy 飞书任务必须先按当前流程重新整理和审核,不能通过省略或降级
`source.workflow` 绕过门禁。飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。
+31 -5
View File
@@ -12,7 +12,8 @@
3. `skiff` 命令可用。
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
`docs/ack/knowledge.yaml`、`AGENTS.md` 或其它 Agent 指令文件。ACK 不会自动
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent
指令文件。ACK 不会自动
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
内容或其它私有配置写入 ACK 项目状态。
@@ -36,7 +37,8 @@ skiff init ack --project <project-root>
docs/ack/
├── project.md
├── tasks.yaml
└── knowledge.yaml
├── knowledge.yaml
└── delivery.yaml # 默认 enabled: false
```
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
@@ -52,6 +54,14 @@ docs/ack/
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
知识库和跨文件引用校验。
### 旧项目补充交付配置
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
任务板补 `project.deliveryFile: docs/ack/delivery.yaml` 与顶层
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。
## 完善项目覆盖层
编辑 `docs/ack/project.md`,填入:
@@ -70,9 +80,13 @@ docs/ack/
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
自动补交付配置。
- `updatedAt` 使用当前带时区时间。
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
真实值。
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
`extraArgs`、`env` 或任意 executable 写进任务板。
@@ -98,6 +112,14 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
最终下沉为测试、lint、CI 或正式规范。
## 初始化项目交付
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表和空 profile。
不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言描述交付要求后,
Coordinator 按 `delivery.md` 维护声明式配置:工具 target 与仓库脚本分开引用,产物、
目的地、环境和有序 profile 使用稳定 ID。配置中不保存 shell、环境变量值或凭据
正文;稳定发布和生产部署必须有显式 approval 步骤。
## 校验
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
@@ -105,13 +127,17 @@ Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
```
同时确认:
- `project.md`、`tasks.yaml` 和 `knowledge.yaml` 没有未替换的 `<...>` 占位符。
- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的
`<...>` 占位符。
- `project.overlayFile` 指向真实文件。
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
- Developer 与 Test 的验证命令可执行。
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
@@ -125,9 +151,9 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
完成后报告:
- 创建或确认的三个项目文件。
- 创建或确认的四个项目文件。
- 检测到的技术栈和验证命令。
- 任务板和项目知识校验结果。
- 任务板、项目知识和交付契约校验结果。
- 仍需用户补充的值。
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
+35 -5
View File
@@ -20,18 +20,25 @@
我要做一个新需求:<一句话需求>。
你作为 ack 的 Coordinator(PM),按 ACK Skill 的 references 规范执行:
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
1. 先读 docs/ack/project.md,并用 `scripts/select_tasks.py docs/ack/tasks.yaml`
读取有预算的 project、summary 和可工作任务;已知任务时传 `--task-id`,不要把
完整 tasks.yaml 注入上下文。校验 docs/ack/knowledge.yaml 并用
`scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与
references/delivery.md,但不要把配置本身当作执行授权。
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
deliveryRuns;默认停在 review_ready,stable/production 步骤再次向我确认。
```
---
@@ -53,8 +60,22 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
`project.orchestration` 是 worker profile 的机器 SSOT;未知字段、非 allowlist 模型、
不匹配的角色/档位和非安全权限必须在派发前 fail closed。
读取任务上下文使用:
```bash
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/select_tasks.py docs/ack/tasks.yaml \
--task-id <ack-task-id>
```
选择器解析并校验完整任务板,但只输出命中任务及其 receipt/delivery run;超过预算时
显式失败,不得改用 `cat` 或整文件 `sed`。
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
发布 channel、部署环境、PR 是否创建/标 ready、停止点,以及哪些步骤会再次审批。
---
## 第 2 步:决定 worktree
@@ -143,7 +164,16 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
---
## 第 5 步:收尾
## 第 5 步:可选交付
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和
production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把
delivery run 标为 `blocked` 或 `failed`。
## 第 6 步:收尾
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
@@ -154,5 +184,5 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
`tasks.yaml`,验证后的
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
跨任务知识由 Coordinator 落 `knowledge.yaml`。
@@ -256,6 +256,13 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
- 待验证 candidate:<remaining candidates or none>
交付(未启用时写 n/a):
- run/profile/status:<delivery run id / profile / review_ready|released|blocked|failed>
- PR/MR:<URL and head/base>
- 产物:<reference, digest, destination>
- 部署:<environment, health/rollback evidence>
- 待审批或未完成:<approval/blocked step or none>
工作树状态:
- <repo_path>: <git status summary>
- <dev_worktree>: <git status summary>
+22 -3
View File
@@ -12,7 +12,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|------|----------|----------|------------|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate | 读 Test 证据并对齐原始意图(不亲自跑测试) | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识 |
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
@@ -109,6 +109,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
---
@@ -145,12 +146,28 @@ failed_retest(累计 3 轮) -> leftover
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
## 交付状态(与任务状态正交)
任务进入 `verified` 后不再改写为发布或部署状态。可选交付的每次执行单独记录在
`tasks.yaml.deliveryRuns`:
```text
planned -> running -> review_ready | released
-> blocked | failed
planned -> skipped
```
`review_ready` 表示 PR、preview 产物和已授权的非生产部署证据已经齐备,等待用户
审核;`released` 只用于用户明确批准后的 stable 发布或 production 部署。交付失败
不会否定已经独立验证的任务,但必须保留失败步骤、revision 与日志引用。完整顺序、
审批点和恢复规则见 `delivery.md`。
---
## 项目状态写入约定(并发安全)
`tasks.yaml` 是任务事实源,`knowledge.yaml` 是跨任务项目知识事实源。为避免多
Agent 并发写冲突:
`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源,
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
对它们都是只读的。
@@ -160,6 +177,8 @@ Agent 并发写冲突:
知识。
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
- `delivery.yaml` 只在用户显式要求维护配置时修改;运行只写
`tasks.yaml.deliveryRuns`,不能反向改写能力定义。
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
+11
View File
@@ -0,0 +1,11 @@
参考项目1
[https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team](https://github.com/zjp1997720/zhijian-skills/tree/main/skills/codex-model-routing-team)
参考项目2
[https://github.com/DannyMac180/sol-advisor](https://github.com/DannyMac180/sol-advisor)
参考项目3
[https://cursor.com/cn/blog/agent-swarm-model-economics](https://cursor.com/cn/blog/agent-swarm-model-economics)
+50
View File
@@ -0,0 +1,50 @@
"""Canonical reviewed Bug task payload shared by intake and validation."""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
PAYLOAD_FIELDS = (
"title",
"description",
"priority",
"actual",
"expected",
"stepsToReproduce",
"fixLogic",
"acceptanceCriteria",
)
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
def review_items(value: str) -> list[str]:
"""Recover line or numbered-list review text as stable non-empty items."""
lines = [line.strip(" \t-*•") for line in value.splitlines() if line.strip()]
if len(lines) > 1:
return lines
text = value.strip()
matches = list(NUMBERED_ITEM.finditer(text))
if matches:
items: list[str] = []
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
item = text[start:end].strip()
if item:
items.append(item)
if items:
return items
return [text] if text else []
def approval_payload_hash(task: dict[str, Any]) -> str:
"""Hash the exact reviewed fields that Developer and Test consume."""
payload = {field: task.get(field) for field in PAYLOAD_FIELDS}
encoded = json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env python3
"""Read and review an ACK-ready Feishu Base view through the official lark-cli.
The only mutation is a bounded draft write to configured Coordinator fields.
The adapter never reads the active profile and emits one JSON document only
on success.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import pwd
import re
import resource
import signal
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
from approval_payload import approval_payload_hash, review_items
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt")
OPTIONAL_FIELDS = ("fixLogic",)
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
COORDINATOR_FIELDS = ("steps", "acceptance", "priority")
BUG_CONTENT_FIELDS = ("title", "actual", "expected", "fixLogic", *COORDINATOR_FIELDS)
MAX_PAGES = 100
MAX_RECORDS = 10_000
PAGE_SIZE = 100
MAX_CLI_STDOUT = 1024 * 1024
MAX_CLI_STDERR = 64 * 1024
MAX_ATTACHMENTS_PER_RECORD = 10
MAX_TOTAL_ATTACHMENTS = 100
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
MAX_TOTAL_ATTACHMENT_BYTES = 200 * 1024 * 1024
MAX_ATTACHMENT_BATCH_SECONDS = 300
MAX_DRAFT_BYTES = 64 * 1024
SAFE_VALUE = re.compile(r"^[^\s\x00-\x1f]{1,256}$")
PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
DRAFT_REVISION = re.compile(r"^sha256:[0-9a-f]{64}$")
WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
class IntakeError(Exception):
pass
def account_home() -> Path:
"""Return the actual account home, never a caller-controlled HOME value."""
try:
home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve(strict=True)
except (KeyError, OSError) as exc:
raise IntakeError("cannot resolve current account home") from exc
if not home.is_dir():
raise IntakeError("current account home is not a directory")
return home
def trusted_lark_cli_dirs() -> list[Path]:
"""Fixed account and system locations; intentionally never consult PATH."""
home = account_home()
candidates = (
home / ".local" / "bin",
home / ".local" / "share" / "mise" / "shims",
home / ".cargo" / "bin",
Path("/home/linuxbrew/.linuxbrew/bin"),
Path("/usr/local/go/bin"),
Path("/usr/local/bin"),
Path("/usr/bin"),
Path("/bin"),
)
result: list[Path] = []
for candidate in candidates:
try:
resolved = candidate.resolve(strict=True)
except OSError:
continue
if resolved.is_dir() and resolved not in result:
result.append(resolved)
return result
def resolve_lark_cli() -> Path:
"""Resolve a safe lark-cli from the fixed trusted locations only."""
for directory in trusted_lark_cli_dirs():
candidate = directory / "lark-cli"
try:
candidate_metadata = os.lstat(candidate)
resolved = candidate.resolve(strict=True)
metadata = resolved.stat()
except OSError:
continue
if not (stat.S_ISREG(candidate_metadata.st_mode) or stat.S_ISLNK(candidate_metadata.st_mode)):
continue
if candidate_metadata.st_uid not in {0, os.getuid()}:
continue
if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK):
continue
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
continue
if resolved.name == "lark-cli":
return resolved
if not stat.S_ISLNK(candidate_metadata.st_mode):
continue
official_binary = official_npm_binary(resolved)
if official_binary is not None:
return official_binary
raise IntakeError("lark-cli is not installed in a trusted account or system directory")
def official_npm_binary(path: Path) -> Path | None:
"""Resolve a validated npm wrapper to its downloaded native CLI binary."""
if path.name != "run.js" or path.parent.name != "scripts":
return None
manifest = path.parent.parent / "package.json"
try:
metadata = manifest.stat()
if not stat.S_ISREG(metadata.st_mode):
return None
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
return None
package = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not (
isinstance(package, dict)
and package.get("name") == "@larksuite/cli"
and isinstance(package.get("bin"), dict)
and package["bin"].get("lark-cli") == "scripts/run.js"
):
return None
native = path.parent.parent / "bin" / "lark-cli"
try:
native_lstat = os.lstat(native)
resolved = native.resolve(strict=True)
metadata = resolved.stat()
except OSError:
return None
if not stat.S_ISREG(native_lstat.st_mode) or resolved.name != "lark-cli":
return None
if not stat.S_ISREG(metadata.st_mode) or not os.access(resolved, os.X_OK):
return None
if metadata.st_uid not in {0, os.getuid()} or stat.S_IMODE(metadata.st_mode) & 0o022:
return None
return resolved
def cli_environment() -> dict[str, str]:
"""Build a minimal environment so env credentials cannot override `--profile`."""
environment = {
"HOME": str(account_home()),
"PATH": os.pathsep.join(str(path) for path in trusted_lark_cli_dirs()),
}
for name in ("LANG", "LC_ALL", "LC_CTYPE"):
value = os.environ.get(name)
if value and "\x00" not in value and len(value) <= 256:
environment[name] = value
return environment
def load_board(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
if path.suffix.lower() == ".json":
value = load_json_unique(text)
else:
try:
import yaml # type: ignore
value = yaml.load(text, Loader=make_unique_pyyaml_loader(yaml))
except ImportError:
value = load_yaml_subset(text)
except (OSError, json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc:
raise IntakeError(f"cannot read task board: {exc}") from exc
except Exception as exc: # PyYAML errors are intentionally not exposed verbatim.
raise IntakeError("cannot parse task board") from exc
if not isinstance(value, dict):
raise IntakeError("task board must be an object")
return value
def load_draft(path: Path) -> dict[str, Any]:
"""Load one bounded, regular JSON file with the two writable draft fields."""
descriptor: int | None = None
try:
before = path.lstat()
if not stat.S_ISREG(before.st_mode) or path.is_symlink():
raise IntakeError("draft input must be a regular file")
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or (before.st_dev, before.st_ino) != (metadata.st_dev, metadata.st_ino)
):
raise IntakeError("draft input changed while opening")
if metadata.st_size <= 0 or metadata.st_size > MAX_DRAFT_BYTES:
raise IntakeError("draft input size is invalid")
chunks: list[bytes] = []
total = 0
while total <= MAX_DRAFT_BYTES:
chunk = os.read(descriptor, min(64 * 1024, MAX_DRAFT_BYTES + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
content = b"".join(chunks)
if len(content) != metadata.st_size:
raise IntakeError("draft input changed while reading")
value = load_json_unique(content.decode("utf-8"))
except IntakeError:
raise
except (OSError, UnicodeError, json.JSONDecodeError, DuplicateKeyError) as exc:
raise IntakeError("cannot read draft input") from exc
finally:
if descriptor is not None:
os.close(descriptor)
if not isinstance(value, dict) or set(value) != {"fixLogic", "acceptance"}:
raise IntakeError("draft input must contain exactly fixLogic and acceptance")
fix_logic = value["fixLogic"]
acceptance = value["acceptance"]
if not isinstance(fix_logic, str) or not fix_logic.strip():
raise IntakeError("draft fixLogic must be a non-empty string")
if (
not isinstance(acceptance, list)
or not acceptance
or any(not isinstance(item, str) or not item.strip() for item in acceptance)
):
raise IntakeError("draft acceptance must be a non-empty string list")
return {
"fixLogic": fix_logic.strip(),
"acceptance": [item.strip() for item in acceptance],
}
def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
project = board.get("project")
if not isinstance(project, dict) or "bugIntake" not in project:
raise IntakeError("project.bugIntake is not configured")
config = project["bugIntake"]
if not isinstance(config, dict):
raise IntakeError("project.bugIntake must be an object")
allowed = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
unknown = sorted(set(config) - allowed)
if unknown:
raise IntakeError("bugIntake has unknown fields")
if config.get("provider") != "feishu-base":
raise IntakeError("bugIntake.provider must be feishu-base")
workflow = config.get("workflow", "read-only-v1")
if workflow not in WORKFLOWS:
raise IntakeError("bugIntake.workflow is invalid")
profile = config.get("profile")
if not isinstance(profile, str) or not PROFILE.fullmatch(profile):
raise IntakeError("bugIntake.profile is invalid")
for key in ("baseToken", "tableId", "viewId"):
value = config.get(key)
if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value):
raise IntakeError(f"bugIntake.{key} is invalid")
fields = config.get("fields")
supported = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
if (
not isinstance(fields, dict)
or not set(REQUIRED_FIELDS).issubset(fields)
or not set(fields).issubset(supported)
):
raise IntakeError("bugIntake.fields must map all required and only supported logical fields")
if any(not isinstance(value, str) or not SAFE_VALUE.fullmatch(value) for value in fields.values()):
raise IntakeError("bugIntake.fields values are invalid")
if len(set(fields.values())) != len(fields):
raise IntakeError("bugIntake.fields values must be unique")
if workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
raise IntakeError("reviewed writeback requires bugIntake.fields.fixLogic")
return config
def limit_child_file_size(limit: int) -> None:
"""Bound regular-file writes by the CLI and any child spawned by its wrapper."""
_, hard = resource.getrlimit(resource.RLIMIT_FSIZE)
bounded = limit if hard == resource.RLIM_INFINITY else min(limit, hard)
resource.setrlimit(resource.RLIMIT_FSIZE, (bounded, bounded))
def run_cli(
args: list[str], *, allow_profile_list: bool = False, max_file_bytes: int = 0,
timeout: float = 60, cwd: Path | None = None,
) -> Any:
"""Run the official CLI and accept only explicit successful JSON shapes."""
executable = resolve_lark_cli()
file_limit = max(MAX_CLI_STDOUT, MAX_CLI_STDERR, max_file_bytes)
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
try:
process = subprocess.Popen(
[str(executable), *args],
shell=False,
stdin=subprocess.DEVNULL,
stdout=stdout_file,
stderr=stderr_file,
env=cli_environment(),
cwd=cwd,
start_new_session=True,
preexec_fn=lambda: limit_child_file_size(file_limit),
)
try:
returncode = process.wait(timeout=timeout)
except subprocess.TimeoutExpired as exc:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
raise IntakeError("lark-cli failed to execute") from exc
except (OSError, subprocess.SubprocessError) as exc:
raise IntakeError("lark-cli failed to execute") from exc
stdout_size = os.fstat(stdout_file.fileno()).st_size
stderr_size = os.fstat(stderr_file.fileno()).st_size
if stdout_size > MAX_CLI_STDOUT or stderr_size > MAX_CLI_STDERR:
raise IntakeError("lark-cli output exceeded the safety limit")
if returncode:
raise IntakeError("lark-cli command failed")
stdout_file.seek(0)
try:
stdout = stdout_file.read(MAX_CLI_STDOUT + 1).decode("utf-8")
except UnicodeDecodeError as exc:
raise IntakeError("lark-cli returned malformed JSON") from exc
try:
value = json.loads(
stdout,
parse_constant=lambda value: (_ for _ in ()).throw(
ValueError(f"non-finite JSON constant: {value}")
),
)
except (json.JSONDecodeError, ValueError) as exc:
raise IntakeError("lark-cli returned malformed JSON") from exc
if isinstance(value, list):
if allow_profile_list:
return value
raise IntakeError("lark-cli returned an unexpected JSON array")
if not isinstance(value, dict):
raise IntakeError("lark-cli returned an invalid JSON response")
if "ok" in value and value["ok"] is not True:
raise IntakeError("lark-cli returned an error response")
if "code" in value and (not isinstance(value["code"], int) or isinstance(value["code"], bool) or value["code"] != 0):
raise IntakeError("lark-cli returned an error response")
if "ok" not in value and "code" not in value:
raise IntakeError("lark-cli returned an ambiguous JSON response")
return value
def profile_check(config: dict[str, Any]) -> None:
# `profile list` is the official non-mutating profile inspection command.
value = run_cli(["profile", "list"], allow_profile_list=True)
if isinstance(value, list):
profiles = value
else:
# Compatibility wrapper: only a successful envelope with a direct list
# is accepted. Do not loosen this into arbitrary nested objects.
profiles = value.get("data")
if not isinstance(profiles, list):
raise IntakeError("profile check returned an invalid response")
matching_profile: dict[str, Any] | None = None
for item in profiles:
# Match the official profile-list item shape. `user` and
# `tokenStatus` are optional and deliberately never propagated.
if (
not isinstance(item, dict)
or not isinstance(item.get("name"), str)
or not PROFILE.fullmatch(item["name"])
or not isinstance(item.get("appId"), str)
or not isinstance(item.get("brand"), str)
or not isinstance(item.get("active"), bool)
):
raise IntakeError("profile check returned an invalid profile entry")
if item["name"] == config["profile"]:
matching_profile = item
if matching_profile is None:
raise IntakeError("configured lark-cli profile does not exist")
if matching_profile["brand"] != "feishu":
raise IntakeError("configured lark-cli profile must use the feishu brand")
def text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return " ".join(value.split())
if isinstance(value, float) and not math.isfinite(value):
raise IntakeError("text field contains a non-finite number")
if isinstance(value, (int, float, bool)):
return str(value)
if isinstance(value, list):
return "\n".join(part for part in (text(item) for item in value) if part)
if isinstance(value, dict):
for key in ("text", "name", "value"):
if key in value:
return text(value[key])
raise IntakeError("text field contains an unsupported object")
raise IntakeError("text field contains an unsupported value")
def attachment_items(value: Any) -> list[tuple[dict[str, Any], str]]:
if value in (None, ""):
return []
if not isinstance(value, list):
raise IntakeError("attachments cell must be a list")
if len(value) > MAX_ATTACHMENTS_PER_RECORD:
raise IntakeError("record exceeded the attachment count limit")
attachments: list[tuple[dict[str, Any], str]] = []
for item in value:
if not isinstance(item, dict):
raise IntakeError("attachment metadata must be an object")
token = item.get("file_token", item.get("token"))
if not isinstance(token, str) or not SAFE_VALUE.fullmatch(token):
raise IntakeError("attachment token is invalid")
metadata = {"name": text(item.get("name")), "type": text(item.get("type", item.get("mime_type"))), "size": item.get("size")}
if (
not isinstance(metadata["size"], int)
or isinstance(metadata["size"], bool)
or metadata["size"] < 0
or metadata["size"] > MAX_ATTACHMENT_BYTES
):
raise IntakeError("attachment size is invalid")
attachments.append((metadata, token))
return attachments
def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tuple[list[str], list[list[Any]]]:
data = response.get("data", response)
if not isinstance(data, dict):
raise IntakeError("record list data is invalid")
fields = data.get("fields")
ids = data.get("record_id_list", data.get("recordIds"))
rows = data.get("data", data.get("records", data.get("items", data.get("rows"))))
if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields):
raise IntakeError("record list fields are invalid")
if fields != field_ids:
raise IntakeError("record list fields do not match configured projection")
if not isinstance(ids, list) or not all(isinstance(x, str) and RECORD_ID.fullmatch(x) for x in ids):
raise IntakeError("record list record_id_list is invalid")
if not isinstance(rows, list) or len(rows) != len(ids) or any(not isinstance(row, list) or len(row) != len(fields) for row in rows):
raise IntakeError("record list matrix does not match fields and record_id_list")
return ids, rows
def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]:
logical_fields = [
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
if logical in config["fields"]
]
field_ids = [config["fields"][logical] for logical in logical_fields]
all_rows: list[tuple[str, list[Any]]] = []
offset = 0
for _ in range(MAX_PAGES):
args = ["base", "+record-list", "--profile", config["profile"], "--base-token", config["baseToken"], "--table-id", config["tableId"], "--view-id", config["viewId"], "--format", "json", "--offset", str(offset), "--limit", str(PAGE_SIZE)]
for field_id in field_ids:
args.extend(["--field-id", field_id])
response = run_cli(args)
ids, rows = matrix_from_response(response, field_ids)
if len(ids) > PAGE_SIZE:
raise IntakeError("record list exceeded requested page size")
all_rows.extend(zip(ids, rows))
if len(all_rows) > MAX_RECORDS:
raise IntakeError("record list exceeded record limit")
data = response.get("data", response)
has_more = data.get("has_more", data.get("hasMore", False))
if not isinstance(has_more, bool):
raise IntakeError("record list pagination marker is invalid")
if not has_more:
return all_rows
if not ids:
raise IntakeError("record list pagination made no progress")
offset += len(ids)
raise IntakeError("record list exceeded page limit")
def download(
config: dict[str, Any], record_id: str, file_token: str,
output_dir: Path, expected_size: int, timeout: float,
) -> str:
try:
output_dir.mkdir(parents=True, exist_ok=False)
except OSError as exc:
raise IntakeError("attachment output directory is unsafe") from exc
if not output_dir.is_dir() or output_dir.is_symlink():
raise IntakeError("attachment output directory is unsafe")
run_cli(
["base", "+record-download-attachment", "--profile", config["profile"], "--base-token", config["baseToken"], "--table-id", config["tableId"], "--record-id", record_id, "--file-token", file_token, "--output", output_dir.name],
max_file_bytes=expected_size,
timeout=timeout,
cwd=output_dir.parent,
)
try:
created = list(output_dir.iterdir())
except OSError as exc:
raise IntakeError("attachment download output is unreadable") from exc
if len(created) != 1 or not created[0].is_file() or created[0].is_symlink():
raise IntakeError("attachment download did not produce one safe file")
root = output_dir.resolve()
resolved = created[0].resolve()
if root not in resolved.parents:
raise IntakeError("attachment download escaped output directory")
if resolved.stat().st_size != expected_size:
raise IntakeError("attachment download size did not match metadata")
return str(resolved)
def source_ref(config: dict[str, Any], record_id: str) -> str:
"""Return a stable opaque identity without serializing configured identifiers."""
identity = "\x1f".join(("ack-feishu-base-source-ref-v1", config["profile"], config["baseToken"], config["tableId"], record_id))
return f"feishu-base:sha256:{hashlib.sha256(identity.encode('utf-8')).hexdigest()}"
def draft_revision(
record: dict[str, Any], attachment_tokens: list[str] | None = None,
) -> str:
"""Bind approval to the normalized source facts and review-controlled fields."""
tokens = attachment_tokens or []
if len(tokens) != len(record["attachments"]):
raise IntakeError("draft revision attachment identity is incomplete")
stable = {
"sourceRef": record["sourceRef"],
"updatedAt": record["updatedAt"],
"title": record["title"],
"actual": record["actual"],
"expected": record["expected"],
"steps": record["steps"],
"fixLogic": record["fixLogic"],
"acceptance": record["acceptance"],
"priority": record["priority"],
"attachments": [
{
**{key: attachment.get(key) for key in ("name", "type", "size")},
"tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}",
}
for attachment, token in zip(record["attachments"], tokens)
],
}
encoded = json.dumps(
stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
profile_check(config)
prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = []
batch_warnings: list[dict[str, str]] = []
total_attachments = 0
total_attachment_bytes = 0
for record_id, row in fetch_pages(config):
logical_fields = [
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
if logical in config["fields"]
]
cells = dict(zip(logical_fields, row))
attachment_data = attachment_items(cells["attachments"])
total_attachments += len(attachment_data)
total_attachment_bytes += sum(metadata["size"] for metadata, _ in attachment_data)
if total_attachments > MAX_TOTAL_ATTACHMENTS:
raise IntakeError("batch exceeded the attachment count limit")
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
raise IntakeError("batch exceeded the attachment byte limit")
record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS):
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
continue
for field in SOURCE_FACT_FIELDS:
if not record[field]:
raise IntakeError(f"record {field} must not be empty")
enrichment_fields = list(COORDINATOR_FIELDS)
if "fixLogic" in config["fields"]:
enrichment_fields.append("fixLogic")
record["enrichmentRequired"] = [
field for field in enrichment_fields if not record[field]
]
record["draftRevision"] = draft_revision(
record, [token for _, token in attachment_data],
)
prepared.append((record, attachment_data))
download_root: Path | None = None
if output_dir is not None:
try:
output_dir.mkdir(parents=True, exist_ok=True)
if not output_dir.is_dir() or output_dir.is_symlink():
raise OSError("unsafe output directory")
download_root = output_dir.resolve(strict=True)
except OSError as exc:
raise IntakeError("attachment output directory is unsafe") from exc
records = []
attachment_deadline = time.monotonic() + MAX_ATTACHMENT_BATCH_SECONDS
for record, attachment_data in prepared:
if download_root is not None:
for index, (attachment, file_token) in enumerate(attachment_data, start=1):
remaining = attachment_deadline - time.monotonic()
if remaining <= 0:
raise IntakeError("attachment batch exceeded the time limit")
attachment_dir = download_root / record["recordId"] / f"attachment-{index:02d}"
attachment["localPath"] = download(
config, record["recordId"], file_token, attachment_dir,
attachment["size"], min(60, remaining),
)
records.append(record)
return {"provider": "feishu-base", "workflow": config.get("workflow", "read-only-v1"), "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings}
def review_record(
config: dict[str, Any], record_id: str, expected_source_ref: str,
expected_revision: str,
) -> dict[str, Any]:
"""Resolve one record inside the configured view and bind its reviewed version."""
if RECORD_ID.fullmatch(record_id) is None:
raise IntakeError("review record id is invalid")
if SOURCE_REF.fullmatch(expected_source_ref) is None:
raise IntakeError("expected source reference is invalid")
if DRAFT_REVISION.fullmatch(expected_revision) is None:
raise IntakeError("expected draft revision is invalid")
matching = [
record for record in fetch(config, None)["records"]
if record["recordId"] == record_id
]
if len(matching) != 1:
raise IntakeError("configured review view did not contain exactly one record")
if (
matching[0]["sourceRef"] != expected_source_ref
or matching[0]["draftRevision"] != expected_revision
):
raise IntakeError("review record changed before the requested operation")
return matching[0]
def write_draft(
config: dict[str, Any], record_id: str, expected_source_ref: str,
expected_revision: str, draft_path: Path,
) -> dict[str, Any]:
"""Overwrite only the configured fix logic and acceptance cells."""
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
raise IntakeError("draft writeback requires reviewed-writeback-v1 workflow")
if "fixLogic" not in config["fields"]:
raise IntakeError("bugIntake.fields.fixLogic is required for draft writeback")
review_record(config, record_id, expected_source_ref, expected_revision)
draft = load_draft(draft_path)
patch = {
config["fields"]["fixLogic"]: draft["fixLogic"],
config["fields"]["acceptance"]: "\n".join(
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
),
}
profile_check(config)
run_cli([
"base", "+record-upsert", "--profile", config["profile"],
"--base-token", config["baseToken"], "--table-id", config["tableId"],
"--record-id", record_id, "--json",
json.dumps(patch, ensure_ascii=False, separators=(",", ":")),
"--format", "json",
])
matching = [record for record in fetch(config, None)["records"] if record["recordId"] == record_id]
if len(matching) != 1 or matching[0]["sourceRef"] != expected_source_ref:
raise IntakeError("draft writeback readback did not find exactly one record")
expected_acceptance = text(patch[config["fields"]["acceptance"]])
if (
matching[0]["fixLogic"] != text(draft["fixLogic"])
or matching[0]["acceptance"] != expected_acceptance
):
raise IntakeError("draft writeback readback did not match the submitted draft")
return {
"provider": "feishu-base",
"recordId": record_id,
"written": ["fixLogic", "acceptance"],
"draftRevision": matching[0]["draftRevision"],
"ok": True,
}
def import_approved(
config: dict[str, Any], record_id: str, expected_source_ref: str,
expected_revision: str,
) -> dict[str, Any]:
"""Emit the canonical task payload for one explicitly approved draft revision."""
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
raise IntakeError("approved import requires reviewed-writeback-v1 workflow")
record = review_record(
config, record_id, expected_source_ref, expected_revision,
)
steps = review_items(record["steps"])
acceptance = review_items(record["acceptance"])
if not record["priority"] or not steps or not record["fixLogic"] or not acceptance:
raise IntakeError("approved record is missing prepared review fields")
task_draft: dict[str, Any] = {
"title": record["title"],
"priority": record["priority"],
"description": record["title"],
"actual": record["actual"],
"expected": record["expected"],
"stepsToReproduce": steps,
"fixLogic": record["fixLogic"],
"acceptanceCriteria": acceptance,
"source": {
"kind": "feishu-base",
"workflow": "reviewed-writeback-v1",
"ref": record["sourceRef"],
"recordId": record["recordId"],
"updatedAt": record["updatedAt"],
"approvedRevision": record["draftRevision"],
},
}
task_draft["source"]["approvedPayloadHash"] = approval_payload_hash(task_draft)
return {
"provider": "feishu-base",
"recordId": record_id,
"draftRevision": record["draftRevision"],
"taskDraft": task_draft,
"ok": True,
}
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Plan idempotent Coordinator actions without mutating the task board."""
tasks = board.get("tasks")
if not isinstance(tasks, list):
raise IntakeError("task board tasks must be a list")
existing: dict[str, dict[str, Any]] = {}
for task in tasks:
if not isinstance(task, dict):
continue
source = task.get("source")
if not isinstance(source, dict) or source.get("kind") != "feishu-base":
continue
ref = source.get("ref")
task_id = task.get("id")
status = task.get("status")
updated_at = source.get("updatedAt")
if (
not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None
or not isinstance(task_id, str) or not task_id
or not isinstance(status, str) or not status
or not isinstance(updated_at, str) or not updated_at
):
raise IntakeError("existing Feishu task source is invalid")
if ref in existing:
raise IntakeError("task board contains duplicate Feishu source references")
existing[ref] = task
actions: list[dict[str, Any]] = []
seen_records: set[str] = set()
for record in records:
ref = record.get("sourceRef")
record_id = record.get("recordId")
updated_at = record.get("updatedAt")
revision = record.get("draftRevision")
if (
not isinstance(ref, str) or SOURCE_REF.fullmatch(ref) is None
or not isinstance(record_id, str) or RECORD_ID.fullmatch(record_id) is None
or not isinstance(updated_at, str) or not updated_at
or not isinstance(revision, str) or DRAFT_REVISION.fullmatch(revision) is None
):
raise IntakeError("normalized Feishu record identity is invalid")
if ref in seen_records:
raise IntakeError("fetched records contain a duplicate source reference")
seen_records.add(ref)
task = existing.get(ref)
if task is None:
planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "draftRevision": record["draftRevision"], "action": "create"}
enrichment_required = record.get("enrichmentRequired")
if enrichment_required:
planned_action["enrichmentRequired"] = enrichment_required
actions.append(planned_action)
continue
source = task["source"]
approved_revision = source.get("approvedRevision")
if isinstance(approved_revision, str):
if approved_revision == revision:
action_name = "unchanged"
elif task["status"] == "open":
action_name = "refresh"
else:
action_name = "drift"
elif source["updatedAt"] == updated_at:
action_name = "unchanged"
elif task["status"] == "open":
action_name = "refresh"
else:
action_name = "drift"
planned_action = {
"sourceRef": ref,
"recordId": record_id,
"draftRevision": record["draftRevision"],
"taskId": task["id"],
"status": task["status"],
"action": action_name,
}
enrichment_required = record.get("enrichmentRequired")
if enrichment_required and action_name == "refresh":
planned_action["enrichmentRequired"] = enrichment_required
actions.append(planned_action)
return actions
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Read and review a configured Feishu Base bug intake")
sub = parser.add_subparsers(dest="command", required=True)
for name in ("check", "fetch", "plan", "write-draft", "import-approved"):
command = sub.add_parser(name)
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
if name in {"fetch", "plan"}:
command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments")
if name in {"write-draft", "import-approved"}:
command.add_argument("--record-id", required=True, help="existing Feishu Base record id")
command.add_argument("--expected-source-ref", required=True, help="sourceRef returned by fetch")
command.add_argument("--expected-draft-revision", required=True, help="draftRevision returned by fetch")
if name == "write-draft":
command.add_argument("--input", type=Path, required=True, help="bounded JSON draft file")
args = parser.parse_args(argv)
try:
board = load_board(args.tasks)
config = config_from_board(board)
if args.command == "check":
profile_check(config)
output = {"provider": "feishu-base", "workflow": config.get("workflow", "read-only-v1"), "profile": config["profile"], "ok": True}
elif args.command == "fetch":
output = fetch(config, args.output_dir)
elif args.command == "plan":
output = fetch(config, args.output_dir)
output["actions"] = plan_actions(board, output["records"])
elif args.command == "write-draft":
output = write_draft(
config, args.record_id, args.expected_source_ref,
args.expected_draft_revision, args.input,
)
else:
output = import_approved(
config, args.record_id, args.expected_source_ref,
args.expected_draft_revision,
)
except IntakeError as exc:
sys.stderr.write(f"Feishu bug intake failed: {exc}\n")
return 1
except (OSError, subprocess.SubprocessError):
sys.stderr.write("Feishu bug intake failed: local I/O failed\n")
return 1
print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""为 ACK Coordinator 输出有预算的任务板上下文。
脚本会解析并校验完整 tasks.yaml,但只输出项目配置、摘要、可工作任务或显式任务,
以及这些任务引用的 worker receipt 和 delivery run。它不会修改任务板,也不会静默
截断超过预算的任务集合。
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from validate_tasks import STATUS_ENUM, load_document, validate_builtin # type: ignore
ACTIONABLE_STATUSES = (
"open",
"dispatched",
"fixed_by_dev",
"retesting",
"failed_retest",
)
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MAX_OUTPUT_BYTES = 512 * 1024
BOARD_METADATA_FIELDS = (
"version",
"updatedAt",
"source",
"ackVersion",
"kitVersion",
"testRecord",
"statusReference",
)
class SelectionError(ValueError):
pass
def _unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(values))
def select_tasks(
data: dict[str, Any],
*,
task_ids: list[str] | None = None,
statuses: list[str] | None = None,
limit: int = DEFAULT_LIMIT,
) -> tuple[str, list[str], list[dict[str, Any]]]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT:
raise SelectionError(f"limit 必须在 1..{MAX_LIMIT} 之间")
tasks = data.get("tasks")
if not isinstance(tasks, list):
raise SelectionError("tasks 必须是列表")
requested_ids = _unique(task_ids or [])
requested_statuses = _unique(statuses or [])
if requested_ids and requested_statuses:
raise SelectionError("--task-id 与 --status 不能同时使用")
if requested_ids:
by_id = {
task.get("id"): task
for task in tasks
if isinstance(task, dict) and isinstance(task.get("id"), str)
}
missing = [task_id for task_id in requested_ids if task_id not in by_id]
if missing:
raise SelectionError("找不到任务: " + ", ".join(missing))
selected = [by_id[task_id] for task_id in requested_ids]
mode = "task_ids"
criteria = requested_ids
else:
effective_statuses = requested_statuses or list(ACTIONABLE_STATUSES)
unknown = [status for status in effective_statuses if status not in STATUS_ENUM]
if unknown:
raise SelectionError("未知状态: " + ", ".join(unknown))
wanted = set(effective_statuses)
selected = [
task
for task in tasks
if isinstance(task, dict) and task.get("status") in wanted
]
mode = "statuses"
criteria = effective_statuses
if len(selected) > limit:
raise SelectionError(
f"命中 {len(selected)} 条任务,超过 --limit={limit};"
"请用 --task-id/--status 缩小范围或显式提高 limit"
)
return mode, criteria, selected
def select_referenced_receipts(
data: dict[str, Any], selected: list[dict[str, Any]]
) -> list[dict[str, Any]]:
receipt_ids: list[str] = []
for task in selected:
dispatch = task.get("dispatch")
if not isinstance(dispatch, dict):
continue
for role in ("developer", "test"):
role_dispatch = dispatch.get(role)
if not isinstance(role_dispatch, dict):
continue
receipt_id = role_dispatch.get("receiptId")
if isinstance(receipt_id, str) and receipt_id not in receipt_ids:
receipt_ids.append(receipt_id)
if not receipt_ids:
return []
receipts = data.get("workerReceipts")
if not isinstance(receipts, list):
raise SelectionError("选中任务引用了 receipt,但 workerReceipts 不是列表")
by_id = {
receipt.get("id"): receipt
for receipt in receipts
if isinstance(receipt, dict) and isinstance(receipt.get("id"), str)
}
missing = [receipt_id for receipt_id in receipt_ids if receipt_id not in by_id]
if missing:
raise SelectionError("选中任务引用了未知 receipt: " + ", ".join(missing))
return [by_id[receipt_id] for receipt_id in receipt_ids]
def select_delivery_runs(
data: dict[str, Any], selected: list[dict[str, Any]]
) -> list[dict[str, Any]]:
selected_ids = {
task.get("id")
for task in selected
if isinstance(task.get("id"), str)
}
runs = data.get("deliveryRuns")
if not selected_ids or not isinstance(runs, list):
return []
return [
run
for run in runs
if isinstance(run, dict)
and isinstance(run.get("taskIds"), list)
and any(task_id in selected_ids for task_id in run["taskIds"])
]
def build_payload(
data: dict[str, Any],
*,
mode: str,
criteria: list[str],
selected: list[dict[str, Any]],
) -> dict[str, Any]:
receipts = select_referenced_receipts(data, selected)
delivery_runs = select_delivery_runs(data, selected)
tasks = data.get("tasks")
metadata = {
field: data[field]
for field in BOARD_METADATA_FIELDS
if field in data
}
selected_ids = [
task["id"]
for task in selected
if isinstance(task.get("id"), str)
]
payload: dict[str, Any] = {
"board": metadata,
"project": data.get("project"),
"summary": data.get("summary"),
"selection": {
"mode": mode,
"criteria": criteria,
"count": len(selected),
"totalTasks": len(tasks) if isinstance(tasks, list) else 0,
"selectedTaskIds": selected_ids,
"referencedReceiptIds": [receipt.get("id") for receipt in receipts],
"deliveryRunIds": [run.get("id") for run in delivery_runs],
},
"tasks": selected,
"workerReceipts": receipts,
"deliveryRuns": delivery_runs,
}
return payload
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="选择 ACK 任务板的有预算 Coordinator 上下文"
)
parser.add_argument(
"tasks",
nargs="?",
default="docs/ack/tasks.yaml",
help="任务板路径",
)
parser.add_argument("--task-id", action="append", default=[])
parser.add_argument(
"--status",
action="append",
default=[],
choices=sorted(STATUS_ENUM),
)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板文件: {tasks_path}\n")
return 2
data = load_document(tasks_path)
errors = validate_builtin(data)
if errors:
sys.stderr.write(f"任务板无效,拒绝选择,共 {len(errors)} 项:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
try:
mode, criteria, selected = select_tasks(
data,
task_ids=args.task_id,
statuses=args.status,
limit=args.limit,
)
payload = build_payload(
data,
mode=mode,
criteria=criteria,
selected=selected,
)
except SelectionError as exc:
sys.stderr.write(f"任务选择失败: {exc}\n")
return 1
if args.compact:
output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
else:
output = json.dumps(payload, ensure_ascii=False, indent=2)
if len(output.encode("utf-8")) > MAX_OUTPUT_BYTES:
sys.stderr.write(
f"任务选择输出超过 {MAX_OUTPUT_BYTES} bytes;请进一步缩小任务范围\n"
)
return 1
sys.stdout.write(output + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+809
View File
@@ -0,0 +1,809 @@
#!/usr/bin/env python3
"""校验 ACK 项目交付契约。
权威结构位于 templates/delivery.schema.json。jsonschema 是可选依赖;内置规则始终
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
用法:
python3 validate_delivery.py docs/ack/delivery.yaml
python3 validate_delivery.py docs/ack/delivery.yaml \
--tasks docs/ack/tasks.yaml --project-root <project-root>
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
"""
from __future__ import annotations
import argparse
import json
import re
import stat
import sys
from pathlib import Path, PurePosixPath
from typing import Any
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
load_json_unique,
load_yaml_subset,
make_unique_pyyaml_loader,
)
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$")
REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
TOP_LEVEL_FIELDS = {
"version",
"updatedAt",
"project",
"enabled",
"defaultProfile",
"entrypoints",
"artifacts",
"destinations",
"environments",
"profiles",
}
ENTRYPOINT_FIELDS = {
"kind",
"target",
"function",
"path",
"args",
"requiredSecrets",
"workingDirectory",
"timeoutSeconds",
}
ARTIFACT_FIELDS = {"type", "build", "outputs", "image", "platforms"}
DESTINATION_FIELDS = {
"type",
"channel",
"registry",
"repository",
"endpoint",
"artifactName",
"upload",
}
ENVIRONMENT_FIELDS = {
"type",
"classification",
"target",
"deploy",
"healthCheck",
"rollback",
"mutex",
}
PROFILE_FIELDS = {"stopAt", "steps"}
STEP_FIELDS = {
"id",
"action",
"entrypoint",
"artifact",
"destination",
"environment",
"gate",
"draft",
"remote",
"baseBranch",
}
ENTRYPOINT_KINDS = {"make", "just", "task", "dagger", "script"}
ARTIFACT_TYPES = {"deb", "oci-image", "file"}
DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
CHANNELS = {"preview", "staging", "stable"}
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
CLASSIFICATIONS = {"development", "staging", "production"}
STOP_POINTS = {"verified", "review_ready", "released"}
ACTIONS = {
"verify",
"pull-request",
"build",
"publish",
"deploy",
"health-check",
"approval",
"mark-ready",
}
ACTION_FIELDS = {
"verify": {"entrypoint"},
"pull-request": {"draft", "remote", "baseBranch"},
"build": {"artifact"},
"publish": {"artifact", "destination"},
"deploy": {"artifact", "environment"},
"health-check": {"environment"},
"approval": {"gate"},
"mark-ready": set(),
}
SECRET_PATTERNS = (
("private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
("OpenAI-style token", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
("URL credentials", re.compile(r"https?://[^/\s:@]+:[^/\s@]+@")),
(
"inline secret assignment",
re.compile(
r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)"
r"\s*[:=]\s*[\"']?[^\s,\"']{8,}"
),
),
)
def _nonempty(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _mapping(value: Any) -> bool:
return isinstance(value, dict)
def _reject_unknown(
value: dict[str, Any],
allowed: set[str],
where: str,
errors: list[str],
) -> None:
for field in sorted(set(value) - allowed):
errors.append(f"{where}: 未知字段 {field!r}")
def _load_document(path: Path, label: str) -> dict[str, Any]:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
sys.stderr.write(f"{label}读取失败: {exc}\n")
raise SystemExit(1)
if path.suffix.lower() == ".json":
try:
data = load_json_unique(content)
except (json.JSONDecodeError, DuplicateKeyError) as exc:
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
import yaml # type: ignore
except ImportError:
try:
data = load_yaml_subset(content)
except YamlSubsetError as exc:
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
raise SystemExit(1)
else:
try:
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
except yaml.YAMLError as exc: # type: ignore
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
raise SystemExit(1)
if not isinstance(data, dict):
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
raise SystemExit(1)
return data
def _safe_relative_path(value: Any, *, allow_glob: bool = False) -> bool:
if not _nonempty(value) or value.startswith("/") or "\\" in value:
return False
if not RELATIVE_PATH_RE.fullmatch(value):
return False
if not allow_glob and any(marker in value for marker in "*?"):
return False
parts = PurePosixPath(value).parts
return ".." not in parts and all(part not in {"", "/"} for part in parts)
def _safe_branch_name(value: Any) -> bool:
if not _nonempty(value) or len(value) > 255:
return False
if value == "@" or value.startswith(("/", ".", "-")):
return False
if value.endswith(("/", ".", ".lock")):
return False
if "@{" in value or ".." in value or "//" in value:
return False
return re.search(r"[\x00-\x20\x7f~^:?*\[\\]", value) is None
def _validate_path_binding(
project_root: Path,
relative_path: str,
where: str,
*,
expected: str,
) -> list[str]:
errors: list[str] = []
current = project_root
parts = PurePosixPath(relative_path).parts
if relative_path == ".":
parts = ()
for index, part in enumerate(parts):
current = current / part
try:
metadata = current.lstat()
except FileNotFoundError:
return [f"{where}: 路径不存在: {relative_path!r}"]
except OSError as exc:
return [f"{where}: 路径不可访问: {relative_path!r}: {exc}"]
if stat.S_ISLNK(metadata.st_mode):
return [f"{where}: 路径不能包含 symlink: {relative_path!r}"]
if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode):
return [f"{where}: 中间路径不是目录: {relative_path!r}"]
try:
metadata
except UnboundLocalError:
metadata = project_root.lstat()
if expected == "directory" and not stat.S_ISDIR(metadata.st_mode):
errors.append(f"{where}: 必须指向目录: {relative_path!r}")
if expected == "executable":
if not stat.S_ISREG(metadata.st_mode):
errors.append(f"{where}: 必须指向普通文件: {relative_path!r}")
elif metadata.st_mode & 0o111 == 0:
errors.append(f"{where}: 脚本不可执行: {relative_path!r}")
return errors
def _scan_secrets(value: Any, where: str, errors: list[str]) -> None:
if isinstance(value, dict):
for key, item in value.items():
_scan_secrets(item, f"{where}.{key}", errors)
return
if isinstance(value, list):
for index, item in enumerate(value):
_scan_secrets(item, f"{where}[{index}]", errors)
return
if not isinstance(value, str):
return
for label, pattern in SECRET_PATTERNS:
if pattern.search(value):
errors.append(f"{where}: 疑似包含敏感信息({label})")
def _validate_ids(values: Any, where: str, errors: list[str]) -> dict[str, Any]:
if not isinstance(values, dict):
errors.append(f"{where}: 必须是对象")
return {}
for key in values:
if not isinstance(key, str) or ID_RE.fullmatch(key) is None:
errors.append(f"{where}: ID {key!r} 必须使用小写连字符格式")
return values
def _validate_entrypoints(
values: dict[str, Any],
errors: list[str],
project_root: Path | None,
) -> None:
for entrypoint_id, value in values.items():
where = f"entrypoints.{entrypoint_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ENTRYPOINT_FIELDS, where, errors)
kind = value.get("kind")
if kind not in ENTRYPOINT_KINDS:
errors.append(f"{where}.kind: 必须是 {sorted(ENTRYPOINT_KINDS)}")
required_selector = {
"make": "target",
"just": "target",
"task": "target",
"dagger": "function",
"script": "path",
}.get(kind)
for selector in ("target", "function", "path"):
if selector == required_selector:
if not _nonempty(value.get(selector)):
errors.append(f"{where}.{selector}: {kind} 入口必须填写非空值")
elif selector in value:
errors.append(f"{where}.{selector}: kind={kind!r} 不允许此字段")
args = value.get("args")
if not isinstance(args, list) or any(not isinstance(item, str) for item in args):
errors.append(f"{where}.args: 必须是字符串列表")
required_secrets = value.get("requiredSecrets")
if (
not isinstance(required_secrets, list)
or any(
not isinstance(item, str) or SECRET_NAME_RE.fullmatch(item) is None
for item in required_secrets
)
or (
isinstance(required_secrets, list)
and len(required_secrets) != len(set(required_secrets))
)
):
errors.append(
f"{where}.requiredSecrets: 必须是唯一的大写 secret 名称列表"
)
working_directory = value.get("workingDirectory")
if not _safe_relative_path(working_directory):
errors.append(f"{where}.workingDirectory: 必须是安全的仓库内相对路径")
timeout = value.get("timeoutSeconds")
if (
not isinstance(timeout, int)
or isinstance(timeout, bool)
or not 1 <= timeout <= 86400
):
errors.append(f"{where}.timeoutSeconds: 必须是 1..86400 的整数")
if kind == "script" and not _safe_relative_path(value.get("path")):
errors.append(f"{where}.path: 必须是安全的仓库内相对路径")
if project_root is not None:
if _safe_relative_path(working_directory):
errors.extend(
_validate_path_binding(
project_root,
working_directory,
f"{where}.workingDirectory",
expected="directory",
)
)
if kind == "script" and _safe_relative_path(value.get("path")):
errors.extend(
_validate_path_binding(
project_root,
value["path"],
f"{where}.path",
expected="executable",
)
)
def _validate_artifacts(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for artifact_id, value in values.items():
where = f"artifacts.{artifact_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ARTIFACT_FIELDS, where, errors)
artifact_type = value.get("type")
if artifact_type not in ARTIFACT_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(ARTIFACT_TYPES)}")
build = value.get("build")
if build not in entrypoints:
errors.append(f"{where}.build: 未定义 entrypoint {build!r}")
outputs = value.get("outputs")
if artifact_type in {"deb", "file"}:
if (
not isinstance(outputs, list)
or not outputs
or any(not _safe_relative_path(item, allow_glob=True) for item in outputs)
):
errors.append(f"{where}.outputs: deb/file 必须填写安全的产物路径列表")
if "image" in value or "platforms" in value:
errors.append(f"{where}: deb/file 不允许 image 或 platforms")
if artifact_type == "oci-image":
if not _nonempty(value.get("image")):
errors.append(f"{where}.image: oci-image 必须填写镜像名")
platforms = value.get("platforms")
if (
not isinstance(platforms, list)
or not platforms
or any(not isinstance(item, str) or PLATFORM_RE.fullmatch(item) is None for item in platforms)
or len(platforms) != len(set(platforms))
):
errors.append(f"{where}.platforms: 必须是唯一的 os/arch 列表")
if "outputs" in value:
errors.append(f"{where}: oci-image 不允许 outputs")
def _validate_destinations(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for destination_id, value in values.items():
where = f"destinations.{destination_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, DESTINATION_FIELDS, where, errors)
destination_type = value.get("type")
if destination_type not in DESTINATION_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(DESTINATION_TYPES)}")
type_fields = {
"apt-repository": {"endpoint", "repository"},
"oci-registry": {"registry", "repository"},
"ci-artifact": {"artifactName"},
}.get(destination_type, set())
allowed_fields = {"type", "channel", "upload"} | type_fields
for field in sorted(set(value) - allowed_fields):
errors.append(f"{where}.{field}: type={destination_type!r} 不允许此字段")
if value.get("channel") not in CHANNELS:
errors.append(f"{where}.channel: 必须是 {sorted(CHANNELS)}")
upload = value.get("upload")
if upload is not None and upload not in entrypoints:
errors.append(f"{where}.upload: 未定义 entrypoint {upload!r}")
if destination_type == "apt-repository":
if not _nonempty(value.get("endpoint")):
errors.append(f"{where}.endpoint: APT 目标必须填写服务地址")
if not _nonempty(value.get("repository")):
errors.append(f"{where}.repository: APT 目标必须填写仓库名")
if destination_type == "oci-registry":
for field in ("registry", "repository"):
if not _nonempty(value.get(field)):
errors.append(f"{where}.{field}: OCI 目标必须填写非空值")
if destination_type == "ci-artifact" and not _nonempty(value.get("artifactName")):
errors.append(f"{where}.artifactName: CI artifact 必须填写名称")
def _validate_environments(
values: dict[str, Any],
entrypoints: dict[str, Any],
errors: list[str],
) -> None:
for environment_id, value in values.items():
where = f"environments.{environment_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, ENVIRONMENT_FIELDS, where, errors)
if value.get("type") not in ENVIRONMENT_TYPES:
errors.append(f"{where}.type: 必须是 {sorted(ENVIRONMENT_TYPES)}")
classification = value.get("classification")
if classification not in CLASSIFICATIONS:
errors.append(f"{where}.classification: 必须是 {sorted(CLASSIFICATIONS)}")
if not _nonempty(value.get("target")):
errors.append(f"{where}.target: 必须是非空目标别名")
for field in ("deploy", "healthCheck"):
reference = value.get(field)
if reference not in entrypoints:
errors.append(f"{where}.{field}: 未定义 entrypoint {reference!r}")
rollback = value.get("rollback")
if rollback is not None and rollback not in entrypoints:
errors.append(f"{where}.rollback: 未定义 entrypoint {rollback!r}")
if classification == "production" and rollback is None:
errors.append(f"{where}.rollback: production 环境必须提供回滚入口")
if not _nonempty(value.get("mutex")):
errors.append(f"{where}.mutex: 必须填写部署互斥锁 ID")
def _artifact_destination_compatible(artifact_type: str, destination_type: str) -> bool:
return destination_type in {
"deb": {"apt-repository", "ci-artifact"},
"oci-image": {"oci-registry", "ci-artifact"},
"file": {"ci-artifact"},
}.get(artifact_type, set())
def _validate_profiles(
values: dict[str, Any],
*,
default_profile: Any,
entrypoints: dict[str, Any],
artifacts: dict[str, Any],
destinations: dict[str, Any],
environments: dict[str, Any],
errors: list[str],
) -> None:
for profile_id, value in values.items():
where = f"profiles.{profile_id}"
if not _mapping(value):
errors.append(f"{where}: 必须是对象")
continue
_reject_unknown(value, PROFILE_FIELDS, where, errors)
stop_at = value.get("stopAt")
if stop_at not in STOP_POINTS:
errors.append(f"{where}.stopAt: 必须是 {sorted(STOP_POINTS)}")
steps = value.get("steps")
if not isinstance(steps, list):
errors.append(f"{where}.steps: 必须是列表")
continue
seen_step_ids: set[str] = set()
built_artifacts: set[str] = set()
published_artifacts: set[str] = set()
deployed_environments: set[str] = set()
approvals: set[str] = set()
has_pull_request = False
has_mark_ready = False
for index, step in enumerate(steps):
step_where = f"{where}.steps[{index}]"
if not _mapping(step):
errors.append(f"{step_where}: 必须是对象")
continue
_reject_unknown(step, STEP_FIELDS, step_where, errors)
step_id = step.get("id")
if not isinstance(step_id, str) or ID_RE.fullmatch(step_id) is None:
errors.append(f"{step_where}.id: 必须使用小写连字符格式")
elif step_id in seen_step_ids:
errors.append(f"{step_where}.id: 不能重复 {step_id!r}")
else:
seen_step_ids.add(step_id)
action = step.get("action")
if action not in ACTIONS:
errors.append(f"{step_where}.action: 必须是 {sorted(ACTIONS)}")
continue
required_fields = ACTION_FIELDS[action]
for field in sorted(required_fields):
if field not in step:
errors.append(f"{step_where}.{field}: action={action!r} 时必填")
allowed_fields = {"id", "action"} | required_fields
for field in sorted(set(step) - allowed_fields):
errors.append(f"{step_where}.{field}: action={action!r} 不允许此字段")
if action == "verify" and step.get("entrypoint") not in entrypoints:
errors.append(
f"{step_where}.entrypoint: 未定义 entrypoint {step.get('entrypoint')!r}"
)
if action == "pull-request":
if not isinstance(step.get("draft"), bool):
errors.append(f"{step_where}.draft: 必须是布尔值")
if (
not isinstance(step.get("remote"), str)
or REMOTE_RE.fullmatch(step["remote"]) is None
):
errors.append(f"{step_where}.remote: 必须是安全的 Git remote 名称")
if not _safe_branch_name(step.get("baseBranch")):
errors.append(f"{step_where}.baseBranch: 必须是安全的 Git 分支名")
has_pull_request = True
if action == "build":
artifact_id = step.get("artifact")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
else:
built_artifacts.add(artifact_id)
if action == "publish":
artifact_id = step.get("artifact")
destination_id = step.get("destination")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
elif artifact_id not in built_artifacts:
errors.append(f"{step_where}: publish 前必须先 build {artifact_id!r}")
if destination_id not in destinations:
errors.append(
f"{step_where}.destination: 未定义 destination {destination_id!r}"
)
elif artifact_id in artifacts:
artifact_type = artifacts[artifact_id].get("type")
destination_type = destinations[destination_id].get("type")
if not _artifact_destination_compatible(artifact_type, destination_type):
errors.append(
f"{step_where}: artifact {artifact_type!r} 不能发布到 "
f"{destination_type!r}"
)
if destinations[destination_id].get("channel") == "stable" and "release" not in approvals:
errors.append(f"{step_where}: stable 发布前必须有 release approval")
published_artifacts.add(artifact_id)
if action == "deploy":
artifact_id = step.get("artifact")
environment_id = step.get("environment")
if artifact_id not in artifacts:
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
elif artifact_id not in built_artifacts:
errors.append(f"{step_where}: deploy 前必须先 build {artifact_id!r}")
if environment_id not in environments:
errors.append(
f"{step_where}.environment: 未定义 environment {environment_id!r}"
)
else:
classification = environments[environment_id].get("classification")
if classification == "production" and "production" not in approvals:
errors.append(f"{step_where}: production 部署前必须有 production approval")
if (
classification == "production"
and artifact_id not in published_artifacts
):
errors.append(f"{step_where}: production 部署前必须先 publish 同一产物")
deployed_environments.add(environment_id)
if action == "health-check":
environment_id = step.get("environment")
if environment_id not in environments:
errors.append(
f"{step_where}.environment: 未定义 environment {environment_id!r}"
)
elif environment_id not in deployed_environments:
errors.append(
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
)
if action == "approval":
gate = step.get("gate")
if gate not in {"release", "production"}:
errors.append(f"{step_where}.gate: 必须是 release/production")
else:
if gate == "release" and not built_artifacts:
errors.append(f"{step_where}: release approval 前必须先 build 产物")
if gate == "production" and not published_artifacts:
errors.append(f"{step_where}: production approval 前必须先 publish 产物")
approvals.add(gate)
if action == "mark-ready":
if not has_pull_request:
errors.append(f"{step_where}: mark-ready 前必须先创建 pull-request")
has_mark_ready = True
if index != len(steps) - 1:
errors.append(f"{step_where}: mark-ready 必须是 profile 最后一步")
if stop_at in {"review_ready", "released"} and (
not has_pull_request or not has_mark_ready
):
errors.append(
f"{where}: {stop_at} 必须包含 pull-request 和末尾 mark-ready"
)
if stop_at == "released" and not ({"release", "production"} & approvals):
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
if profile_id == default_profile:
if stop_at != "review_ready":
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
used_destinations = {
step.get("destination")
for step in steps
if isinstance(step, dict) and step.get("action") == "publish"
}
used_environments = {
step.get("environment")
for step in steps
if isinstance(step, dict) and step.get("action") == "deploy"
}
if any(
destinations.get(item, {}).get("channel") == "stable"
for item in used_destinations
):
errors.append(f"{where}: defaultProfile 不能发布 stable 目标")
if any(
environments.get(item, {}).get("classification") == "production"
for item in used_environments
):
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
errors: list[str] = []
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
if data.get("version") != 1 or isinstance(data.get("version"), bool):
errors.append("version 必须是整数 1")
if "updatedAt" in data and not _nonempty(data.get("updatedAt")):
errors.append("updatedAt 必须是非空字符串")
project = data.get("project")
if not _mapping(project):
errors.append("project 必须是对象")
project = {}
else:
_reject_unknown(project, {"name"}, "project", errors)
if not _nonempty(project.get("name")):
errors.append("project.name 必须是非空字符串")
enabled = data.get("enabled")
if not isinstance(enabled, bool):
errors.append("enabled 必须是布尔值")
default_profile = data.get("defaultProfile")
if default_profile is not None and (
not isinstance(default_profile, str) or ID_RE.fullmatch(default_profile) is None
):
errors.append("defaultProfile 必须是 null 或小写连字符 ID")
entrypoints = _validate_ids(data.get("entrypoints"), "entrypoints", errors)
artifacts = _validate_ids(data.get("artifacts"), "artifacts", errors)
destinations = _validate_ids(data.get("destinations"), "destinations", errors)
environments = _validate_ids(data.get("environments"), "environments", errors)
profiles = _validate_ids(data.get("profiles"), "profiles", errors)
_validate_entrypoints(entrypoints, errors, project_root)
_validate_artifacts(artifacts, entrypoints, errors)
_validate_destinations(destinations, entrypoints, errors)
_validate_environments(environments, entrypoints, errors)
_validate_profiles(
profiles,
default_profile=default_profile,
entrypoints=entrypoints,
artifacts=artifacts,
destinations=destinations,
environments=environments,
errors=errors,
)
if enabled:
if default_profile not in profiles:
errors.append("enabled=true 时 defaultProfile 必须引用已定义 profile")
elif not profiles[default_profile].get("steps"):
errors.append("enabled=true 时 defaultProfile.steps 不能为空")
elif default_profile is not None and default_profile not in profiles:
errors.append("defaultProfile 必须引用已定义 profile")
_scan_secrets(data, "<root>", errors)
return errors
def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list[str]:
errors: list[str] = []
project = tasks.get("project")
if not isinstance(project, dict):
return ["tasks.project 必须是对象"]
if project.get("deliveryFile") != "docs/ack/delivery.yaml":
errors.append("tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml")
delivery_project = delivery.get("project")
if (
isinstance(delivery_project, dict)
and _nonempty(delivery_project.get("name"))
and _nonempty(project.get("name"))
and delivery_project["name"] != project["name"]
):
errors.append("delivery.project.name 必须与 tasks.project.name 一致")
if not isinstance(tasks.get("deliveryRuns"), list):
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
return errors
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
import jsonschema # type: ignore
schema = json.loads(schema_path.read_text(encoding="utf-8"))
validator = jsonschema.Draft7Validator(schema)
errors = []
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
location = "/".join(str(part) for part in error.path) or "<root>"
errors.append(f"[schema] {location}: {error.message}")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
parser.add_argument("delivery", nargs="?", default="docs/ack/delivery.yaml")
parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml")
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
args = parser.parse_args(argv)
delivery_path = Path(args.delivery)
if not delivery_path.is_file():
sys.stderr.write(f"找不到交付契约: {delivery_path}\n")
return 2
project_root = Path(args.project_root).resolve() if args.project_root else None
if project_root is not None and not project_root.is_dir():
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
return 2
delivery = _load_document(delivery_path, "交付契约")
errors = validate_builtin(delivery, project_root)
if args.tasks:
tasks_path = Path(args.tasks)
if not tasks_path.is_file():
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
return 2
tasks = _load_document(tasks_path, "任务板")
errors.extend(validate_tasks_link(delivery, tasks))
schema_path = (
Path(args.schema)
if args.schema
else Path(__file__).resolve().parent.parent / "templates" / "delivery.schema.json"
)
if args.schema and not schema_path.is_file():
sys.stderr.write(f"找不到 schema: {schema_path}\n")
return 2
if schema_path.is_file():
try:
errors.extend(validate_with_schema(delivery, schema_path))
except ImportError:
sys.stderr.write("提示: 未安装 jsonschema,仅执行内置语义规则\n")
except (OSError, json.JSONDecodeError) as exc:
sys.stderr.write(f"schema 读取失败: {exc}\n")
return 2
if errors:
for error in errors:
sys.stderr.write(f"- {error}\n")
return 1
sys.stdout.write("交付契约校验通过\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+320 -2
View File
@@ -24,6 +24,7 @@ import re
import sys
from pathlib import Path
from approval_payload import approval_payload_hash
from yaml_subset import (
DuplicateKeyError,
YamlSubsetError,
@@ -49,6 +50,9 @@ KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"}
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
SEMVER_RE = re.compile(
r"^(0|[1-9][0-9]*)\."
r"(0|[1-9][0-9]*)\."
@@ -86,6 +90,44 @@ KNOWLEDGE_CHECK_FIELDS = {
"checkedBy",
"checkedAt",
}
DELIVERY_RUN_FIELDS = {
"id",
"profile",
"taskIds",
"status",
"sourceRevision",
"configRevision",
"pullRequest",
"artifacts",
"deployments",
"evidence",
"updatedAt",
}
DELIVERY_STATUSES = {
"planned",
"running",
"blocked",
"failed",
"review_ready",
"released",
"skipped",
}
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
FEISHU_REQUIRED_FIELDS = {
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
"attachments", "updatedAt",
}
FEISHU_OPTIONAL_FIELDS = {"fixLogic"}
FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
FEISHU_SOURCE_FIELDS = {
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
"approvedPayloadHash",
}
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
DISPATCH_FIELDS = {
"taskId",
"dispatchId",
@@ -392,6 +434,162 @@ def validate_knowledge_fields(
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
def validate_delivery_runs(
value: object,
task_statuses: dict[str, object],
errors: list[str],
) -> None:
if not isinstance(value, list):
errors.append("deliveryRuns 必须是列表")
return
seen_run_ids: set[str] = set()
for index, run in enumerate(value):
where = f"deliveryRuns[{index}]"
if not isinstance(run, dict):
errors.append(f"{where}: 必须是对象")
continue
reject_unknown_fields(run, DELIVERY_RUN_FIELDS, where, errors)
missing = sorted(DELIVERY_RUN_FIELDS - set(run))
for field in missing:
errors.append(f"{where}.{field}: 必填")
run_id = run.get("id")
if not isinstance(run_id, str) or DELIVERY_RUN_ID_RE.fullmatch(run_id) is None:
errors.append(f"{where}.id: 必须使用 DR-<id> 格式")
elif run_id in seen_run_ids:
errors.append(f"{where}.id: 不能重复 {run_id!r}")
else:
seen_run_ids.add(run_id)
profile = run.get("profile")
if not isinstance(profile, str) or DELIVERY_PROFILE_RE.fullmatch(profile) is None:
errors.append(f"{where}.profile: 必须使用小写连字符 ID")
status = run.get("status")
if status not in DELIVERY_STATUSES:
errors.append(f"{where}.status: 必须是 {sorted(DELIVERY_STATUSES)}")
task_ids = run.get("taskIds")
if (
not isinstance(task_ids, list)
or not task_ids
or any(not _nonempty_string(task_id) for task_id in task_ids)
):
errors.append(f"{where}.taskIds: 必须是非空任务 ID 列表")
task_ids = []
elif len(task_ids) != len(set(task_ids)):
errors.append(f"{where}.taskIds: 不能包含重复值")
for task_id in task_ids:
if task_id not in task_statuses:
errors.append(f"{where}.taskIds: 未知任务 {task_id!r}")
elif task_statuses[task_id] != "verified":
errors.append(
f"{where}: delivery run 只能引用 verified 任务,"
f"{task_id!r} 当前是 {task_statuses[task_id]!r}"
)
for field in ("sourceRevision", "configRevision"):
revision = run.get(field)
if revision is not None and (
not isinstance(revision, str) or GIT_REVISION_RE.fullmatch(revision) is None
):
errors.append(f"{where}.{field}: 必须是 null 或 7..64 位小写十六进制 revision")
pull_request = run.get("pullRequest")
if pull_request is not None and not isinstance(pull_request, str):
errors.append(f"{where}.pullRequest: 必须是字符串或 null")
if status != "skipped":
for field in ("sourceRevision", "configRevision"):
if not _nonempty_string(run.get(field)):
errors.append(f"{where}.{field}: status={status!r} 时必须填写")
if status in {"review_ready", "released"}:
if not _nonempty_string(run.get("pullRequest")):
errors.append(f"{where}.pullRequest: status={status!r} 时必须填写")
artifacts = run.get("artifacts")
if not isinstance(artifacts, list):
errors.append(f"{where}.artifacts: 必须是列表")
else:
seen_artifacts: set[str] = set()
for artifact_index, artifact in enumerate(artifacts):
artifact_where = f"{where}.artifacts[{artifact_index}]"
if not isinstance(artifact, dict):
errors.append(f"{artifact_where}: 必须是对象")
continue
reject_unknown_fields(
artifact,
DELIVERY_ARTIFACT_FIELDS,
artifact_where,
errors,
)
artifact_id = artifact.get("id")
if (
not isinstance(artifact_id, str)
or DELIVERY_PROFILE_RE.fullmatch(artifact_id) is None
):
errors.append(f"{artifact_where}.id: 必须使用小写连字符 ID")
elif artifact_id in seen_artifacts:
errors.append(f"{artifact_where}.id: 不能重复 {artifact_id!r}")
else:
seen_artifacts.add(artifact_id)
if artifact.get("type") not in {"deb", "oci-image", "file"}:
errors.append(f"{artifact_where}.type: 必须是 deb/oci-image/file")
if not _nonempty_string(artifact.get("reference")):
errors.append(f"{artifact_where}.reference: 必须是非空字符串")
digest = artifact.get("digest")
if digest is not None and (
not isinstance(digest, str)
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
):
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
if status in {"review_ready", "released"} and not _nonempty_string(digest):
errors.append(
f"{artifact_where}.digest: status={status!r} 时必须填写"
)
deployments = run.get("deployments")
if not isinstance(deployments, list):
errors.append(f"{where}.deployments: 必须是列表")
else:
seen_environments: set[str] = set()
for deployment_index, deployment in enumerate(deployments):
deployment_where = f"{where}.deployments[{deployment_index}]"
if not isinstance(deployment, dict):
errors.append(f"{deployment_where}: 必须是对象")
continue
reject_unknown_fields(
deployment,
DELIVERY_DEPLOYMENT_FIELDS,
deployment_where,
errors,
)
environment = deployment.get("environment")
if (
not isinstance(environment, str)
or DELIVERY_PROFILE_RE.fullmatch(environment) is None
):
errors.append(f"{deployment_where}.environment: 必须使用小写连字符 ID")
elif environment in seen_environments:
errors.append(f"{deployment_where}.environment: 不能重复 {environment!r}")
else:
seen_environments.add(environment)
if deployment.get("result") not in {"succeeded", "failed", "rolled_back"}:
errors.append(
f"{deployment_where}.result: 必须是 succeeded/failed/rolled_back"
)
if not _nonempty_string(deployment.get("evidence")):
errors.append(f"{deployment_where}.evidence: 必须是非空字符串")
evidence = run.get("evidence")
if not isinstance(evidence, list) or any(
not _nonempty_string(item) for item in evidence
):
errors.append(f"{where}.evidence: 必须是字符串列表")
elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence:
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
if not _nonempty_string(run.get("updatedAt")):
errors.append(f"{where}.updatedAt: 必须是非空字符串")
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
import jsonschema # type: ignore
@@ -406,6 +604,7 @@ def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
def validate_builtin(data: dict) -> list[str]:
errors: list[str] = []
project_intake_workflow = "read-only-v1"
def validate_string_fields(
value: dict,
@@ -468,9 +667,42 @@ def validate_builtin(data: dict) -> list[str]:
errors.append("project.name 必须是非空字符串")
validate_string_fields(
project,
{"repoPath", "baseUrl", "devWorktree", "overlayFile"},
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
"project",
)
if "bugIntake" in project:
intake = project["bugIntake"]
if not isinstance(intake, dict):
errors.append("project.bugIntake 必须是对象")
else:
reject_unknown_fields(intake, FEISHU_CONFIG_FIELDS, "project.bugIntake", errors)
if intake.get("provider") != "feishu-base":
errors.append("project.bugIntake.provider 必须是 feishu-base")
workflow = intake.get("workflow", "read-only-v1")
if workflow in FEISHU_WORKFLOWS:
project_intake_workflow = workflow
if workflow not in FEISHU_WORKFLOWS:
errors.append("project.bugIntake.workflow 非法")
profile = intake.get("profile")
if not isinstance(profile, str) or FEISHU_PROFILE_RE.fullmatch(profile) is None:
errors.append("project.bugIntake.profile 非法")
for key in ("baseToken", "tableId", "viewId"):
value = intake.get(key)
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
fields = intake.get("fields")
if (
not isinstance(fields, dict)
or not FEISHU_REQUIRED_FIELDS.issubset(fields)
or not set(fields).issubset(FEISHU_REQUIRED_FIELDS | FEISHU_OPTIONAL_FIELDS)
):
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
elif len(set(fields.values())) != len(fields):
errors.append("project.bugIntake.fields 字段值不能重复")
elif workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
errors.append("reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic")
if (
"knowledgeFile" in project
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
@@ -478,6 +710,17 @@ def validate_builtin(data: dict) -> list[str]:
errors.append(
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
)
if (
"deliveryFile" in project
and project.get("deliveryFile") != "docs/ack/delivery.yaml"
):
errors.append(
"project.deliveryFile 必须固定为 docs/ack/delivery.yaml"
)
if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list):
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
if "deliveryRuns" in data and "deliveryFile" not in project:
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
ack_version = data.get("ackVersion")
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
@@ -516,6 +759,7 @@ def validate_builtin(data: dict) -> list[str]:
return errors
seen_ids: set[str] = set()
seen_source_refs: set[str] = set()
for i, task in enumerate(tasks):
where = f"tasks[{i}]"
if not isinstance(task, dict):
@@ -550,6 +794,7 @@ def validate_builtin(data: dict) -> list[str]:
"assignee",
"component",
"description",
"fixLogic",
"expected",
"actual",
},
@@ -557,11 +802,76 @@ def validate_builtin(data: dict) -> list[str]:
)
validate_string_lists(
task,
{"specRefs", "testRefs", "stepsToReproduce"},
{"specRefs", "testRefs", "stepsToReproduce", "acceptanceCriteria"},
where,
)
validate_object_fields(task, {"evidence", "verification"}, where)
if "source" in task:
source = task["source"]
# `source` was historically an open extension point. Preserve
# non-Feishu strings/objects and tighten only the namespaced shape.
if isinstance(source, dict) and source.get("kind") == "feishu-base":
reject_unknown_fields(source, FEISHU_SOURCE_FIELDS, f"{where}.source", errors)
ref = source.get("ref")
if not isinstance(ref, str) or FEISHU_SOURCE_REF_RE.fullmatch(ref) is None:
errors.append(f"{where}.source.ref: 必须是不透明 feishu-base SHA-256 引用")
else:
if ref in seen_source_refs:
errors.append(f"{where}.source.ref: 来源引用重复")
seen_source_refs.add(ref)
record_id = source.get("recordId")
if not isinstance(record_id, str) or FEISHU_RECORD_ID_RE.fullmatch(record_id) is None:
errors.append(f"{where}.source.recordId: 必须是合法飞书记录 ID")
if not _nonempty_string(source.get("updatedAt")):
errors.append(f"{where}.source.updatedAt: 必须是非空字符串")
source_workflow = source.get("workflow", "read-only-v1")
if source_workflow not in FEISHU_WORKFLOWS:
errors.append(f"{where}.source.workflow: 非法")
if (
project_intake_workflow == "reviewed-writeback-v1"
and source_workflow != "reviewed-writeback-v1"
and status not in {"verified", "leftover"}
):
errors.append(
f"{where}.source.workflow: reviewed 项目的可执行飞书任务必须先迁移审核"
)
approved_revision = source.get("approvedRevision")
stored_payload_hash = source.get("approvedPayloadHash")
if source_workflow == "reviewed-writeback-v1" and approved_revision is None:
errors.append(f"{where}.source.approvedRevision: reviewed workflow 必填")
elif approved_revision is not None and (
not isinstance(approved_revision, str)
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
):
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
if source_workflow == "reviewed-writeback-v1":
if (
not isinstance(stored_payload_hash, str)
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
):
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
required_strings = ("title", "priority", "actual", "expected", "fixLogic")
for field in required_strings:
if not _nonempty_string(task.get(field)):
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
for field in ("stepsToReproduce", "acceptanceCriteria"):
items = task.get(field)
if (
not isinstance(items, list)
or not items
or any(not _nonempty_string(item) for item in items)
):
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串列表")
if (
isinstance(stored_payload_hash, str)
and re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash)
and stored_payload_hash != approval_payload_hash(task)
):
errors.append(f"{where}.source.approvedPayloadHash: 与任务审核字段不匹配")
elif stored_payload_hash is not None:
errors.append(f"{where}.source.approvedPayloadHash: 只允许 reviewed workflow")
validate_knowledge_fields(task, where, status, errors)
if "dispatch" not in task:
@@ -678,6 +988,14 @@ def validate_builtin(data: dict) -> list[str]:
):
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
if "deliveryRuns" in data:
task_statuses = {
task.get("id"): task.get("status")
for task in tasks
if isinstance(task, dict) and _nonempty_string(task.get("id"))
}
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
return errors
+228
View File
@@ -0,0 +1,228 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/delivery.schema.json",
"title": "ACK project delivery contract",
"description": "docs/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
"type": "object",
"required": [
"version",
"project",
"enabled",
"defaultProfile",
"entrypoints",
"artifacts",
"destinations",
"environments",
"profiles"
],
"additionalProperties": false,
"properties": {
"version": { "type": "integer", "const": 1 },
"updatedAt": { "type": "string" },
"project": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1, "pattern": "\\S" }
}
},
"enabled": { "type": "boolean" },
"defaultProfile": {
"type": ["string", "null"],
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
"entrypoints": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/entrypoint" }
},
"artifacts": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/artifact" }
},
"destinations": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/destination" }
},
"environments": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/environment" }
},
"profiles": {
"type": "object",
"propertyNames": { "$ref": "#/definitions/id" },
"additionalProperties": { "$ref": "#/definitions/profile" }
}
},
"definitions": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
"relativePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/*?+-]+$"
},
"stringList": {
"type": "array",
"items": { "type": "string" }
},
"secretName": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{0,127}$"
},
"entrypoint": {
"type": "object",
"required": [
"kind",
"args",
"requiredSecrets",
"workingDirectory",
"timeoutSeconds"
],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["make", "just", "task", "dagger", "script"]
},
"target": { "type": "string", "minLength": 1 },
"function": { "type": "string", "minLength": 1 },
"path": { "$ref": "#/definitions/relativePath" },
"args": { "$ref": "#/definitions/stringList" },
"requiredSecrets": {
"type": "array",
"uniqueItems": true,
"items": { "$ref": "#/definitions/secretName" }
},
"workingDirectory": { "$ref": "#/definitions/relativePath" },
"timeoutSeconds": {
"type": "integer",
"minimum": 1,
"maximum": 86400
}
}
},
"artifact": {
"type": "object",
"required": ["type", "build"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["deb", "oci-image", "file"] },
"build": { "$ref": "#/definitions/id" },
"outputs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/relativePath" }
},
"image": { "type": "string", "minLength": 1 },
"platforms": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "pattern": "^[a-z0-9]+/[A-Za-z0-9._-]+$" }
}
}
},
"destination": {
"type": "object",
"required": ["type", "channel"],
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["apt-repository", "oci-registry", "ci-artifact"]
},
"channel": {
"type": "string",
"enum": ["preview", "staging", "stable"]
},
"registry": { "type": "string", "minLength": 1 },
"repository": { "type": "string", "minLength": 1 },
"endpoint": { "type": "string", "minLength": 1 },
"artifactName": { "type": "string", "minLength": 1 },
"upload": { "$ref": "#/definitions/id" }
}
},
"environment": {
"type": "object",
"required": [
"type",
"classification",
"target",
"deploy",
"healthCheck",
"mutex"
],
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": ["ssh-host", "docker-compose", "kubernetes", "custom"]
},
"classification": {
"type": "string",
"enum": ["development", "staging", "production"]
},
"target": { "type": "string", "minLength": 1 },
"deploy": { "$ref": "#/definitions/id" },
"healthCheck": { "$ref": "#/definitions/id" },
"rollback": { "$ref": "#/definitions/id" },
"mutex": { "type": "string", "minLength": 1 }
}
},
"step": {
"type": "object",
"required": ["id", "action"],
"additionalProperties": false,
"properties": {
"id": { "$ref": "#/definitions/id" },
"action": {
"type": "string",
"enum": [
"verify",
"pull-request",
"build",
"publish",
"deploy",
"health-check",
"approval",
"mark-ready"
]
},
"entrypoint": { "$ref": "#/definitions/id" },
"artifact": { "$ref": "#/definitions/id" },
"destination": { "$ref": "#/definitions/id" },
"environment": { "$ref": "#/definitions/id" },
"gate": { "type": "string", "enum": ["release", "production"] },
"draft": { "type": "boolean" },
"remote": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
},
"baseBranch": { "type": "string", "minLength": 1, "maxLength": 255 }
}
},
"profile": {
"type": "object",
"required": ["stopAt", "steps"],
"additionalProperties": false,
"properties": {
"stopAt": {
"type": "string",
"enum": ["verified", "review_ready", "released"]
},
"steps": {
"type": "array",
"items": { "$ref": "#/definitions/step" }
}
}
}
}
}
@@ -0,0 +1,24 @@
# 复制为 docs/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
version: 1
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
project:
name: "<project_name>"
enabled: false
defaultProfile: null
# 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。
# 不在此保存 shell、env 或凭据正文。
entrypoints: {}
# 支持 deb、oci-image 和 file。复杂构建逻辑留在项目已有入口中。
artifacts: {}
# 支持 apt-repository、oci-registry 和 ci-artifact。
destinations: {}
# 支持 ssh-host、docker-compose、kubernetes 和 custom。
environments: {}
# enabled=true 时,defaultProfile 必须指向一个非空 profile。
profiles: {}
+13 -4
View File
@@ -8,7 +8,8 @@
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`,
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的
> `delivery.yaml`,
> 不复制或链接 Skill。
## 项目概览
@@ -19,6 +20,7 @@
- Base URL:`<base_url>`
- 任务板:`docs/ack/tasks.yaml`
- 项目知识:`docs/ack/knowledge.yaml`
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
## 通用规范(由 ACK Skill 按需读取)
@@ -30,6 +32,7 @@
- 验收信号与三轮策略:`references/optimization-method.md`
- 派发 prompt 模板:`references/prompt-templates.md`
- Orca 编排命令(可选):`references/orca-adapter.md`
- 验证后交付与配置维护(可选):`references/delivery.md`
## Worker 路由
@@ -64,6 +67,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
## 命令(项目覆盖层)
@@ -91,8 +95,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
`ACK_PROJECT_ROOT_DISPLAY`。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和
`scripts/validate_knowledge.py` 执行。
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
构建/发布/部署的机器入口以 `delivery.yaml` 为准;本文件不维护第二套交付命令。
## 硬规则(其余见 references/)
@@ -112,5 +117,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
- 不提交或推送,除非用户明确要求。
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
+284
View File
@@ -37,6 +37,12 @@
"const": "docs/ack/knowledge.yaml",
"description": "项目知识护栏库的唯一权威路径"
},
"deliveryFile": {
"type": "string",
"const": "docs/ack/delivery.yaml",
"description": "可选项目交付契约的唯一权威路径"
},
"bugIntake": { "$ref": "#/definitions/feishuBugIntake" },
"orchestration": {
"$ref": "#/definitions/orchestration"
}
@@ -57,6 +63,10 @@
"type": "array",
"items": { "$ref": "#/definitions/workerReceipt" }
},
"deliveryRuns": {
"type": "array",
"items": { "$ref": "#/definitions/deliveryRun" }
},
"tasks": {
"type": "array",
"items": { "$ref": "#/definitions/task" }
@@ -128,6 +138,26 @@
}
}
}
},
{
"if": {
"properties": {
"project": {
"type": "object",
"required": ["deliveryFile"],
"properties": {
"deliveryFile": {}
}
}
},
"required": ["project"]
},
"then": {
"required": ["deliveryRuns"],
"properties": {
"deliveryRuns": {}
}
}
}
],
"definitions": {
@@ -135,6 +165,85 @@
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"feishuBugIntake": {
"type": "object",
"required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"],
"additionalProperties": false,
"properties": {
"provider": { "const": "feishu-base" },
"workflow": {
"type": "string",
"enum": ["read-only-v1", "reviewed-writeback-v1"]
},
"profile": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
"baseToken": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"tableId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"viewId": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"fields": {
"type": "object",
"required": ["title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt"],
"additionalProperties": false,
"properties": {
"title": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"actual": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"expected": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"stepsToReproduce": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"fixLogic": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"acceptance": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"priority": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"attachments": { "type": "string", "minLength": 1, "pattern": "^\\S+$" },
"updatedAt": { "type": "string", "minLength": 1, "pattern": "^\\S+$" }
}
}
},
"allOf": [
{
"if": {
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
"required": ["workflow"]
},
"then": {
"properties": {
"fields": { "required": ["fixLogic"] }
}
}
}
]
},
"feishuTaskSource": {
"type": "object",
"required": ["kind", "ref", "recordId", "updatedAt"],
"additionalProperties": false,
"properties": {
"kind": { "const": "feishu-base" },
"workflow": {
"type": "string",
"enum": ["read-only-v1", "reviewed-writeback-v1"]
},
"ref": {
"type": "string",
"pattern": "^feishu-base:sha256:[0-9a-f]{64}$"
},
"recordId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$"
},
"updatedAt": { "type": "string", "minLength": 1, "pattern": "\\S" },
"approvedRevision": { "$ref": "#/definitions/sha256" },
"approvedPayloadHash": { "$ref": "#/definitions/sha256" }
},
"allOf": [
{
"if": {
"properties": { "workflow": { "const": "reviewed-writeback-v1" } },
"required": ["workflow"]
},
"then": {
"required": ["approvedRevision", "approvedPayloadHash"]
}
}
]
},
"profileId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,63}$"
@@ -721,6 +830,165 @@
"checkedAt": { "type": "string" }
}
},
"deliveryArtifactEvidence": {
"type": "object",
"required": ["id", "type", "reference"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" },
"type": { "type": "string", "enum": ["deb", "oci-image", "file"] },
"reference": { "type": "string", "minLength": 1 },
"digest": {
"type": ["string", "null"],
"pattern": "^sha256:[0-9a-f]{64}$"
}
}
},
"deliveryDeploymentEvidence": {
"type": "object",
"required": ["environment", "result", "evidence"],
"additionalProperties": false,
"properties": {
"environment": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
"result": {
"type": "string",
"enum": ["succeeded", "failed", "rolled_back"]
},
"evidence": { "type": "string", "minLength": 1 }
}
},
"deliveryRun": {
"type": "object",
"required": [
"id",
"profile",
"taskIds",
"status",
"sourceRevision",
"configRevision",
"pullRequest",
"artifacts",
"deployments",
"evidence",
"updatedAt"
],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"profile": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
"taskIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"status": {
"type": "string",
"enum": [
"planned",
"running",
"blocked",
"failed",
"review_ready",
"released",
"skipped"
]
},
"sourceRevision": {
"type": ["string", "null"],
"pattern": "^[0-9a-f]{7,64}$"
},
"configRevision": {
"type": ["string", "null"],
"pattern": "^[0-9a-f]{7,64}$"
},
"pullRequest": { "type": ["string", "null"] },
"artifacts": {
"type": "array",
"items": { "$ref": "#/definitions/deliveryArtifactEvidence" }
},
"deployments": {
"type": "array",
"items": { "$ref": "#/definitions/deliveryDeploymentEvidence" }
},
"evidence": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"updatedAt": { "type": "string", "minLength": 1 }
},
"allOf": [
{
"if": {
"properties": {
"status": {
"enum": [
"planned",
"running",
"blocked",
"failed",
"review_ready",
"released"
]
}
},
"required": ["status"]
},
"then": {
"properties": {
"sourceRevision": { "type": "string" },
"configRevision": { "type": "string" }
}
}
},
{
"if": {
"properties": {
"status": {
"enum": [
"blocked",
"failed",
"review_ready",
"released",
"skipped"
]
}
},
"required": ["status"]
},
"then": {
"properties": {
"evidence": { "minItems": 1 }
}
}
},
{
"if": {
"properties": {
"status": { "enum": ["review_ready", "released"] }
},
"required": ["status"]
},
"then": {
"properties": {
"sourceRevision": { "type": "string" },
"configRevision": { "type": "string" },
"pullRequest": { "type": "string", "minLength": 1 },
"evidence": { "minItems": 1 }
}
}
}
]
},
"task": {
"type": "object",
"required": ["id", "title", "status"],
@@ -752,7 +1020,23 @@
"type": "array",
"items": { "$ref": "#/definitions/knowledgeCheck" }
},
"source": {
"if": {
"type": "object",
"required": ["kind"],
"properties": { "kind": { "const": "feishu-base" } }
},
"then": {
"$ref": "#/definitions/feishuTaskSource"
}
},
"description": { "type": "string" },
"fixLogic": { "type": "string", "minLength": 1, "pattern": "\\S" },
"acceptanceCriteria": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1, "pattern": "\\S" }
},
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
"expected": { "type": "string" },
"actual": { "type": "string" },
+38
View File
@@ -10,6 +10,25 @@ project:
devWorktree: "<dev_worktree>"
overlayFile: "docs/ack/project.md"
knowledgeFile: "docs/ack/knowledge.yaml"
deliveryFile: "docs/ack/delivery.yaml"
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
# bugIntake:
# provider: "feishu-base"
# workflow: "reviewed-writeback-v1"
# profile: "project-feishu"
# baseToken: "<base_token>"
# tableId: "<table_id>"
# viewId: "<ack_ready_view_id>"
# fields:
# title: "<field_id>"
# actual: "<field_id>"
# expected: "<field_id>"
# stepsToReproduce: "<field_id>"
# fixLogic: "<field_id>" # 启用飞书审核前协作时必填
# acceptance: "<field_id>"
# priority: "<field_id>"
# attachments: "<field_id>"
# updatedAt: "<field_id>"
orchestration:
profileVersion: 1
mode: "orca"
@@ -69,6 +88,7 @@ project:
developerUpgraded: "codex-dev-strong"
workerReceipts: []
deliveryRuns: []
summary:
verified: []
@@ -93,9 +113,27 @@ tasks:
knowledgeCandidates: []
knowledgeChecks: []
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
# source:
# kind: "feishu-base"
# workflow: "reviewed-writeback-v1"
# ref: "feishu-base:sha256:<64-lowercase-hex>"
# recordId: "<record-id>"
# updatedAt: "<source-updated-at>"
# approvedRevision: "sha256:<approved-draft-digest>"
# approvedPayloadHash: "sha256:<canonical-task-payload-digest>"
description: >
<What is wrong, in user-visible terms.>
# 飞书审核通过后写入批准版本;审核前不创建这条 ACK 任务。
fixLogic: >
<Approved repair logic, affected behavior, invariants, and regression risks.>
acceptanceCriteria:
- "<observable acceptance signal 1>"
- "<observable acceptance signal 2>"
stepsToReproduce:
- "<step 1>"
- "<step 2>"
+8 -4
View File
@@ -56,8 +56,10 @@ description: >-
`git worktree list --porcelain`。
3. 获取远端最新引用后,记录 base 分支的准确 commit。不要把本地过期分支当作发布基线。
4. 识别版本来源、最近稳定 tag、tag 格式、分支命名、合并方式和发布说明来源。
5. 分别检查 Git remote 的读取与推送能力,以及 PR/MR、合并或 Forge Release 阶段所需的
GitHub、GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
5. 分别用 `git remote get-url --all origin` 和
`git remote get-url --push --all origin` 检查读取与推送目标;远端写操作要求两者各只有
一个值且规范化后指向同一仓库。再检查 PR/MR、合并或 Forge Release 阶段所需的 GitHub、
GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
记录匹配该 host 的活动账号;认证检查只读取状态。
6. 查询已有分支、worktree、PR/MR、tag 和 Forge Release,从真实状态判断流程已走到哪一步。
@@ -119,8 +121,10 @@ CHANGELOG 或发布说明,保证它们进入同一个 PR/MR。运行项目声
### 5. 推送并创建 PR/MR
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围。Git 分支与
tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用与托管平台匹配的官方 CLI。
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围,并重新读取
唯一 fetch/push URL。Git 分支与 tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用
与托管平台匹配的官方 CLI。直接执行当前 shell 中的 `git` 和平台 CLI,让工具使用其正常
登录;不要读取、解析或复制 `~/.gitconfig`、tea/gh/glab 配置,也不要从中提取 token。
不要让 CLI 隐式创建 fork、改变 base 或选择另一个 remote。
PR/MR 内容至少说明目标版本、变更摘要、验证命令和结果、发布后续动作。用户只要求开
@@ -21,6 +21,10 @@ CLI。只发布 tag 时,不要因为缺少 Forge CLI 而停止;仍要验证
个账号或 profile 时,必须唯一确定本次使用的身份。CLI 版本之间的 flags 可能不同,
每次执行写操作前读取对应子命令的 `--help`,不要凭记忆拼接参数。
直接从目标 worktree 的当前 shell 运行 `git` 与平台 CLI,让它们使用各自正常的登录机制。
Gitea/Forgejo 使用 `tea whoami` 和 `tea` 对应子命令;不要由 Agent 读取 tea 配置文件、
调用 credential helper 导出 token,或把 token 转存到命令参数、环境文件和发布说明。
当请求确实涉及 PR/MR、合并或 Forge Release,且 remote host 无法识别、CLI 缺失、认证
失败或 CLI 指向另一个实例时,停止对应平台阶段并报告:
+72
View File
@@ -0,0 +1,72 @@
# orc
ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docker 意图映射成阶段,
按静态配置选择 `low`、`mid` 或 `high` worker,再交给对应 Skill。
## 什么时候使用
- 一个请求同时包含写代码、发版本和构建产物。
- 希望由 ORC 监督多个 Agent,并按阶段控制成本与推理能力。
- 需要继续中断的多阶段工程流程并保留依赖关系。
只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。
## 使用前准备
- Orca 正在运行并启用了 orchestration。
- 安装本次需要的 `$ack`、`$manage-release`、`$deb-publisher` 或
`$publish-docker-image`。
- ORC 直接使用 skill 内共享的 `config.yaml`,不需要在每个项目初始化配置。修改这份
配置会影响所有项目;旧的项目级 `docs/orc/config.yaml` 不再参与解析。
- `/usr/bin/python3`;ORC v2 配置必须保持为 JSON-compatible YAML。
resolver 以 `-I -S` 隔离模式运行,不加载项目模块、用户 site-packages 或第三方解析器。
- ORC v2 worker 使用 `workspace-write` sandbox,以便发送 Orca lifecycle 消息;
只读任务会在阶段 prompt 中禁止文件修改。
- release worker 是受信任的发布角色,直接调用当前 shell 中已认证的 `git` 与 Forge CLI
(Gitea/Forgejo 使用 `tea`);ORC 不读取认证配置,也不注入 credential helper。
- release 阶段要求 `origin` 只有一个且一致的 fetch/push URL。Codex 只为该阶段开启网络,
并用 network proxy 把出站目标限制到 remote/Forge API host;GitHub 额外允许其固定 API
与 release upload host,其它本地阶段默认继续禁网。
## Worker 档位
| 档位 | 典型任务 |
|------|----------|
| `low` | 输入明确的测试、构建、打包和上传 |
| `mid` | 常规版本发布与范围清晰的工程任务 |
| `high` | 需求理解、跨系统改动、异常恢复和高风险裁决 |
档位不是权限。三个档位仍受各自 profile 和下游 Skill 的授权边界约束。
这些档位只选择下游 worker,不会切换当前 ORC Coordinator 已经使用的模型。
结构化启动适配器支持 Codex 与 Cursor worker,并固定跟随当前宿主:从 Codex 调用就使用
Codex,从 Cursor 调用就使用 Cursor。共享配置为两个 CLI 分别维护三档模型,不设置跨宿主
默认值。
## 使用示例
```text
$orc high 修复登录问题,验证通过后发布新版本。
$orc code=high release=mid docker=low,完成修复、发版并推送镜像。
$orc low,检查并准备下一个版本,不执行远端写操作。
$orc mid 继续上次中断的 v1.4.0 发布流程。
```
阶段级档位优先于全局档位;未指定时只读取 `stageDefaults` / `defaultLevel`。ORC 不根据
任务复杂度动态升降档;worker 报告能力不足时,ORC 只转发请求。
## Agent 会做什么
1. 识别开发、源码发布、DEB 与 Docker 意图,并按固定表建立阶段依赖。
2. 从当前 Agent 的系统身份锁定宿主 CLI,再解析每个阶段的 profile,把共享配置快照、
可信 Agent CLI executable 和真实 Git
worktree 绑定成 launch fingerprint,并展示计划和外部写入边界。
3. 通过 Orca 分发给对应 Skill,转发完成消息、异常和决策门。
4. 按下游 Skill 声明的证据字段汇总状态和恢复入口,不重新做领域判断。
ORC 不分析实现、不建议版本、不评估发布风险、不选择合并或构建方案;这些工作全部属于
下游 Skill。
## 如何判断完成
最终结果会逐阶段列出所用 Skill、Agent 档位、源 revision、远端或产物状态,以及任何
未完成项。只有所有必要阶段都通过各自验证时,ORC 才会报告整个流程完成。
+179
View File
@@ -0,0 +1,179 @@
---
name: orc
description: >-
作为薄路由器显式识别开发、源码版本发布、DEB 和 Docker 意图,把阶段分发给对应
Skill,并按静态规则选择 low、mid、high 执行档位。仅在用户显式调用 $orc 或 /orc,
要求跨阶段协调、指定 Agent 级别、监督多个 worker 或继续 ORC 编排时使用。
---
# ORC 工程编排入口
当前会话担任薄路由器(Thin Coordinator):只识别阶段、宿主 CLI、静态档位、依赖和
授权边界,然后派发 worker、转发决策门并汇总结构化状态。ORC 不做领域判断,不替代
下游 Skill 执行、分析或复核其领域流程。
开始时解析当前 `SKILL.md` 所在目录,记为 `<orc-skill-dir>`;解析真实项目根目录,
优先使用 `git rev-parse --show-toplevel`。所有项目共享唯一配置
`<orc-skill-dir>/config.yaml`,不得在项目中创建 `docs/orc/config.yaml` 或其它配置副本。
## 选择模式
- 用户要求初始化 ORC:说明 ORC 已改为共享配置、不需要项目初始化,然后执行“检查”。
- 用户要求检查 ORC、CLI、档位或运行环境:执行“检查”。
- 用户明确要求修改 ORC 的共享模型或档位默认值:执行“修改共享配置”。
- 用户要求用 ORC 完成任务:执行“编排”。
配置或运行时无效时 fail closed,不退回裸命令或当前会话直接执行。
## 薄路由器边界
ORC 只执行以下机械步骤:
1. 把用户明确表达的意图映射到 `code`、`release`、`deb`、`docker`。
2. 从系统身份映射宿主 CLI,并按显式覆盖或共享配置解析档位。
3. 根据固定路由表建立任务依赖,生成满足契约的 worker prompt 并派发。
4. 转发 `decision_gate` / `escalation`,按下游 Skill 的完成证据汇总状态。
不得阅读项目实现来形成技术判断,不得决定版本号、实现方案、测试策略、发布风险、
合并方式或产物策略,不得代替 worker 执行命令。意图无法映射时报告范围外;缺少派发所需
的关键输入时建立 decision gate。领域问题原样交给对应下游 Skill,不由 ORC 推理补全。
## 共享配置
`<orc-skill-dir>/config.yaml` 是 ORC profile 的 SSOT,对所有项目生效。配置使用
JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含:
- `cliPolicy: current-host`:Codex 宿主只启动 Codex worker,Cursor 宿主只启动 Cursor worker。
- `defaultLevel` 与 `stageDefaults`:只用于下游 worker 的 `low`、`mid`、`high` 默认选择,
不表示 ORC Coordinator 自身的模型档位。
- `worktreePolicy: registered-same-repository`:允许当前仓库中已注册且身份一致的 worktree。
- `profiles.codex` 与 `profiles.cursor-agent`:两个 CLI 各自完整的三档 profile。
不要把项目路径、命令、argv、环境变量、secret、hook 或 shell 片段写入共享配置。
旧项目若残留 `docs/orc/config.yaml`,resolver 会忽略它;没有用户明确清理授权时不要删除。
## 修改共享配置
只有用户明确要求修改 ORC 的全局档位或模型时才编辑
`<orc-skill-dir>/config.yaml`。修改前说明它会影响所有项目;模型 ID 必须来自用户输入、
目标 CLI 的模型列表或其它可信配置,不能从任务文本猜测。修改后运行:
```bash
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py validate
```
报告两个 CLI 的三个档位、reasoning、权限策略、阶段默认值和宿主 CLI 策略。不要创建
项目配置。
## 检查
1. 运行共享配置校验,确认 `codex` 和 `cursor-agent` 都完整配置 `low`、`mid`、`high`。
2. 检查 `orca status --json`,并确认 orchestration 命令可用。
3. 从当前 Agent 的系统身份确定宿主 CLI:Codex 映射为 `codex`,Cursor 映射为
`cursor-agent`。身份不明确时停止,不从用户任务文本、默认值或已安装 executable 猜测。
确认宿主 CLI 可用;在 Cursor 中还要用 `cursor-agent --list-models`
核对精确模型 ID 对当前账号可见。
4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、
`publish-docker-image`。只检查实际会用到的项。
5. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标是
当前 Git 仓库已注册的 worktree 且身份稳定后才可创建终端。不得只做文本比较或跳过
机器校验。
6. 任何 profile、CLI、Skill、运行时或 worktree 不可用时 fail closed;不得切换其它 CLI、
相邻档位、复用身份不明的终端或手写替代流程。
## 编排
1. 读取 [routing.md](references/routing.md),把请求拆成 `code`、`release`、`deb`、
`docker` 阶段。没有匹配下游 Skill 的工作留在范围外并明确报告。
2. 原样提取用户明确给出的授权最远动作、目标版本、产物目标、源 commit/tag 与停止点;
未给出的领域参数保持未指定并交给下游 Skill。ORC 的调用本身不扩大 push、合并、
打 tag、上传或部署权限。
3. 从系统身份锁定当前宿主 CLI。运行在 Codex 时传 `--host-cli codex`;运行在 Cursor 时传
`--host-cli cursor-agent`。宿主 CLI 不是任务选项,即使用户文本点名另一个 CLI 也不得
跨宿主启动;身份不明确时 fail closed。
4. 解析档位:阶段级指定 > 全局指定 > `stageDefaults` > `defaultLevel`。只接受
`low`、`mid`、`high`;用户显式指定后不得静默升降级。ORC 不根据任务复杂度动态判断
档位;worker 报告能力不足时,只转发 escalation 或 decision gate。
5. 对每个阶段运行 profile resolver。宿主用必填的 `--host-cli`,全局档位用
`--global-level`,阶段
档位用 `--stage-level`;项目根与目标 worktree 必须使用规范绝对路径。Codex 默认复用
`codex-login`,Cursor 默认复用 `cursor-login`;只有明确使用环境凭据时才分别选择
`openai`、`azure-openai` 或 `cursor-api-key`。远端认证默认 `none`。release worker 作为
受信任发布角色,直接调用当前 shell 中已认证的 `git` 与 Forge CLI;Gitea/Forgejo 由
`$manage-release` 在目标 worktree 中运行 `tea`,resolver 不读取 tea 配置、不注入
credential helper,也不把 tea token 转成环境变量。只有明确改用环境 token 或 SSH
agent 时,才选择一个精确的 `github-token`、`gitlab-token`、`gitea-token`、
`forgejo-token`、`ssh-agent` 或 `deb-token`,不得同时交给 worker 多组环境凭据。resolver
不存在静默 fallback:
```bash
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py resolve \
--stage <stage> \
--project-root <absolute-project-root> \
--worktree <absolute-target-worktree> \
--host-cli <codex|cursor-agent> \
[--global-level <low|mid|high>] [--stage-level <low|mid|high>] \
[--model-auth <codex-login|openai|azure-openai|cursor-login|cursor-api-key>] \
[--remote-auth <exact-provider-or-transport>]
```
6. 核对 resolver 返回的 `launchFingerprint`、绝对 executable、worktree identity、CLI 与
档位选择来源,再读取 [orca-adapter.md](references/orca-adapter.md),把阶段组织为 Orca
task DAG。worker prompt 必须显式写出对应 `$skill`、阶段范围、输入 revision、用户授权
边界、验收证据和依赖结果;下游 Skill 无需知道 ORC。
7. 监督 `worker_done`、`escalation` 与 `decision_gate`。`worker_done` 只代表该 worker
回报完成;Coordinator 只核对下游 Skill 声明的证据字段和 DAG 后置条件,不重新进行
领域审查。
8. 逐阶段汇报所选 CLI、档位、执行 Skill、结果、外部状态和未完成项。任一阶段失败时
保留已成功阶段的准确状态,说明安全恢复入口,不把部分成功概括成全部完成。
## 固定路由边界
- 功能、缺陷、重构与验证闭环交给 `$ack`。
- 发布版本、release 分支/PR/MR、合并、tag 与 Forge Release 交给
`$manage-release`。
- DEB 构建或上传交给 `$deb-publisher`。
- Docker/OCI 镜像构建或上传交给 `$publish-docker-image`。
- 普通非发布 PR/MR 不伪装成版本发布;只有 ACK 已验证交付或明确的 release 流程才
进入对应下游能力。
## 依赖与安全边界
- 依赖始终单向:`orc -> 下游 Skill`。不得要求 ACK 或其它下游 Skill 引用 ORC、读取
ORC 配置或改变自身触发规则。
- ORC 档位只选择阶段 worker。进入 `$ack` 后,ACK 自己的 Coordinator、Developer、
Test 角色和 `standard/strong` 模型路由仍完全由 ACK 管理。
- ORC 的 `code` 阶段必须锁定停止点:纯开发停在 ACK `verified`;用户明确要求普通
PR/MR 时最多到 ACK `review_ready`。不得让 ACK 在同一阶段继续执行版本发布、DEB、
Docker 或部署;这些动作由 ORC 的独立阶段负责。
- ORC v2 的 `permissionMode` 固定为 `workspace-write`,因为受监督 worker 需要写入
Orca 运行时目录才能发送 lifecycle 消息。只读任务仍由 prompt 限制不得改文件。
- release 阶段必须绑定 `origin` 唯一且规范化后完全一致的 fetch/push URL,并把 remote
identity 写入 fingerprint;多个 URL、独立 `pushurl` 或不支持的 remote 直接停止。
- Codex release worker 固定使用 `on-request` 与 `auto_review`,开启
`sandbox_workspace_write.network_access=true`,并用 network proxy 只允许绑定的 remote
与确定性的 Forge API host。GitHub 固定追加 `api.github.com` 和 `uploads.github.com`;
Gitea、Forgejo 与 GitLab 默认只使用 remote host。其它本地阶段默认禁网;不得改用
`danger-full-access`。Cursor 继续使用自身的 `--auto-review --sandbox enabled`。
- 安全启动适配器只支持 `codex` 和 `cursor-agent`。Codex 使用结构化 sandbox 与 approval
参数;Cursor 使用 `--auto-review --sandbox enabled --workspace <path>`。不接受
full-access、bypass、`--force`、`--yolo` 或关闭 sandbox。
- `cliPolicy` 固定为 `current-host`,resolver 不提供默认 CLI;漏传宿主身份会直接失败,
不得从共享配置回退到 Codex 或 Cursor。
- Cursor 的 reasoning 档位编码在精确模型 ID 中,因此其 `reasoningEffort` 必须为 null;
Codex 则显式传递 `model_reasoning_effort`。
- resolver 只读取 skill 内有大小上限的普通共享配置文件,拒绝 symlink/special file;
启动计划绑定配置快照、root-owned 隔离 Python、可信绝对 Agent/Orca executable、Git
worktree identity、release remote identity、精确环境认证选择和固定 argv。实际启动会
重新校验 fingerprint;release 阶段允许受信任的 `$manage-release` 直接使用当前 shell
已有的 Git/Forge 登录,resolver 不检查或复制认证文件。显式 token/SSH 认证仍只注入所选
环境变量。不得改写 resolver 返回的 worker argv,也不得把终端创建 argv 的绝对 Orca
路径换成项目 `PATH` 解析。
- 不把模型档位当作权限。`high` 不自动获得更多文件、凭据、网络或远端写权限。
- 不执行 `orca orchestration reset`,除非用户明确要求放弃全部相关运行时状态。
## 完成标准
每个计划阶段都有明确下游 Skill、Agent CLI、档位、输入 revision、授权边界和可核对结果;
DAG 中所有必要阶段完成,或失败阶段具有准确状态与恢复入口。ACK 和其它下游 Skill
保持独立且不存在对 ORC 的反向引用。
+6
View File
@@ -0,0 +1,6 @@
interface:
display_name: "ORC"
short_description: "用 Codex 或 Cursor 分档编排开发、发布与产物任务"
default_prompt: "Use $orc to coordinate this engineering task on the current host CLI with a low, mid, or high worker level."
policy:
allow_implicit_invocation: false
+54
View File
@@ -0,0 +1,54 @@
{
"version": 2,
"cliPolicy": "current-host",
"defaultLevel": "mid",
"stageDefaults": {
"code": "high",
"release": "mid",
"deb": "low",
"docker": "low"
},
"worktreePolicy": "registered-same-repository",
"profiles": {
"codex": {
"low": {
"model": "gpt-5.6-luna",
"reasoningEffort": "low",
"permissionMode": "workspace-write",
"approvalPolicy": "never"
},
"mid": {
"model": "gpt-5.6-terra",
"reasoningEffort": "medium",
"permissionMode": "workspace-write",
"approvalPolicy": "never"
},
"high": {
"model": "gpt-5.6-sol",
"reasoningEffort": "high",
"permissionMode": "workspace-write",
"approvalPolicy": "never"
}
},
"cursor-agent": {
"low": {
"model": "auto",
"reasoningEffort": null,
"permissionMode": "workspace-write",
"approvalPolicy": "auto-review"
},
"mid": {
"model": "gpt-5.6-terra-medium",
"reasoningEffort": null,
"permissionMode": "workspace-write",
"approvalPolicy": "auto-review"
},
"high": {
"model": "gpt-5.6-sol-high",
"reasoningEffort": null,
"permissionMode": "workspace-write",
"approvalPolicy": "auto-review"
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
# ORC 的 Orca 适配
Orca 保存运行时任务、依赖、dispatch 和消息;ORC 保存稳定路由规则。不要在项目中
复制一份 Orca 运行时任务板。
## 运行前
```bash
orca status --json
orca orchestration task-list --json
orca orchestration inbox --limit 20 --json
```
确认 runtime 可达、orchestration 可用,并识别是否存在与当前请求相同的活跃任务。
恢复流程时复用身份匹配的 task;身份不明时不要猜测或清空全局状态。
## 解析安全启动计划
对每个阶段调用 `resolve_profile.py resolve`,同时传入规范绝对 `--project-root`、
`--worktree` 和从当前 Agent 系统身份确定的必填 `--host-cli`,以及本阶段精确的
`--model-auth`、`--remote-auth`。
resolver 始终读取 skill 内共享的 `config.yaml`,不接受项目配置路径。JSON 结果包含
选择来源、结构化 profile、认证选择、配置与 worktree identity、可信 Python/Agent
CLI/Orca executable、
`launchFingerprint`、固定 worker/launcher argv,以及完整的 `terminalCreateArgv` 和
`terminalCreateShellCommand`。
- 优先把返回的 `terminalCreateArgv` 原样交给 argv-capable 进程工具;若工具只接受 shell
字符串,则执行完整的 `terminalCreateShellCommand`。不得再插值、包引号或追加参数。
- `terminalCreateArgv` 的首项是 resolver 选定的绝对 Orca executable,不替换成裸
`orca` 或重新通过项目 `PATH` 查找。
- 不从 YAML 自行拼接命令,不追加 argv、环境变量、hook 或权限参数。
- `remote-auth` 只表示显式注入的单一环境 token 或 SSH agent;默认 `none`,不为方便同时
暴露多组环境凭据。release worker 直接使用当前 shell 已有的 Git/Forge CLI 登录,
resolver 不读取 tea/gh/glab 配置,也不注入 credential helper。
- release 计划要求 `origin` 恰好一个 fetch URL 和一个 push URL,二者规范化后完全一致;
remote identity 会进入 fingerprint,并在实际 launch 时重新解析。独立 `pushurl`、多个
push URL、local/file remote 或带内嵌 HTTPS 凭据的 URL 都 fail closed。
- Codex release 计划固定使用 `on-request` + `auto_review`,开启 workspace-write 网络并启用
network proxy,只允许绑定的 remote 与确定性 Forge API host。GitHub 固定追加
`api.github.com` 和 `uploads.github.com`;其它本地阶段保持默认禁网。不得替换成
`danger-full-access`。Cursor 继续使用自身的 auto-review sandbox。
- resolver 失败时停止;不改用默认模型、邻近档位或已有未知终端。
- 不从任务文本、可执行文件存在性或共享配置猜宿主;Codex 传 `--host-cli codex`,Cursor
传 `--host-cli cursor-agent`。宿主身份不明确时停止。
- 不重复手写 worktree 检查;resolver 已拒绝 root、不存在、非规范、含 symlink、未注册
或跨仓库的目标,并把身份写入 fingerprint。
- ORC v2 profile 必须使用 `workspace-write`。`read-only` sandbox 可能阻止 Orca CLI
写入自身运行时或挂载目录,导致 worker 无法发送 `worker_done`;只读工作通过 task
spec 限制,而不是换成无法完成 lifecycle 的 sandbox。
- Codex 与 Cursor 使用不同的固定 argv 适配器。Cursor 只允许 `--auto-review`、启用的
sandbox 和精确 workspace;不得把 Codex 参数套给 Cursor,也不得给 Cursor 追加
`--force` 或 `--yolo`。
## 创建与派发
每个需要 fresh worker 的阶段依次执行:
1. 使用解析结果创建目标 worktree 内的终端。下面整行代表 resolver 返回的一个完整值,
不是待替换的参数模板:
```bash
<resolver-terminalCreateShellCommand>
```
2. 使用返回的具体 handle 等待 Agent TUI 可接收输入:
```bash
orca terminal wait --terminal <handle> --for tui-idle \
--timeout-ms 60000 --json
```
3. 先为前置阶段创建 task,再用其 ID 构造依赖数组创建后置 task。task spec 使用
`routing.md` 的 worker prompt 契约。
4. 对已就绪 task 执行:
```bash
orca orchestration dispatch --task <task-id> --to <handle> --inject --json
```
不要把同一 task 同时派给多个写 worker。并行阶段必须使用不会竞争同一工作树写入的
独立 worker;否则串行执行。`terminal create` 超时或连接中断时状态不确定:先用
`terminalTitle`、worktree 和 fingerprint 核对 `orca terminal list --json`,没有完成
reconciliation 前不得重试创建。
## 监督循环
```bash
orca orchestration check --wait \
--types worker_done,escalation,decision_gate \
--timeout-ms 900000 --json
```
- timeout 或空结果是检查点,不等于失败;任务仍活跃时继续滚动等待。
- 收到 `decision_gate` 时只回答当前问题,不扩大用户授权。
- 收到 `escalation` 时保留已完成状态,判断是改档、补输入还是停止。
- 收到 `worker_done` 后核对 task/dispatch ID、下游完成证据和外部状态,再把依赖 task
标记完成并派发新就绪阶段。
- 三次连续同因失败后停止重派,报告恢复入口;不要用新终端掩盖同一阻塞。
## ACK 特例
`code` worker 是运行 `$ack` 的阶段 Coordinator。ORC 的 `low/mid/high` 只决定这个
worker 的启动 profile;ACK 启动 Developer/Test 时继续读取自己的项目配置与
`standard/strong` 规则。ORC 不读取、改写或覆盖 ACK 的内部 worker profiles。
## 收尾
不要自动关闭仍承担恢复入口的终端,不自动删除 worktree 或清空 runtime-global task。
只有用户明确要求清理时,才按 Orca 和下游 Skill 的安全规则处理。
+78
View File
@@ -0,0 +1,78 @@
# ORC 阶段路由
ORC 是薄路由器,只负责意图映射、依赖、静态档位和结构化状态汇总。它不判断版本号、
实现方案、发布风险或产物策略;领域步骤、授权检查和完成标准由下游 Skill 自己决定。
## 路由表
| 阶段 | 下游 Skill | 包含 | 不包含 |
|------|------------|------|--------|
| `code` | `$ack` | 功能、缺陷、重构、测试、三角色验证闭环,以及用户明确要求的普通非发布 PR/MR | 版本发布、单独上传产物 |
| `release` | `$manage-release` | 版本号、release worktree/分支、release PR/MR、合并、tag、Forge Release、恢复发布 | 普通非发布 PR/MR、构建或上传 DEB/Docker |
| `deb` | `$deb-publisher` | DEB 构建、校验、上传与仓库可见性 | 源码 tag、Docker 镜像 |
| `docker` | `$publish-docker-image` | Docker/OCI 构建、push、digest 与平台验证 | 源码版本生命周期、DEB |
没有匹配项时不要临时扩写某个 Skill 的职责,也不要让 ORC 自己模仿领域流程。报告缺少
的能力,由用户决定直接执行、安装新 Skill 或另行设计。
路由只依据用户明确表达的目标。缺少目标版本、revision、产物目标或授权停止点时,保留
为未指定并交给下游 Skill;只有缺少创建 task 所必需的项目或阶段身份时才建立
decision gate。不得为了填满 worker prompt 而分析代码、推断版本或设计执行方案。
## 拆分规则
1. 先从用户请求提取最终结果,再拆出真正需要的阶段;不要因为安装了某个 Skill 就
自动增加发布或上传。
2. 为每个阶段锁定输入:项目、worktree、源 commit/tag、版本、目标和用户授权的最远
写操作。
3. 同一领域的连续动作保留在一个下游任务中。例如版本号、release PR、合并和 tag
属于一个 `manage-release` 生命周期,不拆成多个互相争抢状态的 worker。
4. 只有输入 revision 完全相同且互不修改同一工作树时,才并行执行 DEB 与 Docker。
5. 普通代码改动进入 ACK。若项目尚未初始化 ACK,`code` 阶段停在前置条件,不由 ORC
静默初始化。
6. `code` 阶段默认停在 ACK `verified`;用户明确要求普通 PR/MR 时最多到
`review_ready`。worker prompt 必须禁止继续执行版本发布、DEB、Docker 或部署。
## 常见 DAG
完整交付:
```text
code ($ack)
-> release ($manage-release)
-> deb ($deb-publisher)
-> docker ($publish-docker-image)
```
只从当前 commit 构建产物:
```text
deb ($deb-publisher) || docker ($publish-docker-image)
```
仅发布源码版本:
```text
release ($manage-release)
```
依赖不是固定模板,但 ORC v2 不拆分一个 `manage-release` 生命周期。若项目要求在打开
release PR 与合并之间插入 DEB/Docker gate,当前 task 粒度无法安全表达该中间里程碑;
在打开 PR 前建立 decision gate 并报告该流程暂不支持,不得用循环依赖或两个 release
worker 临时拼接。
## Worker prompt 契约
每个 worker prompt 至少包含:
- 第一条指令显式调用唯一的下游 Skill,例如 `Use $manage-release ...`。
- 阶段目标与明确的非目标。
- 项目/worktree、输入 commit/tag 和前置阶段的可核对结果。
- 用户已经授予的最远动作;未授权动作明确禁止。
- 要求遵循项目 Agent 指令和下游 Skill 自身的停止条件。
- 完成证据,以及通过 live dispatch preamble 回报 `worker_done` 的要求。
- `code` prompt 还必须写明 ACK 停止点是 `verified` 或普通 PR 的 `review_ready`,并禁止
ACK 路由版本、DEB、Docker 或部署动作。
不要把 ORC 的 profile、路由器内部规则或其它下游 Skill 注入 worker。worker 只需要
当前阶段、对应 Skill 和必要依赖结果。
+1074
View File
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
from __future__ import annotations
import copy
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
VALIDATOR = SCRIPTS_DIR / "validate_delivery.py"
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "delivery.example.yaml"
sys.path.insert(0, str(SCRIPTS_DIR))
import validate_delivery # noqa: E402
def valid_contract() -> dict:
entrypoints = {
name: {
"kind": "make",
"target": name,
"args": [],
"requiredSecrets": [],
"workingDirectory": ".",
"timeoutSeconds": 300,
}
for name in ("verify", "build", "upload", "deploy", "health", "rollback")
}
return {
"version": 1,
"updatedAt": "2026-08-01T10:00:00+08:00",
"project": {"name": "demo"},
"enabled": True,
"defaultProfile": "review",
"entrypoints": entrypoints,
"artifacts": {
"service-deb": {
"type": "deb",
"build": "build",
"outputs": ["dist/*.deb"],
}
},
"destinations": {
"preview-apt": {
"type": "apt-repository",
"channel": "preview",
"endpoint": "https://packages.example.com",
"repository": "testing",
"upload": "upload",
}
},
"environments": {
"test-server": {
"type": "ssh-host",
"classification": "development",
"target": "test-server",
"deploy": "deploy",
"healthCheck": "health",
"rollback": "rollback",
"mutex": "test-server-deploy",
}
},
"profiles": {
"review": {
"stopAt": "review_ready",
"steps": [
{"id": "verify", "action": "verify", "entrypoint": "verify"},
{
"id": "open-pr",
"action": "pull-request",
"draft": True,
"remote": "origin",
"baseBranch": "main",
},
{"id": "build", "action": "build", "artifact": "service-deb"},
{
"id": "publish",
"action": "publish",
"artifact": "service-deb",
"destination": "preview-apt",
},
{
"id": "deploy",
"action": "deploy",
"artifact": "service-deb",
"environment": "test-server",
},
{
"id": "health",
"action": "health-check",
"environment": "test-server",
},
{"id": "ready", "action": "mark-ready"},
],
}
},
}
class AckDeliveryValidationTests(unittest.TestCase):
def test_example_is_valid_with_and_without_site_packages(self) -> None:
for no_site_packages in (False, True):
command = [sys.executable]
if no_site_packages:
command.append("-S")
result = subprocess.run(
[*command, str(VALIDATOR), str(EXAMPLE)],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
with self.subTest(no_site_packages=no_site_packages):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("交付契约校验通过", result.stdout)
def test_default_profile_rejects_stable_and_production_targets(self) -> None:
contract = valid_contract()
contract["destinations"]["preview-apt"]["channel"] = "stable"
contract["environments"]["test-server"]["classification"] = "production"
errors = validate_delivery.validate_builtin(contract)
self.assertTrue(any("stable 发布前必须有 release approval" in item for item in errors))
self.assertTrue(any("production 部署前必须有 production approval" in item for item in errors))
self.assertTrue(any("defaultProfile 不能发布 stable" in item for item in errors))
self.assertTrue(any("defaultProfile 不能部署 production" in item for item in errors))
def test_nondefault_release_profile_supports_stable_and_production_with_gates(self) -> None:
contract = valid_contract()
contract["destinations"]["stable-apt"] = {
"type": "apt-repository",
"channel": "stable",
"endpoint": "https://packages.example.com",
"repository": "stable",
"upload": "upload",
}
contract["environments"]["prod-server"] = {
"type": "ssh-host",
"classification": "production",
"target": "prod-server",
"deploy": "deploy",
"healthCheck": "health",
"rollback": "rollback",
"mutex": "prod-server-deploy",
}
contract["profiles"]["release"] = {
"stopAt": "released",
"steps": [
{"id": "verify-release", "action": "verify", "entrypoint": "verify"},
{
"id": "open-release-pr",
"action": "pull-request",
"draft": True,
"remote": "origin",
"baseBranch": "main",
},
{"id": "build-release", "action": "build", "artifact": "service-deb"},
{"id": "approve-release", "action": "approval", "gate": "release"},
{
"id": "publish-release",
"action": "publish",
"artifact": "service-deb",
"destination": "stable-apt",
},
{"id": "approve-production", "action": "approval", "gate": "production"},
{
"id": "deploy-production",
"action": "deploy",
"artifact": "service-deb",
"environment": "prod-server",
},
{
"id": "health-production",
"action": "health-check",
"environment": "prod-server",
},
{"id": "ready-release", "action": "mark-ready"},
],
}
self.assertEqual(validate_delivery.validate_builtin(contract), [])
def test_publish_and_health_check_require_prior_steps(self) -> None:
contract = valid_contract()
steps = contract["profiles"]["review"]["steps"]
steps[2], steps[3] = steps[3], steps[2]
steps[4], steps[5] = steps[5], steps[4]
errors = validate_delivery.validate_builtin(contract)
self.assertTrue(any("publish 前必须先 build" in item for item in errors))
self.assertTrue(any("health-check 前必须先 deploy" in item for item in errors))
def test_pull_request_requires_explicit_safe_remote_and_base(self) -> None:
contract = valid_contract()
step = contract["profiles"]["review"]["steps"][1]
del step["remote"]
step["baseBranch"] = "../main"
errors = validate_delivery.validate_builtin(contract)
self.assertTrue(any(".remote: action='pull-request' 时必填" in item for item in errors))
self.assertTrue(any(".remote: 必须是安全的 Git remote 名称" in item for item in errors))
self.assertTrue(any(".baseBranch: 必须是安全的 Git 分支名" in item for item in errors))
def test_unknown_shell_and_inline_secret_are_rejected(self) -> None:
contract = valid_contract()
contract["entrypoints"]["verify"]["shell"] = "make verify"
contract["entrypoints"]["verify"]["requiredSecrets"] = ["token-value"]
contract["destinations"]["preview-apt"]["repository"] = (
"token=abcdefghijklmnop"
)
contract["destinations"]["preview-apt"]["registry"] = "unexpected.example"
errors = validate_delivery.validate_builtin(contract)
self.assertTrue(any("未知字段 'shell'" in item for item in errors))
self.assertTrue(any("requiredSecrets" in item for item in errors))
self.assertTrue(any("type='apt-repository' 不允许此字段" in item for item in errors))
self.assertTrue(any("疑似包含敏感信息" in item for item in errors))
def test_project_script_must_be_executable_and_not_a_symlink(self) -> None:
contract = valid_contract()
contract["entrypoints"]["verify"] = {
"kind": "script",
"path": "scripts/verify.sh",
"args": [],
"requiredSecrets": [],
"workingDirectory": ".",
"timeoutSeconds": 300,
}
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
scripts = root / "scripts"
scripts.mkdir()
target = scripts / "target.sh"
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
target.chmod(0o755)
os.symlink("target.sh", scripts / "verify.sh")
errors = validate_delivery.validate_builtin(contract, root)
self.assertTrue(any("路径不能包含 symlink" in item for item in errors))
(scripts / "verify.sh").unlink()
plain = scripts / "verify.sh"
plain.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
plain.chmod(0o644)
errors = validate_delivery.validate_builtin(contract, root)
self.assertTrue(any("脚本不可执行" in item for item in errors))
def test_tasks_link_requires_fixed_path_runs_and_same_project(self) -> None:
contract = valid_contract()
tasks = {
"project": {"name": "other", "deliveryFile": "delivery.yaml"},
"tasks": [],
}
errors = validate_delivery.validate_tasks_link(contract, tasks)
self.assertIn(
"tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml",
errors,
)
self.assertIn("delivery.project.name 必须与 tasks.project.name 一致", errors)
self.assertIn("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表", errors)
def test_disabled_empty_contract_remains_valid(self) -> None:
contract = copy.deepcopy(valid_contract())
contract.update(
{
"enabled": False,
"defaultProfile": None,
"entrypoints": {},
"artifacts": {},
"destinations": {},
"environments": {},
"profiles": {},
}
)
self.assertEqual(validate_delivery.validate_builtin(contract), [])
if __name__ == "__main__":
unittest.main()
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
import contextlib
import io
from unittest import mock
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
READER = ROOT / "skills/ack/scripts/feishu_bug_intake.py"
sys.path.insert(0, str(READER.parent))
import feishu_bug_intake # noqa: E402
class FeishuIntakeBlackBox(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="feishu-e2e-")
self.d = Path(self.tmp.name)
self.bin = self.d / "bin"
self.bin.mkdir()
self.fixture = self.d / "submitted-bug.json"
self.fixture.write_text(json.dumps({
"recordId": "recBug1",
"title": "Crash on save", "actual": "button crashes",
"expected": "save succeeds", "steps": ["open app", "click Save"],
"acceptance": "regression covered", "priority": "P1",
"attachments": [{"file_token": "fileTok", "name": "screen.png", "type": "image/png", "size": 4}],
"updatedAt": "2026-08-01T12:00:00Z",
}), encoding="utf-8")
self.mode_file = self.d / "mode.txt"
self.mode_file.write_text("", encoding="utf-8")
self.log = self.d / "argv.jsonl"
self.fake = self.bin / "lark-cli"
self.fake.write_text(
"#!" + sys.executable + "\n"
"import json, os, pathlib, sys\n"
f"fixture=json.loads(pathlib.Path({str(self.fixture)!r}).read_text())\n"
f"mode=pathlib.Path({str(self.mode_file)!r}).read_text().strip()\n"
f"log=pathlib.Path({str(self.log)!r}); a=sys.argv[1:]\n"
"with log.open('a') as f: f.write(json.dumps(a)+'\\n')\n"
"p=a[a.index('--profile')+1] if '--profile' in a else None\n"
"if a[:2]==['profile','list']:\n"
" if '--format' in a: raise SystemExit(8)\n"
" print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}])); raise SystemExit\n"
"if p != 'tenant-b': print('wrong tenant',file=sys.stderr); raise SystemExit(9)\n"
"if mode=='malformed': print('{bad'); raise SystemExit\n"
"if a[:2]==['base','+record-list']:\n"
" if mode=='okfalse': print(json.dumps({'ok':False})); raise SystemExit\n"
" if mode=='code': print(json.dumps({'code':7})); raise SystemExit\n"
" if mode=='ambiguous': print(json.dumps({'data':{}})); raise SystemExit\n"
" if mode=='noprog': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[],'data':[],'has_more':True}})); raise SystemExit\n"
" if mode=='max': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['x']*8],'has_more':True}})); raise SystemExit\n"
" if mode=='matrix': print(json.dumps({'code':0,'data':{'fields':['fTitle'],'record_id_list':['recBug1'],'records':[]}})); raise SystemExit\n"
" if mode=='cell': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[[{'unexpected':'value'},'actual','expected','steps','accept','P1',[],f['updatedAt']]]}})); raise SystemExit\n"
" if mode=='empty': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title',None,'expected','steps','accept','P1',[],fixture['updatedAt']]]}})); raise SystemExit\n"
" if mode=='enrichment': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBug1'],'data':[['title','actual','expected',None,None,None,[],fixture['updatedAt']]]}})); raise SystemExit\n"
" if mode=='blank': print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':['recBlank'],'data':[[None,None,None,None,None,None,[],fixture['updatedAt']]]}})); raise SystemExit\n"
" f=fixture; print(json.dumps({'ok':True,'data':{'fields':['fTitle','fActual','fExpected','fSteps','fAcceptance','fPriority','fAttachments','fUpdated'],'record_id_list':[f['recordId']],'data':[[f['title'],f['actual'],f['expected'],f['steps'],f['acceptance'],f['priority'],f['attachments'],f['updatedAt']]]}})); raise SystemExit\n"
"if a[:2]==['base','+record-download-attachment']:\n"
" out=pathlib.Path(a[a.index('--output')+1]); out.mkdir(parents=True,exist_ok=True)\n"
" if mode=='escape': (out/'escape').symlink_to('/tmp'); raise SystemExit\n"
" (out/'screen.png').write_bytes(b'fake'); print(json.dumps({'ok':True})); raise SystemExit\n"
"raise SystemExit(2)\n", encoding="utf-8")
self.fake.chmod(0o755)
self.env = {**os.environ, "PATH": f"{self.bin}{os.pathsep}{os.environ.get('PATH','')}"}
self.env["FAKE_FIXTURE"] = str(self.fixture)
self.board = self.d / "tasks.yaml"
self.board.write_text("""version: 1
project:
name: isolated-fake
bugIntake:
provider: feishu-base
profile: tenant-b
baseToken: base-secret
tableId: tbl-bugs
viewId: view-ready
fields:
title: fTitle
actual: fActual
expected: fExpected
stepsToReproduce: fSteps
acceptance: fAcceptance
priority: fPriority
attachments: fAttachments
updatedAt: fUpdated
tasks: []
""", encoding="utf-8")
def tearDown(self) -> None:
self.tmp.cleanup()
def invoke(self, command: str, *, mode: str | None = None, output: Path | None = None, resolver=None):
env = dict(self.env)
self.mode_file.write_text(mode or "", encoding="utf-8")
args = [command, str(self.board)]
if output:
args += ["--output-dir", str(output)]
stdout, stderr = io.StringIO(), io.StringIO()
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=self.fake) if resolver is None else mock.patch.object(feishu_bug_intake, "resolve_lark_cli", side_effect=resolver), \
mock.patch.dict(os.environ, env, clear=True), \
contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
try:
code = feishu_bug_intake.main(args)
except SystemExit as exc:
code = int(exc.code or 0)
return subprocess.CompletedProcess(args, code, stdout.getvalue(), stderr.getvalue())
def test_isolated_tenant_check_fetch_and_triage_contract(self) -> None:
self.assertEqual(self.invoke("check").returncode, 0)
downloads = self.d / "downloads"
result = self.invoke("fetch", output=downloads)
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
record = payload["records"][0]
self.assertEqual(record["recordId"], "recBug1")
self.assertEqual(record["priority"], "P1")
self.assertEqual(record["updatedAt"], "2026-08-01T12:00:00Z")
self.assertIn("Crash on save", record["title"])
self.assertTrue(Path(record["attachments"][0]["localPath"]).is_relative_to(downloads))
self.assertRegex(record["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$")
for secret in ("tenant-b", "base-secret", "tbl-bugs", "recBug1", "fileTok"):
self.assertNotIn(secret, record["sourceRef"])
for secret in ("base-secret", "fileTok", "app-secret-sentinel"):
self.assertNotIn(secret, result.stdout)
second = self.invoke("fetch", output=self.d / "repeat")
self.assertEqual(second.returncode, 0, second.stderr)
second_record = json.loads(second.stdout)["records"][0]
self.assertRegex(second_record["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$")
self.assertEqual(record["sourceRef"], second_record["sourceRef"])
for secret in ("tenant-b", "base-secret", "tbl-bugs", "recBug1", "fileTok"):
self.assertNotIn(secret, second_record["sourceRef"])
for secret in ("base-secret", "fileTok", "app-secret-sentinel"):
self.assertNotIn(secret, second.stdout)
calls = [json.loads(x) for x in self.log.read_text().splitlines()]
for call in calls:
if call[:1] == ["base"]:
self.assertEqual(call[call.index("--profile") + 1], "tenant-b")
self.assertNotIn("tenant-a", call)
if call[:2] == ["base", "+record-download-attachment"]:
self.assertFalse(Path(call[call.index("--output") + 1]).is_absolute())
self.assertEqual(sum(1 for c in calls if c[:2] == ["base", "+record-list"]), 2)
list_call = next(c for c in reversed(calls) if c[:2] == ["base", "+record-list"])
self.assertEqual(list_call[list_call.index("--view-id") + 1], "view-ready")
self.assertEqual(list_call[:2], ["base", "+record-list"])
self.assertEqual(list_call.count("--field-id"), 8)
def test_fail_closed_wrong_profile_and_unsafe_response(self) -> None:
bad = self.board.read_text().replace("profile: tenant-b", "profile: tenant-a")
self.board.write_text(bad)
result = self.invoke("fetch")
self.assertNotEqual(result.returncode, 0)
self.board.write_text(bad.replace("profile: tenant-a", "profile: tenant-b"))
result = self.invoke("fetch", mode="malformed")
self.assertNotEqual(result.returncode, 0)
result = self.invoke("fetch", mode="matrix", output=self.d / "matrix-output")
self.assertNotEqual(result.returncode, 0)
result = self.invoke("fetch", mode="cell", output=self.d / "cell-output")
self.assertNotEqual(result.returncode, 0)
result = self.invoke("fetch", mode="empty", output=self.d / "empty-output")
self.assertNotEqual(result.returncode, 0)
result = self.invoke("fetch", mode="escape", output=self.d / "downloads")
self.assertNotEqual(result.returncode, 0)
def test_missing_coordinator_fields_are_planned_for_enrichment(self) -> None:
fetched = self.invoke("fetch", mode="enrichment")
self.assertEqual(fetched.returncode, 0, fetched.stderr)
payload = json.loads(fetched.stdout)
self.assertEqual(payload["warnings"], [])
self.assertEqual(payload["records"][0]["enrichmentRequired"], [
"steps", "acceptance", "priority",
])
planned = self.invoke("plan", mode="enrichment")
self.assertEqual(planned.returncode, 0, planned.stderr)
action = json.loads(planned.stdout)["actions"][0]
self.assertEqual(action["action"], "create")
self.assertEqual(action["enrichmentRequired"], [
"steps", "acceptance", "priority",
])
def test_fully_blank_rows_are_skipped_with_a_batch_warning(self) -> None:
result = self.invoke("fetch", mode="blank")
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertEqual(payload["records"], [])
self.assertEqual(payload["warnings"], [{
"recordId": "recBlank",
"code": "blank_record_skipped",
}])
def test_resolver_trust_and_no_executable_override_surface(self) -> None:
trusted, hostile = self.d / "trusted", self.d / "hostile"
trusted.mkdir(); hostile.mkdir()
safe = trusted / "lark-cli"
safe.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8"); safe.chmod(0o755)
(hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
(hostile / "lark-cli").chmod(0o755)
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]), mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False):
self.assertEqual(feishu_bug_intake.resolve_lark_cli(), safe)
safe.chmod(0o775)
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.resolve_lark_cli()
help_result = self.invoke("--help")
self.assertEqual(help_result.returncode, 0)
self.assertNotIn("--lark-cli", help_result.stdout + help_result.stderr)
self.assertNotIn("--executable", help_result.stdout + help_result.stderr)
self.assertNotIn("executable_override", READER.read_text(encoding="utf-8"))
def test_fail_closed_envelopes_pagination_and_missing_trusted_cli(self) -> None:
for mode in ("okfalse", "code", "ambiguous", "noprog"):
result = self.invoke("fetch", mode=mode)
self.assertNotEqual(result.returncode, 0, mode)
self.assertEqual(result.stdout, "", mode)
with mock.patch.object(feishu_bug_intake, "MAX_PAGES", 2):
result = self.invoke("fetch", mode="max")
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", side_effect=feishu_bug_intake.IntakeError("missing trusted")):
result = self.invoke("fetch", resolver=feishu_bug_intake.IntakeError("missing trusted"))
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
def test_reference_template_example_contract_signals(self) -> None:
paths = [ROOT / "skills/ack/references/feishu-bug-intake.md", ROOT / "skills/ack/templates/tasks.template.yaml", ROOT / "skills/ack/examples/tasks.example.yaml"]
content = "\n".join(path.read_text(encoding="utf-8") for path in paths)
self.assertIn("feishu-base:sha256:", content)
self.assertIn('npm install --global --prefix "$HOME/.local" @larksuite/cli@latest', content)
self.assertIn("profile add", content)
self.assertIn("--brand feishu", content)
self.assertTrue("scope" in content)
self.assertIn("不要把 `lark-cli auth check`", content)
self.assertIn("审核通过前的唯一协作区", content)
self.assertIn("不创建或刷新 `tasks.yaml` 中的 ACK 任务", content)
self.assertIn("base:record:write", content)
self.assertIn("fixLogic", content)
self.assertNotIn("--lark-cli", content)
self.assertNotIn("--executable", content)
def test_plan_organizes_stable_sources_against_a_real_task_board(self) -> None:
first = json.loads(self.invoke("fetch").stdout)["records"][0]["sourceRef"]
second = json.loads(self.invoke("fetch").stdout)["records"][0]["sourceRef"]
self.assertRegex(first, r"^feishu-base:sha256:[0-9a-f]{64}$")
self.assertEqual(first, second)
empty_plan = json.loads(self.invoke("plan").stdout)
self.assertEqual(empty_plan["actions"], [{
"sourceRef": first,
"recordId": "recBug1",
"draftRevision": empty_plan["records"][0]["draftRevision"],
"action": "create",
}])
self.assertRegex(
empty_plan["actions"][0]["draftRevision"], r"^sha256:[0-9a-f]{64}$",
)
base = self.board.read_text(encoding="utf-8")
def write_existing(status: str, updated_at: str, *, duplicate: bool = False) -> None:
task = f"""tasks:
- id: BUG-1
title: Existing imported bug
status: {status}
source:
kind: feishu-base
ref: "{first}"
recordId: recBug1
updatedAt: "{updated_at}"
"""
if duplicate:
task += f""" - id: BUG-2
title: Duplicate imported bug
status: open
source:
kind: feishu-base
ref: "{first}"
recordId: recBug1
updatedAt: "{updated_at}"
"""
self.board.write_text(base.replace("tasks: []\n", task), encoding="utf-8")
write_existing("open", "2026-07-31T12:00:00Z")
validator = ROOT / "skills/ack/scripts/validate_tasks.py"
validated = subprocess.run(
[sys.executable, str(validator), str(self.board)],
text=True, capture_output=True, check=False,
)
self.assertEqual(validated.returncode, 0, validated.stderr)
self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "refresh")
write_existing("verified", "2026-07-31T12:00:00Z")
self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "drift")
write_existing("verified", "2026-08-01T12:00:00Z")
self.assertEqual(json.loads(self.invoke("plan").stdout)["actions"][0]["action"], "unchanged")
write_existing("open", "2026-07-31T12:00:00Z", duplicate=True)
duplicate_result = self.invoke("plan")
self.assertNotEqual(duplicate_result.returncode, 0)
self.assertEqual(duplicate_result.stdout, "")
if __name__ == "__main__":
unittest.main()
+396
View File
@@ -0,0 +1,396 @@
from __future__ import annotations
import contextlib
import io
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
ACK_SCRIPTS = REPO_ROOT / "skills" / "ack" / "scripts"
sys.path.insert(0, str(ACK_SCRIPTS))
import feishu_bug_intake # noqa: E402
BOARD = """version: 1
project:
name: demo
bugIntake:
provider: feishu-base
profile: tenant-b
baseToken: bascnDemo
tableId: tblDemo
viewId: vewReady
fields:
title: fldTitle
actual: fldActual
expected: fldExpected
stepsToReproduce: fldSteps
acceptance: fldAcceptance
priority: fldPriority
attachments: fldAttachments
updatedAt: fldUpdated
tasks: []
"""
class FeishuBugIntakeUnitTests(unittest.TestCase):
def make_fake_cli(self, root: Path) -> tuple[Path, Path]:
log_path = root / "calls.jsonl"
fake = root / "lark-cli"
fake.write_text(
"#!" + sys.executable + "\n"
"import json, pathlib, sys\n"
f"log = pathlib.Path({str(log_path)!r})\n"
"args = sys.argv[1:]\n"
"with log.open('a') as f: f.write(json.dumps(args) + '\\n')\n"
"if args[:2] == ['profile', 'list']:\n"
" if '--format' in args: raise SystemExit(8)\n"
" print(json.dumps([{'name':'tenant-a','appId':'cli_a','brand':'feishu','active':True},{'name':'tenant-b','appId':'cli_b','brand':'feishu','active':False}]))\n"
"elif args[:2] == ['base', '+record-list']:\n"
" fields=['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated']; row=[' Bug\\n title ',' actual ','expected',['one','two'],'1. save succeeds\\n2. the crash no longer occurs','P1',[{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}],'2026-08-01']\n"
" if 'fldFixLogic' in args: fields.append('fldFixLogic'); row.append('change parser without widening input')\n"
" print(json.dumps({'code': 0, 'data': {'fields': fields, 'record_id_list': ['recA'], 'data': [row]}}))\n"
"elif args[:2] == ['base', '+record-download-attachment']:\n"
" out = pathlib.Path(args[args.index('--output') + 1]); out.mkdir(parents=True, exist_ok=True); (out / 'shot.png').write_bytes(b'png'); print(json.dumps({'ok': True, 'data': {}}))\n"
"elif args[:2] == ['base', '+record-upsert']:\n"
" print(json.dumps({'code': 0, 'data': {}}))\n"
"else: raise SystemExit(2)\n",
encoding="utf-8",
)
fake.chmod(0o755)
return fake, log_path
def invoke(self, argv: list[str], executable: Path) -> tuple[int, str, str]:
stdout, stderr = io.StringIO(), io.StringIO()
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=executable), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
code = feishu_bug_intake.main(argv)
return code, stdout.getvalue(), stderr.getvalue()
def test_reader_exposes_check_and_fetch_commands(self) -> None:
with self.assertRaises(SystemExit) as exited, contextlib.redirect_stdout(io.StringIO()):
feishu_bug_intake.main(["--help"])
self.assertEqual(exited.exception.code, 0)
def test_fetch_uses_mocked_trusted_executable_and_official_wire_shapes(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
board_path = temp / "tasks.yaml"
board_path.write_text(BOARD, encoding="utf-8")
fake, log_path = self.make_fake_cli(temp)
hostile = temp / "hostile"
hostile.mkdir()
(hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
(hostile / "lark-cli").chmod(0o755)
with mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False):
code, output, error = self.invoke(["fetch", str(board_path), "--output-dir", str(temp / "downloads")], fake)
self.assertEqual(code, 0, error)
payload = json.loads(output)
self.assertEqual(payload["records"][0]["title"], "Bug title")
self.assertEqual(payload["records"][0]["steps"], "one\ntwo")
self.assertRegex(payload["records"][0]["sourceRef"], r"^feishu-base:sha256:[0-9a-f]{64}$")
self.assertNotIn("bascnDemo", output)
self.assertNotIn("fileA", output)
self.assertNotIn("token", payload["records"][0]["attachments"][0])
self.assertTrue(Path(payload["records"][0]["attachments"][0]["localPath"]).is_file())
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(calls[0], ["profile", "list"])
for call in calls:
if call[:1] == ["base"]:
self.assertEqual(call[call.index("--profile") + 1], "tenant-b")
def test_resolver_ignores_hostile_path_and_rejects_unsafe_target(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
trusted, hostile = temp / "trusted", temp / "hostile"
trusted.mkdir()
hostile.mkdir()
safe = trusted / "lark-cli"
safe.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
safe.chmod(0o755)
(hostile / "lark-cli").write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
(hostile / "lark-cli").chmod(0o755)
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]), mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=False):
self.assertEqual(feishu_bug_intake.resolve_lark_cli(), safe)
safe.chmod(0o775)
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
with self.assertRaisesRegex(feishu_bug_intake.IntakeError, "trusted"):
feishu_bug_intake.resolve_lark_cli()
def test_resolver_accepts_only_the_official_npm_wrapper_shape(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
trusted = temp / "trusted"
scripts = temp / "node_modules" / "@larksuite" / "cli" / "scripts"
trusted.mkdir()
scripts.mkdir(parents=True)
wrapper = scripts / "run.js"
wrapper.write_text(
"#!" + sys.executable + "\nimport json\nprint(json.dumps({'ok': True}))\n",
encoding="utf-8",
)
wrapper.chmod(0o755)
manifest = scripts.parent / "package.json"
manifest.write_text(json.dumps({
"name": "@larksuite/cli",
"bin": {"lark-cli": "scripts/run.js"},
}), encoding="utf-8")
native = scripts.parent / "bin" / "lark-cli"
native.parent.mkdir()
native.write_text(
"#!" + sys.executable + "\nimport json\nprint(json.dumps({'ok': True}))\n",
encoding="utf-8",
)
native.chmod(0o755)
(trusted / "lark-cli").symlink_to(wrapper)
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
self.assertEqual(feishu_bug_intake.resolve_lark_cli(), native)
self.assertEqual(feishu_bug_intake.run_cli(["probe"]), {"ok": True})
manifest.write_text(json.dumps({
"name": "lookalike",
"bin": {"lark-cli": "scripts/run.js"},
}), encoding="utf-8")
with mock.patch.object(feishu_bug_intake, "trusted_lark_cli_dirs", return_value=[trusted]):
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.resolve_lark_cli()
def test_cli_environment_drops_credential_and_runtime_overrides(self) -> None:
hostile = {
"LARKSUITE_CLI_APP_ID": "wrong-app",
"LARKSUITE_CLI_APP_SECRET": "wrong-secret",
"LARKSUITE_CLI_CONFIG_DIR": "/tmp/wrong-config",
"LARKSUITE_CLI_BRAND": "lark",
"FEISHU_APP_SECRET": "wrong-feishu-secret",
"NODE_OPTIONS": "--require=/tmp/inject.js",
"PYTHONPATH": "/tmp/inject",
}
with mock.patch.dict(os.environ, hostile, clear=False):
environment = feishu_bug_intake.cli_environment()
with tempfile.TemporaryDirectory() as temp_dir:
fake = Path(temp_dir) / "lark-cli"
fake.write_text(
"#!" + sys.executable + "\n"
"import json, os\n"
"keys = ['LARKSUITE_CLI_APP_ID','LARKSUITE_CLI_APP_SECRET','LARKSUITE_CLI_CONFIG_DIR','LARKSUITE_CLI_BRAND','FEISHU_APP_SECRET','NODE_OPTIONS','PYTHONPATH']\n"
"print(json.dumps({'ok': True, 'data': {key: os.environ.get(key) for key in keys}}))\n",
encoding="utf-8",
)
fake.chmod(0o755)
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=fake):
child = feishu_bug_intake.run_cli(["probe"])
self.assertEqual(set(environment) - {"HOME", "PATH"}, set(environment) & {"LANG", "LC_ALL", "LC_CTYPE"})
for name in hostile:
self.assertNotIn(name, environment)
self.assertIsNone(child["data"][name])
def test_run_cli_rejects_error_and_ambiguous_envelopes(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
fake = temp / "lark-cli"
fake.write_text(
"#!" + sys.executable + "\nimport json, sys\nprint(sys.argv[1])\n",
encoding="utf-8",
)
fake.chmod(0o755)
with mock.patch.object(feishu_bug_intake, "resolve_lark_cli", return_value=fake):
for response in ('{"ok":false}', '{"code":7}', '{"data":{}}'):
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.run_cli([response])
def test_matrix_accepts_official_ok_envelope_and_data_rows(self) -> None:
fields = ["fldTitle"]
response = {
"ok": True,
"data": {"fields": fields, "record_id_list": ["recA"], "data": [["Bug"]]},
}
self.assertEqual(feishu_bug_intake.matrix_from_response(response, fields), (["recA"], [["Bug"]]))
def test_optional_fix_logic_field_is_normalized_for_preapproval_review(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
board_path = Path(temp_dir) / "tasks.yaml"
board_path.write_text(
BOARD.replace(
" profile: tenant-b\n",
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
).replace(
" acceptance: fldAcceptance\n",
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
),
encoding="utf-8",
)
config = feishu_bug_intake.config_from_board(
feishu_bug_intake.load_board(board_path)
)
row = [
"Bug", "actual", "expected", "steps", "acceptance", "P1", [],
"2026-08-01T12:00:00Z", "change parser without widening input",
]
with mock.patch.object(feishu_bug_intake, "profile_check"), mock.patch.object(
feishu_bug_intake, "fetch_pages", return_value=[("recA", row)]
):
payload = feishu_bug_intake.fetch(config, None)
record = payload["records"][0]
self.assertEqual(record["fixLogic"], "change parser without widening input")
self.assertNotIn("fixLogic", record["enrichmentRequired"])
def test_write_draft_uses_trusted_cli_and_only_configured_review_fields(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
board_path = temp / "tasks.yaml"
board_path.write_text(
BOARD.replace(
" profile: tenant-b\n",
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
).replace(
" acceptance: fldAcceptance\n",
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
),
encoding="utf-8",
)
draft_path = temp / "draft.json"
draft_path.write_text(json.dumps({
"fixLogic": "change parser without widening input",
"acceptance": ["save succeeds", "the crash no longer occurs"],
}), encoding="utf-8")
fake, log_path = self.make_fake_cli(temp)
fetched_code, fetched_output, fetched_error = self.invoke([
"fetch", str(board_path),
], fake)
self.assertEqual(fetched_code, 0, fetched_error)
fetched_record = json.loads(fetched_output)["records"][0]
code, output, error = self.invoke([
"write-draft", str(board_path), "--record-id", "recA",
"--expected-source-ref", fetched_record["sourceRef"],
"--expected-draft-revision", fetched_record["draftRevision"],
"--input", str(draft_path),
], fake)
self.assertEqual(code, 0, error)
self.assertEqual(json.loads(output)["written"], ["fixLogic", "acceptance"])
self.assertRegex(json.loads(output)["draftRevision"], r"^sha256:[0-9a-f]{64}$")
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
write_call = next(call for call in calls if call[:2] == ["base", "+record-upsert"])
self.assertEqual(write_call[write_call.index("--profile") + 1], "tenant-b")
patch = json.loads(write_call[write_call.index("--json") + 1])
self.assertEqual(set(patch), {"fldFixLogic", "fldAcceptance"})
self.assertIn("1. save succeeds", patch["fldAcceptance"])
imported_code, imported_output, imported_error = self.invoke([
"import-approved", str(board_path), "--record-id", "recA",
"--expected-source-ref", fetched_record["sourceRef"],
"--expected-draft-revision", fetched_record["draftRevision"],
], fake)
self.assertEqual(imported_code, 0, imported_error)
task_draft = json.loads(imported_output)["taskDraft"]
self.assertEqual(task_draft["source"]["workflow"], "reviewed-writeback-v1")
self.assertEqual(
task_draft["acceptanceCriteria"],
["save succeeds", "the crash no longer occurs"],
)
self.assertRegex(
task_draft["source"]["approvedPayloadHash"], r"^sha256:[0-9a-f]{64}$",
)
def test_write_draft_rejects_stale_revision_before_mutation(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
board_path = temp / "tasks.yaml"
board_path.write_text(
BOARD.replace(
" profile: tenant-b\n",
" workflow: reviewed-writeback-v1\n profile: tenant-b\n",
).replace(
" acceptance: fldAcceptance\n",
" acceptance: fldAcceptance\n fixLogic: fldFixLogic\n",
),
encoding="utf-8",
)
draft_path = temp / "draft.json"
draft_path.write_text(json.dumps({
"fixLogic": "fix",
"acceptance": ["pass"],
}), encoding="utf-8")
fake, log_path = self.make_fake_cli(temp)
fetched = json.loads(self.invoke(["fetch", str(board_path)], fake)[1])["records"][0]
code, _, _ = self.invoke([
"write-draft", str(board_path), "--record-id", "recA",
"--expected-source-ref", fetched["sourceRef"],
"--expected-draft-revision", "sha256:" + "0" * 64,
"--input", str(draft_path),
], fake)
self.assertNotEqual(code, 0)
calls = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()]
self.assertFalse(any(call[:2] == ["base", "+record-upsert"] for call in calls))
def test_write_draft_rejects_symlink_and_unknown_fields(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
target = temp / "draft.json"
target.write_text(json.dumps({
"fixLogic": "fix",
"acceptance": ["pass"],
"unexpected": "do not write",
}), encoding="utf-8")
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.load_draft(target)
target.write_text(json.dumps({
"fixLogic": "fix",
"acceptance": ["pass"],
}), encoding="utf-8")
linked = temp / "linked.json"
linked.symlink_to(target)
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.load_draft(linked)
def test_draft_revision_binds_attachment_identity(self) -> None:
record = {
"sourceRef": "feishu-base:sha256:" + "a" * 64,
"updatedAt": "2026-08-03T12:00:00+08:00",
"title": "Bug",
"actual": "crash",
"expected": "save",
"steps": "open then save",
"fixLogic": "fix parser",
"acceptance": "save succeeds",
"priority": "P1",
"attachments": [{"name": "same.png", "type": "image/png", "size": 4}],
}
first = feishu_bug_intake.draft_revision(record, ["fileTokenA"])
second = feishu_bug_intake.draft_revision(record, ["fileTokenB"])
self.assertNotEqual(first, second)
def test_attachments_and_required_text_are_resource_bounded_and_strict(self) -> None:
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.attachment_items([{
"file_token": "fileA",
"name": "huge.png",
"type": "image/png",
"size": feishu_bug_intake.MAX_ATTACHMENT_BYTES + 1,
}])
too_many = [
{"file_token": f"file{index}", "name": f"{index}.png", "size": 1}
for index in range(feishu_bug_intake.MAX_ATTACHMENTS_PER_RECORD + 1)
]
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.attachment_items(too_many)
with self.assertRaises(feishu_bug_intake.IntakeError):
feishu_bug_intake.text({"unexpected": "value"})
if __name__ == "__main__":
unittest.main()
+19 -1
View File
@@ -16,9 +16,12 @@ class AckSkillContentTests(unittest.TestCase):
"docs/ack/project.md",
"docs/ack/tasks.yaml",
"docs/ack/knowledge.yaml",
"docs/ack/delivery.yaml",
"tasks: []",
"validate_tasks.py",
"validate_knowledge.py",
"validate_delivery.py",
"select_tasks.py",
"select_knowledge.py",
"references/kickoff.md",
"不要修改项目的 `AGENTS.md`",
@@ -52,19 +55,34 @@ class AckSkillContentTests(unittest.TestCase):
"templates/knowledge.schema.json",
"examples/knowledge.example.yaml",
"scripts/validate_knowledge.py",
"scripts/select_tasks.py",
"scripts/select_knowledge.py",
"scripts/run_verification.py",
"scripts/worker_profiles.py",
"scripts/launch_worker.py",
"templates/delivery.template.yaml",
"templates/delivery.schema.json",
"examples/delivery.example.yaml",
"references/delivery.md",
):
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.10.0")
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
self.assertEqual(version, "0.14.0")
self.assertIn(
f'ackVersion: "{version}"',
(ack_dir / "examples" / "tasks.example.yaml").read_text(encoding="utf-8"),
)
self.assertIn(
f"ack v{version}",
(ack_dir / "examples" / "project.example.md").read_text(encoding="utf-8"),
)
self.assertIn(
'ackVersion: "<接入时的 ack skill 版本>"',
(ack_dir / "templates" / "tasks.template.yaml").read_text(
encoding="utf-8"
),
)
self.assertTrue((ack_dir / "references" / "feishu-bug-intake.md").is_file())
if __name__ == "__main__":
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SELECTOR = REPO_ROOT / "skills" / "ack" / "scripts" / "select_tasks.py"
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
SPEC = importlib.util.spec_from_file_location("ack_select_tasks", SELECTOR)
assert SPEC and SPEC.loader
SELECT_TASKS = importlib.util.module_from_spec(SPEC)
sys.path.insert(0, str(SELECTOR.parent))
SPEC.loader.exec_module(SELECT_TASKS)
def board() -> dict:
return {
"version": 1,
"updatedAt": "2026-08-01T20:00:00+08:00",
"source": "Coordinator",
"project": {"name": "demo"},
"summary": {"open": ["T-OPEN"], "verified": ["T-DONE"]},
"tasks": [
{"id": "T-OPEN", "title": "open", "status": "open"},
{"id": "T-DEV", "title": "dev", "status": "fixed_by_dev"},
{"id": "T-BLOCKED", "title": "blocked", "status": "blocked"},
{"id": "T-DONE", "title": "done", "status": "verified"},
],
}
class AckTaskSelectorTests(unittest.TestCase):
def run_selector(
self,
value: dict,
*args: str,
no_site_packages: bool = False,
) -> subprocess.CompletedProcess[str]:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "tasks.json"
path.write_text(json.dumps(value), encoding="utf-8")
command = [sys.executable]
if no_site_packages:
command.append("-S")
command.extend((str(SELECTOR), str(path), *args))
return subprocess.run(
command,
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
def test_default_selects_only_actionable_tasks_in_all_modes(self) -> None:
for no_site_packages in (False, True):
with self.subTest(no_site_packages=no_site_packages):
result = self.run_selector(board(), no_site_packages=no_site_packages)
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertEqual(
payload["selection"]["selectedTaskIds"],
["T-OPEN", "T-DEV"],
)
self.assertEqual(payload["selection"]["totalTasks"], 4)
self.assertEqual(payload["project"]["name"], "demo")
self.assertEqual(payload["summary"]["open"], ["T-OPEN"])
def test_example_yaml_runs_without_site_packages(self) -> None:
result = subprocess.run(
[sys.executable, "-S", str(SELECTOR), str(EXAMPLE), "--compact"],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertEqual(payload["project"]["name"], "notes-web")
self.assertEqual(payload["selection"]["mode"], "statuses")
def test_explicit_task_id_selects_terminal_task(self) -> None:
result = self.run_selector(board(), "--task-id", "T-DONE")
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertEqual(payload["selection"]["mode"], "task_ids")
self.assertEqual(payload["selection"]["selectedTaskIds"], ["T-DONE"])
self.assertEqual([task["id"] for task in payload["tasks"]], ["T-DONE"])
def test_missing_task_and_over_budget_fail_closed(self) -> None:
missing = self.run_selector(board(), "--task-id", "T-MISSING")
self.assertEqual(missing.returncode, 1)
self.assertIn("找不到任务", missing.stderr)
over_budget = self.run_selector(board(), "--limit", "1")
self.assertEqual(over_budget.returncode, 1)
self.assertIn("超过 --limit=1", over_budget.stderr)
def test_invalid_board_is_rejected_before_selection(self) -> None:
invalid = board()
invalid["tasks"].append(
{"id": "T-OPEN", "title": "duplicate", "status": "open"}
)
result = self.run_selector(invalid)
self.assertEqual(result.returncode, 1)
self.assertIn("任务板无效", result.stderr)
self.assertIn("id 重复", result.stderr)
def test_receipts_and_delivery_runs_are_reduced_to_selected_tasks(self) -> None:
selected = [
{
"id": "T-1",
"dispatch": {
"developer": {"receiptId": "WR-1"},
"test": {"receiptId": None},
},
}
]
data = {
"workerReceipts": [{"id": "WR-1"}, {"id": "WR-2"}],
"deliveryRuns": [
{"id": "DR-1", "taskIds": ["T-1"]},
{"id": "DR-2", "taskIds": ["T-2"]},
],
}
self.assertEqual(
SELECT_TASKS.select_referenced_receipts(data, selected),
[{"id": "WR-1"}],
)
self.assertEqual(
SELECT_TASKS.select_delivery_runs(data, selected),
[{"id": "DR-1", "taskIds": ["T-1"]}],
)
if __name__ == "__main__":
unittest.main()
+252 -1
View File
@@ -16,6 +16,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = REPO_ROOT / "skills" / "ack" / "scripts" / "validate_tasks.py"
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "tasks.example.yaml"
SCHEMA = REPO_ROOT / "skills" / "ack" / "templates" / "tasks.schema.json"
sys.path.insert(0, str(VALIDATOR.parent))
from approval_payload import approval_payload_hash # noqa: E402
def valid_knowledge_board() -> dict:
@@ -148,6 +150,126 @@ class AckTaskValidationTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("任务板校验通过", result.stdout)
def test_approved_feishu_plan_fields_are_validated(self) -> None:
board = valid_knowledge_board()
board["tasks"][0]["fixLogic"] = "change the parser and preserve legacy input"
board["tasks"][0]["acceptanceCriteria"] = [
"the submitted value is saved",
"the original crash no longer occurs",
]
self.assert_board_accepted_in_all_modes(board)
invalid = copy.deepcopy(board)
invalid["tasks"][0]["acceptanceCriteria"] = ["valid", 7]
self.assert_board_rejected_in_all_modes(invalid, "列表项必须是字符串")
def test_reviewed_feishu_workflow_requires_fix_logic_and_approval_revision(self) -> None:
fields = {
"title": "fTitle",
"actual": "fActual",
"expected": "fExpected",
"stepsToReproduce": "fSteps",
"acceptance": "fAcceptance",
"priority": "fPriority",
"attachments": "fAttachments",
"updatedAt": "fUpdated",
}
board = valid_knowledge_board()
board["project"]["bugIntake"] = {
"provider": "feishu-base",
"workflow": "reviewed-writeback-v1",
"profile": "tenant-b",
"baseToken": "baseToken",
"tableId": "tblBugs",
"viewId": "vewReview",
"fields": fields,
}
self.assert_board_rejected_in_all_modes(
board, "reviewed-writeback-v1 必须映射",
)
board["project"]["bugIntake"]["fields"]["fixLogic"] = "fFixLogic"
board["tasks"][0].update({
"priority": "P1",
"actual": "save crashes",
"expected": "save succeeds",
"stepsToReproduce": ["open", "save"],
"fixLogic": "preserve input while fixing the parser",
"acceptanceCriteria": ["save succeeds", "the crash no longer occurs"],
})
board["tasks"][0]["source"] = {
"kind": "feishu-base",
"workflow": "reviewed-writeback-v1",
"ref": "feishu-base:sha256:" + "a" * 64,
"recordId": "recA",
"updatedAt": "2026-08-03T12:00:00+08:00",
"approvedRevision": "sha256:" + "b" * 64,
}
board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash(
board["tasks"][0]
)
self.assert_board_accepted_in_all_modes(board)
board["tasks"][0]["source"]["approvedRevision"] = "latest"
self.assert_board_rejected_in_all_modes(board, "必须是 sha256 revision")
board["tasks"][0]["source"]["approvedRevision"] = "sha256:" + "b" * 64
board["tasks"][0]["fixLogic"] = "silently changed after approval"
self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配")
board["tasks"][0]["fixLogic"] = "preserve input while fixing the parser"
board["tasks"][0]["source"]["approvedPayloadHash"] = approval_payload_hash(
board["tasks"][0]
)
board["tasks"][0]["description"] = "modify unrelated modules"
self.assert_board_rejected_in_all_modes(board, "与任务审核字段不匹配")
legacy = copy.deepcopy(board)
legacy["tasks"][0]["description"] = "validate knowledge fields"
legacy["tasks"][0]["source"] = {
"kind": "feishu-base",
"ref": "feishu-base:sha256:" + "c" * 64,
"recordId": "recLegacy",
"updatedAt": "2026-08-01T12:00:00+08:00",
}
self.assert_board_rejected_in_all_modes(legacy, "必须先迁移审核")
legacy["tasks"][0]["status"] = "verified"
self.assert_board_accepted_in_all_modes(legacy)
def test_legacy_sources_remain_open_while_feishu_sources_are_strict(self) -> None:
for legacy_source in (
"manual",
{"kind": "jira", "ref": "JIRA-123", "project": "OPS"},
):
with self.subTest(legacy_source=legacy_source):
board = valid_knowledge_board()
board["tasks"][0]["source"] = legacy_source
self.assert_board_accepted_in_all_modes(board)
valid_ref = "feishu-base:sha256:" + "a" * 64
valid = valid_knowledge_board()
valid["tasks"][0]["source"] = {
"kind": "feishu-base",
"ref": valid_ref,
"recordId": "recA",
"updatedAt": "2026-08-01T12:00:00Z",
}
self.assert_board_accepted_in_all_modes(valid)
raw = copy.deepcopy(valid)
raw["tasks"][0]["source"]["ref"] = "feishu-base:tenant:base-secret:recA"
self.assert_board_rejected_in_all_modes(
raw,
"必须是不透明 feishu-base SHA-256 引用",
)
duplicate = copy.deepcopy(valid)
second = copy.deepcopy(duplicate["tasks"][0])
second["id"] = "T-2"
duplicate["tasks"].append(second)
self.assert_board_rejected_in_all_modes(duplicate, "来源引用重复")
def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None:
current = valid_knowledge_board()
current["ackVersion"] = "0.10.0"
@@ -180,7 +302,7 @@ class AckTaskValidationTests(unittest.TestCase):
with self.subTest(invalid_semver=invalid):
self.assertIsNone(ack_pattern.fullmatch(invalid))
current_gate, orchestration_gate, receipts_gate = schema["allOf"]
current_gate, orchestration_gate, receipts_gate, delivery_gate = schema["allOf"]
current_pattern = re.compile(
current_gate["if"]["properties"]["ackVersion"]["pattern"]
)
@@ -218,6 +340,15 @@ class AckTaskValidationTests(unittest.TestCase):
gate["then"]["properties"]["tasks"]["$ref"],
"#/definitions/launchableTasks",
)
self.assertIn("deliveryRuns", delivery_gate["then"]["required"])
delivery_run = schema["definitions"]["deliveryRun"]
revision_gate = delivery_run["allOf"][0]
self.assertIn("planned", revision_gate["if"]["properties"]["status"]["enum"])
self.assertEqual(
revision_gate["then"]["properties"]["sourceRevision"]["type"],
"string",
)
role_dispatch = schema["definitions"]["roleDispatch"]
self.assertIn("attemptId", role_dispatch["required"])
@@ -231,6 +362,126 @@ class AckTaskValidationTests(unittest.TestCase):
"string",
)
def test_delivery_run_is_separate_and_requires_verified_tasks(self) -> None:
board = valid_manual_routing_board()
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
board["tasks"][0]["status"] = "verified"
board["deliveryRuns"] = [
{
"id": "DR-demo-1",
"profile": "review",
"taskIds": ["T-1"],
"status": "review_ready",
"sourceRevision": "a" * 40,
"configRevision": "b" * 40,
"pullRequest": "https://forge.example/demo/pulls/1",
"artifacts": [
{
"id": "service-deb",
"type": "deb",
"reference": "demo_1.0.0_amd64.deb",
"digest": "sha256:" + "c" * 64,
}
],
"deployments": [
{
"environment": "test-server",
"result": "succeeded",
"evidence": "health endpoint returned 200",
}
],
"evidence": ["CI run 42 passed"],
"updatedAt": "2026-08-01T10:00:00+08:00",
}
]
self.assert_board_accepted_in_all_modes(board)
board["tasks"][0]["status"] = "open"
self.assert_board_rejected_in_all_modes(
board,
"delivery run 只能引用 verified 任务",
)
def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None:
board = valid_manual_routing_board()
board["deliveryRuns"] = []
self.assert_board_rejected_in_all_modes(
board,
"deliveryRuns 存在时 project.deliveryFile 必须存在",
)
board = valid_manual_routing_board()
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
self.assert_board_rejected_in_all_modes(
board,
"引用 deliveryFile 的任务板必须包含 deliveryRuns 列表",
)
def test_delivery_run_binds_revisions_and_final_artifact_digest(self) -> None:
board = valid_manual_routing_board()
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
board["deliveryRuns"] = [
{
"id": "DR-demo-2",
"profile": "review",
"taskIds": ["T-1"],
"status": "planned",
"sourceRevision": None,
"configRevision": None,
"pullRequest": None,
"artifacts": [],
"deployments": [],
"evidence": [],
"updatedAt": "2026-08-01T10:00:00+08:00",
}
]
self.assert_board_rejected_in_all_modes(
board,
"delivery run 只能引用 verified 任务",
"sourceRevision: status='planned' 时必须填写",
"configRevision: status='planned' 时必须填写",
)
board["tasks"][0]["status"] = "verified"
run = board["deliveryRuns"][0]
run.update(
{
"status": "review_ready",
"sourceRevision": "a" * 40,
"configRevision": "b" * 40,
"pullRequest": "https://forge.example/demo/pulls/2",
"artifacts": [
{
"id": "service-deb",
"type": "deb",
"reference": "demo_1.0.0_amd64.deb",
"digest": None,
}
],
"evidence": ["CI run 43 passed"],
}
)
self.assert_board_rejected_in_all_modes(
board,
"digest: status='review_ready' 时必须填写",
)
run.update(
{
"status": "skipped",
"sourceRevision": None,
"configRevision": None,
"pullRequest": None,
"artifacts": [],
"evidence": [],
}
)
self.assert_board_rejected_in_all_modes(
board,
"evidence: status='skipped' 时不能为空",
)
@unittest.skipUnless(
importlib.util.find_spec("jsonschema") is not None,
"jsonschema is required for the schema-only contract test",
+22 -2
View File
@@ -12,6 +12,7 @@ SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import validate_knowledge # noqa: E402
import validate_delivery # noqa: E402
import validate_tasks # noqa: E402
from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402
@@ -28,12 +29,14 @@ paths = (
Path('skills/ack/examples/tasks.example.yaml'),
Path('skills/ack/templates/knowledge.template.yaml'),
Path('skills/ack/examples/knowledge.example.yaml'),
Path('skills/ack/templates/delivery.template.yaml'),
Path('skills/ack/examples/delivery.example.yaml'),
)
for path in paths:
value = load_yaml_subset(path.read_text(encoding='utf-8'))
if not isinstance(value, dict):
raise SystemExit(f'{path}: top-level value is not a mapping')
print('parsed=4')
print('parsed=6')
"""
result = subprocess.run(
[sys.executable, "-S", "-c", script],
@@ -44,7 +47,7 @@ print('parsed=4')
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "parsed=4")
self.assertEqual(result.stdout.strip(), "parsed=6")
def test_tasks_validator_runs_without_site_packages(self) -> None:
result = subprocess.run(
@@ -63,6 +66,23 @@ print('parsed=4')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("任务板校验通过", result.stdout)
def test_delivery_validator_runs_without_site_packages(self) -> None:
result = subprocess.run(
[
sys.executable,
"-S",
str(SCRIPTS_DIR / "validate_delivery.py"),
str(REPO_ROOT / "skills" / "ack" / "templates" / "delivery.template.yaml"),
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("交付契约校验通过", result.stdout)
def test_supported_subset_types_and_block_scalars(self) -> None:
document = load_yaml_subset(
"""
+687
View File
@@ -0,0 +1,687 @@
from __future__ import annotations
import copy
import importlib.util
import json
import os
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
ORC_DIR = REPO_ROOT / "skills" / "orc"
SCRIPT = ORC_DIR / "scripts" / "resolve_profile.py"
CONFIG = ORC_DIR / "config.yaml"
spec = importlib.util.spec_from_file_location("orc_resolve_profile", SCRIPT)
assert spec is not None and spec.loader is not None
orc_profiles = importlib.util.module_from_spec(spec)
spec.loader.exec_module(orc_profiles)
class OrcSkillTests(unittest.TestCase):
def test_orc_is_explicit_and_routes_to_independent_skills(self) -> None:
skill = (ORC_DIR / "SKILL.md").read_text(encoding="utf-8")
routing = (ORC_DIR / "references" / "routing.md").read_text(encoding="utf-8")
metadata = (ORC_DIR / "agents" / "openai.yaml").read_text(encoding="utf-8")
self.assertIn("allow_implicit_invocation: false", metadata)
self.assertIn("low", skill)
self.assertIn("mid", skill)
self.assertIn("high", skill)
self.assertIn("`codex` 和 `cursor-agent`", skill)
self.assertIn("<orc-skill-dir>/config.yaml", skill)
self.assertIn("不得在项目中创建 `docs/orc/config.yaml`", skill)
self.assertIn("薄路由器", skill)
self.assertIn("不做领域判断", skill)
self.assertIn("当前 shell", skill)
self.assertNotIn("用户未指定档位时采用以下判断", skill)
self.assertNotIn("若该档位不足以安全完成", skill)
for child in ("$ack", "$manage-release", "$deb-publisher", "$publish-docker-image"):
self.assertIn(child, routing)
def test_children_do_not_reference_orc(self) -> None:
for child in ("ack", "manage-release", "deb-publisher", "publish-docker-image"):
for path in (REPO_ROOT / "skills" / child).rglob("*"):
if not path.is_file() or "__pycache__" in path.parts:
continue
content = path.read_text(encoding="utf-8", errors="ignore")
self.assertNotIn("$orc", content, str(path))
self.assertNotRegex(
content,
r"(?<![A-Za-z0-9_-])/orc(?![A-Za-z0-9_-])",
str(path),
)
self.assertNotRegex(content, r"\bORC\b", str(path))
self.assertNotIn("skills/orc", content, str(path))
def test_routing_pins_normal_pr_and_unsupported_release_gate(self) -> None:
routing = (ORC_DIR / "references" / "routing.md").read_text(
encoding="utf-8"
)
self.assertIn("普通非发布 PR/MR", routing)
self.assertIn("最多到", routing)
self.assertIn("review_ready", routing)
self.assertIn("当前 task 粒度无法安全表达", routing)
self.assertRegex(
routing,
r"不判断版本号、\s*实现方案、发布风险或产物策略",
)
def test_shared_config_and_cli_validate(self) -> None:
config = orc_profiles.load_config(CONFIG)
self.assertEqual(config["version"], 2)
self.assertEqual(config["cliPolicy"], "current-host")
self.assertEqual(set(config["profiles"]), {"codex", "cursor-agent"})
for cli in ("codex", "cursor-agent"):
self.assertEqual(set(config["profiles"][cli]), {"low", "mid", "high"})
completed = subprocess.run(
[sys.executable, str(SCRIPT), "validate"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn('"ok": true', completed.stdout)
project_config_argument = subprocess.run(
[sys.executable, str(SCRIPT), "validate", str(CONFIG)],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(project_config_argument.returncode, 2)
clean_python = subprocess.run(
[sys.executable, "-I", "-S", str(SCRIPT), "validate"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(clean_python.returncode, 0, clean_python.stderr)
def test_level_precedence_is_stage_then_global_then_config(self) -> None:
config = orc_profiles.load_config(CONFIG)
stage = orc_profiles.resolve_profile(
config,
stage="docker",
host_cli="codex",
global_level="mid",
stage_level="high",
)
global_choice = orc_profiles.resolve_profile(
config, stage="docker", host_cli="codex", global_level="mid"
)
configured = orc_profiles.resolve_profile(
config, stage="docker", host_cli="codex"
)
fallback_config = copy.deepcopy(config)
del fallback_config["stageDefaults"]["docker"]
fallback = orc_profiles.resolve_profile(
fallback_config, stage="docker", host_cli="codex"
)
self.assertEqual((stage["level"], stage["selectionSource"]), ("high", "request.stage"))
self.assertEqual((global_choice["level"], global_choice["selectionSource"]), ("mid", "request.global"))
self.assertEqual((configured["level"], configured["selectionSource"]), ("low", "config.stageDefaults.docker"))
self.assertEqual((fallback["level"], fallback["selectionSource"]), ("mid", "config.defaultLevel"))
self.assertEqual(configured["cli"], "codex")
self.assertEqual(configured["cliSelectionSource"], "runtime.host")
with self.assertRaisesRegex(orc_profiles.ConfigError, "host CLI is required"):
orc_profiles.resolve_profile(config, stage="docker")
def test_resolver_builds_fixed_worker_args_without_free_command_fields(self) -> None:
config = orc_profiles.load_config(CONFIG)
plan = orc_profiles.resolve_profile(
config, stage="release", host_cli="codex"
)
self.assertEqual(plan["workerArgs"][:2], ["--model", "gpt-5.6-terra"])
self.assertIn("--strict-config", plan["workerArgs"])
approval_index = plan["workerArgs"].index("--ask-for-approval")
self.assertEqual(plan["workerArgs"][approval_index + 1], "on-request")
self.assertIn('approvals_reviewer="auto_review"', plan["workerArgs"])
self.assertNotIn(
"sandbox_workspace_write.network_access=true",
plan["workerArgs"],
)
self.assertNotIn("danger-full-access", " ".join(plan["workerArgs"]))
self.assertNotIn("env", plan["profile"])
self.assertNotIn("command", plan["profile"])
cursor = orc_profiles.resolve_profile(
config,
stage="release",
host_cli="cursor-agent",
stage_level="low",
)
self.assertEqual(cursor["cli"], "cursor-agent")
self.assertEqual(cursor["cliSelectionSource"], "runtime.host")
self.assertEqual(cursor["modelAuth"], "cursor-login")
self.assertEqual(
cursor["workerArgs"],
[
"--model",
"auto",
"--auto-review",
"--sandbox",
"enabled",
],
)
with self.assertRaisesRegex(orc_profiles.ConfigError, "not valid for cursor-agent"):
orc_profiles.resolve_profile(
config,
stage="release",
host_cli="cursor-agent",
model_auth="openai",
)
with self.assertRaisesRegex(orc_profiles.ConfigError, "not valid for codex"):
orc_profiles.resolve_profile(
config,
stage="release",
host_cli="codex",
model_auth="cursor-api-key",
)
def test_worker_environment_selects_one_model_and_remote_auth(self) -> None:
ambient = {
"OPENAI_API_KEY": "openai-secret",
"AZURE_OPENAI_API_KEY": "azure-secret",
"GITHUB_TOKEN": "github-secret",
"GITLAB_TOKEN": "gitlab-secret",
"GITEA_TOKEN": "gitea-secret",
"FORGEJO_TOKEN": "forgejo-secret",
"DEB_TOKEN": "deb-secret",
"SSH_AUTH_SOCK": "/tmp/agent.sock",
"HTTPS_PROXY": "https://user:secret@proxy.example",
}
with mock.patch.dict(os.environ, ambient, clear=True):
environment = orc_profiles.worker_environment(
"release",
model_auth="openai",
remote_auth="forgejo-token",
)
self.assertEqual(environment["OPENAI_API_KEY"], "openai-secret")
self.assertEqual(environment["FORGEJO_TOKEN"], "forgejo-secret")
for rejected in (
"AZURE_OPENAI_API_KEY",
"GITHUB_TOKEN",
"GITLAB_TOKEN",
"GITEA_TOKEN",
"DEB_TOKEN",
"SSH_AUTH_SOCK",
"HTTPS_PROXY",
):
self.assertNotIn(rejected, environment)
cursor_ambient = {
"CURSOR_API_KEY": "cursor-secret",
"OPENAI_API_KEY": "openai-secret",
}
with mock.patch.dict(os.environ, cursor_ambient, clear=True):
cursor_environment = orc_profiles.worker_environment(
"release",
model_auth="cursor-api-key",
remote_auth="none",
)
self.assertEqual(cursor_environment["CURSOR_API_KEY"], "cursor-secret")
self.assertNotIn("OPENAI_API_KEY", cursor_environment)
with self.assertRaisesRegex(orc_profiles.ConfigError, "deb remote auth"):
orc_profiles.worker_environment(
"deb",
model_auth="codex-login",
remote_auth="forgejo-token",
)
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"selected authentication variable is unavailable",
):
orc_profiles.worker_environment(
"release",
model_auth="openai",
remote_auth="none",
)
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": ""}, clear=True):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"selected authentication variable is unavailable",
):
orc_profiles.worker_environment(
"release",
model_auth="openai",
remote_auth="none",
)
def test_release_remote_requires_one_identical_fetch_and_push_url(self) -> None:
git = shutil.which("git")
self.assertIsNotNone(git)
assert git is not None
git_path = Path(git).resolve()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
subprocess.run(
[str(git_path), "init", "-q", str(root)],
check=True,
capture_output=True,
)
remote = "https://git.yumee.top/laily/musicpilot.git"
subprocess.run(
[str(git_path), "-C", str(root), "remote", "add", "origin", remote],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
facts = orc_profiles.resolve_release_remote(root)
self.assertEqual(facts["fetchUrl"], remote)
self.assertEqual(facts["pushUrl"], remote)
self.assertEqual(facts["host"], "git.yumee.top")
self.assertEqual(facts["networkHosts"], ["git.yumee.top"])
subprocess.run(
[
str(git_path),
"-C",
str(root),
"config",
"remote.origin.pushurl",
"https://git.example.invalid/other/repo.git",
],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"fetch and push URLs must match",
):
orc_profiles.resolve_release_remote(root)
subprocess.run(
[
str(git_path),
"-C",
str(root),
"config",
"--add",
"remote.origin.pushurl",
"https://git.example.invalid/second/repo.git",
],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"exactly one push URL",
):
orc_profiles.resolve_release_remote(root)
with self.assertRaisesRegex(orc_profiles.ConfigError, "safe remote URL"):
orc_profiles._remote_url_facts(
"https://git.example.invalid:notaport/repo.git",
"test remote",
)
scp = orc_profiles._remote_url_facts(
"git@git.yumee.top:laily/musicpilot.git",
"scp remote",
)
ssh = orc_profiles._remote_url_facts(
"ssh://git@git.yumee.top/laily/musicpilot.git",
"ssh remote",
)
self.assertEqual(scp["canonical"], ssh["canonical"])
with self.assertRaisesRegex(orc_profiles.ConfigError, "local host"):
orc_profiles._remote_url_facts(
"https://localhost/laily/musicpilot.git",
"local remote",
)
with mock.patch.object(
orc_profiles,
"_origin_urls",
side_effect=[
["https://github.com/example/project.git"],
["https://github.com/example/project.git"],
],
), mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
github = orc_profiles.resolve_release_remote(Path("/tmp/project"))
self.assertEqual(
github["networkHosts"],
["github.com", "api.github.com", "uploads.github.com"],
)
def test_ssh_auth_requires_a_trusted_user_socket(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
socket_path = root / "agent.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as agent:
agent.bind(str(socket_path))
socket_path.chmod(0o600)
with mock.patch.dict(
os.environ,
{"SSH_AUTH_SOCK": str(socket_path)},
clear=True,
):
environment = orc_profiles.worker_environment(
"release",
model_auth="codex-login",
remote_auth="ssh-agent",
)
self.assertEqual(environment["SSH_AUTH_SOCK"], str(socket_path))
regular_file = root / "not-a-socket"
regular_file.write_text("not a socket", encoding="utf-8")
regular_file.chmod(0o600)
with mock.patch.dict(
os.environ,
{"SSH_AUTH_SOCK": str(regular_file)},
clear=True,
):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"not a trusted user socket",
):
orc_profiles.worker_environment(
"release",
model_auth="codex-login",
remote_auth="ssh-agent",
)
def test_trusted_orca_ignores_ambient_path(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
trusted = root / "trusted"
hostile = root / "hostile"
trusted.mkdir()
hostile.mkdir()
trusted_orca = trusted / "orca"
hostile_orca = hostile / "orca"
for executable in (trusted_orca, hostile_orca):
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
executable.chmod(0o700)
with (
mock.patch.object(
orc_profiles,
"trusted_path_entries",
return_value=[trusted],
),
mock.patch.dict(os.environ, {"PATH": str(hostile)}, clear=True),
):
resolved = orc_profiles.resolve_trusted_executable("orca")
self.assertEqual(resolved, trusted_orca)
def test_config_rejects_unsafe_or_ambiguous_profiles(self) -> None:
base = orc_profiles.load_config(CONFIG)
free_command = copy.deepcopy(base)
free_command["profiles"]["codex"]["low"]["command"] = "codex --dangerously-bypass"
with self.assertRaisesRegex(orc_profiles.ConfigError, "unknown fields"):
orc_profiles.validate_config(free_command)
full_access = copy.deepcopy(base)
full_access["profiles"]["codex"]["high"]["permissionMode"] = "danger-full-access"
with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v2"):
orc_profiles.validate_config(full_access)
read_only = copy.deepcopy(base)
read_only["profiles"]["codex"]["low"]["permissionMode"] = "read-only"
with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v2"):
orc_profiles.validate_config(read_only)
missing_level = copy.deepcopy(base)
del missing_level["profiles"]["cursor-agent"]["mid"]
with self.assertRaisesRegex(orc_profiles.ConfigError, "missing fields: mid"):
orc_profiles.validate_config(missing_level)
option_model = copy.deepcopy(base)
option_model["profiles"]["codex"]["low"]["model"] = "--model"
with self.assertRaisesRegex(orc_profiles.ConfigError, "safe exact model ID"):
orc_profiles.validate_config(option_model)
list_policy = copy.deepcopy(base)
list_policy["profiles"]["codex"]["low"]["approvalPolicy"] = ["never"]
with self.assertRaisesRegex(orc_profiles.ConfigError, "approvalPolicy"):
orc_profiles.validate_config(list_policy)
cursor_effort = copy.deepcopy(base)
cursor_effort["profiles"]["cursor-agent"]["low"]["reasoningEffort"] = "low"
with self.assertRaisesRegex(orc_profiles.ConfigError, "Cursor requires null"):
orc_profiles.validate_config(cursor_effort)
mislabeled_cursor = copy.deepcopy(base)
mislabeled_cursor["profiles"]["cursor-agent"]["low"]["model"] = (
"gpt-5.6-sol-high"
)
with self.assertRaisesRegex(orc_profiles.ConfigError, "encode the low"):
orc_profiles.validate_config(mislabeled_cursor)
project_specific = copy.deepcopy(base)
project_specific["worktreePolicy"] = ["."]
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"registered-same-repository",
):
orc_profiles.validate_config(project_specific)
default_cli = copy.deepcopy(base)
default_cli["cliPolicy"] = "default-codex"
with self.assertRaisesRegex(orc_profiles.ConfigError, "current-host"):
orc_profiles.validate_config(default_cli)
def test_config_reader_rejects_symlinks_and_redacts_parser_input(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
real = root / "real.yaml"
real.write_text(CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
linked = root / "linked.yaml"
linked.symlink_to(real)
with self.assertRaisesRegex(orc_profiles.ConfigError, "safely read"):
orc_profiles.load_config(linked)
malformed = root / "malformed.yaml"
secret_marker = "PRIVATE_MATERIAL_MUST_NOT_APPEAR"
malformed.write_text(f"{secret_marker}: [unterminated\n", encoding="utf-8")
with self.assertRaises(orc_profiles.ConfigError) as error:
orc_profiles.load_config(malformed)
self.assertNotIn(secret_marker, str(error.exception))
def test_bound_plan_enforces_registered_same_repository_worktree(self) -> None:
git = shutil.which("git")
self.assertIsNotNone(git)
assert git is not None
git_path = Path(git).resolve()
with tempfile.TemporaryDirectory(prefix="orc shell $(id) ' ") as temporary:
temporary_root = Path(temporary).resolve()
root = temporary_root / "project"
root.mkdir()
subprocess.run(
[str(git_path), "init", "-q", str(root)],
check=True,
capture_output=True,
)
subprocess.run(
[
str(git_path),
"-C",
str(root),
"remote",
"add",
"origin",
"https://git.yumee.top/laily/project.git",
],
check=True,
capture_output=True,
)
fake_codex = root / "trusted-codex" / "codex"
fake_codex.parent.mkdir()
fake_codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
fake_codex.chmod(0o700)
fake_cursor = root / "trusted-cursor" / "cursor-agent"
fake_cursor.parent.mkdir()
fake_cursor.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
fake_cursor.chmod(0o700)
fake_orca = root / "trusted-orca" / "orca"
fake_orca.parent.mkdir()
fake_orca.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
fake_orca.chmod(0o700)
def executable(name: str) -> Path:
return {
"git": git_path,
"codex": fake_codex,
"cursor-agent": fake_cursor,
"orca": fake_orca,
}[name]
def executable_facts(path: Path) -> dict[str, object]:
return {
"path": str(path),
"device": 1,
"inode": 2,
"size": 3,
"mtimeNs": 4,
"version": f"{path.name}-test 1",
}
with (
mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
side_effect=executable,
),
mock.patch.object(
orc_profiles,
"resolve_trusted_python",
return_value=Path(sys.executable).resolve(),
),
mock.patch.object(
orc_profiles,
"_executable_facts",
side_effect=executable_facts,
),
):
plan = orc_profiles.build_launch_plan(
project_root=root,
worktree=root,
stage="code",
host_cli="codex",
stage_level="high",
)
cursor_plan = orc_profiles.build_launch_plan(
project_root=root,
worktree=root,
stage="release",
host_cli="cursor-agent",
stage_level="low",
)
release_plan = orc_profiles.build_launch_plan(
project_root=root,
worktree=root,
stage="release",
host_cli="codex",
stage_level="low",
)
self.assertEqual(plan["argv"][0], str(fake_codex))
self.assertEqual(plan["config"]["path"], str(CONFIG))
self.assertEqual(cursor_plan["argv"][0], str(fake_cursor))
self.assertEqual(cursor_plan["executable"]["path"], str(fake_cursor))
self.assertEqual(cursor_plan["argv"][1:3], ["--model", "auto"])
self.assertIn("--auto-review", cursor_plan["argv"])
self.assertEqual(cursor_plan["modelAuth"], "cursor-login")
self.assertEqual(cursor_plan["releaseRemote"]["host"], "git.yumee.top")
self.assertIn("--host-cli", cursor_plan["launcherArgv"])
release_args = release_plan["workerArgs"]
self.assertIn("sandbox_workspace_write.network_access=true", release_args)
self.assertIn("features.network_proxy.enabled=true", release_args)
self.assertIn("features.network_proxy.allow_upstream_proxy=false", release_args)
self.assertIn("features.network_proxy.unix_sockets={}", release_args)
self.assertIn(
'features.network_proxy.domains={ "git.yumee.top" = "allow" }',
release_args,
)
release_approval = release_args.index("--ask-for-approval")
self.assertEqual(release_args[release_approval + 1], "on-request")
self.assertIn('approvals_reviewer="auto_review"', release_args)
self.assertEqual(plan["terminalCreateArgv"][0], str(fake_orca))
self.assertEqual(plan["worktree"], str(root))
self.assertEqual(plan["selectionSource"], "request.stage")
self.assertTrue(plan["launchFingerprint"].startswith("sha256:"))
self.assertIn("_launch", plan["launcherArgv"])
self.assertEqual(plan["launcherArgv"][1:3], ["-I", "-S"])
self.assertIn("python", plan)
self.assertEqual(plan["worktreeSelector"], f"path:{root}")
self.assertNotIn("PRIVATE", json.dumps(plan))
self.assertEqual(shlex.split(plan["terminalCommand"]), plan["launcherArgv"])
self.assertEqual(
shlex.split(plan["terminalCreateShellCommand"]),
plan["terminalCreateArgv"],
)
command_index = plan["terminalCreateArgv"].index("--command")
self.assertEqual(
plan["terminalCreateArgv"][command_index + 1],
plan["terminalCommand"],
)
outside = temporary_root / "outside"
outside.mkdir()
subprocess.run(
[str(git_path), "init", "-q", str(outside)],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
side_effect=executable,
):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"not registered in the project repository",
):
orc_profiles.validate_worktree(
orc_profiles.load_config(CONFIG),
project_root_value=root,
worktree_value=outside,
)
if __name__ == "__main__":
unittest.main()
+119 -9
View File
@@ -37,6 +37,13 @@ class SkillInitTests(unittest.TestCase):
' devWorktree: "<dev_worktree>"\n',
encoding="utf-8",
)
tasks_template = skill / "templates" / "tasks.template.yaml"
tasks_template.write_text(
tasks_template.read_text(encoding="utf-8")
+ ' deliveryFile: "docs/ack/delivery.yaml"\n'
+ 'deliveryRuns: []\n',
encoding="utf-8",
)
(skill / "templates" / "knowledge.template.yaml").write_text(
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'project:\n'
@@ -44,7 +51,25 @@ class SkillInitTests(unittest.TestCase):
' repoPath: "<repo_path>"\n',
encoding="utf-8",
)
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
(skill / "templates" / "delivery.template.yaml").write_text(
'version: 1\n'
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
'project:\n'
' name: "<project_name>"\n'
'enabled: false\n'
'defaultProfile: null\n'
'entrypoints: {}\n'
'artifacts: {}\n'
'destinations: {}\n'
'environments: {}\n'
'profiles: {}\n',
encoding="utf-8",
)
for validator_name in (
"validate_tasks.py",
"validate_knowledge.py",
"validate_delivery.py",
):
(skill / "scripts" / validator_name).write_text(
"raise SystemExit(0)\n",
encoding="utf-8",
@@ -79,12 +104,16 @@ class SkillInitTests(unittest.TestCase):
project_content = (target / "project.md").read_text(encoding="utf-8")
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
delivery_content = (target / "delivery.yaml").read_text(encoding="utf-8")
self.assertIn("# sample-app", project_content)
self.assertIn("version=1.2.3", project_content)
self.assertIn(f'repoPath: "{project}"', tasks_content)
self.assertIn(f'repoPath: "{project}"', knowledge_content)
self.assertIn('name: "sample-app"', delivery_content)
self.assertIn("enabled: false", delivery_content)
self.assertNotIn("<project_name>", tasks_content)
self.assertNotIn("<project_name>", knowledge_content)
self.assertNotIn("<project_name>", delivery_content)
def test_init_refuses_to_overwrite_existing_files(self) -> None:
project = self.home / "existing-app"
@@ -100,6 +129,7 @@ class SkillInitTests(unittest.TestCase):
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
self.assertFalse((target / "tasks.yaml").exists())
self.assertFalse((target / "knowledge.yaml").exists())
self.assertFalse((target / "delivery.yaml").exists())
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
project = self.home / "existing-knowledge-app"
@@ -116,6 +146,22 @@ class SkillInitTests(unittest.TestCase):
self.assertFalse((target / "project.md").exists())
self.assertFalse((target / "tasks.yaml").exists())
def test_init_refuses_to_overwrite_existing_delivery_file(self) -> None:
project = self.home / "existing-delivery-app"
target = project / "docs" / "ack"
target.mkdir(parents=True)
existing = target / "delivery.yaml"
existing.write_text("keep me", encoding="utf-8")
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝覆盖已有路径", result.stderr)
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
self.assertFalse((target / "project.md").exists())
self.assertFalse((target / "tasks.yaml").exists())
self.assertFalse((target / "knowledge.yaml").exists())
def test_init_rejects_symlinked_destination_directories(self) -> None:
for symlink_level in ("docs", "ack"):
with self.subTest(symlink_level=symlink_level):
@@ -147,6 +193,7 @@ class SkillInitTests(unittest.TestCase):
self.assertFalse((outside / "project.md").exists())
self.assertFalse((outside / "tasks.yaml").exists())
self.assertFalse((outside / "knowledge.yaml").exists())
self.assertFalse((outside / "delivery.yaml").exists())
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
project = self.home / "path-traversal-app"
@@ -289,7 +336,7 @@ class SkillInitTests(unittest.TestCase):
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
self.assertEqual(
sorted(path.name for path in moved_target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
@@ -348,7 +395,7 @@ class SkillInitTests(unittest.TestCase):
self.assertFalse((target / "marker").exists())
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
@@ -360,7 +407,7 @@ class SkillInitTests(unittest.TestCase):
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
nonlocal calls
calls += 1
if calls == 7:
if calls == 8:
raise OSError("simulated directory fsync failure")
real_fsync(file_descriptor)
@@ -386,7 +433,7 @@ class SkillInitTests(unittest.TestCase):
target = project / "docs" / "ack"
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_project_root_replacement_aborts_before_publish(self) -> None:
@@ -473,7 +520,7 @@ class SkillInitTests(unittest.TestCase):
target = moved_project / "docs" / "ack"
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_docs_replacement_aborts_before_publish(self) -> None:
@@ -568,7 +615,7 @@ class SkillInitTests(unittest.TestCase):
target = moved_docs / "ack"
self.assertEqual(
sorted(path.name for path in target.iterdir()),
["knowledge.yaml", "project.md", "tasks.yaml"],
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
)
def test_ack_init_requires_knowledge_template(self) -> None:
@@ -588,8 +635,29 @@ class SkillInitTests(unittest.TestCase):
self.assertIn("knowledge.template.yaml", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_ack_init_requires_both_validators(self) -> None:
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
def test_ack_init_requires_delivery_template(self) -> None:
project = self.home / "missing-delivery-template-app"
project.mkdir()
(
self.skills_home
/ "skills"
/ "ack"
/ "templates"
/ "delivery.template.yaml"
).unlink()
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertIn("delivery.template.yaml", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_ack_init_requires_all_validators(self) -> None:
for validator_name in (
"validate_tasks.py",
"validate_knowledge.py",
"validate_delivery.py",
):
with self.subTest(validator_name=validator_name):
project = self.home / f"missing-{validator_name}-app"
project.mkdir()
@@ -632,6 +700,21 @@ class SkillInitTests(unittest.TestCase):
self.assertIn("初始化知识库校验失败", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_delivery_validator_failure_leaves_no_partial_initialization(self) -> None:
project = self.home / "invalid-delivery-app"
project.mkdir()
validator = (
self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py"
)
validator.write_text("raise SystemExit(1)\n", encoding="utf-8")
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("初始化交付契约校验失败", result.stderr)
self.assertFalse((project / "docs" / "ack").exists())
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
project = self.home / "mutated-staging-app"
project.mkdir()
@@ -677,6 +760,32 @@ class SkillInitTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
def test_ack_delivery_init_validates_mirrored_staging_root(self) -> None:
project = self.home / "staged-delivery-app"
project.mkdir()
validator = (
self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py"
)
validator.write_text(
"import sys\n"
"from pathlib import Path\n"
"required = ['--tasks', '--project-root']\n"
"if any(item not in sys.argv for item in required):\n"
" raise SystemExit(3)\n"
"root = Path(sys.argv[sys.argv.index('--project-root') + 1])\n"
"delivery = Path(sys.argv[1])\n"
"tasks = Path(sys.argv[sys.argv.index('--tasks') + 1])\n"
"expected = root / 'docs' / 'ack'\n"
"raise SystemExit(0 if delivery.parent == expected and "
"tasks.parent == expected else 4)\n",
encoding="utf-8",
)
result = self.run_skiff("init", "ack", "--project", str(project))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((project / "docs" / "ack" / "delivery.yaml").is_file())
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
skill = self.skills_home / "skills" / "plain"
(skill / "templates").mkdir(parents=True)
@@ -697,6 +806,7 @@ class SkillInitTests(unittest.TestCase):
self.assertTrue((target / "project.md").is_file())
self.assertTrue((target / "tasks.yaml").is_file())
self.assertFalse((target / "knowledge.yaml").exists())
self.assertFalse((target / "delivery.yaml").exists())
def test_init_rejects_missing_project_directory(self) -> None:
project = self.home / "missing-app"