Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 337f1a9098 | |||
| f02a34e751 | |||
| a0f1c15b85 | |||
| f8d03fad4d | |||
| 5b0e41199d | |||
| 2c3d91c75c | |||
| f08edb6452 |
@@ -59,6 +59,7 @@ AGENTS.md # 本文档
|
|||||||
|
|
||||||
| Skill | 说明 |
|
| Skill | 说明 |
|
||||||
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
| ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 |
|
||||||
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
|
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行项目三角色协作闭环 |
|
||||||
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
| [skiff](skills/skiff/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 |
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ AGENTS.md # 详细规范与架构说明
|
|||||||
|
|
||||||
| Skill | 说明 |
|
| 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 |
|
| [skiff](skills/skiff/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
|
||||||
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
|
||||||
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
|
||||||
@@ -48,6 +49,9 @@ skiff init ack
|
|||||||
skiff init ack --project ~/app
|
skiff init ack --project ~/app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
初始化会生成默认关闭的 `docs/ack/delivery.yaml`;项目可用自然语言让 `/ack` 维护
|
||||||
|
DEB、镜像、PR、发布与部署 profile,任务验证通过后再按已确认计划执行。
|
||||||
|
|
||||||
新建 skill:
|
新建 skill:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# ACK 飞书多维表格 Bug 收件箱
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让用户在飞书多维表格中跨设备记录文字和截图,随后由 ACK Coordinator 通过官方
|
||||||
|
`lark-cli` 读取项目配置的 `ACK Ready` 视图,整理为独立、可验收、可幂等追踪的 ACK
|
||||||
|
任务。不同项目通过不同 CLI profile 访问各自飞书租户。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不用 Skill 承担人工记录入口。
|
||||||
|
- 不抓取公开网页或依赖浏览器登录态。
|
||||||
|
- 不把 App Secret、access token 或飞书用户凭据写入项目。
|
||||||
|
- 第一版不反向更新飞书记录,不把飞书状态与 ACK 状态做双向同步。
|
||||||
|
- 不把多条互不相关的 Bug 合成一次 Developer 派发。
|
||||||
|
|
||||||
|
## 项目配置契约
|
||||||
|
|
||||||
|
可选配置位于 `docs/ack/tasks.yaml` 的 `project.bugIntake`。未配置时 ACK 保持现有行为。
|
||||||
|
配置存在时必须包含:
|
||||||
|
|
||||||
|
| 字段 | 约束 |
|
||||||
|
|------|------|
|
||||||
|
| `provider` | 固定为 `feishu-base` |
|
||||||
|
| `profile` | `lark-cli` profile 名称;每次命令显式传入 |
|
||||||
|
| `baseToken` | 飞书 Base token |
|
||||||
|
| `tableId` | 数据表 ID |
|
||||||
|
| `viewId` | 只包含可导入记录的 `ACK Ready` 视图 ID |
|
||||||
|
| `fields` | 逻辑字段到飞书字段 ID/名称的映射 |
|
||||||
|
|
||||||
|
`fields` 必须映射 `title`、`actual`、`expected`、`stepsToReproduce`、
|
||||||
|
`acceptance`、`priority`、`attachments`、`updatedAt`。字段值只作为单个 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 和投影字段。
|
||||||
|
- 记录超过一页时按 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`;
|
||||||
|
- title、actual、expected、steps、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 和所需只读能力可用。
|
||||||
|
2. 运行读取器 `plan` 读取 `ACK Ready` 视图并生成 create/refresh/unchanged/drift
|
||||||
|
整理动作;需要看截图时使用临时下载目录。
|
||||||
|
3. 将记录分类为 Bug、已有功能、接受的改进、样式偏好或超范围;只导入确认接受的项。
|
||||||
|
4. 每条导入任务保存 `source.kind=feishu-base`、`source.ref`、`source.recordId`、
|
||||||
|
`source.updatedAt`,并把截图观察转成文字证据。
|
||||||
|
5. 导入前扫描已有任务的 `source.ref`。相同来源不得新建第二条任务。
|
||||||
|
6. 来源更新但任务尚为 `open` 时可由 Coordinator刷新描述;任务已派发或进入终态时只报告漂移,由用户决定是否新开任务。
|
||||||
|
7. 飞书记录删除、不可访问或 CLI 暂时失败时保留已有 ACK 任务,不反向删除。
|
||||||
|
|
||||||
|
## 可观测验收信号
|
||||||
|
|
||||||
|
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 项目仍能通过任务板校验并按原流程工作。
|
||||||
|
|
||||||
|
## 最脆弱假设与降级
|
||||||
|
|
||||||
|
本设计假设官方 `lark-cli` 的 Base JSON 输出保持 `fields`、`record_id_list` 与行矩阵
|
||||||
|
对应关系。读取器必须校验三者长度和字段映射;若上游输出契约变化,立即失败并提示
|
||||||
|
升级适配器,不能错列生成 Bug。飞书或 CLI 不可用时,只停止新的同步,已经进入
|
||||||
|
`tasks.yaml` 的任务继续按 ACK 闭环执行。
|
||||||
@@ -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: []
|
||||||
@@ -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`,不提交或推送。
|
||||||
@@ -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
@@ -1311,9 +1311,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
project_file = destination / "project.md"
|
project_file = destination / "project.md"
|
||||||
tasks_file = destination / "tasks.yaml"
|
tasks_file = destination / "tasks.yaml"
|
||||||
knowledge_file = destination / "knowledge.yaml"
|
knowledge_file = destination / "knowledge.yaml"
|
||||||
|
delivery_file = destination / "delivery.yaml"
|
||||||
managed_targets = [project_file, tasks_file]
|
managed_targets = [project_file, tasks_file]
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
managed_targets.append(knowledge_file)
|
managed_targets.extend((knowledge_file, delivery_file))
|
||||||
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||||
if existing:
|
if existing:
|
||||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||||
@@ -1326,8 +1327,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
(tasks_template, tasks_file),
|
(tasks_template, tasks_file),
|
||||||
]
|
]
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
template_targets.append(
|
template_targets.extend(
|
||||||
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file)
|
(
|
||||||
|
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
|
||||||
|
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
missing = [path for path, _ in template_targets if not path.is_file()]
|
missing = [path for path, _ in template_targets if not path.is_file()]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -1335,10 +1339,11 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
||||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||||
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
||||||
|
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
missing_validators = [
|
missing_validators = [
|
||||||
path
|
path
|
||||||
for path in (validator, knowledge_validator)
|
for path in (validator, knowledge_validator, delivery_validator)
|
||||||
if not path.is_file()
|
if not path.is_file()
|
||||||
]
|
]
|
||||||
if missing_validators:
|
if missing_validators:
|
||||||
@@ -1394,6 +1399,23 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"初始化知识库校验失败(exit {completed.returncode})"
|
f"初始化知识库校验失败(exit {completed.returncode})"
|
||||||
)
|
)
|
||||||
|
if args.name == "ack" and delivery_validator.is_file():
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(delivery_validator),
|
||||||
|
str(staged_files[delivery_file]),
|
||||||
|
"--tasks",
|
||||||
|
str(staged_files[tasks_file]),
|
||||||
|
"--project-root",
|
||||||
|
str(staging),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise SystemExit(
|
||||||
|
f"初始化交付契约校验失败(exit {completed.returncode})"
|
||||||
|
)
|
||||||
|
|
||||||
for target, staged in staged_files.items():
|
for target, staged in staged_files.items():
|
||||||
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
||||||
@@ -1621,6 +1643,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|||||||
_print(f" 任务板: {tasks_file}")
|
_print(f" 任务板: {tasks_file}")
|
||||||
if args.name == "ack":
|
if args.name == "ack":
|
||||||
_print(f" 知识库: {knowledge_file}")
|
_print(f" 知识库: {knowledge_file}")
|
||||||
|
_print(f" 交付契约: {delivery_file}(默认关闭)")
|
||||||
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+43
-3
@@ -9,6 +9,10 @@ ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协
|
|||||||
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
||||||
`leftover`,然后继续处理其它任务。
|
`leftover`,然后继续处理其它任务。
|
||||||
|
|
||||||
|
项目还可以声明一个可选的交付阶段:任务全部验证后,ACK 按项目维护的 profile
|
||||||
|
构建 DEB 或镜像、发布产物、创建 PR,并在授权范围内部署。交付配置默认关闭,
|
||||||
|
稳定发布与生产部署始终保留人工批准点。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
全局安装:
|
全局安装:
|
||||||
@@ -38,7 +42,8 @@ skiff init ack --project ~/code/my-app
|
|||||||
docs/ack/
|
docs/ack/
|
||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
└── knowledge.yaml
|
├── knowledge.yaml
|
||||||
|
└── delivery.yaml # 默认 enabled: false
|
||||||
```
|
```
|
||||||
|
|
||||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
||||||
@@ -52,7 +57,7 @@ skills/ack/
|
|||||||
├── README.md
|
├── README.md
|
||||||
├── VERSION
|
├── VERSION
|
||||||
├── references/ # 三角色规范、闭环流程和初始化说明
|
├── references/ # 三角色规范、闭环流程和初始化说明
|
||||||
├── templates/ # project.md、tasks.yaml、knowledge.yaml 模板和 schema
|
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml 模板和 schema
|
||||||
├── examples/ # 完整示例
|
├── examples/ # 完整示例
|
||||||
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
└── scripts/ # 状态校验、知识选择、安全验证执行与结构化 worker launcher
|
||||||
```
|
```
|
||||||
@@ -61,6 +66,8 @@ skills/ack/
|
|||||||
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
`docs/ack/project.md` 只保存当前项目的命令、路径和权限差异;
|
||||||
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
`docs/ack/tasks.yaml` 保存当前任务状态;`docs/ack/knowledge.yaml` 保存跨任务复用、
|
||||||
已经独立验证的项目知识护栏。
|
已经独立验证的项目知识护栏。
|
||||||
|
`docs/ack/delivery.yaml` 声明项目特有的构建、发布和部署能力;每次执行结果另记在
|
||||||
|
`tasks.yaml.deliveryRuns`,配置与运行状态不会混在一起。
|
||||||
|
|
||||||
## 检查项目状态
|
## 检查项目状态
|
||||||
|
|
||||||
@@ -70,6 +77,8 @@ Agent 会从当前 ACK Skill 目录解析校验脚本:
|
|||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
||||||
--tasks docs/ack/tasks.yaml
|
--tasks docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
Coordinator 可以按当前任务上下文做确定性推荐:
|
Coordinator 可以按当前任务上下文做确定性推荐:
|
||||||
@@ -115,6 +124,19 @@ python3 <ack-skill-dir>/scripts/run_verification.py \
|
|||||||
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK 不自动修改项目的
|
||||||
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
`AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
|
|
||||||
|
## 配置与运行交付
|
||||||
|
|
||||||
|
用户可以直接向 `/ack` 描述项目差异,例如“这个项目用 `make deb` 构建 DEB,推到
|
||||||
|
preview APT 源,再部署到开发机”。ACK 会把它维护成
|
||||||
|
`docs/ack/delivery.yaml` 中的声明式 entrypoint、artifact、destination、environment
|
||||||
|
和 profile,校验后展示 diff;首次配置保持关闭,确认后才启用。
|
||||||
|
|
||||||
|
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
|
||||||
|
凭据值。ACK 在任务进入 `verified` 后,按用户确认的 profile 执行,并把 revision、
|
||||||
|
PR、产物摘要、部署目标、健康检查和日志引用写入 `tasks.yaml.deliveryRuns`。默认
|
||||||
|
profile 只能停在 `review_ready`;稳定发布或生产部署必须经过对应 approval 步骤。
|
||||||
|
具体契约见 `references/delivery.md`。
|
||||||
|
|
||||||
## 启动 Worker
|
## 启动 Worker
|
||||||
|
|
||||||
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
|
||||||
@@ -180,8 +202,26 @@ fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功
|
|||||||
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
|
||||||
可观测验收信号;用户确认后才派发实现和复测。
|
可观测验收信号;用户确认后才派发实现和复测。
|
||||||
|
|
||||||
|
首次配置交付可以说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
|
||||||
|
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
|
||||||
|
```
|
||||||
|
|
||||||
|
之后处理需求时只需在确认计划中选择 profile:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。
|
||||||
|
```
|
||||||
|
|
||||||
|
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或
|
||||||
|
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
|
||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
|
|
||||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录
|
||||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||||
同时存在;旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
||||||
|
`project.deliveryFile` 与 `deliveryRuns`。旧项目可以不迁移而继续使用原闭环。旧项目的
|
||||||
|
`kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
|
||||||
|
|||||||
+63
-17
@@ -2,15 +2,16 @@
|
|||||||
name: ack
|
name: ack
|
||||||
description: >-
|
description: >-
|
||||||
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
初始化、检查并运行 ACK 三角色协作闭环。仅在用户显式调用 /ack 或 $ack,并要求
|
||||||
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求或指挥 Coordinator/Developer/Test
|
初始化 ACK、检查 docs/ack 配置、按 ACK 规划需求、指挥 Coordinator/Developer/Test
|
||||||
工作时使用。
|
工作,或配置并执行任务验证后的项目交付流程时使用。
|
||||||
---
|
---
|
||||||
|
|
||||||
# ACK 项目协作入口
|
# ACK 项目协作入口
|
||||||
|
|
||||||
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
本 Skill 是 ACK 的完整能力包:`references/` 保存通用规范,`templates/` 保存项目
|
||||||
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
状态模板,`scripts/` 保存校验工具。目标项目只在 `docs/ack/` 保存 `project.md`、
|
||||||
`tasks.yaml` 和 `knowledge.yaml`,不要复制或链接 Skill 内容。
|
`tasks.yaml`、`knowledge.yaml` 和默认关闭的 `delivery.yaml`,不要复制或链接 Skill
|
||||||
|
内容。
|
||||||
|
|
||||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
开始时解析当前 `SKILL.md` 所在目录,记为 `<ack-skill-dir>`。所有通用规范、模板和
|
||||||
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
脚本都相对此目录访问,不依赖固定的全局安装路径。
|
||||||
@@ -20,6 +21,7 @@ description: >-
|
|||||||
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
- 用户要求初始化、接入或安装 ACK:执行“初始化”。
|
||||||
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
- 用户要求检查 ACK 是否可用、配置是否完整:执行“检查”。
|
||||||
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
- 用户要求用 ACK 做需求、修复问题或继续任务:执行“工作”。
|
||||||
|
- 用户用自然语言要求增加、修改或关闭项目交付流程:执行“交付配置维护”。
|
||||||
|
|
||||||
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
始终先解析真实项目根目录。优先使用 `git rev-parse --show-toplevel`;不是 Git
|
||||||
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
项目时使用用户指定目录或当前目录。不要修改项目的 `AGENTS.md`、`CLAUDE.md`
|
||||||
@@ -54,18 +56,24 @@ description: >-
|
|||||||
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
7. 检查 `docs/ack/knowledge.yaml`。新项目没有已验证的项目经验时保留
|
||||||
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
`verificationRegistry: {}` 与 `entries: []`,不从聊天、README 或单次失败中
|
||||||
猜测并激活知识。
|
猜测并激活知识。
|
||||||
8. 更新 `updatedAt`,并运行:
|
8. 检查 `docs/ack/delivery.yaml`。新项目保留 `enabled: false`、空能力表和空 profile;
|
||||||
|
不从 README 或 CI 猜测、启用交付。旧项目没有该文件时仍可继续使用原 ACK
|
||||||
|
闭环;只有用户明确要求配置交付时,才按“交付配置维护”补齐。
|
||||||
|
9. 更新 `updatedAt`,并运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml \
|
||||||
--tasks docs/ack/tasks.yaml
|
--tasks docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
9. 检查 `project.md`、`tasks.yaml` 与 `knowledge.yaml` 是否仍有 `<...>` 占位符。
|
10. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml` 是否仍有
|
||||||
|
`<...>` 占位符。
|
||||||
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
||||||
缺失值。
|
缺失值。
|
||||||
10. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
11. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
||||||
不推送。
|
不推送。
|
||||||
|
|
||||||
## 检查
|
## 检查
|
||||||
@@ -74,6 +82,7 @@ description: >-
|
|||||||
- `docs/ack/project.md`
|
- `docs/ack/project.md`
|
||||||
- `docs/ack/tasks.yaml`
|
- `docs/ack/tasks.yaml`
|
||||||
- `docs/ack/knowledge.yaml`
|
- `docs/ack/knowledge.yaml`
|
||||||
|
- `docs/ack/delivery.yaml`(旧项目可无;存在或被任务板引用时必须校验)
|
||||||
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
2. 读取 `<ack-skill-dir>/VERSION`,对比 `tasks.yaml` 的 `ackVersion`。旧项目只有
|
||||||
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
`kitVersion` 时仍可读取,但建议迁移为 `ackVersion`。`ackVersion` 必须是合法
|
||||||
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
SemVer;从 `0.10.0` 起 `project.orchestration` 与顶层 `workerReceipts` 必须同时
|
||||||
@@ -82,16 +91,23 @@ description: >-
|
|||||||
黑盒命令和 Base URL。
|
黑盒命令和 Base URL。
|
||||||
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
4. 使用 `<ack-skill-dir>/scripts/validate_tasks.py` 校验任务板,使用
|
||||||
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
|
`<ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks
|
||||||
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。只报告证据明确的问题,不因可选
|
docs/ack/tasks.yaml` 校验项目知识和跨文件引用。如果存在交付配置或任务板声明了
|
||||||
字段缺失而宣称失败。
|
`project.deliveryFile`,再使用 `<ack-skill-dir>/scripts/validate_delivery.py
|
||||||
5. 检查知识引用能解析到固定 revision,candidate 仍留在任务证据中,且
|
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` 知识。
|
`stale`、`superseded` 和 `archived` 不会被当作可派发的 `active` 知识。
|
||||||
6. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
7. 若存在 `project.orchestration`,检查 profile、model allowlist、默认 profile、
|
||||||
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
允许 worktree、顶层 `workerReceipts` 与 `dispatch.developer/test` 的引用;receipt
|
||||||
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
必须绑定当前 ACK task、同一 role/profile/attempt,`receiptId` 与 `attemptId`
|
||||||
必须同时为空或同时填写。
|
必须同时为空或同时填写。
|
||||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||||
7. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||||
|
|
||||||
## 工作
|
## 工作
|
||||||
|
|
||||||
@@ -103,12 +119,20 @@ description: >-
|
|||||||
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
`docs/ack/knowledge.yaml` 选择的当前任务相关 `active` 条目
|
||||||
- `<ack-skill-dir>/references/kickoff.md`
|
- `<ack-skill-dir>/references/kickoff.md`
|
||||||
- kickoff 指定且与当前任务相关的 references 文件
|
- kickoff 指定且与当前任务相关的 references 文件
|
||||||
|
- 若 `tasks.yaml.project.deliveryFile` 存在,再读取该 `delivery.yaml` 和
|
||||||
|
`<ack-skill-dir>/references/delivery.md`
|
||||||
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
3. 当前会话担任 Coordinator,遵守项目覆盖层中的命令、路径权限、模型路由和
|
||||||
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
worker 启动规则。项目覆盖层优先于通用示例命令。按 scope 推荐相关 `active`
|
||||||
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
知识,经确认后把固定 revision 的显式 `knowledgeRefs` 写入当前任务上下文;
|
||||||
不全量注入知识库。
|
不全量注入知识库。
|
||||||
|
配置了 `project.bugIntake` 时,先按 `references/feishu-bug-intake.md` 运行 check,
|
||||||
|
再运行 plan 获取标准化记录及 `create` / `refresh` / `unchanged` / `drift` 整理动作。
|
||||||
|
按每条记录的 `sourceRef` 去重:仅 `open` 任务可刷新描述;
|
||||||
|
`dispatched`、`fixed_by_dev`、`retesting`、`failed_retest`、`verified`、`blocked` 和
|
||||||
|
`leftover` 只报告来源漂移,绝不覆盖;来源消失或读取失败时绝不删除已有任务。
|
||||||
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
4. 新需求先写产品文档、任务拆分与可观测验收信号,更新 `tasks.yaml` 并校验,
|
||||||
然后交给用户确认;确认前不派发实现。
|
然后交给用户确认;若启用了交付,还要把本次 profile、目标、停止点和需要审批的
|
||||||
|
步骤放入同一份计划。确认前不派发实现,也不执行交付。
|
||||||
5. 创建或更换 worker 时,只使用
|
5. 创建或更换 worker 时,只使用
|
||||||
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
`<ack-skill-dir>/scripts/launch_worker.py plan|launch` 读取
|
||||||
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
`tasks.yaml.project.orchestration` 的 profile。不得直接执行
|
||||||
@@ -129,6 +153,25 @@ description: >-
|
|||||||
`leftover` 并继续其它任务。
|
`leftover` 并继续其它任务。
|
||||||
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
9. 关键的安全、正确性和兼容性约束应下沉为测试、lint、CI 或正式规范;
|
||||||
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
`knowledge.yaml` 只保存触发条件、原因与证据引用,不能替代可执行控制。
|
||||||
|
10. 选定任务全部进入 `verified` 后,若 `delivery.enabled: true` 且用户确认的本次计划
|
||||||
|
包含交付,按 `references/delivery.md` 顺序执行 profile,并由 Coordinator 把证据
|
||||||
|
写入 `tasks.yaml.deliveryRuns`。任务状态保持 `verified`;交付失败只改变 delivery
|
||||||
|
run,不回写成任务失败。默认 profile 最多到 `review_ready`,稳定发布和生产部署
|
||||||
|
必须在对应步骤再次取得明确批准。
|
||||||
|
|
||||||
|
## 交付配置维护
|
||||||
|
|
||||||
|
1. 读取 `references/delivery.md`、模板、schema、现有 `delivery.yaml`、项目构建/发布
|
||||||
|
入口和 CI;把用户自然语言描述转换为结构化 entrypoint、artifact、destination、
|
||||||
|
environment 与 profile。配置只引用仓库内脚本或声明式工具 target,不保存 shell。
|
||||||
|
2. 若旧项目首次启用,生成 `docs/ack/delivery.yaml`,在 `tasks.yaml.project` 增加
|
||||||
|
`deliveryFile: docs/ack/delivery.yaml`,并增加顶层 `deliveryRuns: []`;不改写其它
|
||||||
|
项目状态。首次生成保持 `enabled: false`,先展示 diff 和解析出的执行顺序。
|
||||||
|
3. 运行 delivery、tasks 和跨文件校验;需要的脚本不存在、不可执行、引用不完整或
|
||||||
|
涉及凭据正文时 fail closed。凭据只写 secret 名称,值由外部环境提供。
|
||||||
|
4. 用户确认后才把配置设为启用。配置修改只影响下一次 delivery run;已确认或正在
|
||||||
|
执行的 run 使用开始时审阅的 commit/config revision 快照,不能借当前分支修改
|
||||||
|
扩大权限。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -141,10 +184,13 @@ description: >-
|
|||||||
v0.10 不自动复用既有 worker。
|
v0.10 不自动复用既有 worker。
|
||||||
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
- launcher 返回 `indeterminate` 或 `reconcile required` 时,不直接重试;先按
|
||||||
launch ID、外部 record 和 Orca live state 完成人工核对。
|
launch ID、外部 record 和 Orca live state 完成人工核对。
|
||||||
- 不覆盖已有 `docs/ack` 文件,不擅自提交、推送、创建终端或新 worktree。
|
- 不覆盖已有 `docs/ack` 文件;除用户确认的 ACK 任务或 delivery profile 外,不擅自
|
||||||
- 只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`;Developer 与 Test 只读,
|
提交、推送、创建终端、新 worktree、发布产物或部署。
|
||||||
只能通过回报提名或验证知识。
|
- 只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `deliveryRuns`;Developer
|
||||||
|
与 Test 只读,只能通过回报提名或验证知识。`delivery.yaml` 只在显式的交付配置
|
||||||
|
维护中修改。
|
||||||
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
- 不把知识正文或选择器输出拼成 shell;知识检查只能通过 `run_verification.py`
|
||||||
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
按 registry ID 执行。不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml` 和
|
- 项目只保存 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml`;通用资源始终从当前 ACK Skill 目录读取。
|
`docs/ack/knowledge.yaml` 和可选的 `docs/ack/delivery.yaml`;通用资源始终从当前
|
||||||
|
ACK Skill 目录读取。
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.10.0
|
0.12.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "ACK"
|
display_name: "ACK"
|
||||||
short_description: "初始化、检查并运行项目里的 ACK 三角色协作闭环"
|
short_description: "初始化、检查并运行 ACK 开发、验证与可选交付闭环"
|
||||||
default_prompt: "Use $ack to initialize ACK for this project or coordinate work from docs/ack."
|
default_prompt: "Use $ack to initialize or check ACK, coordinate verified work, maintain project delivery configuration, or run an approved delivery profile."
|
||||||
policy:
|
policy:
|
||||||
allow_implicit_invocation: false
|
allow_implicit_invocation: false
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
version: 1
|
||||||
|
updatedAt: "2026-08-01T10:00:00+08:00"
|
||||||
|
project:
|
||||||
|
name: "notes-api"
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
defaultProfile: "review"
|
||||||
|
|
||||||
|
entrypoints:
|
||||||
|
verify:
|
||||||
|
kind: make
|
||||||
|
target: verify
|
||||||
|
args: []
|
||||||
|
requiredSecrets: []
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 1800
|
||||||
|
build-deb:
|
||||||
|
kind: make
|
||||||
|
target: build-deb
|
||||||
|
args: []
|
||||||
|
requiredSecrets: []
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 3600
|
||||||
|
build-image:
|
||||||
|
kind: make
|
||||||
|
target: build-image
|
||||||
|
args: []
|
||||||
|
requiredSecrets: []
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 3600
|
||||||
|
upload-deb:
|
||||||
|
kind: script
|
||||||
|
path: "scripts/upload-preview-deb.sh"
|
||||||
|
args: []
|
||||||
|
requiredSecrets: ["DEB_TOKEN"]
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 900
|
||||||
|
upload-image:
|
||||||
|
kind: script
|
||||||
|
path: "scripts/publish-preview-image.sh"
|
||||||
|
args: []
|
||||||
|
requiredSecrets: ["REGISTRY_TOKEN"]
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 1800
|
||||||
|
deploy-test:
|
||||||
|
kind: script
|
||||||
|
path: "scripts/deploy-test.sh"
|
||||||
|
args: []
|
||||||
|
requiredSecrets: []
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 900
|
||||||
|
health-test:
|
||||||
|
kind: script
|
||||||
|
path: "scripts/check-test.sh"
|
||||||
|
args: []
|
||||||
|
requiredSecrets: []
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 300
|
||||||
|
rollback-test:
|
||||||
|
kind: script
|
||||||
|
path: "scripts/rollback-test.sh"
|
||||||
|
args: []
|
||||||
|
requiredSecrets: []
|
||||||
|
workingDirectory: "."
|
||||||
|
timeoutSeconds: 900
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
service-deb:
|
||||||
|
type: deb
|
||||||
|
build: build-deb
|
||||||
|
outputs: ["dist/*.deb"]
|
||||||
|
service-image:
|
||||||
|
type: oci-image
|
||||||
|
build: build-image
|
||||||
|
image: "registry.example.com/notes/service"
|
||||||
|
platforms: ["linux/amd64", "linux/arm64"]
|
||||||
|
|
||||||
|
destinations:
|
||||||
|
preview-apt:
|
||||||
|
type: apt-repository
|
||||||
|
channel: preview
|
||||||
|
endpoint: "https://packages.example.com"
|
||||||
|
repository: "testing"
|
||||||
|
upload: upload-deb
|
||||||
|
preview-registry:
|
||||||
|
type: oci-registry
|
||||||
|
channel: preview
|
||||||
|
registry: "registry.example.com"
|
||||||
|
repository: "notes/service"
|
||||||
|
upload: upload-image
|
||||||
|
|
||||||
|
environments:
|
||||||
|
test-server:
|
||||||
|
type: ssh-host
|
||||||
|
classification: development
|
||||||
|
target: "notes-test"
|
||||||
|
deploy: deploy-test
|
||||||
|
healthCheck: health-test
|
||||||
|
rollback: rollback-test
|
||||||
|
mutex: "notes-test-deploy"
|
||||||
|
|
||||||
|
profiles:
|
||||||
|
review:
|
||||||
|
stopAt: review_ready
|
||||||
|
steps:
|
||||||
|
- id: verify
|
||||||
|
action: verify
|
||||||
|
entrypoint: verify
|
||||||
|
- id: open-pr
|
||||||
|
action: pull-request
|
||||||
|
draft: true
|
||||||
|
remote: origin
|
||||||
|
baseBranch: main
|
||||||
|
- id: build-deb
|
||||||
|
action: build
|
||||||
|
artifact: service-deb
|
||||||
|
- id: publish-deb
|
||||||
|
action: publish
|
||||||
|
artifact: service-deb
|
||||||
|
destination: preview-apt
|
||||||
|
- id: deploy-test
|
||||||
|
action: deploy
|
||||||
|
artifact: service-deb
|
||||||
|
environment: test-server
|
||||||
|
- id: smoke-test
|
||||||
|
action: health-check
|
||||||
|
environment: test-server
|
||||||
|
- id: ready
|
||||||
|
action: mark-ready
|
||||||
|
review-image:
|
||||||
|
stopAt: review_ready
|
||||||
|
steps:
|
||||||
|
- id: verify
|
||||||
|
action: verify
|
||||||
|
entrypoint: verify
|
||||||
|
- id: open-pr
|
||||||
|
action: pull-request
|
||||||
|
draft: true
|
||||||
|
remote: origin
|
||||||
|
baseBranch: main
|
||||||
|
- id: build-image
|
||||||
|
action: build
|
||||||
|
artifact: service-image
|
||||||
|
- id: publish-image
|
||||||
|
action: publish
|
||||||
|
artifact: service-image
|
||||||
|
destination: preview-registry
|
||||||
|
- id: ready
|
||||||
|
action: mark-ready
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# notes-web Agent 协作协议(示例,项目覆盖层)
|
# notes-web Agent 协作协议(示例,项目覆盖层)
|
||||||
|
|
||||||
> 本项目基于 ack v0.10.0。
|
> 本项目基于 ack v0.12.0。
|
||||||
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
> 通用规范由 `/ack` 从 Skill 自身的 `references/` 读取,本文件只填项目差异。
|
||||||
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
> 覆盖层文件放在 `docs/ack/project.md`,不占用 `AGENTS.md`。
|
||||||
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> ACK 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> `docs/ack/` 只保存 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
|
> `docs/ack/` 只保存 `project.md`、`tasks.yaml`、`knowledge.yaml` 与 `delivery.yaml`。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
- Base URL:`http://localhost:5173`
|
- Base URL:`http://localhost:5173`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`docs/ack/knowledge.yaml`
|
||||||
|
- 交付契约:`docs/ack/delivery.yaml`
|
||||||
- 覆盖层文件:`docs/ack/project.md`
|
- 覆盖层文件:`docs/ack/project.md`
|
||||||
|
|
||||||
## 稳定规范(引用,不重复)
|
## 稳定规范(引用,不重复)
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
|
- 优化方法(验收信号、三轮策略):`references/optimization-method.md`
|
||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令:`references/orca-adapter.md`
|
- Orca 编排命令:`references/orca-adapter.md`
|
||||||
|
- 验证后交付:`references/delivery.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `.env`、`config/local.*` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -75,8 +78,9 @@ curl -s -X POST http://localhost:5173/api/fix/preview -d @fixtures/preview.json
|
|||||||
ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
|
ID 对应仓库内相对 path 和结构化 args。知识正文不保存或自动执行自由 shell 命令。
|
||||||
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
执行时只把检查 ID 交给 Skill 的 `scripts/run_verification.py`,不直接拼接
|
||||||
path/args。
|
path/args。
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 执行。
|
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
||||||
|
交付机器入口以 `delivery.yaml` 为准,本覆盖层不维护第二套发布或部署命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
@@ -93,5 +97,7 @@ path/args。
|
|||||||
`candidate` 不派发,知识库不全量注入。
|
`candidate` 不派发,知识库不全量注入。
|
||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
`knowledgeChecks`;关键约束应继续下沉到测试、lint、CI 或正式规范。
|
||||||
|
- 交付只在任务 `verified` 后运行;默认 profile 停在 `review_ready`。stable 发布与
|
||||||
|
production 部署保留显式 approval,配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续。
|
||||||
- 不提交或推送,除非用户明确要求。
|
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updatedAt: "2026-07-06T09:40:00+08:00"
|
updatedAt: "2026-07-06T09:40:00+08:00"
|
||||||
source: "Coordinator (PM) Agent"
|
source: "Coordinator (PM) Agent"
|
||||||
ackVersion: "0.10.0"
|
ackVersion: "0.12.0"
|
||||||
project:
|
project:
|
||||||
name: "notes-web"
|
name: "notes-web"
|
||||||
repoPath: "/home/dev/notes-web"
|
repoPath: "/home/dev/notes-web"
|
||||||
@@ -11,6 +11,7 @@ project:
|
|||||||
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
devWorktree: "/home/dev/notes-web-wt/fix-preview"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: "docs/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||||
|
deliveryFile: "docs/ack/delivery.yaml"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "manual"
|
mode: "manual"
|
||||||
@@ -70,6 +71,7 @@ project:
|
|||||||
developerUpgraded: "codex-dev-strong"
|
developerUpgraded: "codex-dev-strong"
|
||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
|
deliveryRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: ["BUG-002"]
|
verified: ["BUG-002"]
|
||||||
@@ -102,6 +104,12 @@ tasks:
|
|||||||
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
|
evidence: "服务实例、worktree 与 commit 9f8e7d6 一致"
|
||||||
checkedBy: "test-worker-1"
|
checkedBy: "test-worker-1"
|
||||||
checkedAt: "2026-07-06T09:36:00+08:00"
|
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: >
|
description: >
|
||||||
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
用户在 /fix 页点击“预览变更”后,确认区不渲染 API 返回的 diff。
|
||||||
stepsToReproduce:
|
stepsToReproduce:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
- [ ] ACK Skill 已全局安装或安装到当前项目。
|
||||||
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
- [ ] 已运行 `skiff init ack --project <project-root>`。
|
||||||
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`。
|
- [ ] `docs/ack/` 只包含项目自己的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与
|
||||||
|
默认关闭的 `delivery.yaml`。
|
||||||
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
- [ ] 旧项目缺少 `knowledge.yaml` 时,只补空文件及缺失的
|
||||||
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
`project.knowledgeFile` 指针,没有重跑初始化或覆盖其它项目状态。
|
||||||
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
- [ ] 项目中没有 ACK Skill 的复制目录或 `kit`、`framework` 软链接。
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
- [ ] `tasks.yaml` 的 `project.overlayFile` 指向实际覆盖层。
|
||||||
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
- [ ] `tasks.yaml` 的 `project.knowledgeFile` 固定为
|
||||||
`docs/ack/knowledge.yaml`。
|
`docs/ack/knowledge.yaml`。
|
||||||
|
- [ ] 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,顶层有
|
||||||
|
`deliveryRuns: []`;旧项目未采用交付能力时可无这两项。
|
||||||
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
- [ ] 技术栈、运行、构建、单测和集成测试命令均来自项目证据。
|
||||||
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
- [ ] Coordinator、Developer、Test 的模型档位和升级规则已明确。
|
||||||
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
- [ ] `project.orchestration` 使用受支持的 profileVersion,模型都命中项目
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
- [ ] 私有配置只读且不提交。
|
- [ ] 私有配置只读且不提交。
|
||||||
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
- [ ] `tasks.yaml` 只有 Coordinator 写。
|
||||||
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
- [ ] `knowledge.yaml` 只有 Coordinator 写;Developer 与 Test 只通过回报提名或验证。
|
||||||
|
- [ ] `delivery.yaml` 只在用户显式维护配置时修改;Developer 与 Test 只读。
|
||||||
|
|
||||||
## 任务板
|
## 任务板
|
||||||
|
|
||||||
@@ -38,6 +42,22 @@
|
|||||||
- [ ] 真实任务的验收是可观测信号。
|
- [ ] 真实任务的验收是可观测信号。
|
||||||
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
|
- [ ] 已运行 `<ack-skill-dir>/scripts/validate_tasks.py` 并通过。
|
||||||
|
|
||||||
|
## 可选交付
|
||||||
|
|
||||||
|
- [ ] `delivery.yaml` 首次生成保持 `enabled: false`,没有根据 README/CI 自动启用。
|
||||||
|
- [ ] entrypoint 只使用声明式工具 target 或仓库内无 symlink 的可执行脚本;没有
|
||||||
|
shell、自由 command、凭据值或环境变量值。
|
||||||
|
- [ ] artifact、destination、environment 和 profile 引用均通过
|
||||||
|
`validate_delivery.py --tasks ... --project-root ...` 校验。
|
||||||
|
- [ ] 默认 profile 只使用 preview/staging 与非 production 环境,停止在
|
||||||
|
`review_ready`。
|
||||||
|
- [ ] stable 发布和 production 部署之前存在 approval 步骤;production 环境有
|
||||||
|
rollback 入口。
|
||||||
|
- [ ] 本次 ACK 计划明确 profile、task IDs、目标、停止点和审批步骤;配置本身没有
|
||||||
|
被当作提交、推送、发布或部署授权。
|
||||||
|
- [ ] 每次运行固定 commit/config revision,证据写入 `tasks.yaml.deliveryRuns`;
|
||||||
|
失败不会把已验证任务回退为失败。
|
||||||
|
|
||||||
## 项目知识
|
## 项目知识
|
||||||
|
|
||||||
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
- [ ] 新项目没有已验证知识时使用 `verificationRegistry: {}` 与 `entries: []`,
|
||||||
|
|||||||
@@ -164,11 +164,12 @@ python3 <ack-skill-dir>/scripts/launch_worker.py launch \
|
|||||||
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
| 要保持基线分支干净 | 新 worktree(feature 分支)|
|
||||||
| 小改动、追求快 | 当前 worktree |
|
| 小改动、追求快 | 当前 worktree |
|
||||||
|
|
||||||
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml` 和
|
**项目状态(SSOT)只落一处**:无论开几个 worktree,`tasks.yaml`、
|
||||||
`knowledge.yaml` 都只认一个权威副本(通常在基线/协调所在 worktree),由
|
`knowledge.yaml` 和可选 `delivery.yaml` 都只认一个权威副本(通常在基线/协调所在
|
||||||
Coordinator 单写。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch
|
worktree)。Coordinator 单写任务、知识与 `deliveryRuns`;交付能力只在显式配置维护
|
||||||
也只写入这个副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、
|
时修改。`project.orchestration`、顶层 `workerReceipts` 和任务 dispatch 也只写入这个
|
||||||
launcher 与 receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
副本;不要每个 worktree 各留一份会分叉的项目状态。profile 解析、launcher 与
|
||||||
|
receipt 规则见 `model-routing.md` 和 `orca-adapter.md`。
|
||||||
|
|
||||||
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
|
两种 worktree 方式都只允许 profile 中的 `read-only` / `workspace-write` 安全权限。
|
||||||
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
|
v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 full-access 或等价
|
||||||
@@ -191,7 +192,8 @@ v0.10 的 full-access 授权与撤销机制仍是 Deferred;launcher 遇到 ful
|
|||||||
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
7. 失败则用「复测失败再派发模板」重新发给 Developer,最多累计三轮。
|
||||||
|
|
||||||
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
手动模式下同样遵守:worker_done / 复测报告都不等于最终结论、只有 Coordinator
|
||||||
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。
|
写 `tasks.yaml` 和 `knowledge.yaml`、三轮失败留档。若本次确认计划还包含交付,所有
|
||||||
|
选中任务 `verified` 后由 Coordinator 继续按 `delivery.md` 执行并写 `deliveryRuns`。
|
||||||
|
|
||||||
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
手动交给用户已打开的会话不等于产生 ACK receipt。该会话可以完成本次显式 handoff,
|
||||||
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
|
但不能因此进入 Orca 自动派发信任路径;后续自动派发仍需重新通过 ACK launcher
|
||||||
|
|||||||
@@ -0,0 +1,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 不能绕过它的触发与授权边界。
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 飞书 Base Bug 收件箱
|
||||||
|
|
||||||
|
这是可选的只读接入。项目在 `docs/ack/tasks.yaml` 的 `project.bugIntake` 声明
|
||||||
|
`provider: feishu-base`、显式 `profile`、`baseToken`、`tableId`、ACK Ready 的 `viewId`
|
||||||
|
和八个字段映射;不要保存 App Secret、access token 或任何 profile 凭据。
|
||||||
|
|
||||||
|
## 一次性安装与 profile 设置
|
||||||
|
|
||||||
|
在账号级可信工具目录安装官方 CLI;ACK 读取器只会搜索账号的 `~/.local/bin`、mise/cargo
|
||||||
|
shim 目录和固定系统目录,绝不会采用项目 `PATH` 中的同名文件。它同时识别官方 npm
|
||||||
|
包生成的 `lark-cli -> @larksuite/cli/scripts/run.js` wrapper,并校验 package 名与 bin
|
||||||
|
映射,然后直接执行该官方包下载的 native binary;这样不依赖 nvm PATH,也不会让 Node
|
||||||
|
运行时变量进入凭据边界。缺少 native binary 或同名仿冒 wrapper 都会被拒绝:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --global --prefix "$HOME/.local" @larksuite/cli@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
固定 `--prefix "$HOME/.local"` 是接入契约的一部分,确保 npm wrapper 落在读取器会检查的
|
||||||
|
账号级可信目录;不要依赖 nvm 或其它由当前 shell 动态注入的 PATH 位置。
|
||||||
|
|
||||||
|
随后在受控终端中用 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
|
||||||
|
```
|
||||||
|
|
||||||
|
随后在 project 配置里只填写 `profile: project-feishu`。不要执行 `profile use`,也不要
|
||||||
|
依赖 active profile;每次读取和附件下载都由 adapter 显式传 `--profile project-feishu`。
|
||||||
|
为读取记录和下载附件,profile 必须具有 `base:record:read` 和
|
||||||
|
`docs:document.media:download`。请在飞书开发者后台的应用权限中为该 app 授予这两个
|
||||||
|
scope;不要把 `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`(限定配置的 view 和字段)和按配置附件字段的
|
||||||
|
`base +record-download-attachment`。它输出单一 JSON,验证行矩阵、分页和下载路径;CLI、
|
||||||
|
profile、JSON、分页、附件或路径任一异常都会失败且不输出伪成功结果。
|
||||||
|
|
||||||
|
`plan` 在同一批标准化记录上读取现有 `tasks`,只输出整理计划而不修改文件:新来源为
|
||||||
|
`create`,同来源且现有任务为 `open`、来源时间有变化时为 `refresh`,未变化为
|
||||||
|
`unchanged`,其它 ACK 状态发生来源变化时为 `drift`。任务板或读取结果出现重复
|
||||||
|
`sourceRef` 会直接失败。
|
||||||
|
|
||||||
|
子进程只收到实际账号 HOME、可信 PATH 和基础 locale;调用者环境中的
|
||||||
|
`LARKSUITE_CLI_*`、`FEISHU_*`、`NODE_OPTIONS` 等变量不会传入,避免环境凭据或运行时
|
||||||
|
注入绕过项目 profile。每条记录最多 10 个附件、单批最多 100 个,单个附件最多
|
||||||
|
20 MiB、合计最多 200 MiB,整批附件下载最多 5 分钟,并校验声明大小与落盘大小;
|
||||||
|
请始终使用新的临时目录作为 `--output-dir`。
|
||||||
|
|
||||||
|
`profile list` 只作存在性检查,不会输出 profile 内容。官方 record-list JSON 使用
|
||||||
|
`data.fields`、`data.record_id_list` 与同长度的 `data.data` 行矩阵;`ok: true` 或
|
||||||
|
`code: 0` 是唯一可接受的成功 envelope。
|
||||||
|
|
||||||
|
## Coordinator 整理
|
||||||
|
|
||||||
|
读取结果的每条 `sourceRef` 是稳定且不透明的键,例如
|
||||||
|
`feishu-base:sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`。
|
||||||
|
导入时写入 task 的
|
||||||
|
`source.kind: feishu-base`、`source.ref`、`source.recordId` 与 `source.updatedAt`;先按
|
||||||
|
`source.ref` 查重。相同来源只更新 `open` 任务;`dispatched`、`fixed_by_dev`、
|
||||||
|
`retesting`、`failed_retest`、`verified`、`blocked` 和 `leftover` 任务只告警来源漂移,
|
||||||
|
由用户决定是否新建任务;飞书记录消失、不可访问或同步失败时,已有 ACK 任务一律保留。
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
3. `skiff` 命令可用。
|
3. `skiff` 命令可用。
|
||||||
|
|
||||||
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
不要覆盖已有的 `docs/ack/project.md`、`docs/ack/tasks.yaml`、
|
||||||
`docs/ack/knowledge.yaml`、`AGENTS.md` 或其它 Agent 指令文件。ACK 不会自动
|
`docs/ack/knowledge.yaml`、`docs/ack/delivery.yaml`、`AGENTS.md` 或其它 Agent
|
||||||
|
指令文件。ACK 不会自动
|
||||||
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。不要把 token、`.env`
|
||||||
内容或其它私有配置写入 ACK 项目状态。
|
内容或其它私有配置写入 ACK 项目状态。
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ skiff init ack --project <project-root>
|
|||||||
docs/ack/
|
docs/ack/
|
||||||
├── project.md
|
├── project.md
|
||||||
├── tasks.yaml
|
├── tasks.yaml
|
||||||
└── knowledge.yaml
|
├── knowledge.yaml
|
||||||
|
└── delivery.yaml # 默认 enabled: false
|
||||||
```
|
```
|
||||||
|
|
||||||
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
如果任一目标文件已经存在,命令会拒绝覆盖。初始化过程不会创建 `kit`、
|
||||||
@@ -52,6 +54,14 @@ docs/ack/
|
|||||||
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
`knowledgeFile: docs/ack/knowledge.yaml`,不改写其它项目状态。生成后运行任务板、
|
||||||
知识库和跨文件引用校验。
|
知识库和跨文件引用校验。
|
||||||
|
|
||||||
|
### 旧项目补充交付配置
|
||||||
|
|
||||||
|
`delivery.yaml` 对旧项目是可选能力;缺少它不会影响三角色开发与验证闭环。只有用户
|
||||||
|
明确要求配置项目交付时,才从 `templates/delivery.template.yaml` 生成文件,同时在
|
||||||
|
任务板补 `project.deliveryFile: docs/ack/delivery.yaml` 与顶层
|
||||||
|
`deliveryRuns: []`。首次生成保持 `enabled: false`,按 `delivery.md` 展示并确认
|
||||||
|
解析结果后才启用。不要重跑 `skiff init ack`,也不要改写已有任务或知识。
|
||||||
|
|
||||||
## 完善项目覆盖层
|
## 完善项目覆盖层
|
||||||
|
|
||||||
编辑 `docs/ack/project.md`,填入:
|
编辑 `docs/ack/project.md`,填入:
|
||||||
@@ -70,9 +80,13 @@ docs/ack/
|
|||||||
|
|
||||||
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
- `ackVersion` 使用 ACK Skill 的合法 SemVer `VERSION`;从 `0.10.0` 起
|
||||||
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
`project.orchestration` 与顶层 `workerReceipts` 必须同时存在。
|
||||||
|
- 从 `0.11.0` 起的新项目初始化包含默认关闭的交付契约;旧项目不要求为了版本号升级
|
||||||
|
自动补交付配置。
|
||||||
- `updatedAt` 使用当前带时区时间。
|
- `updatedAt` 使用当前带时区时间。
|
||||||
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
- `project.name`、`repoPath`、`devWorktree`、`overlayFile` 和 `knowledgeFile` 使用
|
||||||
真实值。
|
真实值。
|
||||||
|
- 新项目的 `project.deliveryFile` 固定为 `docs/ack/delivery.yaml`,并保留顶层
|
||||||
|
`deliveryRuns: []`。旧项目只有在采用交付能力时才补这两个字段。
|
||||||
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
- `project.orchestration.allowedWorktrees` 使用已核对的绝对 worktree;模型
|
||||||
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
allowlist、profiles 和 defaults 使用项目实际允许值。不要把完整启动命令、
|
||||||
`extraArgs`、`env` 或任意 executable 写进任务板。
|
`extraArgs`、`env` 或任意 executable 写进任务板。
|
||||||
@@ -98,6 +112,14 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
|
|||||||
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
|
`<ack-skill-dir>/scripts/run_verification.py`,不直接运行 path/args。关键约束应
|
||||||
最终下沉为测试、lint、CI 或正式规范。
|
最终下沉为测试、lint、CI 或正式规范。
|
||||||
|
|
||||||
|
## 初始化项目交付
|
||||||
|
|
||||||
|
新项目的 `docs/ack/delivery.yaml` 保持 `enabled: false`、空能力表和空 profile。
|
||||||
|
不要根据 README 或 CI 自动推断并启用发布/部署。用户用自然语言描述交付要求后,
|
||||||
|
Coordinator 按 `delivery.md` 维护声明式配置:工具 target 与仓库脚本分开引用,产物、
|
||||||
|
目的地、环境和有序 profile 使用稳定 ID。配置中不保存 shell、环境变量值或凭据
|
||||||
|
正文;稳定发布和生产部署必须有显式 approval 步骤。
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
||||||
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
||||||
@@ -105,13 +127,17 @@ Agent 从当前 `SKILL.md` 解析 ACK Skill 目录后运行:
|
|||||||
```bash
|
```bash
|
||||||
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_tasks.py docs/ack/tasks.yaml
|
||||||
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --tasks docs/ack/tasks.yaml
|
||||||
|
python3 <ack-skill-dir>/scripts/validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
同时确认:
|
同时确认:
|
||||||
|
|
||||||
- `project.md`、`tasks.yaml` 和 `knowledge.yaml` 没有未替换的 `<...>` 占位符。
|
- `project.md`、`tasks.yaml`、`knowledge.yaml` 和 `delivery.yaml` 没有未替换的
|
||||||
|
`<...>` 占位符。
|
||||||
- `project.overlayFile` 指向真实文件。
|
- `project.overlayFile` 指向真实文件。
|
||||||
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
- `project.knowledgeFile` 指向 `docs/ack/knowledge.yaml`。
|
||||||
|
- 新项目的 `project.deliveryFile` 指向 `docs/ack/delivery.yaml`;交付默认关闭。
|
||||||
- Developer 与 Test 的验证命令可执行。
|
- Developer 与 Test 的验证命令可执行。
|
||||||
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
- `project.orchestration` 的 profile/allowlist/defaults 通过校验,自动模式只允许
|
||||||
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
`read-only` 或 `workspace-write`;旧任务板未迁移时保持手动模式。
|
||||||
@@ -125,9 +151,9 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
|||||||
|
|
||||||
完成后报告:
|
完成后报告:
|
||||||
|
|
||||||
- 创建或确认的三个项目文件。
|
- 创建或确认的四个项目文件。
|
||||||
- 检测到的技术栈和验证命令。
|
- 检测到的技术栈和验证命令。
|
||||||
- 任务板和项目知识校验结果。
|
- 任务板、项目知识和交付契约校验结果。
|
||||||
- 仍需用户补充的值。
|
- 仍需用户补充的值。
|
||||||
|
|
||||||
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
||||||
|
|||||||
@@ -23,15 +23,20 @@
|
|||||||
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
1. 先读 docs/ack/project.md、docs/ack/tasks.yaml(包括 project.orchestration),校验 docs/ack/knowledge.yaml 并
|
||||||
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
用 `scripts/select_knowledge.py` 只读取当前任务相关的 active 条目,再读
|
||||||
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
references/roles-and-permissions.md、closed-loop.md、optimization-method.md。
|
||||||
|
如果 tasks.yaml 声明 project.deliveryFile,再读取 delivery.yaml 与
|
||||||
|
references/delivery.md,但不要把配置本身当作执行授权。
|
||||||
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
2. 写产品文档到 docs/(PRD / 交互 / 验收),把需求拆成任务,每个任务的验收写成可观测信号(可见文本 / API 结果 / 交互结果)。
|
||||||
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
3. 按任务 scope 从 knowledge.yaml 推荐 active 知识,确认后把固定 revision 的
|
||||||
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
knowledgeRefs 写入任务;不要派发 candidate 或全量知识库。
|
||||||
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
4. 把任务写进 docs/ack/tasks.yaml(只有你写),校验 tasks.yaml 和 knowledge.yaml。
|
||||||
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认,不要急着派发。
|
5. 先把「产品文档 + 任务拆分 + 验收信号 + 适用知识引用」给我确认;若启用了交付,
|
||||||
|
同时列明本次 profile、目标、停止点与审批步骤。不要急着派发或交付。
|
||||||
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
6. 我确认后,按 ack 闭环循环:先用 `scripts/launch_worker.py` 校验结构化
|
||||||
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
Developer/Test profile,审阅 plan 后用 expected fingerprint 创建 fresh worker,
|
||||||
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
dispatch 开发 → worker_done → dispatch 测试独立复测 → 你读证据终检 → 回写 tasks.yaml;
|
||||||
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
每个任务最多三轮,三轮不过记 leftover 并升级我复盘。
|
||||||
|
7. 所选任务都 verified 后,只有本次计划包含交付时才按 profile 顺序执行并写
|
||||||
|
deliveryRuns;默认停在 review_ready,stable/production 步骤再次向我确认。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -55,6 +60,9 @@ python3 <ack-skill-dir>/scripts/validate_knowledge.py docs/ack/knowledge.yaml --
|
|||||||
|
|
||||||
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
5. **停下来给人确认**:这是强模型该花时间的地方,不要跳过。
|
||||||
|
|
||||||
|
如果 `delivery.enabled: true`,确认内容还必须列出:profile、关联 task IDs、构建产物、
|
||||||
|
发布 channel、部署环境、PR 是否创建/标 ready、停止点,以及哪些步骤会再次审批。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第 2 步:决定 worktree
|
## 第 2 步:决定 worktree
|
||||||
@@ -143,7 +151,16 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第 5 步:收尾
|
## 第 5 步:可选交付
|
||||||
|
|
||||||
|
所选任务都由 Coordinator 标记为 `verified` 后,若用户确认的计划包含交付,按
|
||||||
|
`delivery.md` 执行所选 profile。先重新校验 `delivery.yaml`,固定当前 commit 和
|
||||||
|
config revision,然后按有序步骤调用项目入口与已安装的低层 skill。每一步证据写入
|
||||||
|
`tasks.yaml.deliveryRuns`;默认 profile 到 `review_ready` 即停止,stable 发布和
|
||||||
|
production 部署必须在 approval 步骤再次确认。失败时保留任务的 `verified`,把
|
||||||
|
delivery run 标为 `blocked` 或 `failed`。
|
||||||
|
|
||||||
|
## 第 6 步:收尾
|
||||||
|
|
||||||
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
一轮结束时 Coordinator 必须能回答 `optimization-method.md` §「结束条件」的问题:哪些 verified、哪些 leftover、各失败几轮、工作树是否干净、还有没有未处理项。
|
||||||
|
|
||||||
@@ -154,5 +171,5 @@ Coordinator 只内联本轮 `knowledgeRefs` 指向的少量知识,不要求 wo
|
|||||||
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
产品文档 + 验收信号写在前(你,强模型)→ 确认显式 `knowledgeRefs` → 从
|
||||||
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
`tasks.yaml.project.orchestration` 解析安全 profile → 审阅 plan 并用 expected
|
||||||
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
fingerprint 启动 fresh DEV/TEST → dispatch / 复测 / 终检循环 → 任务结论落
|
||||||
`tasks.yaml`,验证后的
|
`tasks.yaml` → 可选 delivery profile 到审核点,验证后的
|
||||||
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
跨任务知识由 Coordinator 落 `knowledge.yaml`。
|
||||||
|
|||||||
@@ -256,6 +256,13 @@ Orca 模式下用 `orca-adapter.md` §「Test 回报复测结果」的命令发
|
|||||||
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
- 新增或更新:<active/stale/superseded entries written by Coordinator, or none>
|
||||||
- 待验证 candidate:<remaining candidates or none>
|
- 待验证 candidate:<remaining candidates or none>
|
||||||
|
|
||||||
|
交付(未启用时写 n/a):
|
||||||
|
- run/profile/status:<delivery run id / profile / review_ready|released|blocked|failed>
|
||||||
|
- PR/MR:<URL and head/base>
|
||||||
|
- 产物:<reference, digest, destination>
|
||||||
|
- 部署:<environment, health/rollback evidence>
|
||||||
|
- 待审批或未完成:<approval/blocked step or none>
|
||||||
|
|
||||||
工作树状态:
|
工作树状态:
|
||||||
- <repo_path>: <git status summary>
|
- <repo_path>: <git status summary>
|
||||||
- <dev_worktree>: <git status summary>
|
- <dev_worktree>: <git status summary>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
|
|
||||||
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
| 角色 | 主要职责 | 验证方式 | 不应做的事 |
|
||||||
|------|----------|----------|------------|
|
|------|----------|----------|------------|
|
||||||
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate | 读 Test 证据并对齐原始意图(不亲自跑测试) | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识 |
|
| Coordinator (PM) | 需求拆解、定验收信号、排优先级、单写 `tasks.yaml` / `knowledge.yaml`、选择知识、向 Developer/Test 派发、跑三轮闭环、做最终 gate;经确认后编排可选交付 | 读 Test 证据并对齐原始意图(不亲自跑测试);核对交付证据 | 修改源码、亲自复测、凭 worker_done 直接标 `verified`、自动激活未验证知识、把配置当作发布授权 |
|
||||||
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
| Test | 黑盒复测、回归验证、执行知识检查、独立验证知识候选、沉淀可执行测试、产出证据 | 浏览器、API、集成脚本、用户可见行为 | 修改应用源码、修改产品规格、写 `tasks.yaml` 或 `knowledge.yaml` |
|
||||||
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
| Developer | 实现修复、写单元测试、运行构建和白盒验证、提名项目知识 | 单元测试、类型检查、构建、本地运行 | 修改产品规格与集成测试、写项目状态、标记 `verified`、绕过测试声称完成 |
|
||||||
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
| User / Decision Owner | 决定范围、优先级、阻塞项是否继续 | 审阅报告和遗留清单 | 直接替代复测证据 |
|
||||||
@@ -109,6 +109,7 @@ ACK 默认三个独立 Agent:**Coordinator 只编排、Test 只验证、Develo
|
|||||||
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
| `<local_config>` | Read-only | Read-only | Read-only | 本地私有配置,不提交 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 见下方「项目状态写入约定」 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | Coordinator 单写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 声明项目交付能力,不保存凭据或执行授权 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,12 +146,28 @@ failed_retest(累计 3 轮) -> leftover
|
|||||||
|
|
||||||
三轮失败的处理细则见 `optimization-method.md` §「三轮失败策略」。
|
三轮失败的处理细则见 `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` 是跨任务项目知识事实源。为避免多
|
`tasks.yaml` 是任务与交付运行事实源,`knowledge.yaml` 是跨任务项目知识事实源,
|
||||||
Agent 并发写冲突:
|
`delivery.yaml` 是项目交付能力事实源。为避免多 Agent 并发写冲突:
|
||||||
|
|
||||||
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
- **只有 Coordinator 写 `tasks.yaml` 和 `knowledge.yaml`**。Test 与 Developer
|
||||||
对它们都是只读的。
|
对它们都是只读的。
|
||||||
@@ -160,6 +177,8 @@ Agent 并发写冲突:
|
|||||||
知识。
|
知识。
|
||||||
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
|
- 每次写入前先读最新内容,写入后更新顶层 `updatedAt`。
|
||||||
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
|
- 单次写入应是一个任务的一次状态跃迁,避免整表批量重写。
|
||||||
|
- `delivery.yaml` 只在用户显式要求维护配置时修改;运行只写
|
||||||
|
`tasks.yaml.deliveryRuns`,不能反向改写能力定义。
|
||||||
|
|
||||||
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
|
全项目范围的 `must`、`never` 或权限类规则还需要 User / Decision Owner 确认。
|
||||||
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
|
关键约束应最终下沉为测试、lint、CI 或正式规范;知识条目保存触发条件、原因和
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read an ACK-ready Feishu Base view through the official lark-cli.
|
||||||
|
|
||||||
|
This is deliberately a small, non-mutating adapter. It 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 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")
|
||||||
|
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
|
||||||
|
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}$")
|
||||||
|
|
||||||
|
|
||||||
|
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 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", "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")
|
||||||
|
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")
|
||||||
|
if not isinstance(fields, dict) or set(fields) != set(REQUIRED_FIELDS):
|
||||||
|
raise IntakeError("bugIntake.fields must map exactly the required 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")
|
||||||
|
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]]]:
|
||||||
|
field_ids = [config["fields"][logical] for logical in REQUIRED_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 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]]]] = []
|
||||||
|
total_attachments = 0
|
||||||
|
total_attachment_bytes = 0
|
||||||
|
for record_id, row in fetch_pages(config):
|
||||||
|
cells = dict(zip(REQUIRED_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"]), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
||||||
|
for field in ("title", "actual", "expected", "steps", "acceptance", "priority", "updatedAt"):
|
||||||
|
if not record[field]:
|
||||||
|
raise IntakeError(f"record {field} must not be empty")
|
||||||
|
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", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records}
|
||||||
|
|
||||||
|
|
||||||
|
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||||
|
"""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, str]] = []
|
||||||
|
seen_records: set[str] = set()
|
||||||
|
for record in records:
|
||||||
|
ref = record.get("sourceRef")
|
||||||
|
record_id = record.get("recordId")
|
||||||
|
updated_at = record.get("updatedAt")
|
||||||
|
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
|
||||||
|
):
|
||||||
|
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:
|
||||||
|
actions.append({"sourceRef": ref, "recordId": record_id, "action": "create"})
|
||||||
|
continue
|
||||||
|
source = task["source"]
|
||||||
|
if source["updatedAt"] == updated_at:
|
||||||
|
action = "unchanged"
|
||||||
|
elif task["status"] == "open":
|
||||||
|
action = "refresh"
|
||||||
|
else:
|
||||||
|
action = "drift"
|
||||||
|
actions.append({
|
||||||
|
"sourceRef": ref,
|
||||||
|
"recordId": record_id,
|
||||||
|
"taskId": task["id"],
|
||||||
|
"status": task["status"],
|
||||||
|
"action": action,
|
||||||
|
})
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Read a configured Feishu Base bug intake")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
for name in ("check", "fetch", "plan"):
|
||||||
|
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")
|
||||||
|
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", "profile": config["profile"], "ok": True}
|
||||||
|
elif args.command == "fetch":
|
||||||
|
output = fetch(config, args.output_dir)
|
||||||
|
else:
|
||||||
|
output = fetch(config, args.output_dir)
|
||||||
|
output["actions"] = plan_actions(board, output["records"])
|
||||||
|
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())
|
||||||
Executable
+809
@@ -0,0 +1,809 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""校验 ACK 项目交付契约。
|
||||||
|
|
||||||
|
权威结构位于 templates/delivery.schema.json。jsonschema 是可选依赖;内置规则始终
|
||||||
|
检查引用、步骤顺序、默认 profile 安全边界、敏感信息和仓库内入口路径。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 validate_delivery.py docs/ack/delivery.yaml
|
||||||
|
python3 validate_delivery.py docs/ack/delivery.yaml \
|
||||||
|
--tasks docs/ack/tasks.yaml --project-root <project-root>
|
||||||
|
|
||||||
|
退出码: 0 通过 / 1 校验失败 / 2 环境或用法错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from yaml_subset import (
|
||||||
|
DuplicateKeyError,
|
||||||
|
YamlSubsetError,
|
||||||
|
load_json_unique,
|
||||||
|
load_yaml_subset,
|
||||||
|
make_unique_pyyaml_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||||
|
RELATIVE_PATH_RE = re.compile(r"^[A-Za-z0-9._/*?+-]+$")
|
||||||
|
PLATFORM_RE = re.compile(r"^[a-z0-9]+/[A-Za-z0-9._-]+$")
|
||||||
|
SECRET_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$")
|
||||||
|
REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||||
|
|
||||||
|
TOP_LEVEL_FIELDS = {
|
||||||
|
"version",
|
||||||
|
"updatedAt",
|
||||||
|
"project",
|
||||||
|
"enabled",
|
||||||
|
"defaultProfile",
|
||||||
|
"entrypoints",
|
||||||
|
"artifacts",
|
||||||
|
"destinations",
|
||||||
|
"environments",
|
||||||
|
"profiles",
|
||||||
|
}
|
||||||
|
ENTRYPOINT_FIELDS = {
|
||||||
|
"kind",
|
||||||
|
"target",
|
||||||
|
"function",
|
||||||
|
"path",
|
||||||
|
"args",
|
||||||
|
"requiredSecrets",
|
||||||
|
"workingDirectory",
|
||||||
|
"timeoutSeconds",
|
||||||
|
}
|
||||||
|
ARTIFACT_FIELDS = {"type", "build", "outputs", "image", "platforms"}
|
||||||
|
DESTINATION_FIELDS = {
|
||||||
|
"type",
|
||||||
|
"channel",
|
||||||
|
"registry",
|
||||||
|
"repository",
|
||||||
|
"endpoint",
|
||||||
|
"artifactName",
|
||||||
|
"upload",
|
||||||
|
}
|
||||||
|
ENVIRONMENT_FIELDS = {
|
||||||
|
"type",
|
||||||
|
"classification",
|
||||||
|
"target",
|
||||||
|
"deploy",
|
||||||
|
"healthCheck",
|
||||||
|
"rollback",
|
||||||
|
"mutex",
|
||||||
|
}
|
||||||
|
PROFILE_FIELDS = {"stopAt", "steps"}
|
||||||
|
STEP_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"action",
|
||||||
|
"entrypoint",
|
||||||
|
"artifact",
|
||||||
|
"destination",
|
||||||
|
"environment",
|
||||||
|
"gate",
|
||||||
|
"draft",
|
||||||
|
"remote",
|
||||||
|
"baseBranch",
|
||||||
|
}
|
||||||
|
|
||||||
|
ENTRYPOINT_KINDS = {"make", "just", "task", "dagger", "script"}
|
||||||
|
ARTIFACT_TYPES = {"deb", "oci-image", "file"}
|
||||||
|
DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
|
||||||
|
CHANNELS = {"preview", "staging", "stable"}
|
||||||
|
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
|
||||||
|
CLASSIFICATIONS = {"development", "staging", "production"}
|
||||||
|
STOP_POINTS = {"verified", "review_ready", "released"}
|
||||||
|
ACTIONS = {
|
||||||
|
"verify",
|
||||||
|
"pull-request",
|
||||||
|
"build",
|
||||||
|
"publish",
|
||||||
|
"deploy",
|
||||||
|
"health-check",
|
||||||
|
"approval",
|
||||||
|
"mark-ready",
|
||||||
|
}
|
||||||
|
ACTION_FIELDS = {
|
||||||
|
"verify": {"entrypoint"},
|
||||||
|
"pull-request": {"draft", "remote", "baseBranch"},
|
||||||
|
"build": {"artifact"},
|
||||||
|
"publish": {"artifact", "destination"},
|
||||||
|
"deploy": {"artifact", "environment"},
|
||||||
|
"health-check": {"environment"},
|
||||||
|
"approval": {"gate"},
|
||||||
|
"mark-ready": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
SECRET_PATTERNS = (
|
||||||
|
("private key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
|
||||||
|
("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
|
||||||
|
("OpenAI-style token", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
|
||||||
|
("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
|
||||||
|
("URL credentials", re.compile(r"https?://[^/\s:@]+:[^/\s@]+@")),
|
||||||
|
(
|
||||||
|
"inline secret assignment",
|
||||||
|
re.compile(
|
||||||
|
r"(?i)\b(?:api[_-]?key|access[_-]?token|password|secret|token)"
|
||||||
|
r"\s*[:=]\s*[\"']?[^\s,\"']{8,}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nonempty(value: Any) -> bool:
|
||||||
|
return isinstance(value, str) and bool(value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any) -> bool:
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_unknown(
|
||||||
|
value: dict[str, Any],
|
||||||
|
allowed: set[str],
|
||||||
|
where: str,
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for field in sorted(set(value) - allowed):
|
||||||
|
errors.append(f"{where}: 未知字段 {field!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_document(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
sys.stderr.write(f"{label}读取失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
try:
|
||||||
|
data = load_json_unique(content)
|
||||||
|
except (json.JSONDecodeError, DuplicateKeyError) as exc:
|
||||||
|
sys.stderr.write(f"{label} JSON 解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
data = load_yaml_subset(content)
|
||||||
|
except YamlSubsetError as exc:
|
||||||
|
sys.stderr.write(f"{label} YAML 子集解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = yaml.load(content, Loader=make_unique_pyyaml_loader(yaml))
|
||||||
|
except yaml.YAMLError as exc: # type: ignore
|
||||||
|
sys.stderr.write(f"{label} YAML 解析失败: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
sys.stderr.write(f"{label}顶层必须是对象(mapping)\n")
|
||||||
|
raise SystemExit(1)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relative_path(value: Any, *, allow_glob: bool = False) -> bool:
|
||||||
|
if not _nonempty(value) or value.startswith("/") or "\\" in value:
|
||||||
|
return False
|
||||||
|
if not RELATIVE_PATH_RE.fullmatch(value):
|
||||||
|
return False
|
||||||
|
if not allow_glob and any(marker in value for marker in "*?"):
|
||||||
|
return False
|
||||||
|
parts = PurePosixPath(value).parts
|
||||||
|
return ".." not in parts and all(part not in {"", "/"} for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_branch_name(value: Any) -> bool:
|
||||||
|
if not _nonempty(value) or len(value) > 255:
|
||||||
|
return False
|
||||||
|
if value == "@" or value.startswith(("/", ".", "-")):
|
||||||
|
return False
|
||||||
|
if value.endswith(("/", ".", ".lock")):
|
||||||
|
return False
|
||||||
|
if "@{" in value or ".." in value or "//" in value:
|
||||||
|
return False
|
||||||
|
return re.search(r"[\x00-\x20\x7f~^:?*\[\\]", value) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_path_binding(
|
||||||
|
project_root: Path,
|
||||||
|
relative_path: str,
|
||||||
|
where: str,
|
||||||
|
*,
|
||||||
|
expected: str,
|
||||||
|
) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
current = project_root
|
||||||
|
parts = PurePosixPath(relative_path).parts
|
||||||
|
if relative_path == ".":
|
||||||
|
parts = ()
|
||||||
|
for index, part in enumerate(parts):
|
||||||
|
current = current / part
|
||||||
|
try:
|
||||||
|
metadata = current.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return [f"{where}: 路径不存在: {relative_path!r}"]
|
||||||
|
except OSError as exc:
|
||||||
|
return [f"{where}: 路径不可访问: {relative_path!r}: {exc}"]
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
return [f"{where}: 路径不能包含 symlink: {relative_path!r}"]
|
||||||
|
if index < len(parts) - 1 and not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
return [f"{where}: 中间路径不是目录: {relative_path!r}"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata
|
||||||
|
except UnboundLocalError:
|
||||||
|
metadata = project_root.lstat()
|
||||||
|
if expected == "directory" and not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
errors.append(f"{where}: 必须指向目录: {relative_path!r}")
|
||||||
|
if expected == "executable":
|
||||||
|
if not stat.S_ISREG(metadata.st_mode):
|
||||||
|
errors.append(f"{where}: 必须指向普通文件: {relative_path!r}")
|
||||||
|
elif metadata.st_mode & 0o111 == 0:
|
||||||
|
errors.append(f"{where}: 脚本不可执行: {relative_path!r}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_secrets(value: Any, where: str, errors: list[str]) -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, item in value.items():
|
||||||
|
_scan_secrets(item, f"{where}.{key}", errors)
|
||||||
|
return
|
||||||
|
if isinstance(value, list):
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
_scan_secrets(item, f"{where}[{index}]", errors)
|
||||||
|
return
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return
|
||||||
|
for label, pattern in SECRET_PATTERNS:
|
||||||
|
if pattern.search(value):
|
||||||
|
errors.append(f"{where}: 疑似包含敏感信息({label})")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ids(values: Any, where: str, errors: list[str]) -> dict[str, Any]:
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
return {}
|
||||||
|
for key in values:
|
||||||
|
if not isinstance(key, str) or ID_RE.fullmatch(key) is None:
|
||||||
|
errors.append(f"{where}: ID {key!r} 必须使用小写连字符格式")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_entrypoints(
|
||||||
|
values: dict[str, Any],
|
||||||
|
errors: list[str],
|
||||||
|
project_root: Path | None,
|
||||||
|
) -> None:
|
||||||
|
for entrypoint_id, value in values.items():
|
||||||
|
where = f"entrypoints.{entrypoint_id}"
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(value, ENTRYPOINT_FIELDS, where, errors)
|
||||||
|
kind = value.get("kind")
|
||||||
|
if kind not in ENTRYPOINT_KINDS:
|
||||||
|
errors.append(f"{where}.kind: 必须是 {sorted(ENTRYPOINT_KINDS)}")
|
||||||
|
required_selector = {
|
||||||
|
"make": "target",
|
||||||
|
"just": "target",
|
||||||
|
"task": "target",
|
||||||
|
"dagger": "function",
|
||||||
|
"script": "path",
|
||||||
|
}.get(kind)
|
||||||
|
for selector in ("target", "function", "path"):
|
||||||
|
if selector == required_selector:
|
||||||
|
if not _nonempty(value.get(selector)):
|
||||||
|
errors.append(f"{where}.{selector}: {kind} 入口必须填写非空值")
|
||||||
|
elif selector in value:
|
||||||
|
errors.append(f"{where}.{selector}: kind={kind!r} 不允许此字段")
|
||||||
|
|
||||||
|
args = value.get("args")
|
||||||
|
if not isinstance(args, list) or any(not isinstance(item, str) for item in args):
|
||||||
|
errors.append(f"{where}.args: 必须是字符串列表")
|
||||||
|
required_secrets = value.get("requiredSecrets")
|
||||||
|
if (
|
||||||
|
not isinstance(required_secrets, list)
|
||||||
|
or any(
|
||||||
|
not isinstance(item, str) or SECRET_NAME_RE.fullmatch(item) is None
|
||||||
|
for item in required_secrets
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
isinstance(required_secrets, list)
|
||||||
|
and len(required_secrets) != len(set(required_secrets))
|
||||||
|
)
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{where}.requiredSecrets: 必须是唯一的大写 secret 名称列表"
|
||||||
|
)
|
||||||
|
working_directory = value.get("workingDirectory")
|
||||||
|
if not _safe_relative_path(working_directory):
|
||||||
|
errors.append(f"{where}.workingDirectory: 必须是安全的仓库内相对路径")
|
||||||
|
timeout = value.get("timeoutSeconds")
|
||||||
|
if (
|
||||||
|
not isinstance(timeout, int)
|
||||||
|
or isinstance(timeout, bool)
|
||||||
|
or not 1 <= timeout <= 86400
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.timeoutSeconds: 必须是 1..86400 的整数")
|
||||||
|
|
||||||
|
if kind == "script" and not _safe_relative_path(value.get("path")):
|
||||||
|
errors.append(f"{where}.path: 必须是安全的仓库内相对路径")
|
||||||
|
if project_root is not None:
|
||||||
|
if _safe_relative_path(working_directory):
|
||||||
|
errors.extend(
|
||||||
|
_validate_path_binding(
|
||||||
|
project_root,
|
||||||
|
working_directory,
|
||||||
|
f"{where}.workingDirectory",
|
||||||
|
expected="directory",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if kind == "script" and _safe_relative_path(value.get("path")):
|
||||||
|
errors.extend(
|
||||||
|
_validate_path_binding(
|
||||||
|
project_root,
|
||||||
|
value["path"],
|
||||||
|
f"{where}.path",
|
||||||
|
expected="executable",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_artifacts(
|
||||||
|
values: dict[str, Any],
|
||||||
|
entrypoints: dict[str, Any],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for artifact_id, value in values.items():
|
||||||
|
where = f"artifacts.{artifact_id}"
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(value, ARTIFACT_FIELDS, where, errors)
|
||||||
|
artifact_type = value.get("type")
|
||||||
|
if artifact_type not in ARTIFACT_TYPES:
|
||||||
|
errors.append(f"{where}.type: 必须是 {sorted(ARTIFACT_TYPES)}")
|
||||||
|
build = value.get("build")
|
||||||
|
if build not in entrypoints:
|
||||||
|
errors.append(f"{where}.build: 未定义 entrypoint {build!r}")
|
||||||
|
outputs = value.get("outputs")
|
||||||
|
if artifact_type in {"deb", "file"}:
|
||||||
|
if (
|
||||||
|
not isinstance(outputs, list)
|
||||||
|
or not outputs
|
||||||
|
or any(not _safe_relative_path(item, allow_glob=True) for item in outputs)
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.outputs: deb/file 必须填写安全的产物路径列表")
|
||||||
|
if "image" in value or "platforms" in value:
|
||||||
|
errors.append(f"{where}: deb/file 不允许 image 或 platforms")
|
||||||
|
if artifact_type == "oci-image":
|
||||||
|
if not _nonempty(value.get("image")):
|
||||||
|
errors.append(f"{where}.image: oci-image 必须填写镜像名")
|
||||||
|
platforms = value.get("platforms")
|
||||||
|
if (
|
||||||
|
not isinstance(platforms, list)
|
||||||
|
or not platforms
|
||||||
|
or any(not isinstance(item, str) or PLATFORM_RE.fullmatch(item) is None for item in platforms)
|
||||||
|
or len(platforms) != len(set(platforms))
|
||||||
|
):
|
||||||
|
errors.append(f"{where}.platforms: 必须是唯一的 os/arch 列表")
|
||||||
|
if "outputs" in value:
|
||||||
|
errors.append(f"{where}: oci-image 不允许 outputs")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_destinations(
|
||||||
|
values: dict[str, Any],
|
||||||
|
entrypoints: dict[str, Any],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for destination_id, value in values.items():
|
||||||
|
where = f"destinations.{destination_id}"
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(value, DESTINATION_FIELDS, where, errors)
|
||||||
|
destination_type = value.get("type")
|
||||||
|
if destination_type not in DESTINATION_TYPES:
|
||||||
|
errors.append(f"{where}.type: 必须是 {sorted(DESTINATION_TYPES)}")
|
||||||
|
type_fields = {
|
||||||
|
"apt-repository": {"endpoint", "repository"},
|
||||||
|
"oci-registry": {"registry", "repository"},
|
||||||
|
"ci-artifact": {"artifactName"},
|
||||||
|
}.get(destination_type, set())
|
||||||
|
allowed_fields = {"type", "channel", "upload"} | type_fields
|
||||||
|
for field in sorted(set(value) - allowed_fields):
|
||||||
|
errors.append(f"{where}.{field}: type={destination_type!r} 不允许此字段")
|
||||||
|
if value.get("channel") not in CHANNELS:
|
||||||
|
errors.append(f"{where}.channel: 必须是 {sorted(CHANNELS)}")
|
||||||
|
upload = value.get("upload")
|
||||||
|
if upload is not None and upload not in entrypoints:
|
||||||
|
errors.append(f"{where}.upload: 未定义 entrypoint {upload!r}")
|
||||||
|
if destination_type == "apt-repository":
|
||||||
|
if not _nonempty(value.get("endpoint")):
|
||||||
|
errors.append(f"{where}.endpoint: APT 目标必须填写服务地址")
|
||||||
|
if not _nonempty(value.get("repository")):
|
||||||
|
errors.append(f"{where}.repository: APT 目标必须填写仓库名")
|
||||||
|
if destination_type == "oci-registry":
|
||||||
|
for field in ("registry", "repository"):
|
||||||
|
if not _nonempty(value.get(field)):
|
||||||
|
errors.append(f"{where}.{field}: OCI 目标必须填写非空值")
|
||||||
|
if destination_type == "ci-artifact" and not _nonempty(value.get("artifactName")):
|
||||||
|
errors.append(f"{where}.artifactName: CI artifact 必须填写名称")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_environments(
|
||||||
|
values: dict[str, Any],
|
||||||
|
entrypoints: dict[str, Any],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for environment_id, value in values.items():
|
||||||
|
where = f"environments.{environment_id}"
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(value, ENVIRONMENT_FIELDS, where, errors)
|
||||||
|
if value.get("type") not in ENVIRONMENT_TYPES:
|
||||||
|
errors.append(f"{where}.type: 必须是 {sorted(ENVIRONMENT_TYPES)}")
|
||||||
|
classification = value.get("classification")
|
||||||
|
if classification not in CLASSIFICATIONS:
|
||||||
|
errors.append(f"{where}.classification: 必须是 {sorted(CLASSIFICATIONS)}")
|
||||||
|
if not _nonempty(value.get("target")):
|
||||||
|
errors.append(f"{where}.target: 必须是非空目标别名")
|
||||||
|
for field in ("deploy", "healthCheck"):
|
||||||
|
reference = value.get(field)
|
||||||
|
if reference not in entrypoints:
|
||||||
|
errors.append(f"{where}.{field}: 未定义 entrypoint {reference!r}")
|
||||||
|
rollback = value.get("rollback")
|
||||||
|
if rollback is not None and rollback not in entrypoints:
|
||||||
|
errors.append(f"{where}.rollback: 未定义 entrypoint {rollback!r}")
|
||||||
|
if classification == "production" and rollback is None:
|
||||||
|
errors.append(f"{where}.rollback: production 环境必须提供回滚入口")
|
||||||
|
if not _nonempty(value.get("mutex")):
|
||||||
|
errors.append(f"{where}.mutex: 必须填写部署互斥锁 ID")
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_destination_compatible(artifact_type: str, destination_type: str) -> bool:
|
||||||
|
return destination_type in {
|
||||||
|
"deb": {"apt-repository", "ci-artifact"},
|
||||||
|
"oci-image": {"oci-registry", "ci-artifact"},
|
||||||
|
"file": {"ci-artifact"},
|
||||||
|
}.get(artifact_type, set())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_profiles(
|
||||||
|
values: dict[str, Any],
|
||||||
|
*,
|
||||||
|
default_profile: Any,
|
||||||
|
entrypoints: dict[str, Any],
|
||||||
|
artifacts: dict[str, Any],
|
||||||
|
destinations: dict[str, Any],
|
||||||
|
environments: dict[str, Any],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
for profile_id, value in values.items():
|
||||||
|
where = f"profiles.{profile_id}"
|
||||||
|
if not _mapping(value):
|
||||||
|
errors.append(f"{where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(value, PROFILE_FIELDS, where, errors)
|
||||||
|
stop_at = value.get("stopAt")
|
||||||
|
if stop_at not in STOP_POINTS:
|
||||||
|
errors.append(f"{where}.stopAt: 必须是 {sorted(STOP_POINTS)}")
|
||||||
|
steps = value.get("steps")
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
errors.append(f"{where}.steps: 必须是列表")
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_step_ids: set[str] = set()
|
||||||
|
built_artifacts: set[str] = set()
|
||||||
|
published_artifacts: set[str] = set()
|
||||||
|
deployed_environments: set[str] = set()
|
||||||
|
approvals: set[str] = set()
|
||||||
|
has_pull_request = False
|
||||||
|
has_mark_ready = False
|
||||||
|
|
||||||
|
for index, step in enumerate(steps):
|
||||||
|
step_where = f"{where}.steps[{index}]"
|
||||||
|
if not _mapping(step):
|
||||||
|
errors.append(f"{step_where}: 必须是对象")
|
||||||
|
continue
|
||||||
|
_reject_unknown(step, STEP_FIELDS, step_where, errors)
|
||||||
|
step_id = step.get("id")
|
||||||
|
if not isinstance(step_id, str) or ID_RE.fullmatch(step_id) is None:
|
||||||
|
errors.append(f"{step_where}.id: 必须使用小写连字符格式")
|
||||||
|
elif step_id in seen_step_ids:
|
||||||
|
errors.append(f"{step_where}.id: 不能重复 {step_id!r}")
|
||||||
|
else:
|
||||||
|
seen_step_ids.add(step_id)
|
||||||
|
|
||||||
|
action = step.get("action")
|
||||||
|
if action not in ACTIONS:
|
||||||
|
errors.append(f"{step_where}.action: 必须是 {sorted(ACTIONS)}")
|
||||||
|
continue
|
||||||
|
required_fields = ACTION_FIELDS[action]
|
||||||
|
for field in sorted(required_fields):
|
||||||
|
if field not in step:
|
||||||
|
errors.append(f"{step_where}.{field}: action={action!r} 时必填")
|
||||||
|
allowed_fields = {"id", "action"} | required_fields
|
||||||
|
for field in sorted(set(step) - allowed_fields):
|
||||||
|
errors.append(f"{step_where}.{field}: action={action!r} 不允许此字段")
|
||||||
|
|
||||||
|
if action == "verify" and step.get("entrypoint") not in entrypoints:
|
||||||
|
errors.append(
|
||||||
|
f"{step_where}.entrypoint: 未定义 entrypoint {step.get('entrypoint')!r}"
|
||||||
|
)
|
||||||
|
if action == "pull-request":
|
||||||
|
if not isinstance(step.get("draft"), bool):
|
||||||
|
errors.append(f"{step_where}.draft: 必须是布尔值")
|
||||||
|
if (
|
||||||
|
not isinstance(step.get("remote"), str)
|
||||||
|
or REMOTE_RE.fullmatch(step["remote"]) is None
|
||||||
|
):
|
||||||
|
errors.append(f"{step_where}.remote: 必须是安全的 Git remote 名称")
|
||||||
|
if not _safe_branch_name(step.get("baseBranch")):
|
||||||
|
errors.append(f"{step_where}.baseBranch: 必须是安全的 Git 分支名")
|
||||||
|
has_pull_request = True
|
||||||
|
if action == "build":
|
||||||
|
artifact_id = step.get("artifact")
|
||||||
|
if artifact_id not in artifacts:
|
||||||
|
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
|
||||||
|
else:
|
||||||
|
built_artifacts.add(artifact_id)
|
||||||
|
if action == "publish":
|
||||||
|
artifact_id = step.get("artifact")
|
||||||
|
destination_id = step.get("destination")
|
||||||
|
if artifact_id not in artifacts:
|
||||||
|
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
|
||||||
|
elif artifact_id not in built_artifacts:
|
||||||
|
errors.append(f"{step_where}: publish 前必须先 build {artifact_id!r}")
|
||||||
|
if destination_id not in destinations:
|
||||||
|
errors.append(
|
||||||
|
f"{step_where}.destination: 未定义 destination {destination_id!r}"
|
||||||
|
)
|
||||||
|
elif artifact_id in artifacts:
|
||||||
|
artifact_type = artifacts[artifact_id].get("type")
|
||||||
|
destination_type = destinations[destination_id].get("type")
|
||||||
|
if not _artifact_destination_compatible(artifact_type, destination_type):
|
||||||
|
errors.append(
|
||||||
|
f"{step_where}: artifact {artifact_type!r} 不能发布到 "
|
||||||
|
f"{destination_type!r}"
|
||||||
|
)
|
||||||
|
if destinations[destination_id].get("channel") == "stable" and "release" not in approvals:
|
||||||
|
errors.append(f"{step_where}: stable 发布前必须有 release approval")
|
||||||
|
published_artifacts.add(artifact_id)
|
||||||
|
if action == "deploy":
|
||||||
|
artifact_id = step.get("artifact")
|
||||||
|
environment_id = step.get("environment")
|
||||||
|
if artifact_id not in artifacts:
|
||||||
|
errors.append(f"{step_where}.artifact: 未定义 artifact {artifact_id!r}")
|
||||||
|
elif artifact_id not in built_artifacts:
|
||||||
|
errors.append(f"{step_where}: deploy 前必须先 build {artifact_id!r}")
|
||||||
|
if environment_id not in environments:
|
||||||
|
errors.append(
|
||||||
|
f"{step_where}.environment: 未定义 environment {environment_id!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
classification = environments[environment_id].get("classification")
|
||||||
|
if classification == "production" and "production" not in approvals:
|
||||||
|
errors.append(f"{step_where}: production 部署前必须有 production approval")
|
||||||
|
if (
|
||||||
|
classification == "production"
|
||||||
|
and artifact_id not in published_artifacts
|
||||||
|
):
|
||||||
|
errors.append(f"{step_where}: production 部署前必须先 publish 同一产物")
|
||||||
|
deployed_environments.add(environment_id)
|
||||||
|
if action == "health-check":
|
||||||
|
environment_id = step.get("environment")
|
||||||
|
if environment_id not in environments:
|
||||||
|
errors.append(
|
||||||
|
f"{step_where}.environment: 未定义 environment {environment_id!r}"
|
||||||
|
)
|
||||||
|
elif environment_id not in deployed_environments:
|
||||||
|
errors.append(
|
||||||
|
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
|
||||||
|
)
|
||||||
|
if action == "approval":
|
||||||
|
gate = step.get("gate")
|
||||||
|
if gate not in {"release", "production"}:
|
||||||
|
errors.append(f"{step_where}.gate: 必须是 release/production")
|
||||||
|
else:
|
||||||
|
if gate == "release" and not built_artifacts:
|
||||||
|
errors.append(f"{step_where}: release approval 前必须先 build 产物")
|
||||||
|
if gate == "production" and not published_artifacts:
|
||||||
|
errors.append(f"{step_where}: production approval 前必须先 publish 产物")
|
||||||
|
approvals.add(gate)
|
||||||
|
if action == "mark-ready":
|
||||||
|
if not has_pull_request:
|
||||||
|
errors.append(f"{step_where}: mark-ready 前必须先创建 pull-request")
|
||||||
|
has_mark_ready = True
|
||||||
|
if index != len(steps) - 1:
|
||||||
|
errors.append(f"{step_where}: mark-ready 必须是 profile 最后一步")
|
||||||
|
|
||||||
|
if stop_at in {"review_ready", "released"} and (
|
||||||
|
not has_pull_request or not has_mark_ready
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{where}: {stop_at} 必须包含 pull-request 和末尾 mark-ready"
|
||||||
|
)
|
||||||
|
if stop_at == "released" and not ({"release", "production"} & approvals):
|
||||||
|
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
|
||||||
|
|
||||||
|
if profile_id == default_profile:
|
||||||
|
if stop_at != "review_ready":
|
||||||
|
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
|
||||||
|
used_destinations = {
|
||||||
|
step.get("destination")
|
||||||
|
for step in steps
|
||||||
|
if isinstance(step, dict) and step.get("action") == "publish"
|
||||||
|
}
|
||||||
|
used_environments = {
|
||||||
|
step.get("environment")
|
||||||
|
for step in steps
|
||||||
|
if isinstance(step, dict) and step.get("action") == "deploy"
|
||||||
|
}
|
||||||
|
if any(
|
||||||
|
destinations.get(item, {}).get("channel") == "stable"
|
||||||
|
for item in used_destinations
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: defaultProfile 不能发布 stable 目标")
|
||||||
|
if any(
|
||||||
|
environments.get(item, {}).get("classification") == "production"
|
||||||
|
for item in used_environments
|
||||||
|
):
|
||||||
|
errors.append(f"{where}: defaultProfile 不能部署 production 环境")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_builtin(data: dict[str, Any], project_root: Path | None = None) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
_reject_unknown(data, TOP_LEVEL_FIELDS, "<root>", errors)
|
||||||
|
|
||||||
|
if data.get("version") != 1 or isinstance(data.get("version"), bool):
|
||||||
|
errors.append("version 必须是整数 1")
|
||||||
|
if "updatedAt" in data and not _nonempty(data.get("updatedAt")):
|
||||||
|
errors.append("updatedAt 必须是非空字符串")
|
||||||
|
project = data.get("project")
|
||||||
|
if not _mapping(project):
|
||||||
|
errors.append("project 必须是对象")
|
||||||
|
project = {}
|
||||||
|
else:
|
||||||
|
_reject_unknown(project, {"name"}, "project", errors)
|
||||||
|
if not _nonempty(project.get("name")):
|
||||||
|
errors.append("project.name 必须是非空字符串")
|
||||||
|
|
||||||
|
enabled = data.get("enabled")
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
errors.append("enabled 必须是布尔值")
|
||||||
|
default_profile = data.get("defaultProfile")
|
||||||
|
if default_profile is not None and (
|
||||||
|
not isinstance(default_profile, str) or ID_RE.fullmatch(default_profile) is None
|
||||||
|
):
|
||||||
|
errors.append("defaultProfile 必须是 null 或小写连字符 ID")
|
||||||
|
|
||||||
|
entrypoints = _validate_ids(data.get("entrypoints"), "entrypoints", errors)
|
||||||
|
artifacts = _validate_ids(data.get("artifacts"), "artifacts", errors)
|
||||||
|
destinations = _validate_ids(data.get("destinations"), "destinations", errors)
|
||||||
|
environments = _validate_ids(data.get("environments"), "environments", errors)
|
||||||
|
profiles = _validate_ids(data.get("profiles"), "profiles", errors)
|
||||||
|
|
||||||
|
_validate_entrypoints(entrypoints, errors, project_root)
|
||||||
|
_validate_artifacts(artifacts, entrypoints, errors)
|
||||||
|
_validate_destinations(destinations, entrypoints, errors)
|
||||||
|
_validate_environments(environments, entrypoints, errors)
|
||||||
|
_validate_profiles(
|
||||||
|
profiles,
|
||||||
|
default_profile=default_profile,
|
||||||
|
entrypoints=entrypoints,
|
||||||
|
artifacts=artifacts,
|
||||||
|
destinations=destinations,
|
||||||
|
environments=environments,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
if default_profile not in profiles:
|
||||||
|
errors.append("enabled=true 时 defaultProfile 必须引用已定义 profile")
|
||||||
|
elif not profiles[default_profile].get("steps"):
|
||||||
|
errors.append("enabled=true 时 defaultProfile.steps 不能为空")
|
||||||
|
elif default_profile is not None and default_profile not in profiles:
|
||||||
|
errors.append("defaultProfile 必须引用已定义 profile")
|
||||||
|
|
||||||
|
_scan_secrets(data, "<root>", errors)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tasks_link(delivery: dict[str, Any], tasks: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
project = tasks.get("project")
|
||||||
|
if not isinstance(project, dict):
|
||||||
|
return ["tasks.project 必须是对象"]
|
||||||
|
if project.get("deliveryFile") != "docs/ack/delivery.yaml":
|
||||||
|
errors.append("tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml")
|
||||||
|
delivery_project = delivery.get("project")
|
||||||
|
if (
|
||||||
|
isinstance(delivery_project, dict)
|
||||||
|
and _nonempty(delivery_project.get("name"))
|
||||||
|
and _nonempty(project.get("name"))
|
||||||
|
and delivery_project["name"] != project["name"]
|
||||||
|
):
|
||||||
|
errors.append("delivery.project.name 必须与 tasks.project.name 一致")
|
||||||
|
if not isinstance(tasks.get("deliveryRuns"), list):
|
||||||
|
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_with_schema(data: dict[str, Any], schema_path: Path) -> list[str]:
|
||||||
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
errors = []
|
||||||
|
for error in sorted(validator.iter_errors(data), key=lambda item: list(item.path)):
|
||||||
|
location = "/".join(str(part) for part in error.path) or "<root>"
|
||||||
|
errors.append(f"[schema] {location}: {error.message}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="校验 ACK 项目交付契约")
|
||||||
|
parser.add_argument("delivery", nargs="?", default="docs/ack/delivery.yaml")
|
||||||
|
parser.add_argument("--tasks", help="关联的 docs/ack/tasks.yaml")
|
||||||
|
parser.add_argument("--project-root", help="项目根目录;提供后检查入口路径")
|
||||||
|
parser.add_argument("--schema", help="delivery.schema.json 路径(默认自动探测)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
delivery_path = Path(args.delivery)
|
||||||
|
if not delivery_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到交付契约: {delivery_path}\n")
|
||||||
|
return 2
|
||||||
|
project_root = Path(args.project_root).resolve() if args.project_root else None
|
||||||
|
if project_root is not None and not project_root.is_dir():
|
||||||
|
sys.stderr.write(f"项目根目录不存在: {project_root}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
delivery = _load_document(delivery_path, "交付契约")
|
||||||
|
errors = validate_builtin(delivery, project_root)
|
||||||
|
|
||||||
|
if args.tasks:
|
||||||
|
tasks_path = Path(args.tasks)
|
||||||
|
if not tasks_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到任务板: {tasks_path}\n")
|
||||||
|
return 2
|
||||||
|
tasks = _load_document(tasks_path, "任务板")
|
||||||
|
errors.extend(validate_tasks_link(delivery, tasks))
|
||||||
|
|
||||||
|
schema_path = (
|
||||||
|
Path(args.schema)
|
||||||
|
if args.schema
|
||||||
|
else Path(__file__).resolve().parent.parent / "templates" / "delivery.schema.json"
|
||||||
|
)
|
||||||
|
if args.schema and not schema_path.is_file():
|
||||||
|
sys.stderr.write(f"找不到 schema: {schema_path}\n")
|
||||||
|
return 2
|
||||||
|
if schema_path.is_file():
|
||||||
|
try:
|
||||||
|
errors.extend(validate_with_schema(delivery, schema_path))
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write("提示: 未安装 jsonschema,仅执行内置语义规则\n")
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
sys.stderr.write(f"schema 读取失败: {exc}\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f"- {error}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sys.stdout.write("交付契约校验通过\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -49,6 +49,9 @@ KNOWLEDGE_CHECK_RESULTS = {"passed", "failed", "not_applicable"}
|
|||||||
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
KNOWLEDGE_REF_RE = re.compile(r"^K-[A-Z0-9][A-Z0-9-]*@[1-9][0-9]*$")
|
||||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
ATTEMPT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*-A[1-9][0-9]*$")
|
||||||
|
DELIVERY_RUN_ID_RE = re.compile(r"^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
DELIVERY_PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||||
|
GIT_REVISION_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
||||||
SEMVER_RE = re.compile(
|
SEMVER_RE = re.compile(
|
||||||
r"^(0|[1-9][0-9]*)\."
|
r"^(0|[1-9][0-9]*)\."
|
||||||
r"(0|[1-9][0-9]*)\."
|
r"(0|[1-9][0-9]*)\."
|
||||||
@@ -86,6 +89,39 @@ KNOWLEDGE_CHECK_FIELDS = {
|
|||||||
"checkedBy",
|
"checkedBy",
|
||||||
"checkedAt",
|
"checkedAt",
|
||||||
}
|
}
|
||||||
|
DELIVERY_RUN_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"profile",
|
||||||
|
"taskIds",
|
||||||
|
"status",
|
||||||
|
"sourceRevision",
|
||||||
|
"configRevision",
|
||||||
|
"pullRequest",
|
||||||
|
"artifacts",
|
||||||
|
"deployments",
|
||||||
|
"evidence",
|
||||||
|
"updatedAt",
|
||||||
|
}
|
||||||
|
DELIVERY_STATUSES = {
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"blocked",
|
||||||
|
"failed",
|
||||||
|
"review_ready",
|
||||||
|
"released",
|
||||||
|
"skipped",
|
||||||
|
}
|
||||||
|
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||||
|
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||||
|
FEISHU_REQUIRED_FIELDS = {
|
||||||
|
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
|
||||||
|
"attachments", "updatedAt",
|
||||||
|
}
|
||||||
|
FEISHU_CONFIG_FIELDS = {"provider", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||||
|
FEISHU_SOURCE_FIELDS = {"kind", "ref", "recordId", "updatedAt"}
|
||||||
|
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 = {
|
DISPATCH_FIELDS = {
|
||||||
"taskId",
|
"taskId",
|
||||||
"dispatchId",
|
"dispatchId",
|
||||||
@@ -392,6 +428,162 @@ def validate_knowledge_fields(
|
|||||||
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
|
errors.append(f"{check_where}: verified 任务不能保留失败的知识检查")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_delivery_runs(
|
||||||
|
value: object,
|
||||||
|
task_statuses: dict[str, object],
|
||||||
|
errors: list[str],
|
||||||
|
) -> 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]:
|
def validate_with_schema(data: dict, schema_path: Path) -> list[str]:
|
||||||
import jsonschema # type: ignore
|
import jsonschema # type: ignore
|
||||||
|
|
||||||
@@ -468,9 +660,31 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append("project.name 必须是非空字符串")
|
errors.append("project.name 必须是非空字符串")
|
||||||
validate_string_fields(
|
validate_string_fields(
|
||||||
project,
|
project,
|
||||||
{"repoPath", "baseUrl", "devWorktree", "overlayFile"},
|
{"repoPath", "baseUrl", "devWorktree", "overlayFile", "deliveryFile"},
|
||||||
"project",
|
"project",
|
||||||
)
|
)
|
||||||
|
if "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")
|
||||||
|
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 set(fields) != FEISHU_REQUIRED_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 字段值不能重复")
|
||||||
if (
|
if (
|
||||||
"knowledgeFile" in project
|
"knowledgeFile" in project
|
||||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||||
@@ -478,6 +692,17 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
errors.append(
|
errors.append(
|
||||||
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
"project.knowledgeFile 必须固定为 docs/ack/knowledge.yaml"
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
"deliveryFile" in project
|
||||||
|
and project.get("deliveryFile") != "docs/ack/delivery.yaml"
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
"project.deliveryFile 必须固定为 docs/ack/delivery.yaml"
|
||||||
|
)
|
||||||
|
if "deliveryFile" in project and not isinstance(data.get("deliveryRuns"), list):
|
||||||
|
errors.append("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表")
|
||||||
|
if "deliveryRuns" in data and "deliveryFile" not in project:
|
||||||
|
errors.append("deliveryRuns 存在时 project.deliveryFile 必须存在")
|
||||||
|
|
||||||
ack_version = data.get("ackVersion")
|
ack_version = data.get("ackVersion")
|
||||||
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
version_match = SEMVER_RE.fullmatch(ack_version) if isinstance(ack_version, str) else None
|
||||||
@@ -516,6 +741,7 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
seen_source_refs: set[str] = set()
|
||||||
for i, task in enumerate(tasks):
|
for i, task in enumerate(tasks):
|
||||||
where = f"tasks[{i}]"
|
where = f"tasks[{i}]"
|
||||||
if not isinstance(task, dict):
|
if not isinstance(task, dict):
|
||||||
@@ -562,6 +788,25 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
)
|
)
|
||||||
validate_object_fields(task, {"evidence", "verification"}, 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: 必须是非空字符串")
|
||||||
|
|
||||||
validate_knowledge_fields(task, where, status, errors)
|
validate_knowledge_fields(task, where, status, errors)
|
||||||
|
|
||||||
if "dispatch" not in task:
|
if "dispatch" not in task:
|
||||||
@@ -678,6 +923,14 @@ def validate_builtin(data: dict) -> list[str]:
|
|||||||
):
|
):
|
||||||
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
|
errors.append(f"{where}: leftover 必须填 resolution.leftoverReason")
|
||||||
|
|
||||||
|
if "deliveryRuns" in data:
|
||||||
|
task_statuses = {
|
||||||
|
task.get("id"): task.get("status")
|
||||||
|
for task in tasks
|
||||||
|
if isinstance(task, dict) and _nonempty_string(task.get("id"))
|
||||||
|
}
|
||||||
|
validate_delivery_runs(data["deliveryRuns"], task_statuses, errors)
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://git.yumee.top/laily/skills/skills/ack/templates/delivery.schema.json",
|
||||||
|
"title": "ACK project delivery contract",
|
||||||
|
"description": "docs/ack/delivery.yaml 的权威结构;语义规则由 scripts/validate_delivery.py 补充。",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"project",
|
||||||
|
"enabled",
|
||||||
|
"defaultProfile",
|
||||||
|
"entrypoints",
|
||||||
|
"artifacts",
|
||||||
|
"destinations",
|
||||||
|
"environments",
|
||||||
|
"profiles"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "integer", "const": 1 },
|
||||||
|
"updatedAt": { "type": "string" },
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "minLength": 1, "pattern": "\\S" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"defaultProfile": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||||
|
},
|
||||||
|
"entrypoints": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "$ref": "#/definitions/id" },
|
||||||
|
"additionalProperties": { "$ref": "#/definitions/entrypoint" }
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "$ref": "#/definitions/id" },
|
||||||
|
"additionalProperties": { "$ref": "#/definitions/artifact" }
|
||||||
|
},
|
||||||
|
"destinations": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "$ref": "#/definitions/id" },
|
||||||
|
"additionalProperties": { "$ref": "#/definitions/destination" }
|
||||||
|
},
|
||||||
|
"environments": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "$ref": "#/definitions/id" },
|
||||||
|
"additionalProperties": { "$ref": "#/definitions/environment" }
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": { "$ref": "#/definitions/id" },
|
||||||
|
"additionalProperties": { "$ref": "#/definitions/profile" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||||
|
},
|
||||||
|
"relativePath": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/*?+-]+$"
|
||||||
|
},
|
||||||
|
"stringList": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
},
|
||||||
|
"secretName": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{0,127}$"
|
||||||
|
},
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"args",
|
||||||
|
"requiredSecrets",
|
||||||
|
"workingDirectory",
|
||||||
|
"timeoutSeconds"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["make", "just", "task", "dagger", "script"]
|
||||||
|
},
|
||||||
|
"target": { "type": "string", "minLength": 1 },
|
||||||
|
"function": { "type": "string", "minLength": 1 },
|
||||||
|
"path": { "$ref": "#/definitions/relativePath" },
|
||||||
|
"args": { "$ref": "#/definitions/stringList" },
|
||||||
|
"requiredSecrets": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "$ref": "#/definitions/secretName" }
|
||||||
|
},
|
||||||
|
"workingDirectory": { "$ref": "#/definitions/relativePath" },
|
||||||
|
"timeoutSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 86400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "build"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": { "type": "string", "enum": ["deb", "oci-image", "file"] },
|
||||||
|
"build": { "$ref": "#/definitions/id" },
|
||||||
|
"outputs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "$ref": "#/definitions/relativePath" }
|
||||||
|
},
|
||||||
|
"image": { "type": "string", "minLength": 1 },
|
||||||
|
"platforms": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "pattern": "^[a-z0-9]+/[A-Za-z0-9._-]+$" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "channel"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["apt-repository", "oci-registry", "ci-artifact"]
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["preview", "staging", "stable"]
|
||||||
|
},
|
||||||
|
"registry": { "type": "string", "minLength": 1 },
|
||||||
|
"repository": { "type": "string", "minLength": 1 },
|
||||||
|
"endpoint": { "type": "string", "minLength": 1 },
|
||||||
|
"artifactName": { "type": "string", "minLength": 1 },
|
||||||
|
"upload": { "$ref": "#/definitions/id" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"classification",
|
||||||
|
"target",
|
||||||
|
"deploy",
|
||||||
|
"healthCheck",
|
||||||
|
"mutex"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ssh-host", "docker-compose", "kubernetes", "custom"]
|
||||||
|
},
|
||||||
|
"classification": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["development", "staging", "production"]
|
||||||
|
},
|
||||||
|
"target": { "type": "string", "minLength": 1 },
|
||||||
|
"deploy": { "$ref": "#/definitions/id" },
|
||||||
|
"healthCheck": { "$ref": "#/definitions/id" },
|
||||||
|
"rollback": { "$ref": "#/definitions/id" },
|
||||||
|
"mutex": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "action"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/definitions/id" },
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"verify",
|
||||||
|
"pull-request",
|
||||||
|
"build",
|
||||||
|
"publish",
|
||||||
|
"deploy",
|
||||||
|
"health-check",
|
||||||
|
"approval",
|
||||||
|
"mark-ready"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entrypoint": { "$ref": "#/definitions/id" },
|
||||||
|
"artifact": { "$ref": "#/definitions/id" },
|
||||||
|
"destination": { "$ref": "#/definitions/id" },
|
||||||
|
"environment": { "$ref": "#/definitions/id" },
|
||||||
|
"gate": { "type": "string", "enum": ["release", "production"] },
|
||||||
|
"draft": { "type": "boolean" },
|
||||||
|
"remote": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
|
||||||
|
},
|
||||||
|
"baseBranch": { "type": "string", "minLength": 1, "maxLength": 255 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["stopAt", "steps"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"stopAt": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["verified", "review_ready", "released"]
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/step" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 复制为 docs/ack/delivery.yaml。默认关闭;由用户明确配置后再启用。
|
||||||
|
version: 1
|
||||||
|
updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"
|
||||||
|
project:
|
||||||
|
name: "<project_name>"
|
||||||
|
|
||||||
|
enabled: false
|
||||||
|
defaultProfile: null
|
||||||
|
|
||||||
|
# 所有可执行入口都使用结构化 argv 语义;requiredSecrets 只保存名称,不保存值。
|
||||||
|
# 不在此保存 shell、env 或凭据正文。
|
||||||
|
entrypoints: {}
|
||||||
|
|
||||||
|
# 支持 deb、oci-image 和 file。复杂构建逻辑留在项目已有入口中。
|
||||||
|
artifacts: {}
|
||||||
|
|
||||||
|
# 支持 apt-repository、oci-registry 和 ci-artifact。
|
||||||
|
destinations: {}
|
||||||
|
|
||||||
|
# 支持 ssh-host、docker-compose、kubernetes 和 custom。
|
||||||
|
environments: {}
|
||||||
|
|
||||||
|
# enabled=true 时,defaultProfile 必须指向一个非空 profile。
|
||||||
|
profiles: {}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
> 若希望 Agent 自动加载,可由项目维护者自行在 `AGENTS.md` 中引用本文件;ACK
|
||||||
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
> 不会自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
> 无论叫什么,都在 `tasks.yaml` 的 `project.overlayFile` 记录实际路径。
|
||||||
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml` 与 `knowledge.yaml`,
|
> `docs/ack/` 只保存本项目的 `project.md`、`tasks.yaml`、`knowledge.yaml` 与默认关闭的
|
||||||
|
> `delivery.yaml`,
|
||||||
> 不复制或链接 Skill。
|
> 不复制或链接 Skill。
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
- Base URL:`<base_url>`
|
- Base URL:`<base_url>`
|
||||||
- 任务板:`docs/ack/tasks.yaml`
|
- 任务板:`docs/ack/tasks.yaml`
|
||||||
- 项目知识:`docs/ack/knowledge.yaml`
|
- 项目知识:`docs/ack/knowledge.yaml`
|
||||||
|
- 交付契约:`docs/ack/delivery.yaml`(默认关闭)
|
||||||
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
- 覆盖层文件:`<overlay_file_path>`(默认 `docs/ack/project.md`)
|
||||||
|
|
||||||
## 通用规范(由 ACK Skill 按需读取)
|
## 通用规范(由 ACK Skill 按需读取)
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
- 验收信号与三轮策略:`references/optimization-method.md`
|
- 验收信号与三轮策略:`references/optimization-method.md`
|
||||||
- 派发 prompt 模板:`references/prompt-templates.md`
|
- 派发 prompt 模板:`references/prompt-templates.md`
|
||||||
- Orca 编排命令(可选):`references/orca-adapter.md`
|
- Orca 编排命令(可选):`references/orca-adapter.md`
|
||||||
|
- 验证后交付与配置维护(可选):`references/delivery.md`
|
||||||
|
|
||||||
## Worker 路由
|
## Worker 路由
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ receipt 全部以 `docs/ack/tasks.yaml` 的 `project.orchestration` 与顶层
|
|||||||
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
| `<local_config_paths>` | Read-only | Read-only | Read-only | 本地私有配置 |
|
||||||
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
| `tasks.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写 |
|
||||||
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
| `knowledge.yaml` | R/W | Read-only | Read-only | 只有 Coordinator 写;Developer/Test 通过回报提名或验证 |
|
||||||
|
| `delivery.yaml` | 仅显式维护时 R/W | Read-only | Read-only | 项目交付能力,不是执行授权 |
|
||||||
|
|
||||||
## 命令(项目覆盖层)
|
## 命令(项目覆盖层)
|
||||||
|
|
||||||
@@ -91,8 +95,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
|
`ACK_PROJECT_ROOT` 是 runner 固定的根目录 fd 路径;仅用于日志的原始路径位于
|
||||||
`ACK_PROJECT_ROOT_DISPLAY`。
|
`ACK_PROJECT_ROOT_DISPLAY`。
|
||||||
|
|
||||||
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py` 和
|
项目状态校验由 `/ack` 使用 Skill 自带的 `scripts/validate_tasks.py`、
|
||||||
`scripts/validate_knowledge.py` 执行。
|
`scripts/validate_knowledge.py` 和 `scripts/validate_delivery.py` 执行。
|
||||||
|
构建/发布/部署的机器入口以 `delivery.yaml` 为准;本文件不维护第二套交付命令。
|
||||||
|
|
||||||
## 硬规则(其余见 references/)
|
## 硬规则(其余见 references/)
|
||||||
|
|
||||||
@@ -112,5 +117,9 @@ Skill 的 `scripts/run_verification.py` 执行,不直接拼接 path/args。检
|
|||||||
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
- Developer 回报 `knowledgeApplied` 与 `knowledgeCandidates`,Test 回报
|
||||||
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
`knowledgeChecks`。关键约束应下沉为测试、lint、CI 或正式规范。
|
||||||
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
- ACK 不自动修改 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
|
||||||
|
- `delivery.yaml` 默认关闭,只描述能力,不自动授权提交、推送、发布或部署;交付仅在
|
||||||
|
任务 `verified` 且本次 profile/目标/停止点得到确认后运行。
|
||||||
|
- 默认交付 profile 最多到 `review_ready`;stable 发布或 production 部署必须有
|
||||||
|
approval 步骤并再次获得明确批准。配置变更只影响下一次 run。
|
||||||
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
- 每个任务最多派发 3 轮,仍不过标记 `leftover` 并继续下一个。
|
||||||
- 不提交或推送,除非用户明确要求。
|
- 不提交、推送、发布或部署,除非用户确认的 ACK 任务或 delivery profile 明确包含。
|
||||||
|
|||||||
@@ -37,6 +37,12 @@
|
|||||||
"const": "docs/ack/knowledge.yaml",
|
"const": "docs/ack/knowledge.yaml",
|
||||||
"description": "项目知识护栏库的唯一权威路径"
|
"description": "项目知识护栏库的唯一权威路径"
|
||||||
},
|
},
|
||||||
|
"deliveryFile": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "docs/ack/delivery.yaml",
|
||||||
|
"description": "可选项目交付契约的唯一权威路径"
|
||||||
|
},
|
||||||
|
"bugIntake": { "$ref": "#/definitions/feishuBugIntake" },
|
||||||
"orchestration": {
|
"orchestration": {
|
||||||
"$ref": "#/definitions/orchestration"
|
"$ref": "#/definitions/orchestration"
|
||||||
}
|
}
|
||||||
@@ -57,6 +63,10 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/workerReceipt" }
|
"items": { "$ref": "#/definitions/workerReceipt" }
|
||||||
},
|
},
|
||||||
|
"deliveryRuns": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/deliveryRun" }
|
||||||
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/task" }
|
"items": { "$ref": "#/definitions/task" }
|
||||||
@@ -128,6 +138,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["deliveryFile"],
|
||||||
|
"properties": {
|
||||||
|
"deliveryFile": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["project"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"required": ["deliveryRuns"],
|
||||||
|
"properties": {
|
||||||
|
"deliveryRuns": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"definitions": {
|
"definitions": {
|
||||||
@@ -135,6 +165,50 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||||
},
|
},
|
||||||
|
"feishuBugIntake": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["provider", "profile", "baseToken", "tableId", "viewId", "fields"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"provider": { "const": "feishu-base" },
|
||||||
|
"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+$" },
|
||||||
|
"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+$" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feishuTaskSource": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind", "ref", "recordId", "updatedAt"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "feishu-base" },
|
||||||
|
"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" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"profileId": {
|
"profileId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[a-z][a-z0-9-]{1,63}$"
|
"pattern": "^[a-z][a-z0-9-]{1,63}$"
|
||||||
@@ -721,6 +795,165 @@
|
|||||||
"checkedAt": { "type": "string" }
|
"checkedAt": { "type": "string" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deliveryArtifactEvidence": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "type", "reference"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" },
|
||||||
|
"type": { "type": "string", "enum": ["deb", "oci-image", "file"] },
|
||||||
|
"reference": { "type": "string", "minLength": 1 },
|
||||||
|
"digest": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deliveryDeploymentEvidence": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["environment", "result", "evidence"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"environment": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["succeeded", "failed", "rolled_back"]
|
||||||
|
},
|
||||||
|
"evidence": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deliveryRun": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"profile",
|
||||||
|
"taskIds",
|
||||||
|
"status",
|
||||||
|
"sourceRevision",
|
||||||
|
"configRevision",
|
||||||
|
"pullRequest",
|
||||||
|
"artifacts",
|
||||||
|
"deployments",
|
||||||
|
"evidence",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^DR-[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||||
|
},
|
||||||
|
"taskIds": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"blocked",
|
||||||
|
"failed",
|
||||||
|
"review_ready",
|
||||||
|
"released",
|
||||||
|
"skipped"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceRevision": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{7,64}$"
|
||||||
|
},
|
||||||
|
"configRevision": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{7,64}$"
|
||||||
|
},
|
||||||
|
"pullRequest": { "type": ["string", "null"] },
|
||||||
|
"artifacts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/deliveryArtifactEvidence" }
|
||||||
|
},
|
||||||
|
"deployments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/deliveryDeploymentEvidence" }
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"updatedAt": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"planned",
|
||||||
|
"running",
|
||||||
|
"blocked",
|
||||||
|
"failed",
|
||||||
|
"review_ready",
|
||||||
|
"released"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["status"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"sourceRevision": { "type": "string" },
|
||||||
|
"configRevision": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"blocked",
|
||||||
|
"failed",
|
||||||
|
"review_ready",
|
||||||
|
"released",
|
||||||
|
"skipped"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["status"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"evidence": { "minItems": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"status": { "enum": ["review_ready", "released"] }
|
||||||
|
},
|
||||||
|
"required": ["status"]
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"sourceRevision": { "type": "string" },
|
||||||
|
"configRevision": { "type": "string" },
|
||||||
|
"pullRequest": { "type": "string", "minLength": 1 },
|
||||||
|
"evidence": { "minItems": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"task": {
|
"task": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "title", "status"],
|
"required": ["id", "title", "status"],
|
||||||
@@ -752,6 +985,16 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "$ref": "#/definitions/knowledgeCheck" }
|
"items": { "$ref": "#/definitions/knowledgeCheck" }
|
||||||
},
|
},
|
||||||
|
"source": {
|
||||||
|
"if": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind"],
|
||||||
|
"properties": { "kind": { "const": "feishu-base" } }
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"$ref": "#/definitions/feishuTaskSource"
|
||||||
|
}
|
||||||
|
},
|
||||||
"description": { "type": "string" },
|
"description": { "type": "string" },
|
||||||
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
"stepsToReproduce": { "type": "array", "items": { "type": "string" } },
|
||||||
"expected": { "type": "string" },
|
"expected": { "type": "string" },
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ project:
|
|||||||
devWorktree: "<dev_worktree>"
|
devWorktree: "<dev_worktree>"
|
||||||
overlayFile: "docs/ack/project.md"
|
overlayFile: "docs/ack/project.md"
|
||||||
knowledgeFile: "docs/ack/knowledge.yaml"
|
knowledgeFile: "docs/ack/knowledge.yaml"
|
||||||
|
deliveryFile: "docs/ack/delivery.yaml"
|
||||||
|
# 可选:飞书 Base Bug 收件箱。只保存 profile 名和资源 ID,绝不保存 App Secret。
|
||||||
|
# bugIntake:
|
||||||
|
# provider: "feishu-base"
|
||||||
|
# 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>"
|
||||||
|
# acceptance: "<field_id>"
|
||||||
|
# priority: "<field_id>"
|
||||||
|
# attachments: "<field_id>"
|
||||||
|
# updatedAt: "<field_id>"
|
||||||
orchestration:
|
orchestration:
|
||||||
profileVersion: 1
|
profileVersion: 1
|
||||||
mode: "orca"
|
mode: "orca"
|
||||||
@@ -69,6 +86,7 @@ project:
|
|||||||
developerUpgraded: "codex-dev-strong"
|
developerUpgraded: "codex-dev-strong"
|
||||||
|
|
||||||
workerReceipts: []
|
workerReceipts: []
|
||||||
|
deliveryRuns: []
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
verified: []
|
verified: []
|
||||||
@@ -93,6 +111,13 @@ tasks:
|
|||||||
knowledgeCandidates: []
|
knowledgeCandidates: []
|
||||||
knowledgeChecks: []
|
knowledgeChecks: []
|
||||||
|
|
||||||
|
# 从飞书导入时由 Coordinator 写入;source.ref 是幂等键。
|
||||||
|
# source:
|
||||||
|
# kind: "feishu-base"
|
||||||
|
# ref: "feishu-base:sha256:<64-lowercase-hex>"
|
||||||
|
# recordId: "<record-id>"
|
||||||
|
# updatedAt: "<source-updated-at>"
|
||||||
|
|
||||||
description: >
|
description: >
|
||||||
<What is wrong, in user-visible terms.>
|
<What is wrong, in user-visible terms.>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# orc
|
||||||
|
|
||||||
|
ORC 是显式调用的工程编排入口:把开发、版本发布、DEB 和 Docker 任务拆成阶段,
|
||||||
|
交给对应 Skill,并为每个执行 Agent 选择 `low`、`mid` 或 `high` 档位。
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- 一个请求同时包含写代码、发版本和构建产物。
|
||||||
|
- 希望由 ORC 监督多个 Agent,并按阶段控制成本与推理能力。
|
||||||
|
- 需要继续中断的多阶段工程流程并保留依赖关系。
|
||||||
|
|
||||||
|
只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- Orca 正在运行并启用了 orchestration。
|
||||||
|
- 安装本次需要的 `$ack`、`$manage-release`、`$deb-publisher` 或
|
||||||
|
`$publish-docker-image`。
|
||||||
|
- 运行 `$orc 初始化` 生成 `docs/orc/config.yaml`,确认三档对应的精确模型。
|
||||||
|
- `/usr/bin/python3`;ORC v1 配置必须保持为内置模板使用的 JSON-compatible YAML。
|
||||||
|
resolver 以 `-I -S` 隔离模式运行,不加载项目模块、用户 site-packages 或第三方解析器。
|
||||||
|
- ORC v1 worker 使用 `workspace-write` sandbox,以便发送 Orca lifecycle 消息;
|
||||||
|
只读任务会在阶段 prompt 中禁止文件修改。
|
||||||
|
|
||||||
|
## Agent 档位
|
||||||
|
|
||||||
|
| 档位 | 典型任务 |
|
||||||
|
|------|----------|
|
||||||
|
| `low` | 输入明确的测试、构建、打包和上传 |
|
||||||
|
| `mid` | 常规版本发布与范围清晰的工程任务 |
|
||||||
|
| `high` | 需求理解、跨系统改动、异常恢复和高风险裁决 |
|
||||||
|
|
||||||
|
档位不是权限。三个档位仍受各自 profile 和下游 Skill 的授权边界约束。
|
||||||
|
当前 ORC v1 的结构化启动适配器支持 Codex worker;其它 Agent CLI 需要独立适配器,
|
||||||
|
不会通过自由命令接入。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
$orc high 修复登录问题,验证通过后发布新版本。
|
||||||
|
$orc code=high release=mid docker=low,完成修复、发版并推送镜像。
|
||||||
|
$orc mid 继续上次中断的 v1.4.0 发布流程。
|
||||||
|
```
|
||||||
|
|
||||||
|
阶段级档位优先于全局档位。用户显式指定后,ORC 不会静默改档;能力不足时会暂停并
|
||||||
|
请求确认。
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 识别开发、源码发布、DEB 与 Docker 阶段及其依赖。
|
||||||
|
2. 解析每个阶段的 Agent profile,把配置快照、可信 Codex executable 和真实 Git
|
||||||
|
worktree 绑定成 launch fingerprint,并展示计划和外部写入边界。
|
||||||
|
3. 通过 Orca 分发给对应 Skill,监督完成消息、异常和决策门。
|
||||||
|
4. 汇总每个阶段的实际状态、证据和安全恢复入口。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
最终结果会逐阶段列出所用 Skill、Agent 档位、源 revision、远端或产物状态,以及任何
|
||||||
|
未完成项。只有所有必要阶段都通过各自验证时,ORC 才会报告整个流程完成。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
name: orc
|
||||||
|
description: >-
|
||||||
|
显式编排开发、源码版本发布、DEB 和 Docker 产物任务,把阶段分发给对应 Skill,
|
||||||
|
并用 low、mid、high 选择执行 Agent 档位。仅在用户显式调用 $orc 或 /orc,要求
|
||||||
|
跨阶段协调、指定 Agent 级别、监督多个 worker 或继续 ORC 编排时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# ORC 工程编排入口
|
||||||
|
|
||||||
|
当前会话担任 Coordinator:拆分阶段、选择档位、派发 worker、监督依赖与决策门,
|
||||||
|
但不替代下游 Skill 执行其领域流程。
|
||||||
|
|
||||||
|
开始时解析当前 `SKILL.md` 所在目录,记为 `<orc-skill-dir>`;解析真实项目根目录,
|
||||||
|
优先使用 `git rev-parse --show-toplevel`。项目配置固定为
|
||||||
|
`<project-root>/docs/orc/config.yaml`。
|
||||||
|
|
||||||
|
## 选择模式
|
||||||
|
|
||||||
|
- 用户要求初始化 ORC:执行“初始化”。
|
||||||
|
- 用户要求检查 ORC、档位或运行环境:执行“检查”。
|
||||||
|
- 用户要求用 ORC 完成任务:执行“编排”。
|
||||||
|
|
||||||
|
不要静默初始化,也不要在配置缺失或无效时退回裸命令或当前会话直接执行。
|
||||||
|
|
||||||
|
## 初始化
|
||||||
|
|
||||||
|
1. 若 `docs/orc/config.yaml` 已存在,停止创建并转入“检查”,不得覆盖。
|
||||||
|
2. 确认 `docs/orc/` 和目标文件都不是 symlink,再从
|
||||||
|
`<orc-skill-dir>/templates/config.template.yaml` 以 create-only 方式创建配置,不覆盖
|
||||||
|
或跟随既有路径。ORC v1 配置必须保持 JSON-compatible YAML,只由隔离的 Python
|
||||||
|
标准库解析;模板中的 `.` 表示当前项目根。模型 ID 必须由用户或项目的可信配置确认,
|
||||||
|
不能从任务文本猜测。
|
||||||
|
3. 运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py validate \
|
||||||
|
<project-root>/docs/orc/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 报告三个档位的 CLI、模型、reasoning、权限和阶段默认值。除非用户明确要求,
|
||||||
|
不安装下游 Skill、不创建终端、不修改 Agent 全局配置。
|
||||||
|
|
||||||
|
## 检查
|
||||||
|
|
||||||
|
1. 校验 `docs/orc/config.yaml`,确认只存在 `low`、`mid`、`high` 三档。
|
||||||
|
2. 检查 `orca status --json`,并确认 orchestration 命令可用。
|
||||||
|
3. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、
|
||||||
|
`publish-docker-image`。只检查实际会用到的项。
|
||||||
|
4. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标
|
||||||
|
命中 `allowedWorktrees`、属于当前 Git 仓库且身份稳定后才可创建终端。不得只做文本
|
||||||
|
比较或跳过机器校验。
|
||||||
|
5. 任何 profile、Skill、运行时或 worktree 不可用时 fail closed;不得选择相邻档位、
|
||||||
|
复用身份不明的终端或手写替代流程。
|
||||||
|
|
||||||
|
## 编排
|
||||||
|
|
||||||
|
1. 读取 [routing.md](references/routing.md),把请求拆成 `code`、`release`、`deb`、
|
||||||
|
`docker` 阶段。没有匹配下游 Skill 的工作留在范围外并明确报告。
|
||||||
|
2. 锁定用户授权的最远动作、目标版本、产物目标、源 commit/tag 与停止点。ORC 的调用
|
||||||
|
本身不扩大 push、合并、打 tag、上传或部署权限;每个下游 Skill 的授权边界继续生效。
|
||||||
|
3. 解析档位:阶段级指定 > 全局指定 > `stageDefaults` > `defaultLevel`。只接受
|
||||||
|
`low`、`mid`、`high`;用户显式指定后不得静默升降级。若该档位不足以安全完成,
|
||||||
|
建立 decision gate,等待用户改档或缩小范围。
|
||||||
|
4. 用户未指定档位时采用以下判断:清晰、机械的构建或上传可用 `low`;常规版本流程
|
||||||
|
用 `mid`;需求理解、跨系统改动、恢复中断流程、目标含糊或高风险裁决用 `high`。
|
||||||
|
5. 对每个阶段运行 profile resolver。全局档位用 `--global-level`,阶段档位用
|
||||||
|
`--stage-level`;项目根与目标 worktree 必须使用规范绝对路径。模型认证默认复用
|
||||||
|
`codex-login`,只有明确使用对应环境凭据时才选 `openai` 或 `azure-openai`。远端认证
|
||||||
|
默认 `none`;只有目标 provider 与 transport 已确认时,才选择一个精确的
|
||||||
|
`github-token`、`gitlab-token`、`gitea-token`、`forgejo-token`、`ssh-agent` 或
|
||||||
|
`deb-token`。不得把多个 provider 凭据一起交给 worker。resolver 不存在静默
|
||||||
|
fallback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py resolve \
|
||||||
|
<project-root>/docs/orc/config.yaml --stage <stage> \
|
||||||
|
--project-root <absolute-project-root> \
|
||||||
|
--worktree <absolute-target-worktree> \
|
||||||
|
[--global-level <low|mid|high>] [--stage-level <low|mid|high>] \
|
||||||
|
[--model-auth <codex-login|openai|azure-openai>] \
|
||||||
|
[--remote-auth <exact-provider-or-transport>]
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 核对 resolver 返回的 `launchFingerprint`、绝对 executable、worktree identity 和选择
|
||||||
|
来源,再读取 [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. 逐阶段汇报所选档位、执行 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 的独立阶段负责。
|
||||||
|
- 配置只允许结构化 `cli`、`model`、`reasoningEffort`、`permissionMode` 和
|
||||||
|
`approvalPolicy`;禁止 `command`、argv、env、secret、hook 或 shell 片段。
|
||||||
|
- ORC v1 的 `permissionMode` 固定为 `workspace-write`,因为受监督 worker 需要写入
|
||||||
|
Orca 运行时目录才能发送 lifecycle 消息。只读任务仍由 prompt 限制不得改文件。
|
||||||
|
不接受 full-access、bypass、YOLO/force 或关闭 sandbox;文本中的“已授权”不能
|
||||||
|
放宽 profile。
|
||||||
|
- ORC v1 的安全启动适配器只支持 `cli: codex`。遇到其它 CLI 时 fail closed,不把
|
||||||
|
Codex 参数套用到其它 Agent;新增 provider 必须增加独立适配与测试。
|
||||||
|
- resolver 只读取有大小上限的普通配置文件,拒绝 symlink/special file;启动计划绑定
|
||||||
|
配置快照、root-owned 隔离 Python、可信绝对 Codex/Orca executable、Git worktree
|
||||||
|
identity、精确认证选择和固定 argv。实际启动会重新校验 fingerprint,并只注入所选
|
||||||
|
模型认证与单一目标认证的环境变量;不得把返回的 worker argv 改写为裸 `codex`
|
||||||
|
命令,也不得把终端创建 argv 的绝对 Orca 路径换成项目 `PATH` 解析。
|
||||||
|
- 不把模型档位当作权限。`high` 不自动获得更多文件、凭据、网络或远端写权限。
|
||||||
|
- 不执行 `orca orchestration reset`,除非用户明确要求放弃全部相关运行时状态。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
每个计划阶段都有明确下游 Skill、Agent 档位、输入 revision、授权边界和可核对结果;
|
||||||
|
DAG 中所有必要阶段完成,或失败阶段具有准确状态与恢复入口。ACK 和其它下游 Skill
|
||||||
|
保持独立且不存在对 ORC 的反向引用。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "ORC"
|
||||||
|
short_description: "按阶段编排开发、版本发布与产物构建,并选择 Agent 档位"
|
||||||
|
default_prompt: "Use $orc to coordinate this engineering task with explicit low, mid, or high worker levels."
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: false
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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`,以及本阶段精确的 `--model-auth`、`--remote-auth`。JSON 结果包含选择来源、
|
||||||
|
结构化 profile、认证选择、配置与 worktree identity、可信 Python/Codex/Orca executable、
|
||||||
|
`launchFingerprint`、固定 worker/launcher argv,以及完整的 `terminalCreateArgv` 和
|
||||||
|
`terminalCreateShellCommand`。
|
||||||
|
|
||||||
|
- 优先把返回的 `terminalCreateArgv` 原样交给 argv-capable 进程工具;若工具只接受 shell
|
||||||
|
字符串,则执行完整的 `terminalCreateShellCommand`。不得再插值、包引号或追加参数。
|
||||||
|
- `terminalCreateArgv` 的首项是 resolver 选定的绝对 Orca executable,不替换成裸
|
||||||
|
`orca` 或重新通过项目 `PATH` 查找。
|
||||||
|
- 不从 YAML 自行拼接命令,不追加 argv、环境变量、hook 或权限参数。
|
||||||
|
- `remote-auth` 必须与本阶段唯一目标 provider/transport 一致;默认 `none`,不为方便
|
||||||
|
同时暴露多个 token 或 SSH agent。需要代理、额外凭据或 Docker registry env secret 时
|
||||||
|
fail closed,由用户确认新的最小权限适配,不继承 ambient proxy/credential 变量。
|
||||||
|
- resolver 失败时停止;不改用默认模型、邻近档位或已有未知终端。
|
||||||
|
- 不重复手写 worktree 检查;resolver 已拒绝 root、不存在、非规范、含 symlink、未注册、
|
||||||
|
跨仓库或不在 allowlist 的目标,并把身份写入 fingerprint。
|
||||||
|
- ORC v1 profile 必须使用 `workspace-write`。`read-only` sandbox 可能阻止 Orca CLI
|
||||||
|
写入自身运行时或挂载目录,导致 worker 无法发送 `worker_done`;只读工作通过 task
|
||||||
|
spec 限制,而不是换成无法完成 lifecycle 的 sandbox。
|
||||||
|
|
||||||
|
## 创建与派发
|
||||||
|
|
||||||
|
每个需要 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 的安全规则处理。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 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 或另行设计。
|
||||||
|
|
||||||
|
## 拆分规则
|
||||||
|
|
||||||
|
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 v1 不拆分一个 `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 和必要依赖结果。
|
||||||
Executable
+889
@@ -0,0 +1,889 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate ORC config and build a worktree-bound Codex launch plan."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
LEVELS = ("low", "mid", "high")
|
||||||
|
STAGES = ("code", "release", "deb", "docker")
|
||||||
|
EXPECTED_EFFORT = {"low": "low", "mid": "medium", "high": "high"}
|
||||||
|
TOP_LEVEL_KEYS = {
|
||||||
|
"version",
|
||||||
|
"defaultLevel",
|
||||||
|
"stageDefaults",
|
||||||
|
"allowedWorktrees",
|
||||||
|
"profiles",
|
||||||
|
}
|
||||||
|
PROFILE_KEYS = {
|
||||||
|
"cli",
|
||||||
|
"model",
|
||||||
|
"reasoningEffort",
|
||||||
|
"permissionMode",
|
||||||
|
"approvalPolicy",
|
||||||
|
}
|
||||||
|
MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}\Z")
|
||||||
|
MAX_CONFIG_SIZE = 64 * 1024
|
||||||
|
MAX_CONTROL_OUTPUT = 64 * 1024
|
||||||
|
CONTROL_TIMEOUT_SECONDS = 15
|
||||||
|
CONFIG_RELATIVE_PATH = Path("docs/orc/config.yaml")
|
||||||
|
COMMON_ENVIRONMENT_NAMES = {
|
||||||
|
"COLORTERM",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"NO_COLOR",
|
||||||
|
"TERM",
|
||||||
|
"TZ",
|
||||||
|
}
|
||||||
|
MODEL_AUTH_ENVIRONMENT = {
|
||||||
|
"codex-login": frozenset(),
|
||||||
|
"openai": frozenset({"OPENAI_API_KEY"}),
|
||||||
|
"azure-openai": frozenset({"AZURE_OPENAI_API_KEY"}),
|
||||||
|
}
|
||||||
|
REMOTE_AUTH_ENVIRONMENT = {
|
||||||
|
"none": frozenset(),
|
||||||
|
"github-token": frozenset({"GITHUB_TOKEN"}),
|
||||||
|
"gitlab-token": frozenset({"GITLAB_TOKEN"}),
|
||||||
|
"gitea-token": frozenset({"GITEA_TOKEN"}),
|
||||||
|
"forgejo-token": frozenset({"FORGEJO_TOKEN"}),
|
||||||
|
"ssh-agent": frozenset({"SSH_AUTH_SOCK"}),
|
||||||
|
"deb-token": frozenset({"DEB_TOKEN"}),
|
||||||
|
}
|
||||||
|
STAGE_REMOTE_AUTH = {
|
||||||
|
"code": frozenset(
|
||||||
|
{
|
||||||
|
"none",
|
||||||
|
"github-token",
|
||||||
|
"gitlab-token",
|
||||||
|
"gitea-token",
|
||||||
|
"forgejo-token",
|
||||||
|
"ssh-agent",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"release": frozenset(
|
||||||
|
{
|
||||||
|
"none",
|
||||||
|
"github-token",
|
||||||
|
"gitlab-token",
|
||||||
|
"gitea-token",
|
||||||
|
"forgejo-token",
|
||||||
|
"ssh-agent",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"deb": frozenset({"none", "deb-token", "ssh-agent"}),
|
||||||
|
"docker": frozenset({"none"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(ValueError):
|
||||||
|
"""ORC configuration or launch state is invalid or unsafe."""
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise ConfigError("JSON-compatible YAML contains a duplicate key")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bounded_regular_file(path: Path) -> tuple[str, dict[str, int]]:
|
||||||
|
absolute = path.absolute()
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor: int | None = None
|
||||||
|
try:
|
||||||
|
descriptor = os.open(absolute, flags)
|
||||||
|
with os.fdopen(descriptor, "rb") as stream:
|
||||||
|
descriptor = None
|
||||||
|
before = os.fstat(stream.fileno())
|
||||||
|
if not stat.S_ISREG(before.st_mode):
|
||||||
|
raise ConfigError(f"config must be a regular file: {absolute}")
|
||||||
|
if before.st_size > MAX_CONFIG_SIZE:
|
||||||
|
raise ConfigError(f"config exceeds {MAX_CONFIG_SIZE} bytes")
|
||||||
|
content = stream.read(MAX_CONFIG_SIZE + 1)
|
||||||
|
after = os.fstat(stream.fileno())
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"cannot safely read config {absolute}: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if descriptor is not None:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
if len(content) > MAX_CONFIG_SIZE:
|
||||||
|
raise ConfigError(f"config exceeds {MAX_CONFIG_SIZE} bytes")
|
||||||
|
identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
||||||
|
identity_after = (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||||
|
if identity_before != identity_after:
|
||||||
|
raise ConfigError("config changed while it was being read")
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ConfigError("config must be valid UTF-8") from exc
|
||||||
|
return text, {
|
||||||
|
"device": before.st_dev,
|
||||||
|
"inode": before.st_ino,
|
||||||
|
"size": before.st_size,
|
||||||
|
"mtimeNs": before.st_mtime_ns,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Any, path: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigError(f"{path} must be a mapping")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_keys(
|
||||||
|
value: dict[str, Any],
|
||||||
|
*,
|
||||||
|
required: set[str],
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
missing = sorted(required - set(value))
|
||||||
|
unknown = sorted(set(value) - required)
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(f"{path} is missing fields: {', '.join(missing)}")
|
||||||
|
if unknown:
|
||||||
|
raise ConfigError(f"{path} has unknown fields: {', '.join(unknown)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _enum(value: Any, allowed: tuple[str, ...] | set[str], path: str) -> str:
|
||||||
|
if not isinstance(value, str) or value not in allowed:
|
||||||
|
raise ConfigError(f"{path} must be one of: {', '.join(sorted(allowed))}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(document: Any) -> dict[str, Any]:
|
||||||
|
config = _mapping(document, "config")
|
||||||
|
_exact_keys(config, required=TOP_LEVEL_KEYS, path="config")
|
||||||
|
|
||||||
|
version = config["version"]
|
||||||
|
if isinstance(version, bool) or version != 1:
|
||||||
|
raise ConfigError("version must be integer 1")
|
||||||
|
|
||||||
|
_enum(config["defaultLevel"], LEVELS, "defaultLevel")
|
||||||
|
|
||||||
|
stage_defaults = _mapping(config["stageDefaults"], "stageDefaults")
|
||||||
|
unknown_stages = sorted(set(stage_defaults) - set(STAGES))
|
||||||
|
if unknown_stages:
|
||||||
|
raise ConfigError(
|
||||||
|
f"stageDefaults has unknown fields: {', '.join(unknown_stages)}"
|
||||||
|
)
|
||||||
|
for stage, level in stage_defaults.items():
|
||||||
|
_enum(level, LEVELS, f"stageDefaults.{stage}")
|
||||||
|
|
||||||
|
allowed = config["allowedWorktrees"]
|
||||||
|
if not isinstance(allowed, list) or not allowed:
|
||||||
|
raise ConfigError("allowedWorktrees must be a non-empty list")
|
||||||
|
seen_worktrees: set[str] = set()
|
||||||
|
for index, entry in enumerate(allowed):
|
||||||
|
if (
|
||||||
|
not isinstance(entry, str)
|
||||||
|
or not entry
|
||||||
|
or entry != entry.strip()
|
||||||
|
or any(character in entry for character in ("\x00", "\n", "\r"))
|
||||||
|
):
|
||||||
|
raise ConfigError(
|
||||||
|
f"allowedWorktrees[{index}] must be a safe non-empty path"
|
||||||
|
)
|
||||||
|
if entry != "." and not Path(entry).is_absolute():
|
||||||
|
raise ConfigError(
|
||||||
|
f"allowedWorktrees[{index}] must be '.' or an absolute path"
|
||||||
|
)
|
||||||
|
normalized = entry if entry == "." else str(Path(entry).absolute())
|
||||||
|
if normalized in seen_worktrees:
|
||||||
|
raise ConfigError(f"allowedWorktrees contains duplicate path: {entry}")
|
||||||
|
seen_worktrees.add(normalized)
|
||||||
|
|
||||||
|
profiles = _mapping(config["profiles"], "profiles")
|
||||||
|
_exact_keys(profiles, required=set(LEVELS), path="profiles")
|
||||||
|
for level in LEVELS:
|
||||||
|
profile = _mapping(profiles[level], f"profiles.{level}")
|
||||||
|
_exact_keys(profile, required=PROFILE_KEYS, path=f"profiles.{level}")
|
||||||
|
if profile["cli"] != "codex":
|
||||||
|
raise ConfigError(f"profiles.{level}.cli must be codex in ORC v1")
|
||||||
|
model = profile["model"]
|
||||||
|
if not isinstance(model, str) or not MODEL_RE.fullmatch(model):
|
||||||
|
raise ConfigError(f"profiles.{level}.model is not a safe exact model ID")
|
||||||
|
expected_effort = EXPECTED_EFFORT[level]
|
||||||
|
if profile["reasoningEffort"] != expected_effort:
|
||||||
|
raise ConfigError(
|
||||||
|
f"profiles.{level}.reasoningEffort must be {expected_effort}"
|
||||||
|
)
|
||||||
|
if profile["permissionMode"] != "workspace-write":
|
||||||
|
raise ConfigError(
|
||||||
|
f"profiles.{level}.permissionMode must be workspace-write in ORC v1"
|
||||||
|
)
|
||||||
|
_enum(
|
||||||
|
profile["approvalPolicy"],
|
||||||
|
{"untrusted", "on-request", "never"},
|
||||||
|
f"profiles.{level}.approvalPolicy",
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_snapshot(path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
raw, identity = _read_bounded_regular_file(path)
|
||||||
|
try:
|
||||||
|
document = json.loads(raw, object_pairs_hook=_json_object)
|
||||||
|
except ConfigError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ConfigError("invalid JSON-compatible YAML config") from exc
|
||||||
|
config = validate_config(document)
|
||||||
|
snapshot = {
|
||||||
|
"sha256": hashlib.sha256(raw.encode("utf-8")).hexdigest(),
|
||||||
|
**identity,
|
||||||
|
}
|
||||||
|
return config, snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> dict[str, Any]:
|
||||||
|
config, _ = load_config_snapshot(path)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile(
|
||||||
|
config: dict[str, Any],
|
||||||
|
*,
|
||||||
|
stage: str,
|
||||||
|
global_level: str | None = None,
|
||||||
|
stage_level: str | None = None,
|
||||||
|
model_auth: str = "codex-login",
|
||||||
|
remote_auth: str = "none",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_enum(stage, STAGES, "stage")
|
||||||
|
if global_level is not None:
|
||||||
|
_enum(global_level, LEVELS, "global level")
|
||||||
|
if stage_level is not None:
|
||||||
|
_enum(stage_level, LEVELS, "stage level")
|
||||||
|
_enum(model_auth, set(MODEL_AUTH_ENVIRONMENT), "model auth")
|
||||||
|
_enum(remote_auth, STAGE_REMOTE_AUTH[stage], f"{stage} remote auth")
|
||||||
|
|
||||||
|
if stage_level is not None:
|
||||||
|
level, source = stage_level, "request.stage"
|
||||||
|
elif global_level is not None:
|
||||||
|
level, source = global_level, "request.global"
|
||||||
|
elif stage in config["stageDefaults"]:
|
||||||
|
level, source = config["stageDefaults"][stage], f"config.stageDefaults.{stage}"
|
||||||
|
else:
|
||||||
|
level, source = config["defaultLevel"], "config.defaultLevel"
|
||||||
|
|
||||||
|
profile = config["profiles"].get(level)
|
||||||
|
if profile is None:
|
||||||
|
raise ConfigError(f"requested profile does not exist: {level}")
|
||||||
|
worker_args = [
|
||||||
|
"--model",
|
||||||
|
profile["model"],
|
||||||
|
"-c",
|
||||||
|
f'model_reasoning_effort="{profile["reasoningEffort"]}"',
|
||||||
|
"--sandbox",
|
||||||
|
profile["permissionMode"],
|
||||||
|
"--ask-for-approval",
|
||||||
|
profile["approvalPolicy"],
|
||||||
|
"--strict-config",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"stage": stage,
|
||||||
|
"level": level,
|
||||||
|
"selectionSource": source,
|
||||||
|
"modelAuth": model_auth,
|
||||||
|
"remoteAuth": remote_auth,
|
||||||
|
"profile": dict(profile),
|
||||||
|
"workerArgs": worker_args,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _path_has_parent_reference(value: str) -> bool:
|
||||||
|
return ".." in Path(value).parts
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_symlink_components(path: Path, label: str) -> None:
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ConfigError(f"{label} must be absolute: {path}")
|
||||||
|
current = Path(path.anchor)
|
||||||
|
for part in path.parts[1:]:
|
||||||
|
current /= part
|
||||||
|
try:
|
||||||
|
metadata = os.lstat(current)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"{label} does not exist: {current}") from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode):
|
||||||
|
raise ConfigError(f"{label} must not contain symlinks: {current}")
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_directory(value: str | Path, label: str) -> Path:
|
||||||
|
text = str(value)
|
||||||
|
if (
|
||||||
|
not text
|
||||||
|
or text != text.strip()
|
||||||
|
or any(character in text for character in ("\x00", "\n", "\r"))
|
||||||
|
or _path_has_parent_reference(text)
|
||||||
|
):
|
||||||
|
raise ConfigError(f"{label} must be a safe canonical absolute path")
|
||||||
|
raw = Path(text)
|
||||||
|
_assert_no_symlink_components(raw, label)
|
||||||
|
try:
|
||||||
|
resolved = raw.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"{label} does not exist: {raw}") from exc
|
||||||
|
if resolved != raw or resolved == Path(resolved.anchor) or not resolved.is_dir():
|
||||||
|
raise ConfigError(f"{label} must be a canonical non-root directory: {resolved}")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def account_identity() -> tuple[Path, str]:
|
||||||
|
account = pwd.getpwuid(os.getuid())
|
||||||
|
account_home = Path(account.pw_dir).resolve(strict=True)
|
||||||
|
if not account_home.is_dir():
|
||||||
|
raise ConfigError("current account home is unavailable")
|
||||||
|
return account_home, account.pw_name
|
||||||
|
|
||||||
|
|
||||||
|
def trusted_path_entries() -> list[Path]:
|
||||||
|
account_home, _ = account_identity()
|
||||||
|
candidates = [
|
||||||
|
account_home / ".local" / "bin",
|
||||||
|
account_home / ".local" / "share" / "mise" / "shims",
|
||||||
|
account_home / ".cargo" / "bin",
|
||||||
|
Path("/home/linuxbrew/.linuxbrew/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 _trusted_executable(path: Path, expected_name: str) -> Path | None:
|
||||||
|
try:
|
||||||
|
candidate_metadata = os.lstat(path)
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
metadata = resolved.stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not (
|
||||||
|
stat.S_ISREG(candidate_metadata.st_mode)
|
||||||
|
or stat.S_ISLNK(candidate_metadata.st_mode)
|
||||||
|
):
|
||||||
|
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()}:
|
||||||
|
return None
|
||||||
|
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||||
|
return None
|
||||||
|
if expected_name == "python3":
|
||||||
|
if not resolved.name.startswith("python3"):
|
||||||
|
return None
|
||||||
|
elif resolved.name != expected_name:
|
||||||
|
return None
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_trusted_executable(name: str) -> Path:
|
||||||
|
if name not in {"codex", "git", "orca"}:
|
||||||
|
raise ConfigError(f"unsupported executable: {name}")
|
||||||
|
search_paths = trusted_path_entries()
|
||||||
|
if name == "git":
|
||||||
|
search_paths = [
|
||||||
|
path
|
||||||
|
for path in search_paths
|
||||||
|
if str(path) in {"/usr/local/bin", "/usr/bin", "/bin"}
|
||||||
|
]
|
||||||
|
for directory in search_paths:
|
||||||
|
resolved = _trusted_executable(directory / name, name)
|
||||||
|
if resolved is not None:
|
||||||
|
return resolved
|
||||||
|
raise ConfigError(f"trusted {name} executable was not found in fixed directories")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_trusted_python() -> Path:
|
||||||
|
for candidate in (Path("/usr/bin/python3"), Path("/usr/local/bin/python3")):
|
||||||
|
resolved = _trusted_executable(candidate, "python3")
|
||||||
|
if resolved is not None and resolved.stat().st_uid == 0:
|
||||||
|
return resolved
|
||||||
|
raise ConfigError("a root-owned Python executable was not found in fixed paths")
|
||||||
|
|
||||||
|
|
||||||
|
def control_environment() -> dict[str, str]:
|
||||||
|
account_home, username = account_identity()
|
||||||
|
result = {
|
||||||
|
"HOME": str(account_home),
|
||||||
|
"LOGNAME": username,
|
||||||
|
"PATH": os.pathsep.join(str(path) for path in trusted_path_entries()),
|
||||||
|
"USER": username,
|
||||||
|
}
|
||||||
|
for name, value in os.environ.items():
|
||||||
|
if (
|
||||||
|
name in COMMON_ENVIRONMENT_NAMES or name.startswith("LC_")
|
||||||
|
) and "\x00" not in value:
|
||||||
|
result[name] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def selected_credential_names(
|
||||||
|
stage: str,
|
||||||
|
*,
|
||||||
|
model_auth: str,
|
||||||
|
remote_auth: str,
|
||||||
|
) -> frozenset[str]:
|
||||||
|
_enum(stage, STAGES, "stage")
|
||||||
|
_enum(model_auth, set(MODEL_AUTH_ENVIRONMENT), "model auth")
|
||||||
|
_enum(remote_auth, STAGE_REMOTE_AUTH[stage], f"{stage} remote auth")
|
||||||
|
names = MODEL_AUTH_ENVIRONMENT[model_auth] | REMOTE_AUTH_ENVIRONMENT[remote_auth]
|
||||||
|
for name in names:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value or "\x00" in value:
|
||||||
|
raise ConfigError(
|
||||||
|
f"selected authentication variable is unavailable: {name}"
|
||||||
|
)
|
||||||
|
if name == "SSH_AUTH_SOCK":
|
||||||
|
socket_path = Path(value)
|
||||||
|
if (
|
||||||
|
not socket_path.is_absolute()
|
||||||
|
or value != value.strip()
|
||||||
|
or any(character in value for character in ("\n", "\r"))
|
||||||
|
or _path_has_parent_reference(value)
|
||||||
|
):
|
||||||
|
raise ConfigError("selected SSH_AUTH_SOCK is not a safe absolute path")
|
||||||
|
_assert_no_symlink_components(socket_path, "selected SSH_AUTH_SOCK")
|
||||||
|
try:
|
||||||
|
metadata = os.lstat(socket_path)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError("selected SSH_AUTH_SOCK is unavailable") from exc
|
||||||
|
if (
|
||||||
|
not stat.S_ISSOCK(metadata.st_mode)
|
||||||
|
or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) & 0o022
|
||||||
|
):
|
||||||
|
raise ConfigError("selected SSH_AUTH_SOCK is not a trusted user socket")
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def worker_environment(
|
||||||
|
stage: str,
|
||||||
|
*,
|
||||||
|
model_auth: str,
|
||||||
|
remote_auth: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
result = control_environment()
|
||||||
|
for name in selected_credential_names(
|
||||||
|
stage,
|
||||||
|
model_auth=model_auth,
|
||||||
|
remote_auth=remote_auth,
|
||||||
|
):
|
||||||
|
result[name] = os.environ[name]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_control(argv: list[str], label: str) -> str:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
argv,
|
||||||
|
shell=False,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=CONTROL_TIMEOUT_SECONDS,
|
||||||
|
env=control_environment(),
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise ConfigError(f"{label} could not be executed safely") from exc
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise ConfigError(f"{label} failed with exit {completed.returncode}")
|
||||||
|
output = completed.stdout.strip()
|
||||||
|
if not output or len(output.encode("utf-8")) > MAX_CONTROL_OUTPUT:
|
||||||
|
raise ConfigError(f"{label} returned invalid output")
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _git_path(value: str, cwd: Path, label: str) -> Path:
|
||||||
|
candidate = Path(value)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = cwd / candidate
|
||||||
|
_assert_no_symlink_components(candidate, label)
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"{label} is invalid") from exc
|
||||||
|
if not resolved.is_dir():
|
||||||
|
raise ConfigError(f"{label} is not a directory")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def validate_worktree(
|
||||||
|
config: dict[str, Any],
|
||||||
|
*,
|
||||||
|
project_root_value: str | Path,
|
||||||
|
worktree_value: str | Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
project_root = canonical_directory(project_root_value, "project root")
|
||||||
|
worktree = canonical_directory(worktree_value, "target worktree")
|
||||||
|
git = resolve_trusted_executable("git")
|
||||||
|
|
||||||
|
top_level = canonical_directory(
|
||||||
|
_run_control(
|
||||||
|
[str(git), "-C", str(project_root), "rev-parse", "--show-toplevel"],
|
||||||
|
"Git project-root check",
|
||||||
|
),
|
||||||
|
"Git project root",
|
||||||
|
)
|
||||||
|
if top_level != project_root:
|
||||||
|
raise ConfigError("project root is not the repository top level")
|
||||||
|
|
||||||
|
common_dir = _git_path(
|
||||||
|
_run_control(
|
||||||
|
[str(git), "-C", str(project_root), "rev-parse", "--git-common-dir"],
|
||||||
|
"Git common-directory check",
|
||||||
|
),
|
||||||
|
project_root,
|
||||||
|
"Git common directory",
|
||||||
|
)
|
||||||
|
listed = _run_control(
|
||||||
|
[str(git), "-C", str(project_root), "worktree", "list", "--porcelain"],
|
||||||
|
"Git worktree listing",
|
||||||
|
)
|
||||||
|
registered: set[Path] = set()
|
||||||
|
for line in listed.splitlines():
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
try:
|
||||||
|
registered.add(
|
||||||
|
canonical_directory(line[9:], "registered worktree")
|
||||||
|
)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
allowed: set[Path] = set()
|
||||||
|
for entry in config["allowedWorktrees"]:
|
||||||
|
allowed.add(
|
||||||
|
project_root
|
||||||
|
if entry == "."
|
||||||
|
else canonical_directory(entry, "allowed worktree")
|
||||||
|
)
|
||||||
|
if not allowed <= registered:
|
||||||
|
raise ConfigError("allowedWorktrees contains an unregistered Git worktree")
|
||||||
|
if worktree not in allowed:
|
||||||
|
raise ConfigError("target worktree is not in allowedWorktrees")
|
||||||
|
|
||||||
|
target_top = canonical_directory(
|
||||||
|
_run_control(
|
||||||
|
[str(git), "-C", str(worktree), "rev-parse", "--show-toplevel"],
|
||||||
|
"Git target-worktree check",
|
||||||
|
),
|
||||||
|
"target Git worktree",
|
||||||
|
)
|
||||||
|
target_common = _git_path(
|
||||||
|
_run_control(
|
||||||
|
[str(git), "-C", str(worktree), "rev-parse", "--git-common-dir"],
|
||||||
|
"target Git common-directory check",
|
||||||
|
),
|
||||||
|
worktree,
|
||||||
|
"target Git common directory",
|
||||||
|
)
|
||||||
|
if target_top != worktree or target_common != common_dir:
|
||||||
|
raise ConfigError("target worktree does not belong to the project repository")
|
||||||
|
|
||||||
|
worktree_metadata = worktree.stat()
|
||||||
|
common_metadata = common_dir.stat()
|
||||||
|
return {
|
||||||
|
"projectRoot": str(project_root),
|
||||||
|
"worktree": str(worktree),
|
||||||
|
"gitCommonDir": str(common_dir),
|
||||||
|
"worktreeIdentity": {
|
||||||
|
"device": worktree_metadata.st_dev,
|
||||||
|
"inode": worktree_metadata.st_ino,
|
||||||
|
"gitCommonDevice": common_metadata.st_dev,
|
||||||
|
"gitCommonInode": common_metadata.st_ino,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _file_facts(path: Path) -> dict[str, Any]:
|
||||||
|
metadata = path.stat()
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"device": metadata.st_dev,
|
||||||
|
"inode": metadata.st_ino,
|
||||||
|
"size": metadata.st_size,
|
||||||
|
"mtimeNs": metadata.st_mtime_ns,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _executable_facts(path: Path) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
**_file_facts(path),
|
||||||
|
"version": _run_control([str(path), "--version"], "Codex version check"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _python_facts(path: Path) -> dict[str, Any]:
|
||||||
|
version = _run_control(
|
||||||
|
[
|
||||||
|
str(path),
|
||||||
|
"-I",
|
||||||
|
"-S",
|
||||||
|
"-c",
|
||||||
|
"import sys; print(sys.version.split()[0])",
|
||||||
|
],
|
||||||
|
"Python version check",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**_file_facts(path),
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(value: dict[str, Any]) -> str:
|
||||||
|
canonical = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return "sha256:" + hashlib.sha256(canonical).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_launch_plan(
|
||||||
|
config_path: Path,
|
||||||
|
*,
|
||||||
|
project_root: str | Path,
|
||||||
|
worktree: str | Path,
|
||||||
|
stage: str,
|
||||||
|
global_level: str | None = None,
|
||||||
|
stage_level: str | None = None,
|
||||||
|
model_auth: str = "codex-login",
|
||||||
|
remote_auth: str = "none",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
project = canonical_directory(project_root, "project root")
|
||||||
|
expected_config = project / CONFIG_RELATIVE_PATH
|
||||||
|
if config_path.absolute() != expected_config:
|
||||||
|
raise ConfigError(f"config path must be {expected_config}")
|
||||||
|
_assert_no_symlink_components(expected_config, "config path")
|
||||||
|
config, config_snapshot = load_config_snapshot(expected_config)
|
||||||
|
worktree_facts = validate_worktree(
|
||||||
|
config,
|
||||||
|
project_root_value=project,
|
||||||
|
worktree_value=worktree,
|
||||||
|
)
|
||||||
|
profile = resolve_profile(
|
||||||
|
config,
|
||||||
|
stage=stage,
|
||||||
|
global_level=global_level,
|
||||||
|
stage_level=stage_level,
|
||||||
|
model_auth=model_auth,
|
||||||
|
remote_auth=remote_auth,
|
||||||
|
)
|
||||||
|
selected_credential_names(
|
||||||
|
stage,
|
||||||
|
model_auth=model_auth,
|
||||||
|
remote_auth=remote_auth,
|
||||||
|
)
|
||||||
|
codex = resolve_trusted_executable("codex")
|
||||||
|
executable = _executable_facts(codex)
|
||||||
|
orca = resolve_trusted_executable("orca")
|
||||||
|
orca_executable = _file_facts(orca)
|
||||||
|
python = resolve_trusted_python()
|
||||||
|
python_executable = _python_facts(python)
|
||||||
|
script = Path(__file__).resolve(strict=True)
|
||||||
|
script_metadata = script.stat()
|
||||||
|
script_sha256 = hashlib.sha256(script.read_bytes()).hexdigest()
|
||||||
|
launch_facts = {
|
||||||
|
**profile,
|
||||||
|
**worktree_facts,
|
||||||
|
"config": {
|
||||||
|
"path": str(expected_config),
|
||||||
|
**config_snapshot,
|
||||||
|
},
|
||||||
|
"executable": executable,
|
||||||
|
"orca": orca_executable,
|
||||||
|
"python": python_executable,
|
||||||
|
"launcher": {
|
||||||
|
"path": str(script),
|
||||||
|
"device": script_metadata.st_dev,
|
||||||
|
"inode": script_metadata.st_ino,
|
||||||
|
"size": script_metadata.st_size,
|
||||||
|
"mtimeNs": script_metadata.st_mtime_ns,
|
||||||
|
"sha256": script_sha256,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fingerprint = _fingerprint(launch_facts)
|
||||||
|
worker_argv = [str(codex), *profile["workerArgs"]]
|
||||||
|
launcher_argv = [
|
||||||
|
str(python),
|
||||||
|
"-I",
|
||||||
|
"-S",
|
||||||
|
str(script),
|
||||||
|
"_launch",
|
||||||
|
str(expected_config),
|
||||||
|
"--project-root",
|
||||||
|
str(project),
|
||||||
|
"--worktree",
|
||||||
|
str(worktree_facts["worktree"]),
|
||||||
|
"--stage",
|
||||||
|
stage,
|
||||||
|
"--expected-fingerprint",
|
||||||
|
fingerprint,
|
||||||
|
]
|
||||||
|
if global_level is not None:
|
||||||
|
launcher_argv.extend(["--global-level", global_level])
|
||||||
|
if stage_level is not None:
|
||||||
|
launcher_argv.extend(["--stage-level", stage_level])
|
||||||
|
if model_auth != "codex-login":
|
||||||
|
launcher_argv.extend(["--model-auth", model_auth])
|
||||||
|
if remote_auth != "none":
|
||||||
|
launcher_argv.extend(["--remote-auth", remote_auth])
|
||||||
|
terminal_command = shlex.join(launcher_argv)
|
||||||
|
worktree_selector = "path:" + str(worktree_facts["worktree"])
|
||||||
|
terminal_title = f"ORC-{stage}-{profile['level']}-{fingerprint[7:15]}"
|
||||||
|
terminal_create_argv = [
|
||||||
|
str(orca),
|
||||||
|
"terminal",
|
||||||
|
"create",
|
||||||
|
"--worktree",
|
||||||
|
worktree_selector,
|
||||||
|
"--title",
|
||||||
|
terminal_title,
|
||||||
|
"--command",
|
||||||
|
terminal_command,
|
||||||
|
"--json",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**launch_facts,
|
||||||
|
"argv": worker_argv,
|
||||||
|
"launchFingerprint": fingerprint,
|
||||||
|
"launcherArgv": launcher_argv,
|
||||||
|
"terminalCommand": terminal_command,
|
||||||
|
"worktreeSelector": worktree_selector,
|
||||||
|
"terminalTitle": terminal_title,
|
||||||
|
"terminalCreateArgv": terminal_create_argv,
|
||||||
|
"terminalCreateShellCommand": shlex.join(terminal_create_argv),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_launch(args: argparse.Namespace) -> int:
|
||||||
|
plan = build_launch_plan(
|
||||||
|
args.config,
|
||||||
|
project_root=args.project_root,
|
||||||
|
worktree=args.worktree,
|
||||||
|
stage=args.stage,
|
||||||
|
global_level=args.global_level,
|
||||||
|
stage_level=args.stage_level,
|
||||||
|
model_auth=args.model_auth,
|
||||||
|
remote_auth=args.remote_auth,
|
||||||
|
)
|
||||||
|
if plan["launchFingerprint"] != args.expected_fingerprint:
|
||||||
|
raise ConfigError("launch fingerprint changed; resolve the profile again")
|
||||||
|
current = canonical_directory(Path.cwd(), "launcher working directory")
|
||||||
|
if current != Path(plan["worktree"]):
|
||||||
|
raise ConfigError("launcher working directory does not match target worktree")
|
||||||
|
current_python = Path(sys.executable).resolve(strict=True)
|
||||||
|
if current_python != Path(plan["python"]["path"]):
|
||||||
|
raise ConfigError("launcher Python does not match the resolved interpreter")
|
||||||
|
if _python_facts(current_python) != plan["python"]:
|
||||||
|
raise ConfigError("Python executable changed before launch")
|
||||||
|
executable = Path(plan["executable"]["path"])
|
||||||
|
if _executable_facts(executable) != plan["executable"]:
|
||||||
|
raise ConfigError("Codex executable changed before launch")
|
||||||
|
try:
|
||||||
|
os.execve(
|
||||||
|
executable,
|
||||||
|
plan["argv"],
|
||||||
|
worker_environment(
|
||||||
|
plan["stage"],
|
||||||
|
model_auth=plan["modelAuth"],
|
||||||
|
remote_auth=plan["remoteAuth"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError("Codex worker could not be launched") from exc
|
||||||
|
return 1 # pragma: no cover - os.execve does not return on success
|
||||||
|
|
||||||
|
|
||||||
|
def _add_resolution_arguments(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("config", type=Path)
|
||||||
|
parser.add_argument("--project-root", required=True)
|
||||||
|
parser.add_argument("--worktree", required=True)
|
||||||
|
parser.add_argument("--stage", required=True, choices=STAGES)
|
||||||
|
parser.add_argument("--global-level", choices=LEVELS)
|
||||||
|
parser.add_argument("--stage-level", choices=LEVELS)
|
||||||
|
parser.add_argument(
|
||||||
|
"--model-auth",
|
||||||
|
choices=tuple(MODEL_AUTH_ENVIRONMENT),
|
||||||
|
default="codex-login",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--remote-auth",
|
||||||
|
choices=tuple(REMOTE_AUTH_ENVIRONMENT),
|
||||||
|
default="none",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
validate = subparsers.add_parser("validate", help="validate config only")
|
||||||
|
validate.add_argument("config", type=Path)
|
||||||
|
|
||||||
|
resolve = subparsers.add_parser("resolve", help="resolve a bound launch plan")
|
||||||
|
_add_resolution_arguments(resolve)
|
||||||
|
|
||||||
|
launch = subparsers.add_parser("_launch", help=argparse.SUPPRESS)
|
||||||
|
_add_resolution_arguments(launch)
|
||||||
|
launch.add_argument("--expected-fingerprint", required=True)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
if args.command == "validate":
|
||||||
|
config = load_config(args.config)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"ok": True,
|
||||||
|
"config": str(args.config.absolute()),
|
||||||
|
"levels": list(LEVELS),
|
||||||
|
"stages": list(STAGES),
|
||||||
|
"defaultLevel": config["defaultLevel"],
|
||||||
|
}
|
||||||
|
elif args.command == "resolve":
|
||||||
|
result = build_launch_plan(
|
||||||
|
args.config,
|
||||||
|
project_root=args.project_root,
|
||||||
|
worktree=args.worktree,
|
||||||
|
stage=args.stage,
|
||||||
|
global_level=args.global_level,
|
||||||
|
stage_level=args.stage_level,
|
||||||
|
model_auth=args.model_auth,
|
||||||
|
remote_auth=args.remote_auth,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return execute_launch(args)
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"ORC config error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"defaultLevel": "mid",
|
||||||
|
"stageDefaults": {
|
||||||
|
"code": "high",
|
||||||
|
"release": "mid",
|
||||||
|
"deb": "low",
|
||||||
|
"docker": "low"
|
||||||
|
},
|
||||||
|
"allowedWorktrees": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"profiles": {
|
||||||
|
"low": {
|
||||||
|
"cli": "codex",
|
||||||
|
"model": "gpt-5.6-luna",
|
||||||
|
"reasoningEffort": "low",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
"approvalPolicy": "never"
|
||||||
|
},
|
||||||
|
"mid": {
|
||||||
|
"cli": "codex",
|
||||||
|
"model": "gpt-5.6-terra",
|
||||||
|
"reasoningEffort": "medium",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
"approvalPolicy": "never"
|
||||||
|
},
|
||||||
|
"high": {
|
||||||
|
"cli": "codex",
|
||||||
|
"model": "gpt-5.6-sol",
|
||||||
|
"reasoningEffort": "high",
|
||||||
|
"permissionMode": "workspace-write",
|
||||||
|
"approvalPolicy": "never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
||||||
|
VALIDATOR = SCRIPTS_DIR / "validate_delivery.py"
|
||||||
|
EXAMPLE = REPO_ROOT / "skills" / "ack" / "examples" / "delivery.example.yaml"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import validate_delivery # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def valid_contract() -> dict:
|
||||||
|
entrypoints = {
|
||||||
|
name: {
|
||||||
|
"kind": "make",
|
||||||
|
"target": name,
|
||||||
|
"args": [],
|
||||||
|
"requiredSecrets": [],
|
||||||
|
"workingDirectory": ".",
|
||||||
|
"timeoutSeconds": 300,
|
||||||
|
}
|
||||||
|
for name in ("verify", "build", "upload", "deploy", "health", "rollback")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||||
|
"project": {"name": "demo"},
|
||||||
|
"enabled": True,
|
||||||
|
"defaultProfile": "review",
|
||||||
|
"entrypoints": entrypoints,
|
||||||
|
"artifacts": {
|
||||||
|
"service-deb": {
|
||||||
|
"type": "deb",
|
||||||
|
"build": "build",
|
||||||
|
"outputs": ["dist/*.deb"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destinations": {
|
||||||
|
"preview-apt": {
|
||||||
|
"type": "apt-repository",
|
||||||
|
"channel": "preview",
|
||||||
|
"endpoint": "https://packages.example.com",
|
||||||
|
"repository": "testing",
|
||||||
|
"upload": "upload",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"environments": {
|
||||||
|
"test-server": {
|
||||||
|
"type": "ssh-host",
|
||||||
|
"classification": "development",
|
||||||
|
"target": "test-server",
|
||||||
|
"deploy": "deploy",
|
||||||
|
"healthCheck": "health",
|
||||||
|
"rollback": "rollback",
|
||||||
|
"mutex": "test-server-deploy",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"review": {
|
||||||
|
"stopAt": "review_ready",
|
||||||
|
"steps": [
|
||||||
|
{"id": "verify", "action": "verify", "entrypoint": "verify"},
|
||||||
|
{
|
||||||
|
"id": "open-pr",
|
||||||
|
"action": "pull-request",
|
||||||
|
"draft": True,
|
||||||
|
"remote": "origin",
|
||||||
|
"baseBranch": "main",
|
||||||
|
},
|
||||||
|
{"id": "build", "action": "build", "artifact": "service-deb"},
|
||||||
|
{
|
||||||
|
"id": "publish",
|
||||||
|
"action": "publish",
|
||||||
|
"artifact": "service-deb",
|
||||||
|
"destination": "preview-apt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deploy",
|
||||||
|
"action": "deploy",
|
||||||
|
"artifact": "service-deb",
|
||||||
|
"environment": "test-server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "health",
|
||||||
|
"action": "health-check",
|
||||||
|
"environment": "test-server",
|
||||||
|
},
|
||||||
|
{"id": "ready", "action": "mark-ready"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AckDeliveryValidationTests(unittest.TestCase):
|
||||||
|
def test_example_is_valid_with_and_without_site_packages(self) -> None:
|
||||||
|
for no_site_packages in (False, True):
|
||||||
|
command = [sys.executable]
|
||||||
|
if no_site_packages:
|
||||||
|
command.append("-S")
|
||||||
|
result = subprocess.run(
|
||||||
|
[*command, str(VALIDATOR), str(EXAMPLE)],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
with self.subTest(no_site_packages=no_site_packages):
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("交付契约校验通过", result.stdout)
|
||||||
|
|
||||||
|
def test_default_profile_rejects_stable_and_production_targets(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
contract["destinations"]["preview-apt"]["channel"] = "stable"
|
||||||
|
contract["environments"]["test-server"]["classification"] = "production"
|
||||||
|
|
||||||
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
|
|
||||||
|
self.assertTrue(any("stable 发布前必须有 release approval" in item for item in errors))
|
||||||
|
self.assertTrue(any("production 部署前必须有 production approval" in item for item in errors))
|
||||||
|
self.assertTrue(any("defaultProfile 不能发布 stable" in item for item in errors))
|
||||||
|
self.assertTrue(any("defaultProfile 不能部署 production" in item for item in errors))
|
||||||
|
|
||||||
|
def test_nondefault_release_profile_supports_stable_and_production_with_gates(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
contract["destinations"]["stable-apt"] = {
|
||||||
|
"type": "apt-repository",
|
||||||
|
"channel": "stable",
|
||||||
|
"endpoint": "https://packages.example.com",
|
||||||
|
"repository": "stable",
|
||||||
|
"upload": "upload",
|
||||||
|
}
|
||||||
|
contract["environments"]["prod-server"] = {
|
||||||
|
"type": "ssh-host",
|
||||||
|
"classification": "production",
|
||||||
|
"target": "prod-server",
|
||||||
|
"deploy": "deploy",
|
||||||
|
"healthCheck": "health",
|
||||||
|
"rollback": "rollback",
|
||||||
|
"mutex": "prod-server-deploy",
|
||||||
|
}
|
||||||
|
contract["profiles"]["release"] = {
|
||||||
|
"stopAt": "released",
|
||||||
|
"steps": [
|
||||||
|
{"id": "verify-release", "action": "verify", "entrypoint": "verify"},
|
||||||
|
{
|
||||||
|
"id": "open-release-pr",
|
||||||
|
"action": "pull-request",
|
||||||
|
"draft": True,
|
||||||
|
"remote": "origin",
|
||||||
|
"baseBranch": "main",
|
||||||
|
},
|
||||||
|
{"id": "build-release", "action": "build", "artifact": "service-deb"},
|
||||||
|
{"id": "approve-release", "action": "approval", "gate": "release"},
|
||||||
|
{
|
||||||
|
"id": "publish-release",
|
||||||
|
"action": "publish",
|
||||||
|
"artifact": "service-deb",
|
||||||
|
"destination": "stable-apt",
|
||||||
|
},
|
||||||
|
{"id": "approve-production", "action": "approval", "gate": "production"},
|
||||||
|
{
|
||||||
|
"id": "deploy-production",
|
||||||
|
"action": "deploy",
|
||||||
|
"artifact": "service-deb",
|
||||||
|
"environment": "prod-server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "health-production",
|
||||||
|
"action": "health-check",
|
||||||
|
"environment": "prod-server",
|
||||||
|
},
|
||||||
|
{"id": "ready-release", "action": "mark-ready"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||||
|
|
||||||
|
def test_publish_and_health_check_require_prior_steps(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
steps = contract["profiles"]["review"]["steps"]
|
||||||
|
steps[2], steps[3] = steps[3], steps[2]
|
||||||
|
steps[4], steps[5] = steps[5], steps[4]
|
||||||
|
|
||||||
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
|
|
||||||
|
self.assertTrue(any("publish 前必须先 build" in item for item in errors))
|
||||||
|
self.assertTrue(any("health-check 前必须先 deploy" in item for item in errors))
|
||||||
|
|
||||||
|
def test_pull_request_requires_explicit_safe_remote_and_base(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
step = contract["profiles"]["review"]["steps"][1]
|
||||||
|
del step["remote"]
|
||||||
|
step["baseBranch"] = "../main"
|
||||||
|
|
||||||
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
|
|
||||||
|
self.assertTrue(any(".remote: action='pull-request' 时必填" in item for item in errors))
|
||||||
|
self.assertTrue(any(".remote: 必须是安全的 Git remote 名称" in item for item in errors))
|
||||||
|
self.assertTrue(any(".baseBranch: 必须是安全的 Git 分支名" in item for item in errors))
|
||||||
|
|
||||||
|
def test_unknown_shell_and_inline_secret_are_rejected(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
contract["entrypoints"]["verify"]["shell"] = "make verify"
|
||||||
|
contract["entrypoints"]["verify"]["requiredSecrets"] = ["token-value"]
|
||||||
|
contract["destinations"]["preview-apt"]["repository"] = (
|
||||||
|
"token=abcdefghijklmnop"
|
||||||
|
)
|
||||||
|
contract["destinations"]["preview-apt"]["registry"] = "unexpected.example"
|
||||||
|
|
||||||
|
errors = validate_delivery.validate_builtin(contract)
|
||||||
|
|
||||||
|
self.assertTrue(any("未知字段 'shell'" in item for item in errors))
|
||||||
|
self.assertTrue(any("requiredSecrets" in item for item in errors))
|
||||||
|
self.assertTrue(any("type='apt-repository' 不允许此字段" in item for item in errors))
|
||||||
|
self.assertTrue(any("疑似包含敏感信息" in item for item in errors))
|
||||||
|
|
||||||
|
def test_project_script_must_be_executable_and_not_a_symlink(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
contract["entrypoints"]["verify"] = {
|
||||||
|
"kind": "script",
|
||||||
|
"path": "scripts/verify.sh",
|
||||||
|
"args": [],
|
||||||
|
"requiredSecrets": [],
|
||||||
|
"workingDirectory": ".",
|
||||||
|
"timeoutSeconds": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
scripts = root / "scripts"
|
||||||
|
scripts.mkdir()
|
||||||
|
target = scripts / "target.sh"
|
||||||
|
target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
|
target.chmod(0o755)
|
||||||
|
os.symlink("target.sh", scripts / "verify.sh")
|
||||||
|
|
||||||
|
errors = validate_delivery.validate_builtin(contract, root)
|
||||||
|
self.assertTrue(any("路径不能包含 symlink" in item for item in errors))
|
||||||
|
|
||||||
|
(scripts / "verify.sh").unlink()
|
||||||
|
plain = scripts / "verify.sh"
|
||||||
|
plain.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
|
plain.chmod(0o644)
|
||||||
|
errors = validate_delivery.validate_builtin(contract, root)
|
||||||
|
self.assertTrue(any("脚本不可执行" in item for item in errors))
|
||||||
|
|
||||||
|
def test_tasks_link_requires_fixed_path_runs_and_same_project(self) -> None:
|
||||||
|
contract = valid_contract()
|
||||||
|
tasks = {
|
||||||
|
"project": {"name": "other", "deliveryFile": "delivery.yaml"},
|
||||||
|
"tasks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = validate_delivery.validate_tasks_link(contract, tasks)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"tasks.project.deliveryFile 必须固定为 docs/ack/delivery.yaml",
|
||||||
|
errors,
|
||||||
|
)
|
||||||
|
self.assertIn("delivery.project.name 必须与 tasks.project.name 一致", errors)
|
||||||
|
self.assertIn("引用 deliveryFile 的任务板必须包含 deliveryRuns 列表", errors)
|
||||||
|
|
||||||
|
def test_disabled_empty_contract_remains_valid(self) -> None:
|
||||||
|
contract = copy.deepcopy(valid_contract())
|
||||||
|
contract.update(
|
||||||
|
{
|
||||||
|
"enabled": False,
|
||||||
|
"defaultProfile": None,
|
||||||
|
"entrypoints": {},
|
||||||
|
"artifacts": {},
|
||||||
|
"destinations": {},
|
||||||
|
"environments": {},
|
||||||
|
"profiles": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(validate_delivery.validate_builtin(contract), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
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',[],f['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_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.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",
|
||||||
|
"action": "create",
|
||||||
|
}])
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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"
|
||||||
|
" print(json.dumps({'code': 0, 'data': {'fields': ['fldTitle','fldActual','fldExpected','fldSteps','fldAcceptance','fldPriority','fldAttachments','fldUpdated'], 'record_id_list': ['recA'], 'data': [[' Bug\\n title ', ' actual ', 'expected', ['one', 'two'], 'accept', 'P1', [{'file_token':'fileA','name':'shot.png','type':'image/png','size':3}], '2026-08-01']]}}))\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"
|
||||||
|
"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_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()
|
||||||
+17
-1
@@ -16,9 +16,11 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"docs/ack/project.md",
|
"docs/ack/project.md",
|
||||||
"docs/ack/tasks.yaml",
|
"docs/ack/tasks.yaml",
|
||||||
"docs/ack/knowledge.yaml",
|
"docs/ack/knowledge.yaml",
|
||||||
|
"docs/ack/delivery.yaml",
|
||||||
"tasks: []",
|
"tasks: []",
|
||||||
"validate_tasks.py",
|
"validate_tasks.py",
|
||||||
"validate_knowledge.py",
|
"validate_knowledge.py",
|
||||||
|
"validate_delivery.py",
|
||||||
"select_knowledge.py",
|
"select_knowledge.py",
|
||||||
"references/kickoff.md",
|
"references/kickoff.md",
|
||||||
"不要修改项目的 `AGENTS.md`",
|
"不要修改项目的 `AGENTS.md`",
|
||||||
@@ -56,15 +58,29 @@ class AckSkillContentTests(unittest.TestCase):
|
|||||||
"scripts/run_verification.py",
|
"scripts/run_verification.py",
|
||||||
"scripts/worker_profiles.py",
|
"scripts/worker_profiles.py",
|
||||||
"scripts/launch_worker.py",
|
"scripts/launch_worker.py",
|
||||||
|
"templates/delivery.template.yaml",
|
||||||
|
"templates/delivery.schema.json",
|
||||||
|
"examples/delivery.example.yaml",
|
||||||
|
"references/delivery.md",
|
||||||
):
|
):
|
||||||
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
self.assertTrue((ack_dir / relative_path).is_file(), relative_path)
|
||||||
self.assertEqual((ack_dir / "VERSION").read_text(encoding="utf-8").strip(), "0.10.0")
|
version = (ack_dir / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
self.assertEqual(version, "0.12.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(
|
self.assertIn(
|
||||||
'ackVersion: "<接入时的 ack skill 版本>"',
|
'ackVersion: "<接入时的 ack skill 版本>"',
|
||||||
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
(ack_dir / "templates" / "tasks.template.yaml").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self.assertTrue((ack_dir / "references" / "feishu-bug-intake.md").is_file())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -148,6 +148,39 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertIn("任务板校验通过", result.stdout)
|
self.assertIn("任务板校验通过", result.stdout)
|
||||||
|
|
||||||
|
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:
|
def test_v010_requires_structured_routing_but_v009_remains_readable(self) -> None:
|
||||||
current = valid_knowledge_board()
|
current = valid_knowledge_board()
|
||||||
current["ackVersion"] = "0.10.0"
|
current["ackVersion"] = "0.10.0"
|
||||||
@@ -180,7 +213,7 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
with self.subTest(invalid_semver=invalid):
|
with self.subTest(invalid_semver=invalid):
|
||||||
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
self.assertIsNone(ack_pattern.fullmatch(invalid))
|
||||||
|
|
||||||
current_gate, orchestration_gate, receipts_gate = schema["allOf"]
|
current_gate, orchestration_gate, receipts_gate, delivery_gate = schema["allOf"]
|
||||||
current_pattern = re.compile(
|
current_pattern = re.compile(
|
||||||
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
current_gate["if"]["properties"]["ackVersion"]["pattern"]
|
||||||
)
|
)
|
||||||
@@ -218,6 +251,15 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
gate["then"]["properties"]["tasks"]["$ref"],
|
gate["then"]["properties"]["tasks"]["$ref"],
|
||||||
"#/definitions/launchableTasks",
|
"#/definitions/launchableTasks",
|
||||||
)
|
)
|
||||||
|
self.assertIn("deliveryRuns", delivery_gate["then"]["required"])
|
||||||
|
|
||||||
|
delivery_run = schema["definitions"]["deliveryRun"]
|
||||||
|
revision_gate = delivery_run["allOf"][0]
|
||||||
|
self.assertIn("planned", revision_gate["if"]["properties"]["status"]["enum"])
|
||||||
|
self.assertEqual(
|
||||||
|
revision_gate["then"]["properties"]["sourceRevision"]["type"],
|
||||||
|
"string",
|
||||||
|
)
|
||||||
|
|
||||||
role_dispatch = schema["definitions"]["roleDispatch"]
|
role_dispatch = schema["definitions"]["roleDispatch"]
|
||||||
self.assertIn("attemptId", role_dispatch["required"])
|
self.assertIn("attemptId", role_dispatch["required"])
|
||||||
@@ -231,6 +273,126 @@ class AckTaskValidationTests(unittest.TestCase):
|
|||||||
"string",
|
"string",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_delivery_run_is_separate_and_requires_verified_tasks(self) -> None:
|
||||||
|
board = valid_manual_routing_board()
|
||||||
|
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||||
|
board["tasks"][0]["status"] = "verified"
|
||||||
|
board["deliveryRuns"] = [
|
||||||
|
{
|
||||||
|
"id": "DR-demo-1",
|
||||||
|
"profile": "review",
|
||||||
|
"taskIds": ["T-1"],
|
||||||
|
"status": "review_ready",
|
||||||
|
"sourceRevision": "a" * 40,
|
||||||
|
"configRevision": "b" * 40,
|
||||||
|
"pullRequest": "https://forge.example/demo/pulls/1",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"id": "service-deb",
|
||||||
|
"type": "deb",
|
||||||
|
"reference": "demo_1.0.0_amd64.deb",
|
||||||
|
"digest": "sha256:" + "c" * 64,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deployments": [
|
||||||
|
{
|
||||||
|
"environment": "test-server",
|
||||||
|
"result": "succeeded",
|
||||||
|
"evidence": "health endpoint returned 200",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence": ["CI run 42 passed"],
|
||||||
|
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assert_board_accepted_in_all_modes(board)
|
||||||
|
|
||||||
|
board["tasks"][0]["status"] = "open"
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"delivery run 只能引用 verified 任务",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delivery_runs_and_delivery_file_must_appear_together(self) -> None:
|
||||||
|
board = valid_manual_routing_board()
|
||||||
|
board["deliveryRuns"] = []
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"deliveryRuns 存在时 project.deliveryFile 必须存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
board = valid_manual_routing_board()
|
||||||
|
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"引用 deliveryFile 的任务板必须包含 deliveryRuns 列表",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delivery_run_binds_revisions_and_final_artifact_digest(self) -> None:
|
||||||
|
board = valid_manual_routing_board()
|
||||||
|
board["project"]["deliveryFile"] = "docs/ack/delivery.yaml"
|
||||||
|
board["deliveryRuns"] = [
|
||||||
|
{
|
||||||
|
"id": "DR-demo-2",
|
||||||
|
"profile": "review",
|
||||||
|
"taskIds": ["T-1"],
|
||||||
|
"status": "planned",
|
||||||
|
"sourceRevision": None,
|
||||||
|
"configRevision": None,
|
||||||
|
"pullRequest": None,
|
||||||
|
"artifacts": [],
|
||||||
|
"deployments": [],
|
||||||
|
"evidence": [],
|
||||||
|
"updatedAt": "2026-08-01T10:00:00+08:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"delivery run 只能引用 verified 任务",
|
||||||
|
"sourceRevision: status='planned' 时必须填写",
|
||||||
|
"configRevision: status='planned' 时必须填写",
|
||||||
|
)
|
||||||
|
|
||||||
|
board["tasks"][0]["status"] = "verified"
|
||||||
|
run = board["deliveryRuns"][0]
|
||||||
|
run.update(
|
||||||
|
{
|
||||||
|
"status": "review_ready",
|
||||||
|
"sourceRevision": "a" * 40,
|
||||||
|
"configRevision": "b" * 40,
|
||||||
|
"pullRequest": "https://forge.example/demo/pulls/2",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"id": "service-deb",
|
||||||
|
"type": "deb",
|
||||||
|
"reference": "demo_1.0.0_amd64.deb",
|
||||||
|
"digest": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence": ["CI run 43 passed"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"digest: status='review_ready' 时必须填写",
|
||||||
|
)
|
||||||
|
|
||||||
|
run.update(
|
||||||
|
{
|
||||||
|
"status": "skipped",
|
||||||
|
"sourceRevision": None,
|
||||||
|
"configRevision": None,
|
||||||
|
"pullRequest": None,
|
||||||
|
"artifacts": [],
|
||||||
|
"evidence": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assert_board_rejected_in_all_modes(
|
||||||
|
board,
|
||||||
|
"evidence: status='skipped' 时不能为空",
|
||||||
|
)
|
||||||
|
|
||||||
@unittest.skipUnless(
|
@unittest.skipUnless(
|
||||||
importlib.util.find_spec("jsonschema") is not None,
|
importlib.util.find_spec("jsonschema") is not None,
|
||||||
"jsonschema is required for the schema-only contract test",
|
"jsonschema is required for the schema-only contract test",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SCRIPTS_DIR = REPO_ROOT / "skills" / "ack" / "scripts"
|
|||||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
import validate_knowledge # noqa: E402
|
import validate_knowledge # noqa: E402
|
||||||
|
import validate_delivery # noqa: E402
|
||||||
import validate_tasks # noqa: E402
|
import validate_tasks # noqa: E402
|
||||||
from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402
|
from yaml_subset import YamlSubsetError, load_yaml_subset # noqa: E402
|
||||||
|
|
||||||
@@ -28,12 +29,14 @@ paths = (
|
|||||||
Path('skills/ack/examples/tasks.example.yaml'),
|
Path('skills/ack/examples/tasks.example.yaml'),
|
||||||
Path('skills/ack/templates/knowledge.template.yaml'),
|
Path('skills/ack/templates/knowledge.template.yaml'),
|
||||||
Path('skills/ack/examples/knowledge.example.yaml'),
|
Path('skills/ack/examples/knowledge.example.yaml'),
|
||||||
|
Path('skills/ack/templates/delivery.template.yaml'),
|
||||||
|
Path('skills/ack/examples/delivery.example.yaml'),
|
||||||
)
|
)
|
||||||
for path in paths:
|
for path in paths:
|
||||||
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
value = load_yaml_subset(path.read_text(encoding='utf-8'))
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise SystemExit(f'{path}: top-level value is not a mapping')
|
raise SystemExit(f'{path}: top-level value is not a mapping')
|
||||||
print('parsed=4')
|
print('parsed=6')
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-S", "-c", script],
|
[sys.executable, "-S", "-c", script],
|
||||||
@@ -44,7 +47,7 @@ print('parsed=4')
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertEqual(result.stdout.strip(), "parsed=4")
|
self.assertEqual(result.stdout.strip(), "parsed=6")
|
||||||
|
|
||||||
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
def test_tasks_validator_runs_without_site_packages(self) -> None:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -63,6 +66,23 @@ print('parsed=4')
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertIn("任务板校验通过", result.stdout)
|
self.assertIn("任务板校验通过", result.stdout)
|
||||||
|
|
||||||
|
def test_delivery_validator_runs_without_site_packages(self) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-S",
|
||||||
|
str(SCRIPTS_DIR / "validate_delivery.py"),
|
||||||
|
str(REPO_ROOT / "skills" / "ack" / "templates" / "delivery.template.yaml"),
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("交付契约校验通过", result.stdout)
|
||||||
|
|
||||||
def test_supported_subset_types_and_block_scalars(self) -> None:
|
def test_supported_subset_types_and_block_scalars(self) -> None:
|
||||||
document = load_yaml_subset(
|
document = load_yaml_subset(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
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 / "templates" / "config.template.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("只支持 `cli: codex`", 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)
|
||||||
|
|
||||||
|
def test_template_and_cli_validate(self) -> None:
|
||||||
|
config = orc_profiles.load_config(CONFIG)
|
||||||
|
self.assertEqual(set(config["profiles"]), {"low", "mid", "high"})
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "validate", str(CONFIG)],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||||
|
self.assertIn('"ok": true', completed.stdout)
|
||||||
|
|
||||||
|
clean_python = subprocess.run(
|
||||||
|
[sys.executable, "-I", "-S", str(SCRIPT), "validate", str(CONFIG)],
|
||||||
|
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", global_level="mid", stage_level="high"
|
||||||
|
)
|
||||||
|
global_choice = orc_profiles.resolve_profile(
|
||||||
|
config, stage="docker", global_level="mid"
|
||||||
|
)
|
||||||
|
configured = orc_profiles.resolve_profile(config, stage="docker")
|
||||||
|
fallback_config = copy.deepcopy(config)
|
||||||
|
del fallback_config["stageDefaults"]["docker"]
|
||||||
|
fallback = orc_profiles.resolve_profile(fallback_config, stage="docker")
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
self.assertEqual(plan["workerArgs"][:2], ["--model", "gpt-5.6-terra"])
|
||||||
|
self.assertIn("--strict-config", plan["workerArgs"])
|
||||||
|
self.assertNotIn("danger-full-access", " ".join(plan["workerArgs"]))
|
||||||
|
self.assertNotIn("env", plan["profile"])
|
||||||
|
self.assertNotIn("command", plan["profile"])
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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_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"]["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"]["high"]["permissionMode"] = "danger-full-access"
|
||||||
|
with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v1"):
|
||||||
|
orc_profiles.validate_config(full_access)
|
||||||
|
|
||||||
|
read_only = copy.deepcopy(base)
|
||||||
|
read_only["profiles"]["low"]["permissionMode"] = "read-only"
|
||||||
|
with self.assertRaisesRegex(orc_profiles.ConfigError, "workspace-write in ORC v1"):
|
||||||
|
orc_profiles.validate_config(read_only)
|
||||||
|
|
||||||
|
missing_level = copy.deepcopy(base)
|
||||||
|
del missing_level["profiles"]["mid"]
|
||||||
|
with self.assertRaisesRegex(orc_profiles.ConfigError, "missing fields: mid"):
|
||||||
|
orc_profiles.validate_config(missing_level)
|
||||||
|
|
||||||
|
option_model = copy.deepcopy(base)
|
||||||
|
option_model["profiles"]["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"]["low"]["approvalPolicy"] = ["never"]
|
||||||
|
with self.assertRaisesRegex(orc_profiles.ConfigError, "approvalPolicy"):
|
||||||
|
orc_profiles.validate_config(list_policy)
|
||||||
|
|
||||||
|
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")
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), "validate", str(malformed)],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(completed.returncode, 1)
|
||||||
|
self.assertNotIn(secret_marker, completed.stderr)
|
||||||
|
|
||||||
|
def test_bound_plan_enforces_registered_allowlisted_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,
|
||||||
|
)
|
||||||
|
config_path = root / "docs" / "orc" / "config.yaml"
|
||||||
|
config_path.parent.mkdir(parents=True)
|
||||||
|
config_path.write_text(CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
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_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,
|
||||||
|
"orca": fake_orca,
|
||||||
|
}[name]
|
||||||
|
|
||||||
|
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",
|
||||||
|
return_value={
|
||||||
|
"path": str(fake_codex),
|
||||||
|
"device": 1,
|
||||||
|
"inode": 2,
|
||||||
|
"size": 3,
|
||||||
|
"mtimeNs": 4,
|
||||||
|
"version": "codex-test 1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
plan = orc_profiles.build_launch_plan(
|
||||||
|
config_path,
|
||||||
|
project_root=root,
|
||||||
|
worktree=root,
|
||||||
|
stage="code",
|
||||||
|
stage_level="high",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(plan["argv"][0], str(fake_codex))
|
||||||
|
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 in allowedWorktrees",
|
||||||
|
):
|
||||||
|
orc_profiles.validate_worktree(
|
||||||
|
orc_profiles.load_config(config_path),
|
||||||
|
project_root_value=root,
|
||||||
|
worktree_value=outside,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+119
-9
@@ -37,6 +37,13 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
' devWorktree: "<dev_worktree>"\n',
|
' devWorktree: "<dev_worktree>"\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
tasks_template = skill / "templates" / "tasks.template.yaml"
|
||||||
|
tasks_template.write_text(
|
||||||
|
tasks_template.read_text(encoding="utf-8")
|
||||||
|
+ ' deliveryFile: "docs/ack/delivery.yaml"\n'
|
||||||
|
+ 'deliveryRuns: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
(skill / "templates" / "knowledge.template.yaml").write_text(
|
(skill / "templates" / "knowledge.template.yaml").write_text(
|
||||||
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||||
'project:\n'
|
'project:\n'
|
||||||
@@ -44,7 +51,25 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
' repoPath: "<repo_path>"\n',
|
' repoPath: "<repo_path>"\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
(skill / "templates" / "delivery.template.yaml").write_text(
|
||||||
|
'version: 1\n'
|
||||||
|
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||||
|
'project:\n'
|
||||||
|
' name: "<project_name>"\n'
|
||||||
|
'enabled: false\n'
|
||||||
|
'defaultProfile: null\n'
|
||||||
|
'entrypoints: {}\n'
|
||||||
|
'artifacts: {}\n'
|
||||||
|
'destinations: {}\n'
|
||||||
|
'environments: {}\n'
|
||||||
|
'profiles: {}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
for validator_name in (
|
||||||
|
"validate_tasks.py",
|
||||||
|
"validate_knowledge.py",
|
||||||
|
"validate_delivery.py",
|
||||||
|
):
|
||||||
(skill / "scripts" / validator_name).write_text(
|
(skill / "scripts" / validator_name).write_text(
|
||||||
"raise SystemExit(0)\n",
|
"raise SystemExit(0)\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -79,12 +104,16 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
project_content = (target / "project.md").read_text(encoding="utf-8")
|
project_content = (target / "project.md").read_text(encoding="utf-8")
|
||||||
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8")
|
||||||
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
knowledge_content = (target / "knowledge.yaml").read_text(encoding="utf-8")
|
||||||
|
delivery_content = (target / "delivery.yaml").read_text(encoding="utf-8")
|
||||||
self.assertIn("# sample-app", project_content)
|
self.assertIn("# sample-app", project_content)
|
||||||
self.assertIn("version=1.2.3", project_content)
|
self.assertIn("version=1.2.3", project_content)
|
||||||
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
self.assertIn(f'repoPath: "{project}"', tasks_content)
|
||||||
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
self.assertIn(f'repoPath: "{project}"', knowledge_content)
|
||||||
|
self.assertIn('name: "sample-app"', delivery_content)
|
||||||
|
self.assertIn("enabled: false", delivery_content)
|
||||||
self.assertNotIn("<project_name>", tasks_content)
|
self.assertNotIn("<project_name>", tasks_content)
|
||||||
self.assertNotIn("<project_name>", knowledge_content)
|
self.assertNotIn("<project_name>", knowledge_content)
|
||||||
|
self.assertNotIn("<project_name>", delivery_content)
|
||||||
|
|
||||||
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
def test_init_refuses_to_overwrite_existing_files(self) -> None:
|
||||||
project = self.home / "existing-app"
|
project = self.home / "existing-app"
|
||||||
@@ -100,6 +129,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||||
self.assertFalse((target / "tasks.yaml").exists())
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
self.assertFalse((target / "knowledge.yaml").exists())
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
|
self.assertFalse((target / "delivery.yaml").exists())
|
||||||
|
|
||||||
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
def test_init_refuses_to_overwrite_existing_knowledge_file(self) -> None:
|
||||||
project = self.home / "existing-knowledge-app"
|
project = self.home / "existing-knowledge-app"
|
||||||
@@ -116,6 +146,22 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "project.md").exists())
|
self.assertFalse((target / "project.md").exists())
|
||||||
self.assertFalse((target / "tasks.yaml").exists())
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
|
|
||||||
|
def test_init_refuses_to_overwrite_existing_delivery_file(self) -> None:
|
||||||
|
project = self.home / "existing-delivery-app"
|
||||||
|
target = project / "docs" / "ack"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
existing = target / "delivery.yaml"
|
||||||
|
existing.write_text("keep me", encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("拒绝覆盖已有路径", result.stderr)
|
||||||
|
self.assertEqual(existing.read_text(encoding="utf-8"), "keep me")
|
||||||
|
self.assertFalse((target / "project.md").exists())
|
||||||
|
self.assertFalse((target / "tasks.yaml").exists())
|
||||||
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
|
|
||||||
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
def test_init_rejects_symlinked_destination_directories(self) -> None:
|
||||||
for symlink_level in ("docs", "ack"):
|
for symlink_level in ("docs", "ack"):
|
||||||
with self.subTest(symlink_level=symlink_level):
|
with self.subTest(symlink_level=symlink_level):
|
||||||
@@ -147,6 +193,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((outside / "project.md").exists())
|
self.assertFalse((outside / "project.md").exists())
|
||||||
self.assertFalse((outside / "tasks.yaml").exists())
|
self.assertFalse((outside / "tasks.yaml").exists())
|
||||||
self.assertFalse((outside / "knowledge.yaml").exists())
|
self.assertFalse((outside / "knowledge.yaml").exists())
|
||||||
|
self.assertFalse((outside / "delivery.yaml").exists())
|
||||||
|
|
||||||
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
|
def test_init_rejects_path_like_skill_name_before_resolving_targets(self) -> None:
|
||||||
project = self.home / "path-traversal-app"
|
project = self.home / "path-traversal-app"
|
||||||
@@ -289,7 +336,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
self.assertEqual(list((project / "docs" / "ack").iterdir()), [])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in moved_target.iterdir()),
|
sorted(path.name for path in moved_target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
def test_transaction_container_replacement_cannot_forge_payload(self) -> None:
|
||||||
@@ -348,7 +395,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "marker").exists())
|
self.assertFalse((target / "marker").exists())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
|
def test_post_publish_fsync_failure_preserves_complete_state(self) -> None:
|
||||||
@@ -360,7 +407,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
def fail_directory_fsync_after_publish(file_descriptor: int) -> None:
|
||||||
nonlocal calls
|
nonlocal calls
|
||||||
calls += 1
|
calls += 1
|
||||||
if calls == 7:
|
if calls == 8:
|
||||||
raise OSError("simulated directory fsync failure")
|
raise OSError("simulated directory fsync failure")
|
||||||
real_fsync(file_descriptor)
|
real_fsync(file_descriptor)
|
||||||
|
|
||||||
@@ -386,7 +433,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = project / "docs" / "ack"
|
target = project / "docs" / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
def test_project_root_replacement_aborts_before_publish(self) -> None:
|
||||||
@@ -473,7 +520,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = moved_project / "docs" / "ack"
|
target = moved_project / "docs" / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_docs_replacement_aborts_before_publish(self) -> None:
|
def test_docs_replacement_aborts_before_publish(self) -> None:
|
||||||
@@ -568,7 +615,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
target = moved_docs / "ack"
|
target = moved_docs / "ack"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(path.name for path in target.iterdir()),
|
sorted(path.name for path in target.iterdir()),
|
||||||
["knowledge.yaml", "project.md", "tasks.yaml"],
|
["delivery.yaml", "knowledge.yaml", "project.md", "tasks.yaml"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_ack_init_requires_knowledge_template(self) -> None:
|
def test_ack_init_requires_knowledge_template(self) -> None:
|
||||||
@@ -588,8 +635,29 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertIn("knowledge.template.yaml", result.stderr)
|
self.assertIn("knowledge.template.yaml", result.stderr)
|
||||||
self.assertFalse((project / "docs" / "ack").exists())
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
def test_ack_init_requires_both_validators(self) -> None:
|
def test_ack_init_requires_delivery_template(self) -> None:
|
||||||
for validator_name in ("validate_tasks.py", "validate_knowledge.py"):
|
project = self.home / "missing-delivery-template-app"
|
||||||
|
project.mkdir()
|
||||||
|
(
|
||||||
|
self.skills_home
|
||||||
|
/ "skills"
|
||||||
|
/ "ack"
|
||||||
|
/ "templates"
|
||||||
|
/ "delivery.template.yaml"
|
||||||
|
).unlink()
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("delivery.template.yaml", result.stderr)
|
||||||
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
|
def test_ack_init_requires_all_validators(self) -> None:
|
||||||
|
for validator_name in (
|
||||||
|
"validate_tasks.py",
|
||||||
|
"validate_knowledge.py",
|
||||||
|
"validate_delivery.py",
|
||||||
|
):
|
||||||
with self.subTest(validator_name=validator_name):
|
with self.subTest(validator_name=validator_name):
|
||||||
project = self.home / f"missing-{validator_name}-app"
|
project = self.home / f"missing-{validator_name}-app"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
@@ -632,6 +700,21 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertIn("初始化知识库校验失败", result.stderr)
|
self.assertIn("初始化知识库校验失败", result.stderr)
|
||||||
self.assertFalse((project / "docs" / "ack").exists())
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
|
def test_delivery_validator_failure_leaves_no_partial_initialization(self) -> None:
|
||||||
|
project = self.home / "invalid-delivery-app"
|
||||||
|
project.mkdir()
|
||||||
|
validator = (
|
||||||
|
self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py"
|
||||||
|
)
|
||||||
|
validator.write_text("raise SystemExit(1)\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
self.assertIn("初始化交付契约校验失败", result.stderr)
|
||||||
|
self.assertFalse((project / "docs" / "ack").exists())
|
||||||
|
|
||||||
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
def test_validator_cannot_replace_staged_bytes_before_install(self) -> None:
|
||||||
project = self.home / "mutated-staging-app"
|
project = self.home / "mutated-staging-app"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
@@ -677,6 +760,32 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
|
self.assertTrue((project / "docs" / "ack" / "knowledge.yaml").is_file())
|
||||||
|
|
||||||
|
def test_ack_delivery_init_validates_mirrored_staging_root(self) -> None:
|
||||||
|
project = self.home / "staged-delivery-app"
|
||||||
|
project.mkdir()
|
||||||
|
validator = (
|
||||||
|
self.skills_home / "skills" / "ack" / "scripts" / "validate_delivery.py"
|
||||||
|
)
|
||||||
|
validator.write_text(
|
||||||
|
"import sys\n"
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
"required = ['--tasks', '--project-root']\n"
|
||||||
|
"if any(item not in sys.argv for item in required):\n"
|
||||||
|
" raise SystemExit(3)\n"
|
||||||
|
"root = Path(sys.argv[sys.argv.index('--project-root') + 1])\n"
|
||||||
|
"delivery = Path(sys.argv[1])\n"
|
||||||
|
"tasks = Path(sys.argv[sys.argv.index('--tasks') + 1])\n"
|
||||||
|
"expected = root / 'docs' / 'ack'\n"
|
||||||
|
"raise SystemExit(0 if delivery.parent == expected and "
|
||||||
|
"tasks.parent == expected else 4)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.run_skiff("init", "ack", "--project", str(project))
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertTrue((project / "docs" / "ack" / "delivery.yaml").is_file())
|
||||||
|
|
||||||
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
|
def test_non_ack_init_still_requires_only_project_and_tasks_templates(self) -> None:
|
||||||
skill = self.skills_home / "skills" / "plain"
|
skill = self.skills_home / "skills" / "plain"
|
||||||
(skill / "templates").mkdir(parents=True)
|
(skill / "templates").mkdir(parents=True)
|
||||||
@@ -697,6 +806,7 @@ class SkillInitTests(unittest.TestCase):
|
|||||||
self.assertTrue((target / "project.md").is_file())
|
self.assertTrue((target / "project.md").is_file())
|
||||||
self.assertTrue((target / "tasks.yaml").is_file())
|
self.assertTrue((target / "tasks.yaml").is_file())
|
||||||
self.assertFalse((target / "knowledge.yaml").exists())
|
self.assertFalse((target / "knowledge.yaml").exists())
|
||||||
|
self.assertFalse((target / "delivery.yaml").exists())
|
||||||
|
|
||||||
def test_init_rejects_missing_project_directory(self) -> None:
|
def test_init_rejects_missing_project_directory(self) -> None:
|
||||||
project = self.home / "missing-app"
|
project = self.home / "missing-app"
|
||||||
|
|||||||
Reference in New Issue
Block a user